├── demo ├── .env ├── .eslintrc ├── src │ ├── App.css │ ├── index.css │ ├── index.js │ ├── App.test.js │ ├── songs.js │ ├── DimensionsProvider.js │ ├── Footer.js │ ├── Header.js │ ├── InstrumentListProvider.js │ ├── App.js │ ├── PlaybackDemo.js │ ├── InteractiveDemo.js │ ├── SoundfontProvider.js │ ├── registerServiceWorker.js │ └── PianoConfig.js ├── public │ ├── favicon.ico │ ├── images │ │ ├── favicon-source.png │ │ ├── recording-demo.gif │ │ └── react-piano-screenshot.png │ ├── manifest.json │ └── index.html ├── .gitignore ├── README.md └── package.json ├── .eslintrc ├── .travis.yml ├── .prettierrc.json ├── src ├── setupTests.js ├── index.js ├── KeyboardShortcuts.test.js ├── MidiNumbers.test.js ├── KeyboardShortcuts.js ├── Piano.js ├── styles.css ├── MidiNumbers.js ├── Key.js ├── Keyboard.js ├── ControlledPiano.js └── Piano.test.js ├── jest.config.json ├── .babelrc ├── .gitignore ├── LICENSE ├── rollup.config.js ├── package.json ├── CHANGELOG.md └── README.md /demo/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /demo/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - lts/* 4 | -------------------------------------------------------------------------------- /demo/src/App.css: -------------------------------------------------------------------------------- 1 | .bg-yellow { 2 | background-color: #f8e8d5; 3 | } 4 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinsqi/react-piano/HEAD/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/public/images/favicon-source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinsqi/react-piano/HEAD/demo/public/images/favicon-source.png -------------------------------------------------------------------------------- /demo/public/images/recording-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinsqi/react-piano/HEAD/demo/public/images/recording-demo.gif -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 100, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /demo/public/images/react-piano-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevinsqi/react-piano/HEAD/demo/public/images/react-piano-screenshot.png -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": "src", 3 | "setupFilesAfterEnv": ["/setupTests.js"], 4 | "coverageDirectory": "/../coverage" 5 | } 6 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 5 | } 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "targets": { "node": "6", "browsers": [">0.25%"] } 7 | } 8 | ], 9 | "@babel/preset-react" 10 | ], 11 | "plugins": ["@babel/plugin-proposal-class-properties"] 12 | } 13 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import registerServiceWorker from './registerServiceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | registerServiceWorker(); 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ControlledPiano from './ControlledPiano'; 2 | import Piano from './Piano'; 3 | import Keyboard from './Keyboard'; 4 | import KeyboardShortcuts from './KeyboardShortcuts'; 5 | import MidiNumbers from './MidiNumbers'; 6 | 7 | export { ControlledPiano, Piano, Keyboard, KeyboardShortcuts, MidiNumbers }; 8 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /demo/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | 11 | it.skip('removes event listeners upon unmount', () => { 12 | // TODO 13 | }); 14 | -------------------------------------------------------------------------------- /demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # distribution folder 10 | /dist 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .idea 23 | -------------------------------------------------------------------------------- /demo/src/songs.js: -------------------------------------------------------------------------------- 1 | export const lostWoods = [ 2 | [65], 3 | [69], 4 | [71], 5 | [], 6 | [65], 7 | [69], 8 | [71], 9 | [], 10 | [65], 11 | [69], 12 | [71], 13 | [76], 14 | [74], 15 | [], 16 | [71], 17 | [72], 18 | [71], 19 | [67], 20 | [64], 21 | [], 22 | [], 23 | [], 24 | [], 25 | [62], 26 | [64], 27 | [67], 28 | [64], 29 | [], 30 | [], 31 | [], 32 | [], 33 | [], 34 | ]; 35 | -------------------------------------------------------------------------------- /demo/src/DimensionsProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Dimensions from 'react-dimensions'; 3 | 4 | class DimensionsProvider extends React.Component { 5 | render() { 6 | return ( 7 |
8 | {this.props.children({ 9 | containerWidth: this.props.containerWidth, 10 | containerHeight: this.props.containerHeight, 11 | })} 12 |
13 | ); 14 | } 15 | } 16 | 17 | export default Dimensions()(DimensionsProvider); 18 | -------------------------------------------------------------------------------- /demo/src/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Footer(props) { 4 | return ( 5 |
6 |
7 |
8 | Made with{' '} 9 | 10 | 🎵 11 | 12 | by{' '} 13 | 14 | @kevinsqi 15 | 16 |
17 |
18 |
19 | ); 20 | } 21 | 22 | export default Footer; 23 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # react-piano demo site 2 | 3 | ## Developing 4 | 5 | In the parent react-piano directory, run: 6 | 7 | ``` 8 | yarn install 9 | yarn link 10 | yarn start 11 | ``` 12 | 13 | In this repo, run: 14 | 15 | ``` 16 | yarn install 17 | yarn link react-piano 18 | yarn start 19 | ``` 20 | 21 | The demo site will be running at [localhost:3000](http://localhost:3000). Now you can make changes to react-piano and they'll be reflected on the demo site. 22 | 23 | ## Deploying 24 | 25 | This site is hosted on github pages at https://www.kevinqi.com/react-piano. Deploy new updates by running: 26 | 27 | ``` 28 | yarn run deploy 29 | ``` 30 | -------------------------------------------------------------------------------- /demo/src/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Header() { 4 | return ( 5 |
6 |
7 |
8 |

react-piano

9 |

10 | An interactive piano keyboard for React. Supports custom sounds, 11 |
touch/click/keyboard events, and fully configurable 12 | styling. 13 |

14 | 22 |
23 |
24 |
25 | ); 26 | } 27 | 28 | export default Header; 29 | -------------------------------------------------------------------------------- /demo/src/InstrumentListProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class InstrumentListProvider extends React.Component { 5 | static propTypes = { 6 | hostname: PropTypes.string.isRequired, 7 | soundfont: PropTypes.oneOf(['MusyngKite', 'FluidR3_GM']), 8 | render: PropTypes.func, 9 | }; 10 | 11 | static defaultProps = { 12 | soundfont: 'MusyngKite', 13 | }; 14 | 15 | state = { 16 | instrumentList: null, 17 | }; 18 | 19 | componentDidMount() { 20 | this.loadInstrumentList(); 21 | } 22 | 23 | loadInstrumentList = () => { 24 | fetch(`${this.props.hostname}/${this.props.soundfont}/names.json`) 25 | .then((response) => response.json()) 26 | .then((data) => { 27 | this.setState({ 28 | instrumentList: data, 29 | }); 30 | }); 31 | }; 32 | 33 | render() { 34 | return this.props.render(this.state.instrumentList); 35 | } 36 | } 37 | 38 | export default InstrumentListProvider; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kevin Qi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-piano-demo", 3 | "homepage": "https://www.kevinqi.com/react-piano", 4 | "version": "0.1.0", 5 | "license": "MIT", 6 | "dependencies": { 7 | "classnames": "^2.2.5", 8 | "lodash": "^4.17.13", 9 | "prop-types": "^15.6.2", 10 | "react": "^16.9.0", 11 | "react-dimensions": "^1.3.1", 12 | "react-dom": "^16.9.0", 13 | "react-icons": "^2.2.7", 14 | "react-piano": "../", 15 | "react-scripts": "3.3.0", 16 | "soundfont-player": "^0.10.6" 17 | }, 18 | "scripts": { 19 | "predeploy": "npm run build", 20 | "deploy": "gh-pages -d build", 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test --env=jsdom", 24 | "eject": "react-scripts eject" 25 | }, 26 | "devDependencies": { 27 | "gh-pages": "^1.1.0" 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.2%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/KeyboardShortcuts.test.js: -------------------------------------------------------------------------------- 1 | import KeyboardShortcuts from './KeyboardShortcuts'; 2 | 3 | describe('create', () => { 4 | test('correct configuration', () => { 5 | const keyboardShortcuts = KeyboardShortcuts.create({ 6 | firstNote: 40, 7 | lastNote: 50, 8 | keyboardConfig: [ 9 | { 10 | natural: 's', 11 | flat: 'w', 12 | }, 13 | { 14 | natural: 'd', 15 | flat: 'e', 16 | }, 17 | ], 18 | }); 19 | 20 | expect(keyboardShortcuts).toEqual([{ key: 's', midiNumber: 40 }, { key: 'd', midiNumber: 41 }]); 21 | }); 22 | test('does not create shortcuts exceeding lastNote', () => { 23 | const keyboardShortcuts = KeyboardShortcuts.create({ 24 | firstNote: 40, 25 | lastNote: 41, 26 | keyboardConfig: [ 27 | { 28 | natural: 's', 29 | flat: 'w', 30 | }, 31 | { 32 | natural: 'd', 33 | flat: 'e', 34 | }, 35 | { 36 | natural: 'f', 37 | flat: 'r', 38 | }, 39 | ], 40 | }); 41 | 42 | expect(keyboardShortcuts).toEqual([{ key: 's', midiNumber: 40 }, { key: 'd', midiNumber: 41 }]); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import filesize from 'rollup-plugin-filesize'; 4 | import nodeResolve from 'rollup-plugin-node-resolve'; 5 | import replace from 'rollup-plugin-replace'; 6 | import sourceMaps from 'rollup-plugin-sourcemaps'; 7 | import pkg from './package.json'; 8 | 9 | const input = 'src/index.js'; 10 | const external = ['react']; 11 | 12 | const plugins = [ 13 | nodeResolve(), 14 | replace({ 15 | exclude: 'node_modules/**', 16 | // Set correct NODE_ENV for React 17 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 18 | }), 19 | babel({ 20 | exclude: ['node_nodules/**'], 21 | }), 22 | commonjs(), 23 | sourceMaps(), 24 | filesize(), 25 | ]; 26 | 27 | export default [ 28 | // UMD 29 | { 30 | input, 31 | external, 32 | output: [ 33 | { 34 | file: pkg.unpkg, 35 | format: 'umd', 36 | sourcemap: true, 37 | name: 'ReactPiano', 38 | globals: { 39 | react: 'React', 40 | }, 41 | }, 42 | ], 43 | plugins, 44 | }, 45 | { 46 | input, 47 | external: external.concat(Object.keys(pkg.dependencies)), 48 | output: [ 49 | // ES module 50 | { 51 | file: pkg.module, 52 | format: 'es', 53 | sourcemap: true, 54 | }, 55 | // CommonJS 56 | { 57 | file: pkg.main, 58 | format: 'cjs', 59 | sourcemap: true, 60 | }, 61 | ], 62 | plugins, 63 | }, 64 | ]; 65 | -------------------------------------------------------------------------------- /src/MidiNumbers.test.js: -------------------------------------------------------------------------------- 1 | import MidiNumbers from './MidiNumbers'; 2 | 3 | describe('getAttributes', () => { 4 | test('valid notes', () => { 5 | expect(MidiNumbers.getAttributes(12)).toMatchObject({ 6 | note: 'C0', 7 | pitchName: 'C', 8 | octave: 0, 9 | isAccidental: false, 10 | midiNumber: 12, 11 | }); 12 | expect(MidiNumbers.getAttributes(51)).toMatchObject({ 13 | note: 'Eb3', 14 | pitchName: 'Eb', 15 | octave: 3, 16 | isAccidental: true, 17 | midiNumber: 51, 18 | }); 19 | }); 20 | test('invalid notes', () => { 21 | expect(() => MidiNumbers.getAttributes(5)).toThrow(); 22 | }); 23 | }); 24 | 25 | describe('fromNote', () => { 26 | test('valid notes', () => { 27 | expect(MidiNumbers.fromNote('C#0')).toBe(13); 28 | expect(MidiNumbers.fromNote('c#0')).toBe(13); 29 | expect(MidiNumbers.fromNote('c3')).toBe(48); 30 | expect(MidiNumbers.fromNote('eb5')).toBe(75); 31 | expect(MidiNumbers.fromNote('G4')).toBe(67); 32 | }); 33 | test('invalid notes', () => { 34 | expect(() => MidiNumbers.fromNote('fb1')).toThrow(); 35 | expect(() => MidiNumbers.fromNote('')).toThrow(); 36 | expect(() => MidiNumbers.fromNote(null)).toThrow(); 37 | }); 38 | }); 39 | 40 | describe('NATURAL_MIDI_NUMBERS', () => { 41 | test('does not contain out-of-range numbers', () => { 42 | expect(MidiNumbers.NATURAL_MIDI_NUMBERS).not.toContain(11); // out of range 43 | expect(MidiNumbers.NATURAL_MIDI_NUMBERS).not.toContain(128); // out of range 44 | }); 45 | test('contains only natural note numbers', () => { 46 | expect(MidiNumbers.NATURAL_MIDI_NUMBERS).toContain(74); // D5 47 | expect(MidiNumbers.NATURAL_MIDI_NUMBERS).not.toContain(75); // Eb5 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | react-piano demo page 23 | 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /demo/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'react-piano/dist/styles.css'; 3 | 4 | import Header from './Header'; 5 | import Footer from './Footer'; 6 | import InteractiveDemo from './InteractiveDemo'; 7 | import PlaybackDemo from './PlaybackDemo'; 8 | import { lostWoods } from './songs'; 9 | import './App.css'; 10 | 11 | const audioContext = new (window.AudioContext || window.webkitAudioContext)(); 12 | const soundfontHostname = 'https://d1pzp51pvbm36p.cloudfront.net'; 13 | 14 | function Installation() { 15 | return ( 16 |
17 |

Installation

18 |

Install with yarn or npm:

19 |

20 | yarn add react-piano 21 |

22 | 27 |
28 | ); 29 | } 30 | 31 | class App extends React.Component { 32 | render() { 33 | return ( 34 |
35 |
36 |
37 |
38 |
39 | 40 |
41 |
42 |
43 |
44 |
45 | 50 |
51 |
52 |
53 |
54 |
55 | 56 |
57 |
58 |
59 |
60 |
61 | ); 62 | } 63 | } 64 | 65 | export default App; 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-piano", 3 | "version": "3.1.3", 4 | "description": "A responsive, customizable react piano keyboard component", 5 | "main": "dist/react-piano.cjs.js", 6 | "module": "dist/react-piano.esm.js", 7 | "unpkg": "dist/react-piano.umd.js", 8 | "style": "dist/styles.css", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "build": "npm-run-all clean build:css build:js", 14 | "build:css": "postcss src/styles.css --use autoprefixer -d dist/ --no-map", 15 | "build:js": "rollup -c", 16 | "clean": "rimraf dist", 17 | "format": "prettier --write 'src/**/*' 'demo/src/**/*'", 18 | "prepare": "npm run build", 19 | "start": "npm-run-all --parallel start:js start:css", 20 | "start:js": "rollup -c -w", 21 | "start:css": "postcss src/styles.css --use autoprefixer -d dist/ --no-map --watch", 22 | "test": "jest --config jest.config.json --coverage" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/kevinsqi/react-piano.git" 27 | }, 28 | "keywords": [ 29 | "react", 30 | "react-component", 31 | "piano", 32 | "keyboard" 33 | ], 34 | "author": "Kevin Qi ", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/kevinsqi/react-piano/issues" 38 | }, 39 | "homepage": "https://github.com/kevinsqi/react-piano", 40 | "devDependencies": { 41 | "@babel/core": "^7.0.0", 42 | "@babel/plugin-proposal-class-properties": "^7.0.0", 43 | "@babel/preset-env": "^7.0.0", 44 | "@babel/preset-react": "^7.0.0", 45 | "autoprefixer": "^8.5.0", 46 | "babel-core": "^7.0.0-0", 47 | "babel-jest": "^23.4.2", 48 | "enzyme": "^3.5.0", 49 | "enzyme-adapter-react-16": "^1.3.1", 50 | "jest": "^24.8.0", 51 | "npm-run-all": "^4.1.3", 52 | "postcss-cli": "^6.1.3", 53 | "prettier": "^1.14.2", 54 | "react": "^16.4.2", 55 | "react-dom": "^16.4.2", 56 | "react-test-renderer": "^16.4.2", 57 | "rimraf": "^2.6.2", 58 | "rollup": "^0.65.0", 59 | "rollup-plugin-babel": "^4.0.2", 60 | "rollup-plugin-commonjs": "^9.1.6", 61 | "rollup-plugin-filesize": "^4.0.1", 62 | "rollup-plugin-node-resolve": "^3.3.0", 63 | "rollup-plugin-replace": "^2.0.0", 64 | "rollup-plugin-sourcemaps": "^0.4.2" 65 | }, 66 | "peerDependencies": { 67 | "react": "*" 68 | }, 69 | "dependencies": { 70 | "classnames": "^2.2.6", 71 | "just-range": "^2.1.0", 72 | "lodash.difference": "^4.5.0", 73 | "prop-types": "^15.6.2" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/KeyboardShortcuts.js: -------------------------------------------------------------------------------- 1 | import MidiNumbers from './MidiNumbers'; 2 | 3 | function createKeyboardShortcuts({ firstNote, lastNote, keyboardConfig }) { 4 | let currentMidiNumber = firstNote; 5 | let naturalKeyIndex = 0; 6 | let keyboardShortcuts = []; 7 | 8 | while ( 9 | // There are still keys to be assigned 10 | naturalKeyIndex < keyboardConfig.length && 11 | // Note to be assigned does not surpass range 12 | currentMidiNumber <= lastNote 13 | ) { 14 | const key = keyboardConfig[naturalKeyIndex]; 15 | const { isAccidental } = MidiNumbers.getAttributes(currentMidiNumber); 16 | if (isAccidental) { 17 | keyboardShortcuts.push({ 18 | key: key.flat, 19 | midiNumber: currentMidiNumber, 20 | }); 21 | } else { 22 | keyboardShortcuts.push({ 23 | key: key.natural, 24 | midiNumber: currentMidiNumber, 25 | }); 26 | naturalKeyIndex += 1; 27 | } 28 | currentMidiNumber += 1; 29 | } 30 | return keyboardShortcuts; 31 | } 32 | 33 | export default { 34 | create: createKeyboardShortcuts, 35 | // Preset configurations 36 | BOTTOM_ROW: [ 37 | { natural: 'z', flat: 'a', sharp: 's' }, 38 | { natural: 'x', flat: 's', sharp: 'd' }, 39 | { natural: 'c', flat: 'd', sharp: 'f' }, 40 | { natural: 'v', flat: 'f', sharp: 'g' }, 41 | { natural: 'b', flat: 'g', sharp: 'h' }, 42 | { natural: 'n', flat: 'h', sharp: 'j' }, 43 | { natural: 'm', flat: 'j', sharp: 'k' }, 44 | { natural: ',', flat: 'k', sharp: 'l' }, 45 | { natural: '.', flat: 'l', sharp: ';' }, 46 | { natural: '/', flat: ';', sharp: "'" }, 47 | ], 48 | HOME_ROW: [ 49 | { natural: 'a', flat: 'q', sharp: 'w' }, 50 | { natural: 's', flat: 'w', sharp: 'e' }, 51 | { natural: 'd', flat: 'e', sharp: 'r' }, 52 | { natural: 'f', flat: 'r', sharp: 't' }, 53 | { natural: 'g', flat: 't', sharp: 'y' }, 54 | { natural: 'h', flat: 'y', sharp: 'u' }, 55 | { natural: 'j', flat: 'u', sharp: 'i' }, 56 | { natural: 'k', flat: 'i', sharp: 'o' }, 57 | { natural: 'l', flat: 'o', sharp: 'p' }, 58 | { natural: ';', flat: 'p', sharp: '[' }, 59 | { natural: "'", flat: '[', sharp: ']' }, 60 | ], 61 | QWERTY_ROW: [ 62 | { natural: 'q', flat: '1', sharp: '2' }, 63 | { natural: 'w', flat: '2', sharp: '3' }, 64 | { natural: 'e', flat: '3', sharp: '4' }, 65 | { natural: 'r', flat: '4', sharp: '5' }, 66 | { natural: 't', flat: '5', sharp: '6' }, 67 | { natural: 'y', flat: '6', sharp: '7' }, 68 | { natural: 'u', flat: '7', sharp: '8' }, 69 | { natural: 'i', flat: '8', sharp: '9' }, 70 | { natural: 'o', flat: '9', sharp: '0' }, 71 | { natural: 'p', flat: '0', sharp: '-' }, 72 | { natural: '[', flat: '-', sharp: '=' }, 73 | ], 74 | }; 75 | -------------------------------------------------------------------------------- /src/Piano.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import difference from 'lodash.difference'; 5 | import ControlledPiano from './ControlledPiano'; 6 | import Keyboard from './Keyboard'; 7 | 8 | class Piano extends React.Component { 9 | static propTypes = { 10 | noteRange: PropTypes.object.isRequired, 11 | activeNotes: PropTypes.arrayOf(PropTypes.number.isRequired), 12 | playNote: PropTypes.func.isRequired, 13 | stopNote: PropTypes.func.isRequired, 14 | onPlayNoteInput: PropTypes.func, 15 | onStopNoteInput: PropTypes.func, 16 | renderNoteLabel: PropTypes.func, 17 | className: PropTypes.string, 18 | disabled: PropTypes.bool, 19 | width: PropTypes.number, 20 | keyWidthToHeight: PropTypes.number, 21 | keyboardShortcuts: PropTypes.arrayOf( 22 | PropTypes.shape({ 23 | key: PropTypes.string.isRequired, 24 | midiNumber: PropTypes.number.isRequired, 25 | }), 26 | ), 27 | }; 28 | 29 | state = { 30 | activeNotes: this.props.activeNotes || [], 31 | }; 32 | 33 | componentDidUpdate(prevProps) { 34 | // Make activeNotes "controllable" by using internal 35 | // state by default, but allowing prop overrides. 36 | if ( 37 | prevProps.activeNotes !== this.props.activeNotes && 38 | this.state.activeNotes !== this.props.activeNotes 39 | ) { 40 | this.setState({ 41 | activeNotes: this.props.activeNotes || [], 42 | }); 43 | } 44 | } 45 | 46 | handlePlayNoteInput = (midiNumber) => { 47 | this.setState((prevState) => { 48 | // Need to be handled inside setState in order to set prevActiveNotes without 49 | // race conditions. 50 | if (this.props.onPlayNoteInput) { 51 | this.props.onPlayNoteInput(midiNumber, { prevActiveNotes: prevState.activeNotes }); 52 | } 53 | 54 | // Don't append note to activeNotes if it's already present 55 | if (prevState.activeNotes.includes(midiNumber)) { 56 | return null; 57 | } 58 | return { 59 | activeNotes: prevState.activeNotes.concat(midiNumber), 60 | }; 61 | }); 62 | }; 63 | 64 | handleStopNoteInput = (midiNumber) => { 65 | this.setState((prevState) => { 66 | // Need to be handled inside setState in order to set prevActiveNotes without 67 | // race conditions. 68 | if (this.props.onStopNoteInput) { 69 | this.props.onStopNoteInput(midiNumber, { prevActiveNotes: this.state.activeNotes }); 70 | } 71 | return { 72 | activeNotes: prevState.activeNotes.filter((note) => midiNumber !== note), 73 | }; 74 | }); 75 | }; 76 | 77 | render() { 78 | const { activeNotes, onPlayNoteInput, onStopNoteInput, ...otherProps } = this.props; 79 | return ( 80 | 86 | ); 87 | } 88 | } 89 | 90 | export default Piano; 91 | -------------------------------------------------------------------------------- /demo/src/PlaybackDemo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Piano, MidiNumbers } from 'react-piano'; 3 | import classNames from 'classnames'; 4 | 5 | import DimensionsProvider from './DimensionsProvider'; 6 | import SoundfontProvider from './SoundfontProvider'; 7 | 8 | class PlaybackDemo extends React.Component { 9 | state = { 10 | activeNotesIndex: 0, 11 | isPlaying: false, 12 | stopAllNotes: () => console.warn('stopAllNotes not yet loaded'), 13 | }; 14 | 15 | constructor(props) { 16 | super(props); 17 | this.playbackIntervalFn = null; 18 | } 19 | 20 | componentDidUpdate(prevProps, prevState) { 21 | if (prevState.isPlaying !== this.state.isPlaying) { 22 | if (this.state.isPlaying) { 23 | this.playbackIntervalFn = setInterval(() => { 24 | this.setState({ 25 | activeNotesIndex: (this.state.activeNotesIndex + 1) % this.props.song.length, 26 | }); 27 | }, 200); 28 | } else { 29 | clearInterval(this.playbackIntervalFn); 30 | this.state.stopAllNotes(); 31 | this.setState({ 32 | activeNotesIndex: 0, 33 | }); 34 | } 35 | } 36 | } 37 | 38 | setPlaying = (value) => { 39 | this.setState({ isPlaying: value }); 40 | }; 41 | 42 | render() { 43 | const noteRange = { 44 | first: MidiNumbers.fromNote('c3'), 45 | last: MidiNumbers.fromNote('f5'), 46 | }; 47 | 48 | return ( 49 |
50 |
51 |

Or try playing it back.

52 |
53 | 62 |
63 |
64 |
65 | this.setState({ stopAllNotes })} 70 | render={({ isLoading, playNote, stopNote, stopAllNotes }) => ( 71 | 72 | {({ containerWidth }) => ( 73 | 83 | )} 84 | 85 | )} 86 | /> 87 |
88 |
89 | ); 90 | } 91 | } 92 | 93 | export default PlaybackDemo; 94 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .ReactPiano__Keyboard { 2 | /* Used for absolute positioning of .ReactPiano__Key--accidental elements */ 3 | position: relative; 4 | /* Used to lay out .ReactPiano__Key--natural elements */ 5 | display: flex; 6 | } 7 | 8 | .ReactPiano__Key { 9 | /* Used for flexbox layout of the child .ReactPiano__NoteLabelContainer elements */ 10 | display: flex; 11 | } 12 | 13 | /* 14 | * Styles of accidental notes (flat or sharp) 15 | */ 16 | .ReactPiano__Key--accidental { 17 | background: #555; 18 | border: 1px solid #fff; 19 | border-top: 1px solid transparent; 20 | border-radius: 0 0 4px 4px; 21 | cursor: pointer; 22 | height: 66%; 23 | /* Overlay on top of natural keys */ 24 | z-index: 1; 25 | /* Use absolute positioning along with inline styles specified in JS to put keys in correct locations. */ 26 | position: absolute; 27 | top: 0; 28 | } 29 | 30 | /* 31 | * Styles of natural notes (white keys) 32 | */ 33 | .ReactPiano__Key--natural { 34 | background: #f6f5f3; 35 | border: 1px solid #888; 36 | border-radius: 0 0 6px 6px; 37 | cursor: pointer; 38 | z-index: 0; 39 | /* 40 | * Uses flexbox with margin instead of absolute positioning to have more consistent margin rendering. 41 | * This causes inline styles to be ignored. 42 | */ 43 | flex: 1; 44 | margin-right: 1px; 45 | } 46 | 47 | .ReactPiano__Key--natural:last-child { 48 | /* Don't render extra margin on the last natural note */ 49 | margin-right: 0; 50 | } 51 | 52 | /* 53 | * Styles of "active" or pressed-down keys 54 | */ 55 | .ReactPiano__Key--active { 56 | background: #3ac8da; 57 | } 58 | 59 | .ReactPiano__Key--active.ReactPiano__Key--accidental { 60 | border: 1px solid #fff; 61 | border-top: 1px solid #3ac8da; 62 | /* Slight height reduction for "pushed-down" effect */ 63 | height: 65%; 64 | } 65 | 66 | .ReactPiano__Key--active.ReactPiano__Key--natural { 67 | border: 1px solid #3ac8da; 68 | /* Slight height reduction for "pushed-down" effect */ 69 | height: 98%; 70 | } 71 | 72 | /* 73 | * Styles for disabled state 74 | */ 75 | .ReactPiano__Key--disabled.ReactPiano__Key--accidental { 76 | background: #ddd; 77 | border: 1px solid #999; 78 | } 79 | 80 | .ReactPiano__Key--disabled.ReactPiano__Key--natural { 81 | background: #eee; 82 | border: 1px solid #aaa; 83 | } 84 | 85 | /* 86 | * Styles for the note label inside a piano key 87 | */ 88 | .ReactPiano__NoteLabelContainer { 89 | flex: 1; 90 | /* Align children .ReactPiano__NoteLabel to the bottom of the key */ 91 | align-self: flex-end; 92 | } 93 | 94 | .ReactPiano__NoteLabel { 95 | font-size: 12px; 96 | text-align: center; 97 | text-transform: capitalize; 98 | /* Disable text selection */ 99 | user-select: none; 100 | } 101 | 102 | .ReactPiano__NoteLabel--accidental { 103 | color: #f8e8d5; 104 | margin-bottom: 3px; 105 | } 106 | 107 | .ReactPiano__NoteLabel--natural { 108 | color: #888; 109 | margin-bottom: 3px; 110 | } 111 | 112 | .ReactPiano__NoteLabel--natural.ReactPiano__NoteLabel--active { 113 | color: #f8e8d5; 114 | } 115 | -------------------------------------------------------------------------------- /src/MidiNumbers.js: -------------------------------------------------------------------------------- 1 | import range from 'just-range'; 2 | 3 | const SORTED_PITCHES = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; 4 | const ACCIDENTAL_PITCHES = ['Db', 'Eb', 'Gb', 'Ab', 'Bb']; 5 | const PITCH_INDEXES = { 6 | C: 0, 7 | 'C#': 1, 8 | Db: 1, 9 | D: 2, 10 | 'D#': 3, 11 | Eb: 3, 12 | E: 4, 13 | F: 5, 14 | 'F#': 6, 15 | Gb: 6, 16 | G: 7, 17 | 'G#': 8, 18 | Ab: 8, 19 | A: 9, 20 | 'A#': 10, 21 | Bb: 10, 22 | B: 11, 23 | }; 24 | const MIDI_NUMBER_C0 = 12; 25 | const MIN_MIDI_NUMBER = MIDI_NUMBER_C0; 26 | const MAX_MIDI_NUMBER = 127; 27 | const NOTE_REGEX = /([a-g])([#b]?)(\d+)/; 28 | const NOTES_IN_OCTAVE = 12; 29 | 30 | // Converts string notes in scientific pitch notation to a MIDI number, or null. 31 | // 32 | // Example: "c#0" => 13, "eb5" => 75, "abc" => null 33 | // 34 | // References: 35 | // - http://www.flutopedia.com/octave_notation.htm 36 | // - https://github.com/danigb/tonal/blob/master/packages/note/index.js 37 | function fromNote(note) { 38 | if (!note) { 39 | throw Error('Invalid note argument'); 40 | } 41 | const match = NOTE_REGEX.exec(note.toLowerCase()); 42 | if (!match) { 43 | throw Error('Invalid note argument'); 44 | } 45 | const [, letter, accidental, octave] = match; 46 | const pitchName = `${letter.toUpperCase()}${accidental}`; 47 | const pitchIndex = PITCH_INDEXES[pitchName]; 48 | if (pitchIndex == null) { 49 | throw Error('Invalid note argument'); 50 | } 51 | return MIDI_NUMBER_C0 + pitchIndex + NOTES_IN_OCTAVE * parseInt(octave, 10); 52 | } 53 | 54 | // 55 | // Build cache for getAttributes 56 | // 57 | function buildMidiNumberAttributes(midiNumber) { 58 | const pitchIndex = (midiNumber - MIDI_NUMBER_C0) % NOTES_IN_OCTAVE; 59 | const octave = Math.floor((midiNumber - MIDI_NUMBER_C0) / NOTES_IN_OCTAVE); 60 | const pitchName = SORTED_PITCHES[pitchIndex]; 61 | return { 62 | note: `${pitchName}${octave}`, 63 | pitchName, 64 | octave, 65 | midiNumber, 66 | isAccidental: ACCIDENTAL_PITCHES.includes(pitchName), 67 | }; 68 | } 69 | 70 | function buildMidiNumberAttributesCache() { 71 | return range(MIN_MIDI_NUMBER, MAX_MIDI_NUMBER + 1).reduce((cache, midiNumber) => { 72 | cache[midiNumber] = buildMidiNumberAttributes(midiNumber); 73 | return cache; 74 | }, {}); 75 | } 76 | 77 | const midiNumberAttributesCache = buildMidiNumberAttributesCache(); 78 | 79 | // Returns an object containing various attributes for a given MIDI number. 80 | // Throws error for invalid midiNumbers. 81 | function getAttributes(midiNumber) { 82 | const attrs = midiNumberAttributesCache[midiNumber]; 83 | if (!attrs) { 84 | throw Error('Invalid MIDI number'); 85 | } 86 | return attrs; 87 | } 88 | 89 | // Returns all MIDI numbers corresponding to natural notes, e.g. C and not C# or Bb. 90 | const NATURAL_MIDI_NUMBERS = range(MIN_MIDI_NUMBER, MAX_MIDI_NUMBER + 1).filter( 91 | (midiNumber) => !getAttributes(midiNumber).isAccidental, 92 | ); 93 | 94 | export default { 95 | fromNote, 96 | getAttributes, 97 | MIN_MIDI_NUMBER, 98 | MAX_MIDI_NUMBER, 99 | NATURAL_MIDI_NUMBERS, 100 | }; 101 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 3.1.3 (July 21, 2019) 2 | 3 | - Fix prevActiveNotes race condition, and don't add 'active' style to disabled piano keys [`#46`](https://github.com/kevinsqi/react-piano/pull/46) 4 | - Upgrade dependencies for security warnings [`#54`](https://github.com/kevinsqi/react-piano/pull/54) 5 | - Upgrade /demo dependencies (react, react-dom, react-scripts) [`#53`](https://github.com/kevinsqi/react-piano/pull/53) 6 | - Bump lodash from 4.17.10 to 4.17.13 in /demo [`#50`](https://github.com/kevinsqi/react-piano/pull/50) 7 | - Bump lodash.template from 4.4.0 to 4.5.0 in /demo [`#49`](https://github.com/kevinsqi/react-piano/pull/49) 8 | - Bump lodash from 4.17.10 to 4.17.15 [`#52`](https://github.com/kevinsqi/react-piano/pull/52) 9 | - Bump handlebars from 4.0.11 to 4.1.2 in /demo [`#48`](https://github.com/kevinsqi/react-piano/pull/48) 10 | - Bump handlebars from 4.0.11 to 4.1.2 [`#47`](https://github.com/kevinsqi/react-piano/pull/47) 11 | 12 | ## 3.1.2 (November 10, 2018) 13 | 14 | * Make Piano use activeNotes prop for initial state [#40] 15 | 16 | ## 3.1.1 (November 3, 2018) 17 | 18 | * Fix issue where Piano does not expand to container size when "width" prop is omitted [#38] 19 | 20 | ## 3.1.0 (September 30, 2018) 21 | 22 | * Fix activeNotes prop behavior, and add onPlayNoteInput/onStopNoteInput props [#37] 23 | 24 | ## 3.0.0 (September 23, 2018) 25 | 26 | Migration guide from 2.x.x: 27 | 28 | * Piano component's `playbackNotes` prop has been replaced with `activeNotes` [#36] 29 | * Rename Piano's `onPlayNote` prop to `playNote` [#36] 30 | * Rename Piano's `onStopNote` prop to `stopNote` [#36] 31 | * If you need to support IE, you may now need to polyfill `Array.find` [#30] 32 | 33 | Non-migratable changes: 34 | 35 | * Gliss behavior is modified so that clicking down on mouse outside the Piano component will not start a gliss - you have to click within the Piano element to start a gliss [#33] 36 | 37 | PRs: 38 | 39 | * Make Piano a controllable component, and export a ControlledPiano component [#36] 40 | * Only apply mouse/touch listeners on the piano component [#33] 41 | * Remove lodash utilities in favour of just/native [#30] 42 | * Use Rollup filesize plugin [#28] 43 | 44 | Thanks to @ritz078 for #30 and #33, and for making the suggestion for #28 and #36! 45 | 46 | ## 2.0.1 (September 8, 2018) 47 | 48 | * Use babel env target to compile ES6 features to ES5 to fix create-react-app prod build [#25] 49 | 50 | ## 2.0.0 (September 8, 2018) 51 | 52 | Migration guide from 1.x.x: 53 | 54 | * Import the styles with `import 'react-piano/dist/styles.css'` instead of `import 'react-piano/build/styles.css'` [#23] 55 | * If you customized the `renderNoteLabel` prop, you may need to adjust its behavior because it is now called on all keys, not just ones with keyboardShortcuts. See [this commit](https://github.com/kevinsqi/react-piano/pull/24/commits/822b66738e79909009ccea41b8a8f13554c7c01e) for more detail. 56 | 57 | PRs: 58 | 59 | * Call renderNoteLabel for all keys, even if it doesn't have a keyboardShortcut [#24] 60 | * Fix build size and replace webpack with rollup [#23] 61 | 62 | ## 1.1.0 (July 27, 2018) 63 | 64 | * Add className prop [#21] 65 | * Add enzyme tests [#20] 66 | 67 | ## 1.0.0 (July 20, 2018) 68 | 69 | First major release. 70 | -------------------------------------------------------------------------------- /demo/src/InteractiveDemo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Piano, KeyboardShortcuts, MidiNumbers } from 'react-piano'; 3 | import MdArrowDownward from 'react-icons/lib/md/arrow-downward'; 4 | 5 | import DimensionsProvider from './DimensionsProvider'; 6 | import InstrumentListProvider from './InstrumentListProvider'; 7 | import SoundfontProvider from './SoundfontProvider'; 8 | import PianoConfig from './PianoConfig'; 9 | 10 | class InteractiveDemo extends React.Component { 11 | state = { 12 | config: { 13 | instrumentName: 'acoustic_grand_piano', 14 | noteRange: { 15 | first: MidiNumbers.fromNote('c3'), 16 | last: MidiNumbers.fromNote('f5'), 17 | }, 18 | keyboardShortcutOffset: 0, 19 | }, 20 | }; 21 | 22 | render() { 23 | const keyboardShortcuts = KeyboardShortcuts.create({ 24 | firstNote: this.state.config.noteRange.first + this.state.config.keyboardShortcutOffset, 25 | lastNote: this.state.config.noteRange.last + this.state.config.keyboardShortcutOffset, 26 | keyboardConfig: KeyboardShortcuts.HOME_ROW, 27 | }); 28 | 29 | return ( 30 | ( 35 |
36 |
37 |

Try it by clicking, tapping, or using your keyboard:

38 |
39 | 40 |
41 |
42 |
43 | 44 | {({ containerWidth }) => ( 45 | 53 | )} 54 | 55 |
56 |
57 |
58 | ( 61 | { 64 | this.setState({ 65 | config: Object.assign({}, this.state.config, config), 66 | }); 67 | stopAllNotes(); 68 | }} 69 | instrumentList={instrumentList || [this.state.config.instrumentName]} 70 | keyboardShortcuts={keyboardShortcuts} 71 | /> 72 | )} 73 | /> 74 |
75 |
76 |
77 | )} 78 | /> 79 | ); 80 | } 81 | } 82 | 83 | export default InteractiveDemo; 84 | -------------------------------------------------------------------------------- /src/Key.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | 5 | import MidiNumbers from './MidiNumbers'; 6 | 7 | class Key extends React.Component { 8 | static propTypes = { 9 | midiNumber: PropTypes.number.isRequired, 10 | naturalKeyWidth: PropTypes.number.isRequired, // Width as a ratio between 0 and 1 11 | gliss: PropTypes.bool.isRequired, 12 | useTouchEvents: PropTypes.bool.isRequired, 13 | accidental: PropTypes.bool.isRequired, 14 | active: PropTypes.bool.isRequired, 15 | disabled: PropTypes.bool.isRequired, 16 | onPlayNoteInput: PropTypes.func.isRequired, 17 | onStopNoteInput: PropTypes.func.isRequired, 18 | accidentalWidthRatio: PropTypes.number.isRequired, 19 | pitchPositions: PropTypes.object.isRequired, 20 | children: PropTypes.node, 21 | }; 22 | 23 | static defaultProps = { 24 | accidentalWidthRatio: 0.65, 25 | pitchPositions: { 26 | C: 0, 27 | Db: 0.55, 28 | D: 1, 29 | Eb: 1.8, 30 | E: 2, 31 | F: 3, 32 | Gb: 3.5, 33 | G: 4, 34 | Ab: 4.7, 35 | A: 5, 36 | Bb: 5.85, 37 | B: 6, 38 | }, 39 | }; 40 | 41 | onPlayNoteInput = () => { 42 | this.props.onPlayNoteInput(this.props.midiNumber); 43 | }; 44 | 45 | onStopNoteInput = () => { 46 | this.props.onStopNoteInput(this.props.midiNumber); 47 | }; 48 | 49 | // Key position is represented by the number of natural key widths from the left 50 | getAbsoluteKeyPosition(midiNumber) { 51 | const OCTAVE_WIDTH = 7; 52 | const { octave, pitchName } = MidiNumbers.getAttributes(midiNumber); 53 | const pitchPosition = this.props.pitchPositions[pitchName]; 54 | const octavePosition = OCTAVE_WIDTH * octave; 55 | return pitchPosition + octavePosition; 56 | } 57 | 58 | getRelativeKeyPosition(midiNumber) { 59 | return ( 60 | this.getAbsoluteKeyPosition(midiNumber) - 61 | this.getAbsoluteKeyPosition(this.props.noteRange.first) 62 | ); 63 | } 64 | 65 | render() { 66 | const { 67 | naturalKeyWidth, 68 | accidentalWidthRatio, 69 | midiNumber, 70 | gliss, 71 | useTouchEvents, 72 | accidental, 73 | active, 74 | disabled, 75 | children, 76 | } = this.props; 77 | 78 | // Need to conditionally include/exclude handlers based on useTouchEvents, 79 | // because otherwise mobile taps double fire events. 80 | return ( 81 |
102 |
{children}
103 |
104 | ); 105 | } 106 | } 107 | 108 | function ratioToPercentage(ratio) { 109 | return `${ratio * 100}%`; 110 | } 111 | 112 | export default Key; 113 | -------------------------------------------------------------------------------- /demo/src/SoundfontProvider.js: -------------------------------------------------------------------------------- 1 | // See https://github.com/danigb/soundfont-player 2 | // for more documentation on prop options. 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import Soundfont from 'soundfont-player'; 6 | 7 | class SoundfontProvider extends React.Component { 8 | static propTypes = { 9 | instrumentName: PropTypes.string.isRequired, 10 | hostname: PropTypes.string.isRequired, 11 | format: PropTypes.oneOf(['mp3', 'ogg']), 12 | soundfont: PropTypes.oneOf(['MusyngKite', 'FluidR3_GM']), 13 | audioContext: PropTypes.instanceOf(window.AudioContext), 14 | onLoad: PropTypes.func, 15 | render: PropTypes.func, 16 | }; 17 | 18 | static defaultProps = { 19 | format: 'mp3', 20 | soundfont: 'MusyngKite', 21 | instrumentName: 'acoustic_grand_piano', 22 | }; 23 | 24 | constructor(props) { 25 | super(props); 26 | this.state = { 27 | activeAudioNodes: {}, 28 | instrument: null, 29 | }; 30 | } 31 | 32 | componentDidMount() { 33 | this.loadInstrument(this.props.instrumentName); 34 | } 35 | 36 | componentDidUpdate(prevProps, prevState) { 37 | if (prevProps.instrumentName !== this.props.instrumentName) { 38 | this.loadInstrument(this.props.instrumentName); 39 | } 40 | 41 | if (prevState.instrument !== this.state.instrument) { 42 | if (!this.props.onLoad) { 43 | return; 44 | } 45 | this.props.onLoad({ 46 | playNote: this.playNote, 47 | stopNote: this.stopNote, 48 | stopAllNotes: this.stopAllNotes, 49 | }); 50 | } 51 | } 52 | 53 | loadInstrument = (instrumentName) => { 54 | // Re-trigger loading state 55 | this.setState({ 56 | instrument: null, 57 | }); 58 | Soundfont.instrument(this.props.audioContext, instrumentName, { 59 | format: this.props.format, 60 | soundfont: this.props.soundfont, 61 | nameToUrl: (name, soundfont, format) => { 62 | return `${this.props.hostname}/${soundfont}/${name}-${format}.js`; 63 | }, 64 | }).then((instrument) => { 65 | this.setState({ 66 | instrument, 67 | }); 68 | }); 69 | }; 70 | 71 | playNote = (midiNumber) => { 72 | this.resumeAudio().then(() => { 73 | const audioNode = this.state.instrument.play(midiNumber); 74 | this.setState({ 75 | activeAudioNodes: Object.assign({}, this.state.activeAudioNodes, { 76 | [midiNumber]: audioNode, 77 | }), 78 | }); 79 | }); 80 | }; 81 | 82 | stopNote = (midiNumber) => { 83 | this.resumeAudio().then(() => { 84 | if (!this.state.activeAudioNodes[midiNumber]) { 85 | return; 86 | } 87 | const audioNode = this.state.activeAudioNodes[midiNumber]; 88 | audioNode.stop(); 89 | this.setState({ 90 | activeAudioNodes: Object.assign({}, this.state.activeAudioNodes, { [midiNumber]: null }), 91 | }); 92 | }); 93 | }; 94 | 95 | resumeAudio = () => { 96 | if (this.props.audioContext.state === 'suspended') { 97 | return this.props.audioContext.resume(); 98 | } else { 99 | return Promise.resolve(); 100 | } 101 | }; 102 | 103 | // Clear any residual notes that don't get called with stopNote 104 | stopAllNotes = () => { 105 | this.props.audioContext.resume().then(() => { 106 | const activeAudioNodes = Object.values(this.state.activeAudioNodes); 107 | activeAudioNodes.forEach((node) => { 108 | if (node) { 109 | node.stop(); 110 | } 111 | }); 112 | this.setState({ 113 | activeAudioNodes: {}, 114 | }); 115 | }); 116 | }; 117 | 118 | render() { 119 | return this.props.render 120 | ? this.props.render({ 121 | isLoading: !this.state.instrument, 122 | playNote: this.playNote, 123 | stopNote: this.stopNote, 124 | stopAllNotes: this.stopAllNotes, 125 | }) 126 | : null; 127 | } 128 | } 129 | 130 | export default SoundfontProvider; 131 | -------------------------------------------------------------------------------- /src/Keyboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import range from 'just-range'; 4 | import classNames from 'classnames'; 5 | 6 | import Key from './Key'; 7 | import MidiNumbers from './MidiNumbers'; 8 | 9 | class Keyboard extends React.Component { 10 | static propTypes = { 11 | noteRange: noteRangePropType, 12 | activeNotes: PropTypes.arrayOf(PropTypes.number), 13 | onPlayNoteInput: PropTypes.func.isRequired, 14 | onStopNoteInput: PropTypes.func.isRequired, 15 | renderNoteLabel: PropTypes.func.isRequired, 16 | keyWidthToHeight: PropTypes.number.isRequired, 17 | className: PropTypes.string, 18 | disabled: PropTypes.bool, 19 | gliss: PropTypes.bool, 20 | useTouchEvents: PropTypes.bool, 21 | // If width is not provided, must have fixed width and height in parent container 22 | width: PropTypes.number, 23 | }; 24 | 25 | static defaultProps = { 26 | disabled: false, 27 | gliss: false, 28 | useTouchEvents: false, 29 | keyWidthToHeight: 0.33, 30 | renderNoteLabel: () => {}, 31 | }; 32 | 33 | // Range of midi numbers on keyboard 34 | getMidiNumbers() { 35 | return range(this.props.noteRange.first, this.props.noteRange.last + 1); 36 | } 37 | 38 | getNaturalKeyCount() { 39 | return this.getMidiNumbers().filter((number) => { 40 | const { isAccidental } = MidiNumbers.getAttributes(number); 41 | return !isAccidental; 42 | }).length; 43 | } 44 | 45 | // Returns a ratio between 0 and 1 46 | getNaturalKeyWidth() { 47 | return 1 / this.getNaturalKeyCount(); 48 | } 49 | 50 | getWidth() { 51 | return this.props.width ? this.props.width : '100%'; 52 | } 53 | 54 | getHeight() { 55 | if (!this.props.width) { 56 | return '100%'; 57 | } 58 | const keyWidth = this.props.width * this.getNaturalKeyWidth(); 59 | return `${keyWidth / this.props.keyWidthToHeight}px`; 60 | } 61 | 62 | render() { 63 | const naturalKeyWidth = this.getNaturalKeyWidth(); 64 | return ( 65 |
69 | {this.getMidiNumbers().map((midiNumber) => { 70 | const { note, isAccidental } = MidiNumbers.getAttributes(midiNumber); 71 | const isActive = !this.props.disabled && this.props.activeNotes.includes(midiNumber); 72 | return ( 73 | 86 | {this.props.disabled 87 | ? null 88 | : this.props.renderNoteLabel({ 89 | isActive, 90 | isAccidental, 91 | midiNumber, 92 | })} 93 | 94 | ); 95 | })} 96 |
97 | ); 98 | } 99 | } 100 | 101 | function isNaturalMidiNumber(value) { 102 | if (typeof value !== 'number') { 103 | return false; 104 | } 105 | return MidiNumbers.NATURAL_MIDI_NUMBERS.includes(value); 106 | } 107 | 108 | function noteRangePropType(props, propName, componentName) { 109 | const { first, last } = props[propName]; 110 | if (!first || !last) { 111 | return new Error( 112 | `Invalid prop ${propName} supplied to ${componentName}. ${propName} must be an object with .first and .last values.`, 113 | ); 114 | } 115 | if (!isNaturalMidiNumber(first) || !isNaturalMidiNumber(last)) { 116 | return new Error( 117 | `Invalid prop ${propName} supplied to ${componentName}. ${propName} values must be valid MIDI numbers, and should not be accidentals (sharp or flat notes).`, 118 | ); 119 | } 120 | if (first >= last) { 121 | return new Error( 122 | `Invalid prop ${propName} supplied to ${componentName}. ${propName}.first must be smaller than ${propName}.last.`, 123 | ); 124 | } 125 | } 126 | 127 | export default Keyboard; 128 | -------------------------------------------------------------------------------- /demo/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/), 17 | ); 18 | 19 | export default function register() { 20 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 21 | // The URL constructor is available in all browsers that support SW. 22 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 23 | if (publicUrl.origin !== window.location.origin) { 24 | // Our service worker won't work if PUBLIC_URL is on a different origin 25 | // from what our page is served on. This might happen if a CDN is used to 26 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 27 | return; 28 | } 29 | 30 | window.addEventListener('load', () => { 31 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 32 | 33 | if (isLocalhost) { 34 | // This is running on localhost. Lets check if a service worker still exists or not. 35 | checkValidServiceWorker(swUrl); 36 | 37 | // Add some additional logging to localhost, pointing developers to the 38 | // service worker/PWA documentation. 39 | navigator.serviceWorker.ready.then(() => { 40 | console.log( 41 | 'This web app is being served cache-first by a service ' + 42 | 'worker. To learn more, visit https://goo.gl/SC7cgQ', 43 | ); 44 | }); 45 | } else { 46 | // Is not local host. Just register service worker 47 | registerValidSW(swUrl); 48 | } 49 | }); 50 | } 51 | } 52 | 53 | function registerValidSW(swUrl) { 54 | navigator.serviceWorker 55 | .register(swUrl) 56 | .then((registration) => { 57 | registration.onupdatefound = () => { 58 | const installingWorker = registration.installing; 59 | installingWorker.onstatechange = () => { 60 | if (installingWorker.state === 'installed') { 61 | if (navigator.serviceWorker.controller) { 62 | // At this point, the old content will have been purged and 63 | // the fresh content will have been added to the cache. 64 | // It's the perfect time to display a "New content is 65 | // available; please refresh." message in your web app. 66 | console.log('New content is available; please refresh.'); 67 | } else { 68 | // At this point, everything has been precached. 69 | // It's the perfect time to display a 70 | // "Content is cached for offline use." message. 71 | console.log('Content is cached for offline use.'); 72 | } 73 | } 74 | }; 75 | }; 76 | }) 77 | .catch((error) => { 78 | console.error('Error during service worker registration:', error); 79 | }); 80 | } 81 | 82 | function checkValidServiceWorker(swUrl) { 83 | // Check if the service worker can be found. If it can't reload the page. 84 | fetch(swUrl) 85 | .then((response) => { 86 | // Ensure service worker exists, and that we really are getting a JS file. 87 | if ( 88 | response.status === 404 || 89 | response.headers.get('content-type').indexOf('javascript') === -1 90 | ) { 91 | // No service worker found. Probably a different app. Reload the page. 92 | navigator.serviceWorker.ready.then((registration) => { 93 | registration.unregister().then(() => { 94 | window.location.reload(); 95 | }); 96 | }); 97 | } else { 98 | // Service worker found. Proceed as normal. 99 | registerValidSW(swUrl); 100 | } 101 | }) 102 | .catch(() => { 103 | console.log('No internet connection found. App is running in offline mode.'); 104 | }); 105 | } 106 | 107 | export function unregister() { 108 | if ('serviceWorker' in navigator) { 109 | navigator.serviceWorker.ready.then((registration) => { 110 | registration.unregister(); 111 | }); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /demo/src/PianoConfig.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MidiNumbers } from 'react-piano'; 3 | 4 | class AutoblurSelect extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | this.selectRef = React.createRef(); 8 | } 9 | 10 | onChange = (event) => { 11 | this.props.onChange(event); 12 | this.selectRef.current.blur(); 13 | }; 14 | 15 | render() { 16 | const { children, onChange, ...otherProps } = this.props; 17 | return ( 18 | 21 | ); 22 | } 23 | } 24 | 25 | function Label(props) { 26 | return {props.children}; 27 | } 28 | 29 | class PianoConfig extends React.Component { 30 | componentDidMount() { 31 | window.addEventListener('keydown', this.handleKeyDown); 32 | } 33 | 34 | componentWillUnmount() { 35 | window.removeEventListener('keydown', this.handleKeyDown); 36 | } 37 | 38 | handleKeyDown = (event) => { 39 | const numNotes = this.props.config.noteRange.last - this.props.config.noteRange.first + 1; 40 | const minOffset = 0; 41 | const maxOffset = numNotes - this.props.keyboardShortcuts.length; 42 | if (event.key === 'ArrowLeft') { 43 | const reducedOffset = this.props.config.keyboardShortcutOffset - 1; 44 | if (reducedOffset >= minOffset) { 45 | this.props.setConfig({ 46 | keyboardShortcutOffset: reducedOffset, 47 | }); 48 | } 49 | } else if (event.key === 'ArrowRight') { 50 | const increasedOffset = this.props.config.keyboardShortcutOffset + 1; 51 | if (increasedOffset <= maxOffset) { 52 | this.props.setConfig({ 53 | keyboardShortcutOffset: increasedOffset, 54 | }); 55 | } 56 | } 57 | }; 58 | 59 | onChangeFirstNote = (event) => { 60 | this.props.setConfig({ 61 | noteRange: { 62 | first: parseInt(event.target.value, 10), 63 | last: this.props.config.noteRange.last, 64 | }, 65 | }); 66 | }; 67 | 68 | onChangeLastNote = (event) => { 69 | this.props.setConfig({ 70 | noteRange: { 71 | first: this.props.config.noteRange.first, 72 | last: parseInt(event.target.value, 10), 73 | }, 74 | }); 75 | }; 76 | 77 | onChangeInstrument = (event) => { 78 | this.props.setConfig({ 79 | instrumentName: event.target.value, 80 | }); 81 | }; 82 | 83 | render() { 84 | const midiNumbersToNotes = MidiNumbers.NATURAL_MIDI_NUMBERS.reduce((obj, midiNumber) => { 85 | obj[midiNumber] = MidiNumbers.getAttributes(midiNumber).note; 86 | return obj; 87 | }, {}); 88 | const { noteRange, instrumentName } = this.props.config; 89 | 90 | return ( 91 |
92 |
93 | 94 | 99 | {MidiNumbers.NATURAL_MIDI_NUMBERS.map((midiNumber) => ( 100 | 103 | ))} 104 | 105 |
106 |
107 | 108 | 113 | {MidiNumbers.NATURAL_MIDI_NUMBERS.map((midiNumber) => ( 114 | 117 | ))} 118 | 119 |
120 |
121 | 122 | 127 | {this.props.instrumentList.map((value) => ( 128 | 131 | ))} 132 | 133 |
134 |
135 | 136 | Use left arrow and right arrow to move the keyboard 137 | shortcuts around. 138 | 139 |
140 |
141 | ); 142 | } 143 | } 144 | 145 | export default PianoConfig; 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-piano 2 | 3 | [![npm version](https://img.shields.io/npm/v/react-piano.svg)](https://www.npmjs.com/package/react-piano) 4 | [![build status](https://travis-ci.com/kevinsqi/react-piano.svg?branch=master)](https://travis-ci.com/kevinsqi/react-piano) 5 | [![bundle size](https://img.shields.io/bundlephobia/min/react-piano.svg)](https://bundlephobia.com/result?p=react-piano) 6 | 7 | An interactive piano keyboard for React. Supports custom sounds, touch/click/keyboard events, and fully configurable styling. [**Try it out on CodeSandbox**](https://codesandbox.io/s/7wq15pm1n1). 8 | 9 | react-piano screenshot 10 | 11 | ## Installing 12 | 13 | ``` 14 | yarn add react-piano 15 | ``` 16 | 17 | Alternatively, you can download the UMD build from [unpkg](https://unpkg.com/react-piano). 18 | 19 | ## Usage 20 | 21 | You can view or fork the [**CodeSandbox demo**](https://codesandbox.io/s/7wq15pm1n1) to get a live version of the component in action. 22 | 23 | Import the component and styles: 24 | 25 | ```jsx 26 | import { Piano, KeyboardShortcuts, MidiNumbers } from 'react-piano'; 27 | import 'react-piano/dist/styles.css'; 28 | ``` 29 | 30 | Importing CSS requires a CSS loader (if you're using create-react-app, this is already set up for you). If you don't have a CSS loader, you can alternatively copy the CSS file into your project from [src/styles.css](src/styles.css). 31 | 32 | Then to use the component: 33 | 34 | ```jsx 35 | function App() { 36 | const firstNote = MidiNumbers.fromNote('c3'); 37 | const lastNote = MidiNumbers.fromNote('f5'); 38 | const keyboardShortcuts = KeyboardShortcuts.create({ 39 | firstNote: firstNote, 40 | lastNote: lastNote, 41 | keyboardConfig: KeyboardShortcuts.HOME_ROW, 42 | }); 43 | 44 | return ( 45 | { 48 | // Play a given note - see notes below 49 | }} 50 | stopNote={(midiNumber) => { 51 | // Stop playing a given note - see notes below 52 | }} 53 | width={1000} 54 | keyboardShortcuts={keyboardShortcuts} 55 | /> 56 | ); 57 | } 58 | ``` 59 | 60 | ## Implementing audio playback 61 | 62 | react-piano does not implement audio playback of each note, so you have to implement it with `playNote` and `stopNote` props. This gives you the ability to use any sounds you'd like with the rendered piano. The [react-piano demo page](https://www.kevinqi.com/react-piano/) uses @danigb's excellent [soundfont-player](https://github.com/danigb/soundfont-player) to play realistic-sounding soundfont samples. Take a look at the [**CodeSandbox demo**](https://codesandbox.io/s/7wq15pm1n1) to see how you can implement that yourself. 63 | 64 | ## Props 65 | 66 | | Name | Type | Description | 67 | | ---- | ---- | ----------- | 68 | | `noteRange` | **Required** object | An object with format `{ first: 48, last: 77 }` where first and last are MIDI numbers that correspond to natural notes. You can use `MidiNumbers.NATURAL_MIDI_NUMBERS` to identify whether a number is a natural note or not. | 69 | | `playNote` | **Required** function | `(midiNumber) => void` function to play a note specified by MIDI number. | 70 | | `stopNote` | **Required** function | `(midiNumber) => void` function to stop playing a note. | 71 | | `width` | **Conditionally required** number | Width in pixels of the component. While this is not strictly required, if you omit it, the container around the `` will need to have an explicit width and height in order to render correctly. | 72 | | `activeNotes` | Array of numbers | An array of MIDI numbers, e.g. `[44, 47, 54]`, which allows you to programmatically play notes on the piano. | 73 | | `keyWidthToHeight` | Number | Ratio of key width to height. Used to specify the dimensions of the piano key. | 74 | | `renderNoteLabel` | Function | `({ keyboardShortcut, midiNumber, isActive, isAccidental }) => node` function to render a label on piano keys that have keyboard shortcuts | 75 | | `className` | String | A className to add to the component. | 76 | | `disabled` | Boolean | Whether to show disabled state. Useful when audio sounds need to be asynchronously loaded. | 77 | | `keyboardShortcuts` | Array of object | An array of form `[{ key: 'a', midiNumber: 48 }, ...]`, where `key` is a `keyEvent.key` value. You can generate this using `KeyboardShortcuts.create`, or use your own method to generate it. You can omit it if you don't want to use keyboard shortcuts. **Note:** this shouldn't be generated inline in JSX because it can cause problems when diffing for shortcut changes. | 78 | | `onPlayNoteInput` | Function | `(midiNumber, { prevActiveNotes }) => void` function that fires whenever a play-note event is fired. Can use `prevActiveNotes` to record notes. | 79 | | `onStopNoteInput` | Function | `(midiNumber, { prevActiveNotes }) => void` function that fires whenever a stop-note event is fired. Can use `prevActiveNotes` to record notes. | 80 | 81 | ## Recording/saving notes 82 | 83 | You can "record" notes that are played on a `` by using `onPlayNoteInput` or `onStopNoteInput`, and you can then play back the recording by using `activeNotes`. See [this CodeSandbox](https://codesandbox.io/s/l4jjvzmp47) which demonstrates how to set that up. 84 | 85 | demo of recording 86 | 87 | ## Customizing styles 88 | 89 | You can customize many aspects of the piano using CSS. In javascript, you can override the base styles by creating your own set of overrides: 90 | 91 | ```javascript 92 | import 'react-piano/dist/styles.css'; 93 | import './customPianoStyles.css'; // import a set of overrides 94 | ``` 95 | 96 | In the CSS file you can do things like: 97 | 98 | ```css 99 | .ReactPiano__Key--active { 100 | background: #f00; /* Change the default active key color to bright red */ 101 | } 102 | 103 | .ReactPiano__Key--accidental { 104 | background: #000; /* Change accidental keys to be completely black */ 105 | } 106 | ``` 107 | 108 | See [styles.css](/src/styles.css) for more detail on what styles can be customized. 109 | 110 | ## Upgrading versions 111 | 112 | See the [CHANGELOG](CHANGELOG.md) which contains migration guides for instructions on upgrading to each major version. 113 | 114 | ## Browser compatibility 115 | 116 | To support IE, you'll need to provide an `Array.find` polyfill. 117 | 118 | ## License 119 | 120 | MIT 121 | -------------------------------------------------------------------------------- /src/ControlledPiano.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import difference from 'lodash.difference'; 5 | import Keyboard from './Keyboard'; 6 | 7 | class ControlledPiano extends React.Component { 8 | static propTypes = { 9 | noteRange: PropTypes.object.isRequired, 10 | activeNotes: PropTypes.arrayOf(PropTypes.number.isRequired).isRequired, 11 | playNote: PropTypes.func.isRequired, 12 | stopNote: PropTypes.func.isRequired, 13 | onPlayNoteInput: PropTypes.func.isRequired, 14 | onStopNoteInput: PropTypes.func.isRequired, 15 | renderNoteLabel: PropTypes.func.isRequired, 16 | className: PropTypes.string, 17 | disabled: PropTypes.bool, 18 | width: PropTypes.number, 19 | keyWidthToHeight: PropTypes.number, 20 | keyboardShortcuts: PropTypes.arrayOf( 21 | PropTypes.shape({ 22 | key: PropTypes.string.isRequired, 23 | midiNumber: PropTypes.number.isRequired, 24 | }), 25 | ), 26 | }; 27 | 28 | static defaultProps = { 29 | renderNoteLabel: ({ keyboardShortcut, midiNumber, isActive, isAccidental }) => 30 | keyboardShortcut ? ( 31 |
38 | {keyboardShortcut} 39 |
40 | ) : null, 41 | }; 42 | 43 | state = { 44 | isMouseDown: false, 45 | useTouchEvents: false, 46 | }; 47 | 48 | componentDidMount() { 49 | window.addEventListener('keydown', this.onKeyDown); 50 | window.addEventListener('keyup', this.onKeyUp); 51 | } 52 | 53 | componentWillUnmount() { 54 | window.removeEventListener('keydown', this.onKeyDown); 55 | window.removeEventListener('keyup', this.onKeyUp); 56 | } 57 | 58 | componentDidUpdate(prevProps, prevState) { 59 | if (this.props.activeNotes !== prevProps.activeNotes) { 60 | this.handleNoteChanges({ 61 | prevActiveNotes: prevProps.activeNotes || [], 62 | nextActiveNotes: this.props.activeNotes || [], 63 | }); 64 | } 65 | } 66 | 67 | // This function is responsible for diff'ing activeNotes 68 | // and playing or stopping notes accordingly. 69 | handleNoteChanges = ({ prevActiveNotes, nextActiveNotes }) => { 70 | if (this.props.disabled) { 71 | return; 72 | } 73 | const notesStopped = difference(prevActiveNotes, nextActiveNotes); 74 | const notesStarted = difference(nextActiveNotes, prevActiveNotes); 75 | notesStarted.forEach((midiNumber) => { 76 | this.props.playNote(midiNumber); 77 | }); 78 | notesStopped.forEach((midiNumber) => { 79 | this.props.stopNote(midiNumber); 80 | }); 81 | }; 82 | 83 | getMidiNumberForKey = (key) => { 84 | if (!this.props.keyboardShortcuts) { 85 | return null; 86 | } 87 | const shortcut = this.props.keyboardShortcuts.find((sh) => sh.key === key); 88 | return shortcut && shortcut.midiNumber; 89 | }; 90 | 91 | getKeyForMidiNumber = (midiNumber) => { 92 | if (!this.props.keyboardShortcuts) { 93 | return null; 94 | } 95 | const shortcut = this.props.keyboardShortcuts.find((sh) => sh.midiNumber === midiNumber); 96 | return shortcut && shortcut.key; 97 | }; 98 | 99 | onKeyDown = (event) => { 100 | // Don't conflict with existing combinations like ctrl + t 101 | if (event.ctrlKey || event.metaKey || event.shiftKey) { 102 | return; 103 | } 104 | const midiNumber = this.getMidiNumberForKey(event.key); 105 | if (midiNumber) { 106 | this.onPlayNoteInput(midiNumber); 107 | } 108 | }; 109 | 110 | onKeyUp = (event) => { 111 | // This *should* also check for event.ctrlKey || event.metaKey || event.ShiftKey like onKeyDown does, 112 | // but at least on Mac Chrome, when mashing down many alphanumeric keystrokes at once, 113 | // ctrlKey is fired unexpectedly, which would cause onStopNote to NOT be fired, which causes problematic 114 | // lingering notes. Since it's fairly safe to call onStopNote even when not necessary, 115 | // the ctrl/meta/shift check is removed to fix that issue. 116 | const midiNumber = this.getMidiNumberForKey(event.key); 117 | if (midiNumber) { 118 | this.onStopNoteInput(midiNumber); 119 | } 120 | }; 121 | 122 | onPlayNoteInput = (midiNumber) => { 123 | if (this.props.disabled) { 124 | return; 125 | } 126 | // Pass in previous activeNotes for recording functionality 127 | this.props.onPlayNoteInput(midiNumber, this.props.activeNotes); 128 | }; 129 | 130 | onStopNoteInput = (midiNumber) => { 131 | if (this.props.disabled) { 132 | return; 133 | } 134 | // Pass in previous activeNotes for recording functionality 135 | this.props.onStopNoteInput(midiNumber, this.props.activeNotes); 136 | }; 137 | 138 | onMouseDown = () => { 139 | this.setState({ 140 | isMouseDown: true, 141 | }); 142 | }; 143 | 144 | onMouseUp = () => { 145 | this.setState({ 146 | isMouseDown: false, 147 | }); 148 | }; 149 | 150 | onTouchStart = () => { 151 | this.setState({ 152 | useTouchEvents: true, 153 | }); 154 | }; 155 | 156 | renderNoteLabel = ({ midiNumber, isActive, isAccidental }) => { 157 | const keyboardShortcut = this.getKeyForMidiNumber(midiNumber); 158 | return this.props.renderNoteLabel({ keyboardShortcut, midiNumber, isActive, isAccidental }); 159 | }; 160 | 161 | render() { 162 | return ( 163 |
170 | 183 |
184 | ); 185 | } 186 | } 187 | 188 | export default ControlledPiano; 189 | -------------------------------------------------------------------------------- /src/Piano.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import Piano from './Piano'; 5 | import MidiNumbers from './MidiNumbers'; 6 | 7 | let eventListenerCallbacks; 8 | let spyConsoleError; 9 | 10 | describe('', () => { 11 | beforeEach(() => { 12 | // For asserting that proptype validation happens through console.error. 13 | // mockImplementation prevents the actual console.error from appearing in test output. 14 | spyConsoleError = jest.spyOn(global.console, 'error').mockImplementation(() => {}); 15 | 16 | // document.addEventListener is not triggered by .simulate() 17 | // https://github.com/airbnb/enzyme/issues/426 18 | eventListenerCallbacks = {}; 19 | window.addEventListener = jest.fn((event, callback) => { 20 | eventListenerCallbacks[event] = callback; 21 | }); 22 | }); 23 | 24 | afterEach(() => { 25 | spyConsoleError.mockRestore(); 26 | }); 27 | 28 | describe('noteRange', () => { 29 | test('requires natural midi numbers', () => { 30 | const wrapper = mount( 31 | {}} 34 | stopNote={() => {}} 35 | />, 36 | ); 37 | expect(spyConsoleError).toHaveBeenCalledWith( 38 | expect.stringContaining( 39 | 'noteRange values must be valid MIDI numbers, and should not be accidentals (sharp or flat notes)', 40 | ), 41 | ); 42 | }); 43 | test('requires note range to be in order', () => { 44 | const wrapper = mount( 45 | {}} 48 | stopNote={() => {}} 49 | />, 50 | ); 51 | expect(spyConsoleError).toHaveBeenCalledWith( 52 | expect.stringContaining('noteRange.first must be smaller than noteRange.last'), 53 | ); 54 | }); 55 | test('renders correct keys', () => { 56 | const wrapper = mount( 57 | {}} 60 | stopNote={() => {}} 61 | />, 62 | ); 63 | expect(spyConsoleError).not.toHaveBeenCalled(); 64 | expect(wrapper.find('.ReactPiano__Key').length).toBe(13); // Should have 12 + 1 keys 65 | expect(wrapper.find('.ReactPiano__Key--natural').length).toBe(8); // Should have 7 + 1 natural keys 66 | expect(wrapper.find('.ReactPiano__Key--accidental').length).toBe(5); 67 | }); 68 | }); 69 | 70 | describe('playNote and stopNote', () => { 71 | test('is fired upon mousedown and mouseup', () => { 72 | const mockPlayNote = jest.fn(); 73 | const mockStopNote = jest.fn(); 74 | const wrapper = mount( 75 | , 80 | ); 81 | 82 | wrapper 83 | .find('.ReactPiano__Key') 84 | .first() 85 | .simulate('mousedown'); 86 | 87 | expect(mockPlayNote).toHaveBeenCalledTimes(1); 88 | expect(mockStopNote).toHaveBeenCalledTimes(0); 89 | 90 | wrapper 91 | .find('.ReactPiano__Key') 92 | .first() 93 | .simulate('mouseup'); 94 | 95 | expect(mockPlayNote).toHaveBeenCalledTimes(1); 96 | expect(mockStopNote).toHaveBeenCalledTimes(1); 97 | }); 98 | test('is fired upon touchstart and touchend', () => { 99 | const mockPlayNote = jest.fn(); 100 | const mockStopNote = jest.fn(); 101 | const wrapper = mount( 102 | , 107 | ); 108 | 109 | // Simulate touch on container component to trigger useTouchEvents 110 | // (ideally this wouldn't need a separate touchStart to test) 111 | const container = wrapper.find('[data-testid="container"]').first(); 112 | container.simulate('touchStart'); 113 | 114 | // touchStart event on Key should play note 115 | const firstKey = wrapper.find('.ReactPiano__Key').first(); 116 | firstKey.simulate('touchStart'); 117 | expect(mockPlayNote).toHaveBeenCalledTimes(1); 118 | expect(mockStopNote).toHaveBeenCalledTimes(0); 119 | 120 | // touchEnd event on Key should stop note 121 | firstKey.simulate('touchend'); 122 | expect(mockPlayNote).toHaveBeenCalledTimes(1); 123 | expect(mockStopNote).toHaveBeenCalledTimes(1); 124 | }); 125 | }); 126 | 127 | describe('keyboardShortcuts', () => { 128 | test('key events trigger playNote and stopNote', () => { 129 | const firstNote = MidiNumbers.fromNote('c3'); 130 | const lastNote = MidiNumbers.fromNote('c4'); 131 | const mockPlayNote = jest.fn(); 132 | const mockStopNote = jest.fn(); 133 | const keyboardShortcuts = [{ key: 'a', midiNumber: MidiNumbers.fromNote('c3') }]; 134 | 135 | const wrapper = mount( 136 | , 142 | ); 143 | 144 | // Trigger window keydown with a mock event 145 | eventListenerCallbacks['keydown']({ 146 | key: 'a', 147 | }); 148 | 149 | expect(mockPlayNote).toHaveBeenCalledTimes(1); 150 | expect(mockStopNote).toHaveBeenCalledTimes(0); 151 | 152 | eventListenerCallbacks['keyup']({ 153 | key: 'a', 154 | }); 155 | 156 | expect(mockPlayNote).toHaveBeenCalledTimes(1); 157 | expect(mockStopNote).toHaveBeenCalledTimes(1); 158 | }); 159 | }); 160 | 161 | describe('disabled', () => { 162 | test('disables firing of playNote and stopNote', () => { 163 | const mockPlayNote = jest.fn(); 164 | const mockStopNote = jest.fn(); 165 | const wrapper = mount( 166 | , 172 | ); 173 | 174 | wrapper 175 | .find('.ReactPiano__Key') 176 | .first() 177 | .simulate('mousedown'); 178 | 179 | expect(mockPlayNote).toHaveBeenCalledTimes(0); 180 | 181 | wrapper 182 | .find('.ReactPiano__Key') 183 | .first() 184 | .simulate('mouseup'); 185 | 186 | expect(mockStopNote).toHaveBeenCalledTimes(0); 187 | }); 188 | 189 | test('renders disabled key state', () => { 190 | const wrapper = mount( 191 | {}} 194 | stopNote={() => {}} 195 | disabled 196 | />, 197 | ); 198 | 199 | expect(wrapper.find('.ReactPiano__Key--disabled').length).toBe(13); 200 | }); 201 | }); 202 | 203 | describe('className', () => { 204 | test('adds a className', () => { 205 | const wrapper = mount( 206 | {}} 209 | stopNote={() => {}} 210 | className="Hello" 211 | />, 212 | ); 213 | 214 | expect(wrapper.find('.ReactPiano__Keyboard').hasClass('Hello')).toBe(true); 215 | }); 216 | }); 217 | 218 | describe('renderNoteLabel', () => { 219 | test('default value has correct behavior', () => { 220 | const keyboardShortcuts = [{ key: 'a', midiNumber: MidiNumbers.fromNote('c3') }]; 221 | const wrapper = mount( 222 | {}} 225 | stopNote={() => {}} 226 | keyboardShortcuts={keyboardShortcuts} 227 | />, 228 | ); 229 | 230 | // First key should have label with correct classes 231 | const firstKey = wrapper.find('.ReactPiano__Key').at(0); 232 | expect(firstKey.find('.ReactPiano__NoteLabel').text()).toBe('a'); 233 | expect( 234 | firstKey.find('.ReactPiano__NoteLabel').hasClass('ReactPiano__NoteLabel--natural'), 235 | ).toBe(true); 236 | 237 | // Second key should not have label 238 | const secondKey = wrapper.find('.ReactPiano__Key').at(1); 239 | expect(secondKey.find('.ReactPiano__NoteLabel').exists()).toBe(false); 240 | }); 241 | test('works for keys with and without shortcuts', () => { 242 | const keyboardShortcuts = [{ key: 'a', midiNumber: MidiNumbers.fromNote('c3') }]; 243 | const wrapper = mount( 244 | {}} 247 | stopNote={() => {}} 248 | keyboardShortcuts={keyboardShortcuts} 249 | renderNoteLabel={({ keyboardShortcut, midiNumber, isActive, isAccidental }) => { 250 | return ( 251 |
252 |
{keyboardShortcut}
253 |
{midiNumber}
254 |
255 | ); 256 | }} 257 | />, 258 | ); 259 | 260 | // First key should have midinumber and keyboard shortcut labels 261 | const firstKey = wrapper.find('.ReactPiano__Key').at(0); 262 | expect(firstKey.find('.label-midiNumber').text()).toBe(`${MidiNumbers.fromNote('c3')}`); 263 | expect(firstKey.find('.label-keyboardShortcut').text()).toBe('a'); 264 | 265 | // Second key should only have midinumber label 266 | const secondKey = wrapper.find('.ReactPiano__Key').at(1); 267 | expect(secondKey.find('.label-midiNumber').text()).toBe(`${MidiNumbers.fromNote('db3')}`); 268 | expect(secondKey.find('.label-keyboardShortcut').text()).toBe(''); 269 | }); 270 | }); 271 | }); 272 | --------------------------------------------------------------------------------