├── .gitignore ├── screenshots ├── current.png ├── note_lock.gif ├── note_menu.gif ├── note_resize.gif ├── playmarker.gif ├── visualizer.gif ├── instrument_menu.gif └── sticky_toolbar.gif ├── notes ├── trumpeticonideas.png ├── pianorollbrowsernotes0.jpg ├── pianorollbrowsernotes1.jpg ├── pianorollbrowsernotes2.jpg ├── pianorollbrowsernotes3.jpg ├── todo.txt └── references.txt ├── example_presets ├── piano │ ├── piano-A2.ogg │ ├── piano-A3.ogg │ ├── piano-A4.ogg │ ├── piano-A5.ogg │ ├── piano-A6.ogg │ ├── piano-A7.ogg │ ├── piano-B2.ogg │ ├── piano-B3.ogg │ ├── piano-B4.ogg │ ├── piano-B5.ogg │ ├── piano-B6.ogg │ ├── piano-B7.ogg │ ├── piano-C2.ogg │ ├── piano-C3.ogg │ ├── piano-C4.ogg │ ├── piano-C5.ogg │ ├── piano-C6.ogg │ ├── piano-C7.ogg │ ├── piano-C8.ogg │ ├── piano-D2.ogg │ ├── piano-D3.ogg │ ├── piano-D4.ogg │ ├── piano-D5.ogg │ ├── piano-D6.ogg │ ├── piano-D7.ogg │ ├── piano-E2.ogg │ ├── piano-E3.ogg │ ├── piano-E4.ogg │ ├── piano-E5.ogg │ ├── piano-E6.ogg │ ├── piano-E7.ogg │ ├── piano-F2.ogg │ ├── piano-F3.ogg │ ├── piano-F4.ogg │ ├── piano-F5.ogg │ ├── piano-F6.ogg │ ├── piano-F7.ogg │ ├── piano-G2.ogg │ ├── piano-G3.ogg │ ├── piano-G4.ogg │ ├── piano-G5.ogg │ ├── piano-G6.ogg │ ├── piano-G7.ogg │ ├── piano-Ab2.ogg │ ├── piano-Ab3.ogg │ ├── piano-Ab4.ogg │ ├── piano-Ab5.ogg │ ├── piano-Ab6.ogg │ ├── piano-Ab7.ogg │ ├── piano-As2.ogg │ ├── piano-As3.ogg │ ├── piano-As4.ogg │ ├── piano-As5.ogg │ ├── piano-As6.ogg │ ├── piano-As7.ogg │ ├── piano-Bb2.ogg │ ├── piano-Bb3.ogg │ ├── piano-Bb4.ogg │ ├── piano-Bb5.ogg │ ├── piano-Bb6.ogg │ ├── piano-Bb7.ogg │ ├── piano-Cs2.ogg │ ├── piano-Cs3.ogg │ ├── piano-Cs4.ogg │ ├── piano-Cs5.ogg │ ├── piano-Cs6.ogg │ ├── piano-Cs7.ogg │ ├── piano-Ds2.ogg │ ├── piano-Ds3.ogg │ ├── piano-Ds4.ogg │ ├── piano-Ds5.ogg │ ├── piano-Ds6.ogg │ ├── piano-Ds7.ogg │ ├── piano-Eb2.ogg │ ├── piano-Eb3.ogg │ ├── piano-Eb4.ogg │ ├── piano-Eb5.ogg │ ├── piano-Eb6.ogg │ ├── piano-Eb7.ogg │ ├── piano-Fs2.ogg │ ├── piano-Fs3.ogg │ ├── piano-Fs4.ogg │ ├── piano-Fs5.ogg │ ├── piano-Fs6.ogg │ ├── piano-Fs7.ogg │ ├── piano-Gs2.ogg │ ├── piano-Gs3.ogg │ ├── piano-Gs4.ogg │ ├── piano-Gs5.ogg │ ├── piano-Gs6.ogg │ └── piano-Gs7.ogg ├── delaySine.json ├── belltone.json └── dissonant.json ├── db_stuff ├── views │ ├── forbidden.ejs │ ├── register.ejs │ ├── login.ejs │ ├── profile.ejs │ └── index.ejs ├── config │ ├── database.js │ └── passport.js ├── notes.txt ├── models │ └── user.js ├── server.js └── routes │ └── routes.js ├── server.js ├── tests ├── jsdom.js ├── testing-scenarios.txt ├── grid_builder - spec.js ├── classes - spec.js ├── functionality - spec.js └── dom_modification - spec.js ├── icons ├── recordButton.svg ├── playButton.svg ├── pauseButton.svg ├── stopButton.svg ├── autoscrollArrow.svg ├── playAllButton.svg ├── clearGrid.svg ├── toggleStickyToolbar.svg ├── addMeasure.svg ├── importInstrument.svg ├── removeMeasure.svg ├── saveProject.svg ├── importProject.svg ├── addInstrument.svg ├── pauseButton-old.svg ├── importButton.svg ├── saveProjectToDB.svg └── clearGridAlt.svg ├── eslint.config.mjs ├── .circleci └── config.yml ├── package.json ├── style.css ├── style.scss ├── main.js ├── cypress └── integration │ └── index.spec.js ├── src ├── visualizer.js ├── visualizerWorker.js ├── gridBuilder.js ├── instrumentPreset.js ├── mmpGenerator.js └── classes.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output -------------------------------------------------------------------------------- /screenshots/current.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/screenshots/current.png -------------------------------------------------------------------------------- /notes/trumpeticonideas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/notes/trumpeticonideas.png -------------------------------------------------------------------------------- /screenshots/note_lock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/screenshots/note_lock.gif -------------------------------------------------------------------------------- /screenshots/note_menu.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/screenshots/note_menu.gif -------------------------------------------------------------------------------- /screenshots/note_resize.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/screenshots/note_resize.gif -------------------------------------------------------------------------------- /screenshots/playmarker.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/screenshots/playmarker.gif -------------------------------------------------------------------------------- /screenshots/visualizer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/screenshots/visualizer.gif -------------------------------------------------------------------------------- /notes/pianorollbrowsernotes0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/notes/pianorollbrowsernotes0.jpg -------------------------------------------------------------------------------- /notes/pianorollbrowsernotes1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/notes/pianorollbrowsernotes1.jpg -------------------------------------------------------------------------------- /notes/pianorollbrowsernotes2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/notes/pianorollbrowsernotes2.jpg -------------------------------------------------------------------------------- /notes/pianorollbrowsernotes3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/notes/pianorollbrowsernotes3.jpg -------------------------------------------------------------------------------- /screenshots/instrument_menu.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/screenshots/instrument_menu.gif -------------------------------------------------------------------------------- /screenshots/sticky_toolbar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/screenshots/sticky_toolbar.gif -------------------------------------------------------------------------------- /example_presets/piano/piano-A2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-A2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-A3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-A3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-A4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-A4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-A5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-A5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-A6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-A6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-A7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-A7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-B2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-B2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-B3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-B3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-B4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-B4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-B5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-B5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-B6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-B6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-B7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-B7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-C2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-C2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-C3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-C3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-C4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-C4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-C5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-C5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-C6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-C6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-C7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-C7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-C8.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-C8.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-D2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-D2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-D3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-D3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-D4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-D4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-D5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-D5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-D6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-D6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-D7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-D7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-E2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-E2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-E3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-E3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-E4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-E4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-E5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-E5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-E6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-E6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-E7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-E7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-F2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-F2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-F3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-F3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-F4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-F4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-F5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-F5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-F6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-F6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-F7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-F7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-G2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-G2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-G3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-G3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-G4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-G4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-G5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-G5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-G6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-G6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-G7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-G7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ab2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ab2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ab3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ab3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ab4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ab4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ab5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ab5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ab6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ab6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ab7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ab7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-As2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-As2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-As3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-As3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-As4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-As4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-As5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-As5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-As6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-As6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-As7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-As7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Bb2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Bb2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Bb3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Bb3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Bb4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Bb4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Bb5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Bb5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Bb6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Bb6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Bb7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Bb7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Cs2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Cs2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Cs3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Cs3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Cs4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Cs4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Cs5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Cs5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Cs6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Cs6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Cs7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Cs7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ds2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ds2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ds3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ds3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ds4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ds4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ds5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ds5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ds6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ds6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Ds7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Ds7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Eb2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Eb2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Eb3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Eb3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Eb4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Eb4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Eb5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Eb5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Eb6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Eb6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Eb7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Eb7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Fs2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Fs2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Fs3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Fs3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Fs4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Fs4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Fs5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Fs5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Fs6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Fs6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Fs7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Fs7.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Gs2.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Gs2.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Gs3.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Gs3.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Gs4.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Gs4.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Gs5.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Gs5.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Gs6.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Gs6.ogg -------------------------------------------------------------------------------- /example_presets/piano/piano-Gs7.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syncopika/piano_roll_browser/HEAD/example_presets/piano/piano-Gs7.ogg -------------------------------------------------------------------------------- /db_stuff/views/forbidden.ejs: -------------------------------------------------------------------------------- 1 |
2 |

forbidden. sorry :(

3 |

try here instead maybe? https://github.com/syncopika/piano_roll_browser/

4 |
-------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const path = require('path'); 4 | const port = 3000; 5 | 6 | app.use(express.static((path.join(__dirname , "")))); 7 | 8 | app.listen(port, () => console.log("listening on port: " + port)); -------------------------------------------------------------------------------- /tests/jsdom.js: -------------------------------------------------------------------------------- 1 | // https://jasmine.github.io/tutorials/react_with_npm 2 | 3 | const jsdom = require('jsdom'); 4 | 5 | const dom = new jsdom.JSDOM(''); 6 | global.document = dom.window.document; 7 | global.window = dom.window; 8 | global.navigator = dom.window.navigator; 9 | 10 | -------------------------------------------------------------------------------- /db_stuff/config/database.js: -------------------------------------------------------------------------------- 1 | // configure the path to the MongoDB database 2 | module.exports = { 3 | // the url will look something like this but not exactly using a remote db, since there will also need to be a username and password given in the url 4 | // I'm using mLab btw 5 | 'url': 'mongodb://127.0.0.1:27017/pianorollapp' 6 | }; -------------------------------------------------------------------------------- /icons/recordButton.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /icons/playButton.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /icons/pauseButton.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /icons/stopButton.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /icons/autoscrollArrow.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /icons/playAllButton.svg: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // eslint.config.mjs 2 | // https://github.com/eslint/eslint/issues/17400 3 | 4 | import js from "@eslint/js"; 5 | import html from "@html-eslint/eslint-plugin"; 6 | import parser from "@html-eslint/parser"; 7 | 8 | export default [ 9 | { 10 | rules: { 11 | "semi": "error", 12 | "prefer-const": "error", 13 | "no-var": "error", 14 | "indent": ["error", 2], 15 | }, 16 | }, 17 | { 18 | ignores: [ 19 | "**/node_modules/**", 20 | "dist/**/*", 21 | ], 22 | }, 23 | { 24 | files: ["**/*.html"], 25 | plugins: { 26 | "@html-eslint": html, 27 | }, 28 | languageOptions: { 29 | parser, 30 | }, 31 | rules: { 32 | "@html-eslint/indent": ["error", 2], 33 | }, 34 | }, 35 | ]; -------------------------------------------------------------------------------- /tests/testing-scenarios.txt: -------------------------------------------------------------------------------- 1 | manual testing scenarios for piano-roll-browser 2 | 3 | notes 4 | - add a note 5 | - remove a note 6 | - change volume 7 | - change style 8 | - make sure yellow row highlight on mouseover spans the whole container, even when scrolled 9 | 10 | instruments 11 | - add an instrument 12 | - remove an instrument 13 | - mute an instrument 14 | - change an instrument's base volume 15 | - change an instrument's panning 16 | - change an instrument's notes' color 17 | - change an instrument's sound 18 | - rename an instrument 19 | 20 | instruments presets 21 | - add a new preset 22 | - make sure it's present in dropdown for instrument context menu 23 | 24 | projects 25 | - import a project 26 | - export a project 27 | 28 | toolbar 29 | - make sure all the buttons do what they're supposed to -------------------------------------------------------------------------------- /notes/todo.txt: -------------------------------------------------------------------------------- 1 | - need to spend some time checking the math for calculating start times (precision)? - watch out for drift 2 | 3 | - looping implementation was questionable and removed for now 4 | 5 | - https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/cancelAndHoldAtTime is not supported in Firefox and 6 | I'm using it :/ 7 | 8 | current things to do now: 9 | - some kind of visual signal for note volume? 10 | - use sliders for changing value of volume, pan instead of dropdown 11 | - improve percussion 12 | - no panning currently 13 | - the pianoNotes div (i.e. mobile piano bar) implementation could be cleaned up a bit maybe? 14 | - add metronome? is scheduler scheduling on time? 15 | - accessibility? 16 | - maybe make it at least a little mobile friendly, i.e. support also touch events? 17 | - refactor? things like play and stopPlay - should those be piano roll methods instead of standalone functions? -------------------------------------------------------------------------------- /db_stuff/notes.txt: -------------------------------------------------------------------------------- 1 | setting up the mongodb db 2 | 3 | - I'm using windows so after installing MongoDB community, follow "Run MongoDB Community Edition from the Command Interpreter" 4 | from https://docs.mongodb.com/manual/tutorial/install-mongodb-on-windows/ 5 | - some helpful commands: 6 | - show dbs 7 | - show collections 8 | - use 9 | - db..find() to show all documents in a collection 10 | - "C:\Program Files\MongoDB\Server\5.0\bin\mongod.exe" --dbpath="c:\data\db" to start up the DB 11 | 12 | - Since MongoDB creates a new database only when we add new documents/records, we don't have to do anything beforehand 13 | (see https://docs.mongodb.com/manual/core/databases-and-collections/) 14 | We can run the piano roll app now! :D 15 | 16 | 17 | // misc 18 | you'll need >= node v14 to be able to run the server (b/c of mongoose) 19 | https://stackoverflow.com/questions/55692084/what-is-the-difference-between-nodes-bodyparser-and-expresss-urlencoded-middle -------------------------------------------------------------------------------- /icons/clearGrid.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /icons/toggleStickyToolbar.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 27 | 42 | 43 | -------------------------------------------------------------------------------- /example_presets/delaySine.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "delaySine", 3 | "data": { 4 | "AudioDestinationNode": { 5 | "feedsFrom": [ 6 | "GainNode1" 7 | ], 8 | "feedsInto": [], 9 | "node": { 10 | "maxChannelCount": 2 11 | } 12 | }, 13 | "GainNode1": { 14 | "id": "GainNode1", 15 | "feedsFrom": [ 16 | "OscillatorNode1", 17 | "ADSREnvelope1" 18 | ], 19 | "feedsInto": [ 20 | "AudioDestinationNode" 21 | ], 22 | "node": { 23 | "gain": 0.30000001192092896 24 | } 25 | }, 26 | "OscillatorNode1": { 27 | "id": "OscillatorNode1", 28 | "feedsFrom": [], 29 | "feedsInto": [ 30 | "GainNode1" 31 | ], 32 | "node": { 33 | "type": "sine", 34 | "frequency": 440, 35 | "detune": 0 36 | } 37 | }, 38 | "ADSREnvelope1": { 39 | "id": "ADSREnvelope1", 40 | "feedsFrom": [], 41 | "feedsInto": [ 42 | "GainNode1" 43 | ], 44 | "node": { 45 | "attack": 0.15, 46 | "sustain": 0, 47 | "decay": 0, 48 | "release": 0, 49 | "sustainLevel": 0, 50 | "id": "ADSREnvelope1" 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: cimg/node:20.12.2 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/project 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: npm install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | # run lint 37 | - run: npm run lint 38 | 39 | # run tests 40 | - run: npm test -------------------------------------------------------------------------------- /icons/addMeasure.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /example_presets/belltone.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "belltone", 3 | "data": { 4 | "AudioDestinationNode": { 5 | "feedsFrom": [ 6 | "GainNode1" 7 | ], 8 | "feedsInto": [], 9 | "node": { 10 | "maxChannelCount": 2 11 | } 12 | }, 13 | "GainNode1": { 14 | "id": "GainNode1", 15 | "feedsFrom": [ 16 | "OscillatorNode1", 17 | "ADSREnvelope1" 18 | ], 19 | "feedsInto": [ 20 | "AudioDestinationNode" 21 | ], 22 | "node": { 23 | "gain": 0.11999999731779099 24 | } 25 | }, 26 | "OscillatorNode1": { 27 | "id": "OscillatorNode1", 28 | "feedsFrom": [], 29 | "feedsInto": [ 30 | "GainNode1" 31 | ], 32 | "node": { 33 | "type": "sine", 34 | "frequency": 440, 35 | "detune": 0 36 | } 37 | }, 38 | "ADSREnvelope1": { 39 | "id": "ADSREnvelope1", 40 | "feedsFrom": [], 41 | "feedsInto": [ 42 | "GainNode1" 43 | ], 44 | "node": { 45 | "attack": 0, 46 | "sustain": 1, 47 | "decay": 0.3, 48 | "release": 0, 49 | "sustainLevel": 0.3, 50 | "id": "ADSREnvelope1" 51 | } 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /icons/importInstrument.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 28 | 29 | 30 | 45 | 46 | -------------------------------------------------------------------------------- /icons/removeMeasure.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /db_stuff/models/user.js: -------------------------------------------------------------------------------- 1 | // user model FOR PIANO ROLL APP 2 | 3 | // required tools 4 | const mongoose = require('mongoose'); 5 | const bcrypt = require('bcryptjs'); 6 | 7 | // define schema for user model. 8 | // only handling local authentication 9 | const userSchema = mongoose.Schema({ 10 | local: { 11 | username: String, 12 | password: String, 13 | about: String, 14 | location: String, 15 | joinDate: String, 16 | scores: Array // an array of JSON files corresponding to scores 17 | } 18 | }); 19 | 20 | 21 | // some important methods! -------------------------- 22 | 23 | // generate a hash with a given password. 24 | userSchema.methods.generateHash = function(password){ 25 | return bcrypt.hashSync(password, bcrypt.genSaltSync(8)); 26 | }; 27 | 28 | // check if a password is valid (i.e. don't allow certain characters) 29 | userSchema.methods.validPassword = function(password){ 30 | return bcrypt.compareSync(password, this.local.password); 31 | }; 32 | 33 | // create the user model and expose it to the application 34 | // IMPORTANT!!! 35 | // this mongoose.model looks for the collection 'userData', since I supplied it as an argument 36 | // it will use this collection to insert new users 37 | // https://stackoverflow.com/questions/7486528/mongoose-force-collection-name/7722490#7722490 38 | module.exports = mongoose.model('User', userSchema, 'userData'); -------------------------------------------------------------------------------- /example_presets/dissonant.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dissonant", 3 | "data": { 4 | "AudioDestinationNode": { 5 | "feedsFrom": [ 6 | "GainNode1", 7 | "GainNode2" 8 | ], 9 | "feedsInto": [], 10 | "node": { 11 | "maxChannelCount": 2 12 | } 13 | }, 14 | "GainNode1": { 15 | "id": "GainNode1", 16 | "feedsFrom": [ 17 | "OscillatorNode2" 18 | ], 19 | "feedsInto": [ 20 | "AudioDestinationNode" 21 | ], 22 | "node": { 23 | "gain": 0.05000000074505806 24 | } 25 | }, 26 | "GainNode2": { 27 | "id": "GainNode2", 28 | "feedsFrom": [ 29 | "OscillatorNode1" 30 | ], 31 | "feedsInto": [ 32 | "AudioDestinationNode" 33 | ], 34 | "node": { 35 | "gain": 0.20000000298023224 36 | } 37 | }, 38 | "OscillatorNode1": { 39 | "id": "OscillatorNode1", 40 | "feedsFrom": [], 41 | "feedsInto": [ 42 | "GainNode2" 43 | ], 44 | "node": { 45 | "type": "sine", 46 | "frequency": 440, 47 | "detune": 0 48 | } 49 | }, 50 | "OscillatorNode2": { 51 | "id": "OscillatorNode2", 52 | "feedsFrom": [], 53 | "feedsInto": [ 54 | "GainNode1" 55 | ], 56 | "node": { 57 | "type": "sawtooth", 58 | "frequency": 440, 59 | "detune": 371 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "piano_roll_browser", 3 | "version": "1.0.0", 4 | "description": "a piano roll in the browser", 5 | "main": "", 6 | "scripts": { 7 | "test": "node ./node_modules/nyc/bin/nyc ./node_modules/mocha/bin/mocha --require tests/jsdom.js tests/*.js", 8 | "cypress": "./node_modules/.bin/cypress open", 9 | "lint": "eslint", 10 | "lint:fix": "eslint --fix" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/syncopika/piano_roll_browser.git" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/syncopika/piano_roll_browser/issues" 20 | }, 21 | "homepage": "https://github.com/syncopika/piano_roll_browser#readme", 22 | "devDependencies": { 23 | "@html-eslint/eslint-plugin": "^0.24.1", 24 | "@html-eslint/parser": "^0.24.1", 25 | "bcryptjs": "^2.4.3", 26 | "body-parser": "^1.19.0", 27 | "chai": "^4.2.0", 28 | "connect-flash": "^0.1.1", 29 | "connect-mongo": "^4.6.0", 30 | "cookie-parser": "^1.4.5", 31 | "cypress": "^9.1.0", 32 | "ejs": "^3.1.6", 33 | "eslint": "^9.1.1", 34 | "express": "^4.17.1", 35 | "express-session": "^1.17.2", 36 | "jsdom": "^14.0.0", 37 | "mocha": "^6.2.0", 38 | "mongodb": "^4.1.2", 39 | "mongoose": "^6.0.8", 40 | "nyc": "^14.1.1", 41 | "passport": "^0.5.0", 42 | "passport-local": "^1.0.0", 43 | "pre-commit": "^1.2.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /icons/saveProject.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 45 | 46 | -------------------------------------------------------------------------------- /icons/importProject.svg: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 45 | 46 | -------------------------------------------------------------------------------- /db_stuff/views/register.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | register - Piano Roll Online 7 | 8 | 9 | 10 | 27 | 28 | 29 | 30 | 31 |
32 | 33 |

Welcome to Piano Roll Online

34 | 35 |

please register here:

36 | 37 | 38 | <% if(message.length > 0) { %> 39 |
<%= message %>
40 | <% } %> 41 | 42 | 43 |
44 |
45 | 46 |
47 | 48 |
49 | 50 |
51 | 52 |
53 | 54 |
55 | 56 | 57 |
58 | 59 | 67 | 68 |
69 | 70 |
71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /icons/addInstrument.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: Arial; 3 | } 4 | html body { 5 | padding-bottom: 0; 6 | display: none; 7 | } 8 | 9 | #piano { 10 | display: inline-block; 11 | white-space: nowrap; 12 | overflow: scroll; 13 | max-width: 100%; 14 | position: relative; 15 | } 16 | 17 | #pianoNotes { 18 | position: absolute; 19 | width: 50px; 20 | background-color: #fff; 21 | z-index: 200; 22 | } 23 | 24 | #changeTempo { 25 | width: 40px; 26 | } 27 | 28 | #title { 29 | margin: 0 auto; 30 | text-align: center; 31 | } 32 | 33 | #toolbar { 34 | position: relative; 35 | margin: 0 auto; 36 | text-align: center; 37 | background-color: #fff; 38 | z-index: 201; 39 | top: 0; 40 | } 41 | #toolbar ul { 42 | text-align: center; 43 | margin-top: 0; 44 | margin-bottom: 0; 45 | padding-bottom: 0; 46 | } 47 | #toolbar li { 48 | list-style-type: none; 49 | display: inline-block; 50 | } 51 | #toolbar li h2, #toolbar label { 52 | font-size: 12px; 53 | font-weight: bold; 54 | } 55 | 56 | #instrumentGrid { 57 | position: relative; 58 | } 59 | 60 | #demo { 61 | margin-top: 10px; 62 | } 63 | 64 | table, tr, td { 65 | border: 1px solid #000; 66 | } 67 | 68 | .thinBorder { 69 | background: linear-gradient(to right, rgba(255, 255, 255, 0) 97%, black 3%); 70 | } 71 | 72 | .thickBorder { 73 | background: linear-gradient(to right, rgba(255, 255, 255, 0) 93%, black 7%); 74 | } 75 | 76 | .footer { 77 | position: relative; 78 | text-align: center; 79 | margin-top: 3%; 80 | font-size: 12px; 81 | font-family: Arial; 82 | border-top: 1px solid #000; 83 | } 84 | 85 | .btn { 86 | border: 1px solid #000; 87 | border-radius: 2px; 88 | padding: 2px; 89 | box-shadow: 0.1em 0.1em #d3d3d3; 90 | } 91 | 92 | .btn:hover { 93 | background-color: #d0d0d0; 94 | } 95 | 96 | .context-menu-icon:hover { 97 | cursor: pointer; 98 | } 99 | 100 | select, option { 101 | height: 24px; 102 | } 103 | 104 | img { 105 | width: 3vw; 106 | height: auto; 107 | } 108 | 109 | /*# sourceMappingURL=style.css.map */ 110 | -------------------------------------------------------------------------------- /icons/pauseButton-old.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 38 | 40 | 41 | 43 | image/svg+xml 44 | 46 | 47 | 48 | 49 | 50 | 54 | 61 | 68 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /style.scss: -------------------------------------------------------------------------------- 1 | // style.scss 2 | 3 | $font-family: Arial; 4 | $border-regular: 1px solid #000; 5 | 6 | html { 7 | font-family: $font-family; 8 | 9 | body { 10 | // override padding-bottom from contextMenu/css/screen.css 11 | padding-bottom: 0; 12 | 13 | // display none initially 14 | display: none; 15 | } 16 | } 17 | 18 | #piano { 19 | display: inline-block; 20 | 21 | // this is important for letting the grid expand horizontally 22 | white-space: nowrap; 23 | 24 | overflow: scroll; 25 | max-width: 100%; 26 | position: relative; 27 | } 28 | 29 | #pianoNotes { 30 | position: absolute; 31 | width: 50px; 32 | z-index: 200; 33 | background-color: #fff; 34 | } 35 | 36 | #changeTempo { 37 | width: 40px; 38 | } 39 | 40 | #title { 41 | margin: 0 auto; 42 | text-align: center; 43 | } 44 | 45 | #toolbar { 46 | position: relative; 47 | margin: 0 auto; 48 | text-align: center; 49 | background-color: #fff; 50 | z-index: 201; 51 | 52 | ul { 53 | text-align: center; 54 | margin-top: 0; 55 | margin-bottom: 0; 56 | padding-bottom: 0; 57 | } 58 | 59 | li { 60 | list-style-type: none; 61 | display: inline-block; 62 | } 63 | 64 | li h2, label { 65 | font-size: 12px; 66 | font-weight: bold; 67 | } 68 | } 69 | 70 | #instrumentGrid { 71 | position: relative; 72 | } 73 | 74 | #demo { 75 | margin-top: 10px; 76 | } 77 | 78 | table, tr, td { 79 | border: $border-regular; 80 | } 81 | 82 | .thinBorder { 83 | background: linear-gradient(to right, rgba(255,255,255,0) 97%, rgba(0,0,0,1) 3%); 84 | } 85 | 86 | .thickBorder { 87 | background: linear-gradient(to right, rgba(255,255,255,0) 93%, rgba(0,0,0,1) 7%); 88 | } 89 | 90 | .footer { 91 | position: relative; 92 | text-align: center; 93 | margin-top: 3%; 94 | font-size: 12px; 95 | font-family: $font-family; 96 | border-top: $border-regular; 97 | } 98 | 99 | .btn { 100 | border: $border-regular; 101 | border-radius: 2px; 102 | padding: 2px; 103 | box-shadow: .1em .1em #d3d3d3; 104 | } 105 | 106 | .btn:hover { 107 | background-color: #d0d0d0; 108 | } 109 | 110 | .context-menu-icon:hover { 111 | cursor: pointer; 112 | } 113 | 114 | select, option { 115 | height: 24px; 116 | } 117 | 118 | img { 119 | width: 3vw; 120 | height: auto; 121 | } 122 | -------------------------------------------------------------------------------- /db_stuff/server.js: -------------------------------------------------------------------------------- 1 | /* notes: 2 | super helpful! https://scotch.io/tutorials/easy-node-authentication-setup-and-local 3 | https://medium.com/@johnnyszeto/node-js-user-authentication-with-passport-local-strategy-37605fd99715 4 | http://aleksandrov.ws/2013/09/12/restful-api-with-nodejs-plus-mongodb/ 5 | https://www.sitepoint.com/local-authentication-using-passport-node-js/ 6 | https://www.raymondcamden.com/2016/06/23/some-quick-tips-for-passport/ 7 | */ 8 | 9 | // set up server 10 | const express = require('express'); 11 | const app = express(); 12 | 13 | // the order is important here! 14 | const port = process.env.PORT || 3000; 15 | const http = require('http').Server(app); 16 | const mongoose = require('mongoose'); 17 | const passport = require('passport'); 18 | const flash = require('connect-flash'); 19 | 20 | const cookieParser = require('cookie-parser'); 21 | const session = require('express-session'); 22 | const assert = require('assert'); 23 | const mongoStore = require("connect-mongo"); 24 | 25 | const configDB = require('./config/database.js'); 26 | mongoose.connect(configDB.url); 27 | require('./config/passport.js')(passport); 28 | 29 | app.use(cookieParser()); 30 | 31 | // body parser provided by express (no need for body-parser lib) 32 | app.use(express.urlencoded({extended: false})); 33 | 34 | app.set('view engine', 'ejs'); 35 | 36 | // this is required for passport 37 | // TODO: read up on session secret 38 | 39 | // make a sessionMiddleware variable to link up mongoStore in order to log all the current sessions 40 | // that way we can access all the current users and list them in the chatroom 41 | const sessionMiddleware = session({ 42 | secret: 'aweawesomeawesomeawesomesome', 43 | store: mongoStore.create({ 44 | mongoUrl: configDB.url 45 | }), 46 | resave: false, 47 | saveUninitialized: false, 48 | }); 49 | 50 | app.use(sessionMiddleware); // use the sessionMiddlware variable for cookies 51 | app.use(passport.initialize()); 52 | app.use(passport.session()); // persistent login session (what does that mean?) 53 | app.use(flash()); // connect-flash is used for flash messages stored in session. 54 | 55 | // pass app and passport to the routes 56 | require('./routes/routes.js')(app, passport); 57 | 58 | // set directory path so the piano roll (index.ejs) will know where to look to find the required javascript files 59 | // since this project is a nested directory and I want to reference my script folder outside it, I need to do this. 60 | // this treats the crrent directory's parent as the root directory 61 | let parentDir = (__dirname).split("\\"); 62 | parentDir = parentDir.slice(0, parentDir.length - 1).join("\\"); 63 | app.use(express.static(parentDir)); 64 | 65 | 66 | http.listen(port, function(){ 67 | console.log('listening on *:' + port); 68 | }); 69 | -------------------------------------------------------------------------------- /icons/importButton.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 38 | 40 | 41 | 43 | image/svg+xml 44 | 46 | 47 | 48 | 49 | 50 | 55 | 62 | 79 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | // prevent flash of unstylized content 2 | document.addEventListener("DOMContentLoaded", () => { 3 | //console.log("im ready"); 4 | document.body.style.display = "block"; 5 | document.getElementById("pianoNotes").style.display = "block"; 6 | 7 | // guard against inadvertently closing the page 8 | window.addEventListener('beforeunload', function(evt){ 9 | // this should trigger the generic popup asking to confirm if you want to leave 10 | evt.returnValue = "are you sure you want to leave?"; // this text doesn't actually appear 11 | return "are you sure you want to leave?"; 12 | }); 13 | }); 14 | 15 | // flag for toggling the toolbar to be static or sticky 16 | // this gets used in utils.js 17 | // eslint-disable-next-line prefer-const 18 | let toggleStickyToolbar = false; 19 | 20 | // set up piano roll 21 | const pianoRoll = new PianoRoll(); 22 | pianoRoll.init(); 23 | 24 | bindButtons(pianoRoll); // from utils.js 25 | 26 | document.getElementById('measures').textContent = "measure count: " + pianoRoll.numberOfMeasures; 27 | 28 | // set up initial instrument 29 | const context = pianoRoll.audioContext; 30 | const gain = initGain(context); 31 | gain.connect(context.destination); 32 | 33 | const initialInstrument = new Instrument("Instrument 1", gain, []); 34 | pianoRoll.instruments.push(initialInstrument); 35 | pianoRoll.currentInstrument = pianoRoll.instruments[0]; 36 | 37 | // create piano roll grid 38 | buildGridHeader('columnHeaderRow', pianoRoll); 39 | buildGrid('grid', pianoRoll); 40 | 41 | // load in presets and piano notes (TODO: maybe lazy load only when selected as instrument sound?) 42 | loadExamplePresets(document.getElementById('loadingMsg')).then(_ => { 43 | pianoRoll.PianoManager.loadPianoNotes(document.getElementById('loadingMsg')); 44 | }); 45 | 46 | document.getElementById("piano").addEventListener("scroll", (evt) => { 47 | document.getElementById("pianoNotes").style.left = evt.target.scrollLeft + "px"; 48 | 49 | if(!toggleStickyToolbar){ 50 | document.getElementById("toolbar").style.top = "0px"; 51 | } 52 | }); 53 | 54 | document.addEventListener('contextmenu', (evt) => { 55 | // close context menu if opened 56 | const instCtxMenu = document.getElementById('instrument-context-menu'); 57 | if(instCtxMenu && instCtxMenu.style.display !== "none"){ 58 | instCtxMenu.parentNode.removeChild(instCtxMenu); 59 | } 60 | 61 | const noteCtxMenu = document.getElementById('note-context-menu'); 62 | if(noteCtxMenu && noteCtxMenu.style.display !== "none"){ 63 | noteCtxMenu.parentNode.removeChild(noteCtxMenu); 64 | } 65 | 66 | if(evt.target.classList.contains('context-menu-instrument')){ 67 | evt.preventDefault(); 68 | setupInstrumentContextMenu(pianoRoll, evt); 69 | } 70 | 71 | if(evt.target.classList.contains('context-menu-note')){ 72 | evt.preventDefault(); 73 | setupNoteContextMenu(pianoRoll, evt); 74 | } 75 | }); 76 | 77 | document.getElementById('mmpExport').addEventListener('click', () => { 78 | // getJSONData is from utils.js 79 | const data = getJSONData(pianoRoll); 80 | 81 | exportMMPFile(data); // from mmpGenerator.js 82 | }); 83 | -------------------------------------------------------------------------------- /tests/grid_builder - spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const expect = require('chai').expect; 3 | const { replaceSharp, buildGrid, buildGridHeader, highlightHeader } = require('../src/gridBuilder.js'); 4 | const { PianoRoll } = require('../src/classes.js'); 5 | 6 | describe('testing gridBuilder.js', function(){ 7 | 8 | let pianoRoll, el; 9 | 10 | beforeEach(function(){ 11 | pianoRoll = new PianoRoll(); 12 | el = document.createElement('div'); 13 | }); 14 | 15 | it('testing replaceSharp', function(){ 16 | /* 17 | * cases: "F#5","F5","Eb5" 18 | */ 19 | let str = "F#5"; 20 | assert.equal(str.indexOf('#'), 1); 21 | str = replaceSharp(str); 22 | assert.equal(str.indexOf('#'), -1); 23 | 24 | str = "F5"; 25 | assert.equal(str.indexOf('#'), -1); 26 | str = replaceSharp(str); 27 | assert.equal(str.indexOf('#'), -1); 28 | 29 | str = "Eb5"; 30 | assert.equal(str.indexOf('#'), -1); 31 | str = replaceSharp(str); 32 | expect(str.indexOf('#')).to.equal(-1); 33 | }); 34 | 35 | it('testing buildGridHeader', function(){ 36 | el.id = "columnHeaderRow"; 37 | document.body.appendChild(el); 38 | buildGridHeader(el.id, pianoRoll); 39 | // expect 33 elements in the header: 8 cells * 4 measures = 32 + 1 for the piano keys 40 | expect(document.getElementById(el.id).children.length).to.equal(33); 41 | document.body.removeChild(el); 42 | }); 43 | 44 | // 61 unique notes for current config 45 | it('testing buildGrid', function(){ 46 | el.id = "piano"; 47 | document.body.appendChild(el); 48 | 49 | // pianoNotes is a separate div from piano 50 | const pianoNotes = document.createElement('div'); 51 | pianoNotes.id = "pianoNotes"; 52 | document.body.appendChild(pianoNotes); 53 | 54 | buildGrid(el.id, pianoRoll); 55 | 56 | // there should be a row (div) for each note, + the pianoNotes div 57 | // the pianoNotes div should have n children, where n = number of unique notes 58 | const numUniqueNotes = 73; // 6 octaves + C8 59 | expect(document.getElementById(el.id).children.length).to.equal(numUniqueNotes); 60 | expect(document.getElementById(pianoNotes.id).children.length).to.equal(numUniqueNotes); 61 | 62 | document.body.removeChild(el); 63 | document.body.removeChild(pianoNotes); 64 | }); 65 | 66 | it('testing highlightHeader', function(){ 67 | el.id = "columnHeaderRow"; 68 | document.body.appendChild(el); 69 | buildGridHeader(el.id, pianoRoll); 70 | 71 | expect(document.getElementById("col_1").style.backgroundColor).to.equal(""); 72 | highlightHeader("col_1", pianoRoll); 73 | expect(pianoRoll.playMarker).to.equal("col_1"); 74 | expect(document.getElementById("col_1").style.backgroundColor).to.equal("rgb(50, 205, 50)"); 75 | 76 | document.body.removeChild(el); 77 | 78 | // as an integration test later simulate a click on another header column 79 | // and check that the previous one does not have a colored background anymore 80 | // and make sure the right element is highlighted 81 | }); 82 | 83 | 84 | }); -------------------------------------------------------------------------------- /db_stuff/views/login.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | login - Piano Roll Online 82 | 83 | 84 | 85 | 86 |
87 |
88 |

Piano Roll Online

89 |
90 | 91 | 92 | <% if (message.length > 0) { %> 93 |
<%= message %>
94 | <% } %> 95 | 96 | 97 |
98 |
99 | 100 | 101 |
102 | 103 | 104 |
105 | 106 | 107 | 108 | 109 |

or register as a new user

110 | 111 |
112 | 113 |

disclaimer: this application is not designed to be used on mobile devices

114 | 115 | 116 |
117 | 118 | 119 | 120 | 122 | 123 | -------------------------------------------------------------------------------- /db_stuff/views/profile.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= user.local.username %>'s profile - Piano Roll Online 9 | 10 | 11 | 12 | 13 | 76 | 77 | 78 | 79 | 80 |
81 | 82 | 83 |
84 |

welcome back <%= user.local.username %>

85 | 86 |
87 |

avatar feature in progress...

88 |
89 | 90 |
91 |

location: <%= user.local.location %>

92 |
93 | 94 |

joined: <%= user.local.joinDate %>

95 | 96 |
97 | 98 |
99 |

about

100 |
101 |

<%= user.local.about %>

102 |

103 |
104 |
105 | 106 |
107 | 108 | 114 | 115 |
116 | 117 | 118 |
119 | 120 | 121 |
122 | <% for(var i = 0; i < user.local.scores.length; i++){ %> 123 |
124 |

<%= user.local.scores[i].title %>

125 |

|

126 |

delete

127 |
128 | <% } %> 129 |
130 |
131 | 132 |
133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /cypress/integration/index.spec.js: -------------------------------------------------------------------------------- 1 | // untitled.spec.js created with Cypress 2 | // 3 | // Start writing your Cypress tests below! 4 | // If you're unfamiliar with how Cypress works, 5 | // check out the link below and learn how to write your first test: 6 | // https://on.cypress.io/writing-first-test 7 | 8 | describe('first test', function(){ 9 | 10 | beforeEach(() => { 11 | cy.visit('index.html'); 12 | }); 13 | 14 | it('check existence of stuff', function(){ 15 | // make sure instrument grid is there 16 | cy.get('#instrumentGrid').should('be.visible'); 17 | 18 | // make sure one instrument exists 19 | cy.get('#instrumentGrid').find("#instrumentTable").should('be.visible'); 20 | cy.get('#instrumentGrid').find("#instrumentTable").children().its('length').should('eq', 1); 21 | 22 | // make sure the piano roll grid is there 23 | cy.get('#piano').should('be.visible'); 24 | cy.get('#grid').should('be.visible'); 25 | 26 | // make sure the mobile piano keys div is there 27 | cy.get('#pianoNotes').should('be.visible'); 28 | 29 | // check number of notes 30 | cy.get('#grid').children().its('length').should('eq', 73); 31 | 32 | // check buttons 33 | cy.get('#buttons').should('be.visible'); 34 | cy.get('#buttons').find('li').its('length').should('eq', 13); 35 | }); 36 | 37 | it('can create a new note', function(){ 38 | const elementId = '#A7col_5'; 39 | const note = '#note0'; 40 | 41 | cy.get(elementId).click(); 42 | 43 | cy.get(elementId).find(note).should('have.css', 'width').and('eq', '40px'); 44 | 45 | // cypress appears to be adding extra stuff to the background style?? 46 | const expectedBgStyle = "rgba(0, 0, 0, 0) linear-gradient(90deg, rgb(0, 158, 52) 90%, rgb(52, 208, 0) 99%) repeat scroll 0% 0% / auto padding-box border-box"; 47 | cy.get(elementId).find(note).should('have.css', 'background').and('eq', expectedBgStyle); 48 | 49 | // check context-menu on right-click 50 | cy.get('#context-menu-layer').should('not.exist'); 51 | cy.get(elementId).find(note).should('have.class', 'context-menu-note'); 52 | cy.get(elementId).find(note).rightclick(); // open context-menu 53 | 54 | cy.get('#note-context-menu').should('be.visible'); 55 | cy.get('#context-menu-layer').click(); // remove context-menu by clicking somewhere else 56 | cy.get('#note-context-menu').should('not.exist'); 57 | 58 | // check deleting note 59 | cy.get(elementId).find(note).rightclick(); 60 | cy.get('.context-menu-delete').click(); 61 | cy.get(note).should('not.exist'); 62 | }); 63 | 64 | it('can create and delete an instrument', function(){ 65 | const instrumentTable = '#instrumentTable'; 66 | const addInstrument = '#addInstrument'; 67 | 68 | cy.get(instrumentTable).find('#1').should('be.visible'); 69 | cy.get(addInstrument).click(); 70 | 71 | cy.get(instrumentTable).find('#2').should('be.visible'); 72 | cy.get(instrumentTable).find('#2').should('have.css', 'background-color').and('eq', 'rgba(0, 0, 0, 0)'); 73 | 74 | cy.get(instrumentTable).find('#2').click(); // switch to new instrument 75 | 76 | cy.get(instrumentTable).find('#2').rightclick(); 77 | cy.get('#instrument-context-menu').should('be.visible'); 78 | 79 | // delete the newly added instrument 80 | cy.get('.context-menu-delete').click(); 81 | cy.get(instrumentTable).find('#2').should('not.exist'); 82 | 83 | }); 84 | 85 | // TODO: 86 | // check context-menu for instrument 87 | // check time signature change correctly changing num measures, headers 88 | // check add/delete measure 89 | 90 | }); 91 | -------------------------------------------------------------------------------- /tests/classes - spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const expect = require('chai').expect; 3 | const { 4 | PianoRoll, 5 | Instrument, 6 | Note, 7 | ElementNode, 8 | PriorityQueue, 9 | } = require('../src/classes.js'); 10 | 11 | describe('testing classes.js', function(){ 12 | 13 | it('testing PianoRoll class', function(){ 14 | const pianoRoll = new PianoRoll(); 15 | expect(pianoRoll.numberOfMeasures).to.equal(4); 16 | expect(pianoRoll.currentTempo).to.equal(250); 17 | expect(pianoRoll.subdivision).to.equal(8); 18 | expect(pianoRoll.timeSignature).to.equal("4/4"); 19 | expect(pianoRoll.audioContext).to.be.undefined; 20 | expect(pianoRoll.init).to.not.be.undefined; 21 | 22 | // check note styles 23 | expect(Object.keys(pianoRoll.defaultNoteStyles).length).to.equal(4); 24 | 25 | // check default instruments 26 | expect(Object.keys(pianoRoll.defaultInstrumentSounds).length).to.equal(6); 27 | 28 | // check note size map (i.e. 8th, 16th, 32nd mapped to their cell size in px) 29 | expect(Object.keys(pianoRoll.noteSizeMap).length).to.equal(3); 30 | }); 31 | 32 | it('testing Instrument class', function(){ 33 | const name = 'test'; 34 | const dummyGain = {}; 35 | const notesArr = []; 36 | const instrument = new Instrument(name, dummyGain, notesArr); 37 | expect(instrument.volume).to.equal(0.2); 38 | expect(instrument.name).to.equal('test'); 39 | expect(instrument.onionSkinOn).to.be.true; 40 | expect(instrument.isMute).to.be.false; 41 | expect(instrument.noteColorStart).to.not.be.undefined; 42 | expect(instrument.noteColorEnd).to.not.be.undefined; 43 | }); 44 | 45 | it('testing ElementNode class', function(){ 46 | const el = document.createElement('div'); 47 | el.setAttribute("data-volume", .5); 48 | el.setAttribute("data-type", "legato"); 49 | el.id = "test"; 50 | 51 | const elNode = new ElementNode(el); 52 | expect(elNode.id).to.equal(el.id); 53 | expect(elNode.volume).to.equal(el.dataset.volume); 54 | expect(elNode.style).to.equal(el.dataset.type); 55 | }); 56 | 57 | it('testing Note class', function(){ 58 | const freq = 100; 59 | const duration = 100; 60 | 61 | const el = document.createElement('div'); 62 | el.setAttribute("data-length", 5); 63 | el.setAttribute("data-volume", .5); 64 | el.setAttribute("data-type", "legato"); 65 | el.id = "test"; 66 | 67 | const note = new Note(freq, duration, el); 68 | expect(note.freq).to.equal(freq); 69 | }); 70 | 71 | describe('testing priority queue (min heap)', function(){ 72 | it('priority queue creation', function(){ 73 | const pq = new PriorityQueue(); 74 | expect(pq).to.not.be.null; 75 | expect(pq.array.length).to.equal(0); 76 | expect(pq.lastIndex).to.equal(0); 77 | expect(pq.remove()).to.be.null; 78 | }); 79 | 80 | it('priority queue works', function(){ 81 | const pq = new PriorityQueue(); 82 | pq.add(5); 83 | pq.add(100); 84 | pq.add(3); 85 | pq.add(1); 86 | 87 | expect(pq.array.length).to.equal(4); 88 | expect(pq.peek()).to.equal(1); 89 | expect(pq.lastIndex).to.equal(4); 90 | expect(pq.size).to.equal(4); 91 | 92 | let smallest = pq.remove(); 93 | expect(smallest).to.equal(1); 94 | expect(pq.peek()).to.equal(3); 95 | expect(pq.lastIndex).to.equal(3); 96 | expect(pq.size).to.equal(3); 97 | expect(pq.array.length).to.equal(4); //we're not actually removing anything so the length won't decrease 98 | 99 | smallest = pq.remove(); 100 | expect(smallest).to.equal(3); 101 | expect(pq.lastIndex).to.equal(2); 102 | 103 | smallest = pq.remove(); 104 | expect(smallest).to.equal(5); 105 | 106 | expect(pq.peek()).to.equal(100); 107 | expect(pq.array.length).to.equal(4); 108 | expect(pq.lastIndex).to.equal(1); 109 | 110 | pq.add(2); 111 | expect(pq.peek()).to.equal(2); 112 | expect(pq.lastIndex).to.equal(2); 113 | 114 | pq.remove(); 115 | pq.remove(); 116 | expect(pq.lastIndex).to.equal(0); 117 | expect(pq.size).to.equal(0); 118 | expect(pq.remove()).to.be.null; 119 | }); 120 | }); 121 | 122 | }); -------------------------------------------------------------------------------- /notes/references.txt: -------------------------------------------------------------------------------- 1 | // references: 2 | http://www.phy.mtu.edu/~suits/notefreqs.html 3 | http://jsfiddle.net/remotesynth/73cD5/?utm_source=website&utm_medium=embed&utm_campaign=73cD5 4 | https://modernweb.com/creating-sound-with-the-web-audio-api-and-oscillators/ 5 | http://stackoverflow.com/questions/1114024/constructors-in-javascript-objects 6 | http://stackoverflow.com/questions/32239560/web-audio-oscillators-unexpectedly-glide-from-one-frequency-to-another-in-chrome 7 | http://stackoverflow.com/questions/15261030/web-audio-start-and-stop-oscillator-then-start-it-again 8 | http://stackoverflow.com/questions/18785951/how-to-get-width-of-a-div-in-percentage-using-jquery 9 | http://alemangui.github.io/blog//2015/12/26/ramp-to-value.html 10 | http://stackoverflow.com/questions/4909167/how-to-add-a-custom-right-click-menu-to-a-webpage 11 | https://swisnl.github.io/jQuery-contextMenu/docs.html 12 | http://stackoverflow.com/questions/11950811/get-id-of-clicked-element-which-opened-context-menu-using-jquery-contextmenu-plu 13 | http://stackoverflow.com/questions/22550424/how-can-i-change-the-background-color-of-multiple-divs-by-dragging-over-them-wit 14 | http://www.sengpielaudio.com/calculator-bpmtempotime.htm 15 | http://stackoverflow.com/questions/14976495/get-selected-option-text-with-javascript 16 | http://stackoverflow.com/questions/7346563/loading-local-json-file 17 | http://stackoverflow.com/questions/14740927/using-html-5-file-api-to-load-a-json-file 18 | https://www.smashingmagazine.com/2012/06/introduction-to-javascript-unit-testing/ 19 | http://stackoverflow.com/questions/3847121/how-can-i-disable-all-settimeout-events 20 | http://stackoverflow.com/questions/3955229/remove-all-child-elements-of-a-dom-node-in-javascript 21 | https://github.com/swisnl/jQuery-contextMenu/issues/15 22 | http://swisnl.github.io/jQuery-contextMenu/demo/dynamic-create.html 23 | https://stackoverflow.com/questions/26233180/resize-a-div-on-border-drag-and-drop-without-adding-extra-markup 24 | https://stackoverflow.com/questions/45534302/jquery-how-to-disable-not-allowed-cursor-while-dragging 25 | https://stackoverflow.com/questions/18094134/fixed-gradient-background-with-css 26 | https://stackoverflow.com/questions/16348031/disable-scrolling-when-touch-moving-certain-element 27 | https://stackoverflow.com/questions/42723494/div-horizontal-scrollbar-child-div-background-doesnt-get-full-width-when-hove 28 | https://stackoverflow.com/questions/10004723/html5-input-type-range-show-range-value 29 | https://stackoverflow.com/questions/2304941/what-is-the-non-jquery-equivalent-of-document-ready 30 | 31 | // super, super important! 32 | https://github.com/WebAudio/web-audio-api/issues/76#issuecomment-107679878 33 | 34 | // more instrument sounds? 35 | http://stackoverflow.com/questions/18752925/html5-audio-getting-the-sound-of-piano 36 | https://dev.opera.com/articles/drum-sounds-webaudio/ 37 | 38 | // ADSR stuff 39 | https://www.redblobgames.com/x/1618-webaudio/#orgeb1ffeb 40 | https://blog.landr.com/adsr-envelopes-infographic/ 41 | https://www.vdveen.net/webaudio/waprog.htm 42 | https://sound.stackexchange.com/questions/27798/what-time-range-is-used-for-adsr-envelopes 43 | https://github.com/sonic-pi-net/sonic-pi/blob/main/etc/doc/tutorial/02.4-Durations-with-Envelopes.md 44 | https://stackoverflow.com/questions/34694580/how-do-i-correctly-cancel-a-currently-changing-audioparam-in-the-web-audio-api 45 | https://bugzilla.mozilla.org/show_bug.cgi?id=1308431 46 | https://github.com/WebAudio/web-audio-api/issues/2437 47 | 48 | // make it recordable? 49 | https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/createMediaStreamDestination 50 | https://stackoverflow.com/questions/38443084/how-can-i-add-predefined-length-to-audio-recorded-from-mediarecorder-in-chrome 51 | 52 | // super helpful hints on how to synchronize web audio properly 53 | https://www.html5rocks.com/en/tutorials/audio/scheduling/#disqus_thread 54 | https://github.com/cwilso/metronome/blob/master/js/metronome.js 55 | http://catarak.github.io/blog/2014/12/02/web-audio-timing-tutorial/ 56 | https://github.com/catarak/web-audio-sequencer/blob/master/javascripts/app.js 57 | http://sriku.org/blog/2013/01/30/taming-the-scriptprocessornode/#replacing-oscillator-with-scriptprocessornode 58 | 59 | // svg icons 60 | https://stackoverflow.com/questions/53699098/responsive-svg-viewbox/53699141 61 | https://www.aleksandrhovhannisyan.com/blog/svg-tutorial-how-to-code-svg-icons-by-hand/ -> amazing tutorial 62 | 63 | // circular dependency found when writing tests I think? 64 | https://stackoverflow.com/questions/23875233/require-returns-an-empty-object/23875299 65 | -------------------------------------------------------------------------------- /src/visualizer.js: -------------------------------------------------------------------------------- 1 | // for visualizing the piano roll 2 | // 3 | // WARNING: visualization appears to not work after exceeding a certain width, e.g. past 51 measures, 4 | // the visualizer seems to not draw anything. 51 measures seems to be the max width of the canvas allowable for visualization. 5 | // See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/canvas about max canvas size. 6 | // I guess it makes sense though that my visualization strategy using the HTML Canvas isn't infinitely scalable lol. 7 | 8 | // @param gridDivId: a string representing an HTML element id of the grid 9 | // @param pianoRollObject: an instance of PianoRoll 10 | function buildVisualizer(gridDivId, pianoRollObject){ 11 | // remove existing visualizer if there is one (e.g. if pause -> play) 12 | removeVisualizer(pianoRollObject); 13 | 14 | const thePiano = document.getElementById(gridDivId); 15 | 16 | const dimensions = thePiano.getBoundingClientRect(); 17 | const canvas = document.createElement('canvas'); 18 | canvas.id = 'visuailzer'; 19 | 20 | canvas.width = thePiano.scrollWidth; //dimensions.width; 21 | canvas.height = dimensions.height; 22 | 23 | canvas.style.width = thePiano.scrollWidth + 'px'; 24 | canvas.style.height = dimensions.height + 'px'; 25 | canvas.style.position = 'absolute'; 26 | canvas.style.top = 0; 27 | canvas.style.left = 0; 28 | 29 | thePiano.appendChild(canvas); 30 | 31 | pianoRollObject.visualizerCanvas = canvas; 32 | 33 | pianoRollObject.visualizerWebWorker = new Worker('./src/visualizerWorker.js'); 34 | 35 | const offscreen = canvas.transferControlToOffscreen(); 36 | pianoRollObject.visualizerWebWorker.postMessage( 37 | {canvas: offscreen}, [offscreen] 38 | ); 39 | } 40 | 41 | function updateVisualizer(pianoRollObject, stop=false){ 42 | if(pianoRollObject.visualizerCanvas){ 43 | // use a web worker offscreen canvas to 44 | // do this drawing stuff. pass it the analyser node data. 45 | // https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas 46 | // https://web.dev/articles/offscreen-canvas 47 | const bufferLen = pianoRollObject.analyserNode.frequencyBinCount; 48 | const dataArray = new Uint8Array(bufferLen); 49 | pianoRollObject.analyserNode.getByteTimeDomainData(dataArray); 50 | 51 | // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects 52 | pianoRollObject.visualizerWebWorker.postMessage( 53 | [{data: dataArray, stop}, dataArray.buffer] 54 | ); 55 | } 56 | 57 | pianoRollObject.visualizerRequestAnimationFrameId = 58 | window.requestAnimationFrame((timestamp) => updateVisualizer(pianoRollObject, stop)); 59 | } 60 | 61 | // for passing note data for note ripples visualization 62 | // we will pass data for ALL notes of a piece to the worker (is this a bad idea?? ¯\_(ツ)_/¯) 63 | // I think it's easier to work with requestAnimationFrame this way 64 | // @stop completely stops the visualization (which should happen when switching between visualizers) 65 | function updateRipplesVisualizer(pianoRollObject, noteData, stop=false, stopRender=false){ 66 | // noteData should be an array of objects, with each object representing a note of the piece 67 | // each object in noteData should look like: 68 | // { 69 | // start: noteStart, // should be unix timestamp 70 | // end: noteEnd, // unix timestamp 71 | // freq: number, 72 | // color, // string, e.g. rgb(x,y,z) 73 | // } 74 | // 75 | if(pianoRollObject.visualizerCanvas){ 76 | pianoRollObject.visualizerWebWorker.postMessage( 77 | [{ 78 | visualizationType: 'ripples', 79 | stop, 80 | data: noteData, 81 | }] 82 | ); 83 | } 84 | } 85 | 86 | // @stopRender only prevents the ripples from being rendered (so visualizer can still be toggled on/off sequentially) 87 | function stopRipplesVisualizerRender(pianoRollObject, stopRender){ 88 | if(pianoRollObject.visualizerCanvas){ 89 | pianoRollObject.visualizerWebWorker.postMessage( 90 | [{ 91 | visualizationType: 'ripples', 92 | action: 'render', 93 | stopRender, 94 | }] 95 | ); 96 | } 97 | } 98 | 99 | function removeVisualizer(pianoRollObject){ 100 | if(pianoRollObject.visualizerCanvas){ 101 | pianoRollObject.visualizerCanvas.parentNode.removeChild(pianoRollObject.visualizerCanvas); 102 | pianoRollObject.visualizerCanvas = null; 103 | pianoRollObject.visualizerOffscreenCanvas = null; 104 | pianoRollObject.visualizerWebWorker.terminate(); // important! 105 | pianoRollObject.visualizerWebWorker = null; 106 | } 107 | } -------------------------------------------------------------------------------- /tests/functionality - spec.js: -------------------------------------------------------------------------------- 1 | const expect = require('chai').expect; 2 | 3 | const { 4 | Instrument, 5 | PianoRoll, 6 | PriorityQueue, 7 | } = require('../src/classes.js'); 8 | 9 | const { 10 | //initGain, 11 | //readInNotes, 12 | getMinGainNodes, 13 | stopPlay, 14 | getCorrectLength, 15 | getNumGainNodesPerInstrument, 16 | //getNotePosition, 17 | //createNewInstrument, 18 | } = require('../src/playbackFunctionality.js'); 19 | 20 | describe('testing playbackFunctionality.js', function(){ 21 | 22 | let pianoRoll; 23 | 24 | beforeEach(function(){ 25 | global.lastNote = null; 26 | global.currNote = null; // these vars are supposed to be global vars atm. :| 27 | global.PriorityQueue = PriorityQueue; // hmmmm :/ 28 | 29 | pianoRoll = new PianoRoll(); 30 | }); 31 | 32 | it('testing getCorrectLength', function(){ 33 | // make sure pianoRoll has default tempo of 250 ms per eigth note 34 | expect(pianoRoll.currentTempo).to.equal(250); 35 | 36 | // check quarter note length (we're assuming an 8th note has length of 40px) 37 | const quarter = 80; 38 | expect(getCorrectLength(quarter, pianoRoll)).to.equal(500); 39 | 40 | // check eighth note at 120bpm 41 | expect(getCorrectLength(40, pianoRoll)).to.equal(250); 42 | 43 | // check 16th note at 120bpm 44 | expect(getCorrectLength(20, pianoRoll)).to.equal(125); 45 | }); 46 | 47 | it('testing stopPlay', function(){ 48 | stopPlay(pianoRoll); 49 | expect(pianoRoll.isPlaying).to.be.false; 50 | expect(pianoRoll.timers.length).to.equal(0); 51 | }); 52 | 53 | describe('testing getMinGainNodes', function(){ 54 | it('test getMinGainNodes 1', function(){ 55 | const instrument = new Instrument("Instrument 1", {}, []); 56 | 57 | // TODO: test w/ Cypress for getNotesStartAndEnd AND getNumGainNodesPerInstrument 58 | expect( 59 | getMinGainNodes([{start: 60, end: 100}, {start: 60, end: 120}]) 60 | ).to.equal(2); 61 | }); 62 | 63 | it('test getMinGainNodes 2', function(){ 64 | const instrument = new Instrument("Instrument 1", {}, []); 65 | expect( 66 | getMinGainNodes([ 67 | {start: 60, end: 100}, 68 | {start: 60, end: 120}, 69 | {start: 80, end: 120}, 70 | ]) 71 | ).to.equal(3); 72 | }); 73 | 74 | it('test getMinGainNodes 3', function(){ 75 | const instrument = new Instrument("Instrument 1", {}, []); 76 | expect( 77 | getMinGainNodes([ 78 | {start: 60, end: 100}, 79 | {start: 100, end: 140}, 80 | ]) 81 | ).to.equal(1); 82 | }); 83 | 84 | it('test getMinGainNodes 4', function(){ 85 | const instrument = new Instrument("Instrument 1", {}, []); 86 | expect( 87 | getMinGainNodes([ 88 | {start: 10940, end: 11020}, 89 | {start: 10940, end: 10960}, 90 | {start: 10980, end: 11000}, 91 | {start: 11020, end: 11040}, 92 | ]) 93 | ).to.equal(2); 94 | }); 95 | 96 | }); 97 | 98 | /* probably not a great idea since we really need a working DOM to 99 | // test correctness. this is probably best tested in an e2e setting but just trying it out 100 | it('testing getNumGainNodesPerInstrument', function(){ 101 | // mock some functions first (this is actually not needed in the case of a single note (or a single chord)) 102 | // having these messes up subsequent tests though as well. 103 | //document.getElementById = function(element){ 104 | // return { 105 | // style: { 106 | // width: '40px' 107 | // } 108 | // }; 109 | //} 110 | 111 | //getNotePosition = function(element){ 112 | // return 60; 113 | //} 114 | 115 | var instrument = new Instrument("Instrument 1", {}, []); 116 | instrument.notes = [ 117 | [ 118 | {freq: 3729.31, duration: 250, block: {id: 'note0', volume: '0.2', style: 'default'}}, 119 | {freq: 440.0, duration: 250, block: {id: 'note1', volume: '0.2', style: 'default'}} 120 | ], // this represents a single chord 121 | ]; 122 | 123 | var instrumentNotePointers = [0]; 124 | 125 | var res = getNumGainNodesPerInstrument([instrument], instrumentNotePointers); 126 | //console.log(res); 127 | 128 | expect(typeof(res)).to.equal('object'); 129 | expect(res[0]).to.equal(2); 130 | });*/ 131 | 132 | }); 133 | -------------------------------------------------------------------------------- /db_stuff/config/passport.js: -------------------------------------------------------------------------------- 1 | // configure the passport strategy for local authentication 2 | // using the passport object created in server.js 3 | // see if can keep session even after refresh: 4 | // https://stackoverflow.com/questions/29721225/staying-authenticated-after-the-page-is-refreshed-using-passportjs 5 | 6 | const LocalStrategy = require('passport-local').Strategy; 7 | 8 | // load user model 9 | const User = require('../models/user.js'); 10 | 11 | // expose the following function to rest of application 12 | module.exports = function(passport){ 13 | 14 | // setup passport session 15 | // required for persistent login sessions! (what is a persistent login session?) 16 | // passport needs to serialize and unserialize users out of sessions 17 | 18 | // serialize the user for the session 19 | passport.serializeUser(function(user, done){ 20 | done(null, user.id); 21 | }); 22 | 23 | // unserialize the user 24 | passport.deserializeUser(function(id, done){ 25 | User.findById(id, function(err, user){ 26 | done(err, user); 27 | }); 28 | }); 29 | 30 | /**** 31 | 32 | this section takes care of registering new users 33 | 34 | ****/ 35 | passport.use('local-register', new LocalStrategy({ 36 | usernameField: 'username', 37 | passwordField: 'password', 38 | passReqToCallback: true // this allows the entire request to be passed to the function below 39 | }, 40 | function(req, username, password, done){ 41 | // this is an asynchronous step 42 | // User.findOne won't execute unless data is sent back 43 | process.nextTick(function(){ 44 | 45 | // see if there is another user already with the same username 46 | User.findOne({ 'local.username' : username }, function(err, user){ 47 | 48 | // if any errors, return the error 49 | if(err){ 50 | console.log("error!: " + err); 51 | return done(err); 52 | } 53 | 54 | // if there is a user with the same username already, show an error message 55 | if(user){ 56 | return done(null, false, req.flash('registerMessage', 'Sorry, that username already exists')); // check template for register error message! 57 | }else{ 58 | //console.log("creating a new user with username: " + newUser.local.username + ", password: " + newUser.local.password); 59 | // if no user with given username, create the new user 60 | const newUser = new User(); 61 | 62 | // set user's local credentials 63 | newUser.local.username = username; 64 | newUser.local.password = newUser.generateHash(password); 65 | newUser.local.about = ""; 66 | newUser.local.location = "Unknown"; 67 | 68 | // get the current date (M/D/YYYY) 69 | const currDate = new Date(); 70 | // add 1 to month because it only goes 0-11 71 | newUser.local.joinDate = (currDate.getMonth()+1) + "/" + currDate.getDate() + "/" + currDate.getFullYear(); 72 | 73 | newUser.local.scores = []; 74 | 75 | // save the user in the database 76 | newUser.save(function(err){ 77 | if(err){ 78 | console.log("error!: " + err); 79 | throw err; 80 | } 81 | return done(null, newUser); 82 | }); 83 | } 84 | }); 85 | }); 86 | 87 | })); 88 | 89 | /**** 90 | 91 | this section takes care of local login 92 | 93 | ****/ 94 | passport.use('local-login', new LocalStrategy({ 95 | usernameField: 'username', 96 | passwordField: 'password', 97 | passReqToCallback: true 98 | }, 99 | function(req, username, password, done){ // callback with username and password from form 100 | 101 | User.findOne({'local.username' : username}, function(err, user){ 102 | if(err){ 103 | return done(err); 104 | } 105 | 106 | // if no user found, return error message 107 | if(!user){ 108 | // req.flash sets flashdata using connect-flash 109 | return done(null, false, req.flash('loginMessage', 'Sorry, that user is not registered!')); 110 | } 111 | 112 | // if user is found but password incorrect 113 | if(!user.validPassword(password)){ 114 | return done(null, false, req.flash('loginMessage', 'Wrong password!')); 115 | } 116 | 117 | // success, return user 118 | return done(null, user); 119 | }); 120 | })); 121 | 122 | 123 | }; -------------------------------------------------------------------------------- /src/visualizerWorker.js: -------------------------------------------------------------------------------- 1 | // this is the worker script for the audio visualizer 2 | // https://web.dev/articles/offscreen-canvas 3 | 4 | let canvas = null; 5 | 6 | // for note ripples visualizer 7 | let ripples = []; 8 | let ripplesVisualizerIsRunning = false; 9 | let stopRenderingRipples = false; 10 | 11 | self.onmessage = function(msg){ 12 | //console.log(msg); 13 | 14 | if(msg.data.canvas){ 15 | canvas = msg.data.canvas; 16 | }else{ 17 | if(msg.data[0].visualizationType === 'ripples'){ 18 | // ripples visualizer 19 | if(msg.data[0].action === 'render'){ 20 | stopRenderingRipples = msg.data[0].stopRender; 21 | }else{ 22 | const stop = msg.data[0].stop; 23 | if(!stop && !ripplesVisualizerIsRunning){ 24 | ripplesVisualizerIsRunning = true; 25 | const noteData = msg.data[0].data; 26 | drawRipplesVisualization(noteData, canvas); 27 | }else if(stop && ripplesVisualizerIsRunning){ 28 | ripplesVisualizerIsRunning = false; 29 | } 30 | } 31 | }else{ 32 | // wave visualizer 33 | const data = msg.data[0].data; 34 | const stop = msg.data[0].stop; 35 | drawWaveVisualization(data, canvas, stop); 36 | } 37 | } 38 | }; 39 | 40 | function drawWaveVisualization(data, canvas, stop){ 41 | const width = canvas.width; 42 | const height = canvas.height; 43 | const bufferLen = data.length; 44 | 45 | //console.log(`width: ${width}, height: ${height}, buffer len: ${bufferLen}`); 46 | 47 | const ctx = canvas.getContext('2d'); 48 | ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; 49 | ctx.clearRect(0, 0, width, height); 50 | 51 | if(!stop){ 52 | ctx.lineWidth = 1; 53 | ctx.strokeStyle = 'rgb(0, 0, 0)'; 54 | ctx.beginPath(); 55 | 56 | const sliceWidth = width / bufferLen; 57 | let xPos = 0; 58 | 59 | for(let i = 0; i < bufferLen; i++){ 60 | const dataVal = data[i] / 128.0; // why 128? 61 | const yPos = dataVal * (height/2); 62 | 63 | if(i === 0){ 64 | ctx.moveTo(xPos, yPos); 65 | }else{ 66 | ctx.lineTo(xPos, yPos); 67 | } 68 | 69 | xPos += sliceWidth; 70 | } 71 | 72 | ctx.stroke(); 73 | } 74 | } 75 | 76 | class Ripple { 77 | lights = []; 78 | colors = ['red', 'black', 'yellow', 'green', 'blue', 'pink', 'purple']; 79 | lineCaps = ['butt', 'round', 'square']; 80 | 81 | constructor(canvasCtx, x, y, start){ 82 | this.speed = Math.random() + 0.2; 83 | this.color = this.colors[Math.floor(Math.random() * this.colors.length)]; 84 | this.lineCap = this.lineCaps[Math.floor(Math.random() * this.lineCaps.length)]; 85 | this.ctx = canvasCtx; 86 | this.startX = x; 87 | this.startY = y; 88 | this.startTime = start; 89 | this.isFinished = false; 90 | 91 | this.lights.push({ 92 | currX: x, 93 | currY: y, 94 | currRadius: Math.random() * 5, 95 | maxRadius: 30 * Math.random() + (5 * Math.random()), 96 | done: false, 97 | }); 98 | } 99 | 100 | distance(currX, currY){ 101 | const xDelta = currX - this.startX; 102 | const yDelta = currY - this.startY; 103 | return Math.sqrt((xDelta * xDelta) + (yDelta * yDelta)); 104 | } 105 | 106 | render(){ 107 | const now = Date.now(); 108 | if(this.lights && now >= this.startTime){ 109 | this.lights.forEach(l => { 110 | if(!l.done){ 111 | if(l.currRadius < l.maxRadius){ 112 | if(!stopRenderingRipples){ 113 | this.ctx.strokeStyle = this.color; 114 | this.ctx.lineCap = this.lineCap; 115 | this.ctx.lineWidth = l.currWidth + this.speed; 116 | this.ctx.beginPath(); 117 | this.ctx.arc(l.currX, l.currY, l.currRadius, 0, 2 * Math.PI); 118 | this.ctx.stroke(); 119 | } 120 | l.currRadius += this.speed; 121 | }else{ 122 | l.done = true; 123 | } 124 | } 125 | }); 126 | this.lights = this.lights.filter(l => !l.done); 127 | if(this.lights.length === 0){ 128 | this.isFinished = true; 129 | } 130 | } 131 | } 132 | } 133 | 134 | function renderRipples(){ 135 | if(!ripplesVisualizerIsRunning){ 136 | ripples = []; 137 | return; 138 | } 139 | 140 | if(canvas){ 141 | //console.log("rendering ripples"); 142 | const ctx = canvas.getContext('2d'); 143 | ctx.clearRect(0, 0, canvas.width, canvas.height); 144 | 145 | ripples.forEach(f => f.render()); 146 | ripples = ripples.filter(f => !f.isFinished); 147 | 148 | if(ripples.length === 0){ 149 | ripplesVisualizerIsRunning = false; 150 | return; 151 | } 152 | 153 | if(ripples.length > 0){ 154 | requestAnimationFrame(renderRipples); 155 | } 156 | } 157 | } 158 | 159 | function drawRipplesVisualization(data, canvas){ 160 | const width = canvas.width; 161 | const height = canvas.height; 162 | const ctx = canvas.getContext('2d'); 163 | 164 | ripples = data.map(d => { 165 | return new Ripple(ctx, d.x, d.y, d.start); 166 | }); 167 | 168 | renderRipples(); 169 | } 170 | 171 | function toggleRippleVisualization(stopRender){ 172 | stopRenderingRipples = stopRender; 173 | } 174 | -------------------------------------------------------------------------------- /tests/dom_modification - spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const expect = require('chai').expect; 3 | const { PianoRoll, Instrument } = require('../src/classes.js'); 4 | const { buildGrid } = require('../src/gridBuilder.js'); 5 | const { 6 | addNote, 7 | createNewNoteElement, 8 | highlightRow, 9 | getSubdivisionPositions, 10 | canPlaceNote, 11 | clearGrid, 12 | } = require('../src/domModification.js'); 13 | 14 | describe('testing domModification.js', function(){ 15 | 16 | let pianoRoll, container; 17 | 18 | beforeEach(function(){ 19 | global.lastNote = null; 20 | global.currNote = null; // these vars are supposed to be global vars. :| 21 | pianoRoll = new PianoRoll(); 22 | //pianoRoll.init(); // have some mocking to do (i.e. audiocontext) 23 | 24 | // create an instrument 25 | const initialInstrument = new Instrument("Instrument 1", {}, []); 26 | pianoRoll.instruments.push(initialInstrument); 27 | pianoRoll.currentInstrument = pianoRoll.instruments[0]; 28 | 29 | container = document.createElement('div'); 30 | }); 31 | 32 | it('testing getSubdivisionPositions with lock size == 16th note', function(){ 33 | expect(pianoRoll.lockNoteSize).to.equal("16th"); 34 | expect(pianoRoll.noteSizeMap[pianoRoll.lockNoteSize]).to.equal(20); 35 | 36 | // the first column grid cell should have an offset of 60 37 | const gridCell = {style: {width: "40px"}, getBoundingClientRect: function(){return {left: 60};}}; // mock a grid cell element 38 | const possiblePositions = getSubdivisionPositions(gridCell, pianoRoll); 39 | 40 | expect(possiblePositions.length).to.equal(3); 41 | expect(possiblePositions[0]).to.equal(60); 42 | expect(possiblePositions[1]).to.equal(80); 43 | expect(possiblePositions[2]).to.equal(100); 44 | }); 45 | 46 | it('testing getSubdivisionPositions with lock size == 32nd note', function(){ 47 | pianoRoll.lockNoteSize = "32nd"; 48 | expect(pianoRoll.lockNoteSize).to.equal("32nd"); 49 | expect(pianoRoll.noteSizeMap[pianoRoll.lockNoteSize]).to.equal(10); 50 | 51 | // the first column grid cell should have an offset of 60 52 | const gridCell = {style: {width: "40px"}, getBoundingClientRect: function(){return {left: 60};}}; 53 | const possiblePositions = getSubdivisionPositions(gridCell, pianoRoll); 54 | 55 | expect(possiblePositions.length).to.equal(5); 56 | expect(possiblePositions[0]).to.equal(60); 57 | expect(possiblePositions[1]).to.equal(70); 58 | expect(possiblePositions[2]).to.equal(80); 59 | expect(possiblePositions[3]).to.equal(90); 60 | expect(possiblePositions[4]).to.equal(100); 61 | }); 62 | 63 | it('testing canPlaceNote', function(){ 64 | // mock child elements of a grid cell (e.g. notes that already exist within a cell) 65 | // in this case we have a note at pos with x=60 so another note should not be able to be placed there 66 | // we also have an onion-skinned note@ x=70 which belongs to another instrument, so we can still place a 67 | // note for the current instrument at that same position 68 | const children = [ 69 | {style: {opacity: 1}, getBoundingClientRect: function(){return {left: 60};}}, 70 | {style: {opacity: 0.5}, getBoundingClientRect: function(){return {left: 70};}}, 71 | ]; 72 | 73 | expect(canPlaceNote(60, children)).to.equal(false); 74 | expect(canPlaceNote(70, children)).to.equal(true); 75 | expect(canPlaceNote(80, children)).to.equal(true); 76 | }); 77 | 78 | it('testing createNewNoteElement', function(){ 79 | const newNote = createNewNoteElement(pianoRoll); 80 | assert(newNote !== undefined); 81 | assert(newNote.style.zIndex == 100); 82 | }); 83 | 84 | it('testing highlightRow', function(){ 85 | // setting up pianoNotes is necessary 86 | const pianoNotes = document.createElement('div'); 87 | pianoNotes.id = "pianoNotes"; 88 | document.body.appendChild(pianoNotes); 89 | 90 | container.id = "piano2"; 91 | document.body.appendChild(container); 92 | buildGrid(container.id, pianoRoll); 93 | 94 | const target = document.getElementById('C8col_0'); 95 | 96 | highlightRow(target.id, 'rgb(0, 255, 255)'); 97 | 98 | assert(target.parentNode.style.backgroundColor === 'rgb(0, 255, 255)'); 99 | 100 | document.body.removeChild(pianoNotes); 101 | document.body.removeChild(container); 102 | }); 103 | 104 | it('testing clearGrid', function(){ 105 | clearGrid(pianoRoll.currentInstrument); 106 | expect(Object.keys(pianoRoll.currentInstrument.activeNotes).length).to.equal(0); 107 | expect(pianoRoll.currentInstrument.notes.length).to.equal(0); 108 | }); 109 | 110 | /* TODO: need to mock some stuff first. also might need to add column headers first (integration testing?) 111 | it('testing addNote', function(){ 112 | container.id = "piano2"; 113 | document.body.appendChild(container); 114 | 115 | // pianoNotes is a separate div from piano, which is necessary 116 | var pianoNotes = document.createElement('div'); 117 | pianoNotes.id = "pianoNotes"; 118 | document.body.appendChild(pianoNotes); 119 | 120 | buildGrid(container.id, pianoRoll); 121 | 122 | var target = document.getElementById('C8col_0'); 123 | addNote("note", pianoRoll, {'target': target, 'x': target.getBoundingClientRect().x, 'y': target.getBoundingClientRect().y}); 124 | //console.log(target.style.zIndex); 125 | 126 | document.body.removeChild(container); 127 | document.body.removeChild(pianoNotes); 128 | }); 129 | */ 130 | 131 | }); -------------------------------------------------------------------------------- /db_stuff/routes/routes.js: -------------------------------------------------------------------------------- 1 | // these are all the functions that handle routes (i.e. POST, GET, DELETE) 2 | // all of these routes will be controlled by passport for ensuring proper access for users 3 | // super helpful! https://scotch.io/tutorials/easy-node-authentication-setup-and-local 4 | 5 | // load user model 6 | const User = require('../models/user.js'); 7 | 8 | module.exports = function(app, passport){ 9 | // this will serve the login page to the user first! 10 | // if login is successful, then the server can serve the chat page 11 | app.get('/', function(req, res){ 12 | res.render('login.ejs', { message: "" }); 13 | }); 14 | 15 | // show the login page 16 | app.get('/login', function(req, res){ 17 | res.render('login.ejs', { message: req.flash('loginMessage') }); 18 | }); 19 | 20 | // when server receives a POST request to /login, need to check form input 21 | // and authenticate 22 | app.post('/login', passport.authenticate('local-login', { 23 | successRedirect: '/pianoroll', 24 | failureRedirect: '/login', 25 | failureFlash: true 26 | }) 27 | ); 28 | 29 | // show the register page 30 | app.get('/register', function(req, res){ 31 | res.render('register.ejs', { message: req.flash('registerMessage') }); 32 | }); 33 | 34 | // take care of registering user after form input has been submitted 35 | app.post('/register', passport.authenticate('local-register', { 36 | successRedirect: '/pianoroll', // go to profile for user instead? (goes to piano roll for now) 37 | failureRedirect: '/register', 38 | failureFlash: true 39 | }) 40 | ); 41 | 42 | // direct to piano roll, with pianoRoll in the url 43 | app.get('/pianoroll', function(req, res){ 44 | if(req.user){ 45 | res.render('index.ejs', { 46 | user: req.user // get user name from session and pass to template 47 | }); 48 | }else{ 49 | res.render('forbidden.ejs'); 50 | } 51 | }); 52 | 53 | app.get('/profile', function(req, res){ 54 | res.render('profile.ejs', { 55 | user: req.user 56 | }); 57 | }); 58 | 59 | // if user updates their profile 60 | app.put('/profile', function(req, res){ 61 | // get the info as supplied by the url query 62 | const locationInfo = req.query.location.trim(); 63 | const aboutInfo = req.query.about.trim(); 64 | const username = req.user.local.username; 65 | 66 | User.findOneAndUpdate( 67 | {'local.username': username}, 68 | { 69 | $set: { 70 | 'local.location': locationInfo, 71 | 'local.about': aboutInfo 72 | }, 73 | }, 74 | { 75 | new: true, 76 | upsert: true 77 | }, 78 | function(err, user){ 79 | if(err){ 80 | throw err; 81 | } 82 | res.send(user); 83 | } 84 | ); 85 | }); 86 | 87 | // save score 88 | app.post('/score', function(req, res){ 89 | const username = req.user.local.username; 90 | // careful - the req.body is actually an object where the data is mapped to "score", the name you gave 91 | // for the query attribute when making the post request 92 | const body = req.body; 93 | const scorejson = JSON.parse(body.score); 94 | 95 | // if score exists, update the note data (via the instruments field). otherwise, add it. 96 | User.updateOne( 97 | {'local.username': username, 'local.scores.title': scorejson.title}, // conditions to find 98 | {$set: {'local.scores.$.instruments': scorejson.instruments}}, // what to do when found 99 | {}, 100 | function(err, result){ 101 | if(err){ 102 | throw err; 103 | } 104 | if(result.modifiedCount === 0){ 105 | // nothing was modified, so score is new. push it to the scores array 106 | //console.log("need to add new score!"); 107 | User.updateOne({'local.username': username}, 108 | {$push: {"local.scores": [scorejson]}}, 109 | {upsert: true}, 110 | function(err, result){ 111 | if(err){ 112 | throw err; 113 | } 114 | res.send(result); 115 | } 116 | ); 117 | 118 | }else{ 119 | res.send(result); 120 | } 121 | } 122 | ); 123 | 124 | }); 125 | 126 | // retrieve requested score from user's scores list 127 | app.get('/score', function(req, res){ 128 | const username = req.user.local.username; 129 | const scoreName = req.query.name; 130 | 131 | // look for the score (this is actually returning the whole user! :O maybe not a good idea...) 132 | User.find( 133 | {'local.username': username}, 134 | function(err, user){ 135 | if(err){ 136 | throw err; 137 | } 138 | res.send(user); 139 | } 140 | ); 141 | }); 142 | 143 | // delete a score (this option is selected from the user's profile page) 144 | app.delete('/score', function(req, res){ 145 | const username = req.user.local.username; 146 | const scoreToDelete = req.query.name; 147 | 148 | // delete the score 149 | User.updateOne( 150 | {'local.username': username, 'local.scores.title': scoreToDelete}, 151 | {$pull: {'local.scores': {'title': scoreToDelete}} }, 152 | {}, 153 | function(err, user){ 154 | if(err){ 155 | throw err; 156 | } 157 | res.send("success"); 158 | } 159 | ); 160 | }); 161 | 162 | // show logout page 163 | // https://stackoverflow.com/questions/13758207/why-is-passportjs-in-node-not-removing-session-on-logout 164 | app.get('/logout', function(req, res){ 165 | // TODO: remove username from current users list 166 | req.logout(); // this is a passport function 167 | res.redirect('/'); // go back to home page 168 | }); 169 | 170 | // middleware function to make sure user is logged in 171 | function isLoggedIn(req, res, next){ 172 | // if user is authenticated, then ok 173 | if(req.isAuthenticated()){ 174 | return next(); 175 | } 176 | 177 | // if not authenticated, take them back to the home page 178 | res.redirect('/'); 179 | } 180 | 181 | }; -------------------------------------------------------------------------------- /src/gridBuilder.js: -------------------------------------------------------------------------------- 1 | /*********** 2 | 3 | build the grid 4 | 5 | ***********/ 6 | 7 | // create a column header (the first element of a column) 8 | // @param num: an integer (i.e. a column number) 9 | // @param pianoRollObject: an instance of PianoRoll 10 | function createColumnHeader(num, pianoRollObject){ 11 | const newHeader = document.createElement('div'); 12 | newHeader.id = "col_" + (num-1); 13 | newHeader.style.margin = "0 auto"; 14 | newHeader.style.display = 'inline-block'; 15 | newHeader.style.textAlign = "center"; 16 | newHeader.style.width = '40px'; 17 | newHeader.style.height = '12px'; 18 | newHeader.style.fontSize = '10px'; 19 | newHeader.setAttribute("data-num-notes", 0); // keep track of whether this column has notes or not 20 | 21 | const subdiv = (num % pianoRollObject.subdivision) === 0 ? pianoRollObject.subdivision : (num % pianoRollObject.subdivision); 22 | 23 | if(num > 0){ 24 | newHeader.className = "thinBorder"; 25 | if(subdiv === 1){ 26 | // mark the measure number (first column of measure) 27 | const measureNumber = document.createElement("h2"); 28 | measureNumber.innerHTML = (Math.floor(num / pianoRollObject.subdivision)+1); 29 | measureNumber.style.margin = '0 0 0 0'; 30 | measureNumber.style.color = pianoRollObject.measureNumberColor; 31 | newHeader.appendChild(measureNumber); 32 | newHeader.className = ""; 33 | }else{ 34 | if(pianoRollObject.subdivision === subdiv){ 35 | newHeader.className = "thickBorder"; 36 | } 37 | newHeader.textContent = subdiv; 38 | } 39 | } 40 | 41 | // attach highlightHeader function to allow user to specify playing to start at this column 42 | newHeader.addEventListener("click", function(){highlightHeader(this.id, pianoRollObject);}); 43 | 44 | return newHeader; 45 | } 46 | 47 | // set up grid headers first (the headers for each column) 48 | // @param columnHeaderRowId: a dom element ID 49 | // @param pianoRollObject: an isntance of PianoRoll 50 | function buildGridHeader(columnHeaderRowId, pianoRollObject){ 51 | const columnHeaderRow = document.getElementById(columnHeaderRowId); 52 | 53 | // this will provide the headers for each column in the grid (i.e. number for each beat/subbeat) 54 | for(let i = 0; i < pianoRollObject.numberOfMeasures * pianoRollObject.subdivision + 1; i++){ 55 | const columnHeader = createColumnHeader(i, pianoRollObject); 56 | 57 | // the very first column header is special :) 58 | if(i === 0){ 59 | columnHeader.style.width = '50px'; 60 | columnHeader.style.border = '1px solid #000'; 61 | } 62 | 63 | columnHeaderRow.append(columnHeader); 64 | } 65 | } 66 | 67 | // used for setting a play marker to indicate where to start playing 68 | // @param headerId: an HTML element id of a column header 69 | // @param pianoRollObject: an instance of PianoRoll 70 | function highlightHeader(headerId, pianoRollObject){ 71 | const element = document.getElementById(headerId); 72 | const currColor = element.style.backgroundColor; 73 | if(currColor !== pianoRollObject.playMarkerColor){ 74 | if(pianoRollObject.playMarker){ 75 | const oldMarker = document.getElementById(pianoRollObject.playMarker); 76 | oldMarker.style.backgroundColor = "rgb(255, 255, 255)"; 77 | } 78 | const columnIndex = parseInt(headerId.match(/\d+/)[0]); 79 | pianoRollObject.playMarker = headerId; 80 | element.style.backgroundColor = pianoRollObject.playMarkerColor; 81 | }else{ 82 | pianoRollObject.playMarker = null; 83 | element.style.backgroundColor = "rgb(255, 255, 255)"; 84 | } 85 | } 86 | 87 | 88 | // build out cells of grid 89 | // @param gridDivId: a string representing an HTML element id of the grid 90 | // @param pianoRollObject: an instance of PianoRoll 91 | function buildGrid(gridDivId, pianoRollObject){ 92 | const thePiano = document.getElementById(gridDivId); 93 | 94 | // this special div is the bar that shows the available notes. this scrolls with the user. 95 | const pianoNotes = document.getElementById('pianoNotes'); 96 | 97 | for(const note in pianoRollObject.noteFrequencies){ 98 | // ignore enharmonics 99 | if(note.substring(0, 2) === "Gb" || note.substring(0, 2) === "Db" || 100 | note.substring(0, 2) === "D#" || note.substring(0, 2) === "G#" || 101 | note.substring(0, 2) === "A#"){ 102 | continue; 103 | } 104 | 105 | //first create new element for new pitch 106 | const newRow = document.createElement('div'); 107 | newRow.id = replaceSharp(note); 108 | newRow.style.display = "table-row"; 109 | 110 | // this creates the notes on the left of the piano roll. it is static. 111 | const newRowText = document.createElement('div'); 112 | newRowText.innerHTML = note.substring(0, note.length - 1) + "" + note[note.length-1] + ""; 113 | newRowText.style.fontSize = "11px"; 114 | newRowText.style.border = "1px solid #000"; 115 | newRowText.style.display = "inline-block"; 116 | newRowText.style.width = "50px"; 117 | newRowText.style.verticalAlign = "middle"; 118 | 119 | newRow.appendChild(newRowText); 120 | thePiano.append(newRow); 121 | 122 | // add new note to pianoNotes div 123 | const newRowClone = newRow.cloneNode(); 124 | newRowClone.id = "pianoNotes_" + newRow.id; 125 | const textClone = newRowText.cloneNode(); 126 | textClone.innerHTML = note.substring(0, note.length - 1) + "" + note[note.length-1] + ""; 127 | newRowClone.appendChild(textClone); 128 | pianoNotes.append(newRowClone); 129 | 130 | // append column cells to each row 131 | for(let j = 0; j < pianoRollObject.numberOfMeasures * pianoRollObject.subdivision; j++){ 132 | const column = createColumnCell(note, j, pianoRollObject); 133 | newRow.appendChild(column); 134 | } 135 | } 136 | } 137 | 138 | // @param pitch: a string representing the pitch of a note, i.e. Fs5 (f sharp 5) 139 | // @param colNum: an integer representing a column number 140 | // @param pianoRollObject: an instance of PianoRoll 141 | function createColumnCell(pitch, colNum, pianoRollObject){ 142 | const column = document.createElement("div"); 143 | column.id = replaceSharp(pitch) + "col_" + colNum; 144 | column.style.display = 'inline-block'; 145 | column.style.width = "40px"; 146 | column.style.height = "15px"; 147 | column.style.verticalAlign = "middle"; 148 | column.style.backgroundColor = "transparent"; 149 | column.setAttribute("data-type", "default"); 150 | column.setAttribute("data-volume", 0.2); 151 | column.className = "noteContainer"; 152 | 153 | if((colNum + 1) % pianoRollObject.subdivision == 0){ 154 | column.classList.add("thickBorder"); 155 | }else{ 156 | column.classList.add("thinBorder"); 157 | } 158 | 159 | // hook up an event listener to allow for picking notes on the grid! 160 | column.addEventListener("click", function(evt){ 161 | addNote(this.id, pianoRollObject, evt, true); 162 | }); 163 | 164 | // allow for highlighting to make it clear which note a block corresponds to 165 | column.addEventListener("mouseenter", function(){ highlightRow(this.id, pianoRollObject.highlightColor); }); 166 | column.addEventListener("mouseleave", function(){ highlightRow(this.id, 'transparent'); }); 167 | return column; 168 | } 169 | 170 | function replaceSharp(string){ 171 | // this adjustment is only necessary for labeling the DOM elements so that they're clickable 172 | return string.replace('#', 's'); // s for sharp 173 | } 174 | 175 | 176 | try{ 177 | module.exports = { 178 | replaceSharp: replaceSharp, 179 | buildGridHeader: buildGridHeader, 180 | buildGrid: buildGrid, 181 | highlightHeader: highlightHeader 182 | }; 183 | }catch(e){ 184 | // ignore 185 | } -------------------------------------------------------------------------------- /db_stuff/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | "<%= user.local.username %>'s" Piano Roll - Piano Roll Online 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |

piano roll fun 🎹

29 | 30 |
    31 |
  • title:

  • 32 |
  • hi

  • 33 |
  • |
  • 34 |
  • composer:

  • 35 |
  • hi

  • 36 |
  • |
  • 37 |
  • 38 |
  • |
  • 39 |
  • 40 | 43 | 48 |
  • 49 |
  • |
  • 50 |
  • 51 | 54 | 60 |
  • 61 |
  • |
  • 62 |
  • 63 | 66 | 70 |
  • 71 |
  • |
  • 72 |
  • 73 | 74 | 75 |
  • 76 |
  • |
  • 77 |
  • help!

  • 78 |
  • |
  • 79 |
  • profile

  • 80 |
  • |
  • 81 |
  • logout

  • 82 |
83 | 84 | 85 |
    86 |
  • 87 | 90 |
  • 91 | 92 |
  • 93 | 96 |
  • 97 | 98 |
  • 99 | 102 |
  • 103 | 104 | 105 |
  • 106 | 109 |
  • 110 | 111 |
  • 112 | 115 |
  • 116 | 117 |
  • 118 | 121 |
  • 122 | 123 |
  • 124 | 127 |
  • 128 | 129 |
  • 130 | 133 |
  • 134 | 135 |
  • 136 | 139 |
  • 140 | 141 |
  • 142 | 145 |
  • 146 | 147 | 148 |
  • 149 | 152 |
  • 153 | 154 | 155 |
  • 156 | 159 |
  • 160 | 161 |
  • 162 | 165 |
  • 166 |
167 | 168 |
169 | 170 | 185 |
186 | 187 |
188 | 189 | 195 |
196 | 197 |

198 | 199 |
200 | 201 | 202 |
203 | 204 | 205 | 206 | 207 |
default instrument
208 |
209 | 210 |
211 | 212 |
213 |
214 |
215 | 216 | 217 |
218 |
219 | 220 | 221 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | -------------------------------------------------------------------------------- /icons/saveProjectToDB.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 25 | 31 | 37 | 38 | 57 | 59 | 60 | 62 | image/svg+xml 63 | 65 | 66 | 67 | 68 | 69 | 74 | 78 | 89 | 93 | 104 | 108 | 112 | 130 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # piano_roll_browser 2 | a music sequencer inspired by LMMS, one of the best software applications ever! 3 | also influenced a bit by PxTone Collage, another great application! 4 | 5 | **Please note that this application is not intended to be used on mobile devices.** 6 | 7 | ![screenshot of the piano roll](screenshots/current.png "current look") 8 | 9 | ### cool features: 10 | - savable projects (as .json) 11 | - can click-and-drag to move and resize notes (down to as small as 32nd notes) 12 | - each note is customizable 13 | - togglable onion skin 14 | - recordable 15 | - can export project as an .mmp file for use in LMMS 16 | - has a couple visualizers (although may be a bit buggy atm and doesn't seem to work for any projects that have more than 51 measures) 17 | ![visualizer](screenshots/visualizer.gif "visualizer") 18 | 19 | ### instructions: 20 | - To change the name of the piece or the composer, double-click on 'title' or 'composer', just above the buttons. 21 | 22 | - Right-click an instrument block to change the sound, its default volume or toggle its notes' visibility when switching to another instrument. 23 | 24 | **Instrument context-menu on right-click:** 25 | ![instrument context menu](screenshots/instrument_menu.gif "instrument context menu") 26 | 27 | - Left-click a block on the grid to place a note; right-click to open a context menu to delete or use the middle mouse button. Stretch or shorten notes by grabbing the right side of a note and dragging. Notes can also be moved. Form chords by placing multiple notes in a column. 28 | 29 | **Note context-menu on right-click:** 30 | ![note context menu](screenshots/note_menu.gif "note context menu") 31 |      32 | **Resizing notes:** 33 | ![resizing notes](screenshots/note_resize.gif "resizing notes") 34 | 35 | - Change the note lock size to adjust the range of note sizes and positions. 36 | 37 | **Changing note lock size:** 38 | ![changing note lock size](screenshots/note_lock.gif "changing note lock size") 39 | 40 | - Start playback at any column by clicking on any column header. 41 | 42 | **setting play marker:** 43 | ![setting play marker](screenshots/playmarker.gif "setting playback at a certain column with the play marker") 44 | 45 | **toggling sticky toolbar:** 46 | ![toggle sticky toolbar](screenshots/sticky_toolbar.gif "toggling the toolbar to be sticky") 47 | 48 | I've also implemented a rudimentary custom instrument preset import ability (use this to create a custom instrument preset: https://github.com/syncopika/soundmaker). You can see some basic example presets in /example_presets, which are imported automatically in my demo. 49 | 50 | Disclaimer: the custom instrument functionality is currently pretty limited; there are lots of possible custom preset configurations that will break under my current implementation. So this feature is definitely a work-in-progress but you can maybe at least see its future potential! :) 51 | 52 | ### current issues/limitations: 53 | - (Chrome) downloading the audio isn't great - the audio comes out fine but the duration is messed up (see: https://stackoverflow.com/questions/38443084/how-can-i-add-predefined-length-to-audio-recorded-from-mediarecorder-in-chrome). 54 | - visualization doesn't work for projects that are more than 51 measures long 55 | - projects longer than 51 measures may experience some lag with some notes on playback (probably due to the way I'm scheduling notes for playback) 56 | 57 | ### current next steps?: 58 | - refactoring + tests 59 | 60 | ### features I would like to implement: 61 | - be able to repeat a section 62 | - looping 63 | 64 | ### some notes on implementation / design: 65 | The objective of my piano roll is to allow users to arrange a number of notes with varying lengths and pitches with the help of a grid, put these notes in an array, and then create audio nodes for each note so that the sequence of notes can be played back. 66 | 67 | Users can place and move notes freely on the piano roll. In order to do that, my program looks at a couple of factors: the x-position of the cursor and the note lock size, which can be an 8th note (1 grid cell on the piano roll), 16th note (half a cell), or 32nd note. The note lock size determines the incremental distance a note block can be moved. 68 | 69 | My implementation also does not use the canvas element like some other piano roll implementations (e.g. https://onlinesequencer.net/) and instead relies on just DOM manipulation of a grid to manipulate notes. This might also be a source of concern with regards to performance (but so far I haven't had any issues yet with this approach). 70 | 71 | One limitation I noticed is that the performance of what the Web Audio API has to offer is based on the user's computer CPU. Depending on the computer, the creation of new, separate audio nodes per musical note can cause considerable lag and render an application useless (this number can get very large especially because some instruments involve multiple nodes per note). 72 | 73 | To optimize things a bit, instead of allowing new nodes to be created for each note, I calculate the minimum number of nodes needed for each instrument based on the maximum number of notes playing at the same time for that instrument. This strategy appears to work pretty well and improved performance considerably on my laptop (HP Notebook 15-ay011nr). Check out some of my hand-drawn diagrams in `/notes` for visual representations of my idea. Another possible performance enhancement might be the way I'm scheduling notes. Instead of scheduling them all upfront and creating all the necessary nodes at once, maybe I can create them gradually? 74 | 75 | For the svg icons, I crafted them manually (with help from [this wonderful tutorial](https://www.aleksandrhovhannisyan.com/blog/svg-tutorial-how-to-code-svg-icons-by-hand/) by Aleksandr Hovhannisyan). Hopefully they're not too awful! 76 | 77 | For the piano instrument sounds I used the Steinway D from the Equinox Grand Pianos soundfont. 78 | 79 | For the context menus used to edit instruments and notes, I used the awesome jQuery contextMenu library provided here: https://swisnl.github.io/jQuery-contextMenu/ as inspiration. Thanks very much to them! 80 | 81 | ### installation: 82 | You don't need to install anything to use the piano roll itself (limited to just the basic sounds such as sine, sawtooth, triangle and square) locally; however, if you want to load in the demos and example custom presets, you'll need to serve the html page first on a local server. 83 | 84 | If you have Python, you can just run `python -m http.server` in this repo after you've downloaded it and navigate to `http://localhost:8000`. If you have node and npm, run `npm install` in this repo to get the dependencies (which also includes the needed libraries for running the tests!) and then run `node server.js`. Then navigate to `http://localhost:3000/` to see the piano roll. 85 | 86 | For styling I played a bit with Sass and made a .scss file. To compile the .css file used you can download the Dart Sass binary [here](https://github.com/sass/dart-sass/releases/) and run `sass style.scss`. 87 | 88 | To run the tests, make sure the dependencies have been downloaded via `npm install`. Then run `npm run test`. 89 | 90 | I also have some code to accommodate a MongoDB backend with a basic login feature in case you might want to create an application that needs a login/auth feature and a MongoDB backend ;). To get that set up locally, check out the `db_stuff` folder. You'll also need to make sure to install the devDependencies in `package.json`. I haven't touched that code in a while though and don't really plan to in the near future so it may be a bit broken. 91 | 92 | ### demos: 93 | Intrada - Johann Pezel (1639 - 1694). One of my favorite brass quintet pieces! 94 | 95 | Les Barricades Mysterieuses (excerpt) - François Couperin (1668-1733) 96 | 97 | Sand Canyon (Kirby's Dream Land 3) - Jun Ishikawa 98 | 99 | 3_4 time demo - my own composition 100 | 101 | copycat_demo - my own composition (check out the original [here](https://opengameart.org/content/copycat)!) 102 | 103 | なかよしセンセーション (Princess Connect! Re:Dive) - Kaoru Okubo 104 | 105 | Route 209 theme (Pokémon Diamond/Pearl) - Hitomi Sato 106 | -------------------------------------------------------------------------------- /src/instrumentPreset.js: -------------------------------------------------------------------------------- 1 | // utility functions to handle importing instrument presets 2 | // currently experimental and should correspond with: https://github.com/syncopika/soundmaker 3 | 4 | class ADSREnvelope { 5 | constructor(){ 6 | this.attack = 0; 7 | this.sustain = 0; 8 | this.decay = 0; 9 | this.release = 0; 10 | this.sustainLevel = 0; 11 | } 12 | 13 | updateParams(params){ 14 | for(const param in params){ 15 | if(param in this){ 16 | this[param] = params[param]; 17 | } 18 | } 19 | } 20 | 21 | applyADSR(targetNodeParam, time, duration, volToUse=null){ 22 | // @targetNodeParam might be the gain property of a gain node, or a filter node for example 23 | // the targetNode just needs to have fields that make sense to be manipulated with ADSR 24 | // i.e. pass in gain.gain as targetNodeParam for applying the envelope to a gain node 25 | // @time == current time 26 | // @duration == how long the note should last. it may be less than the sum of the params + start time 27 | // so we need to make sure the node is stopped when it needs to be. 28 | this.sustainLevel = this.sustainLevel === 0 ? 1 : this.sustainLevel; 29 | 30 | const baseParamVal = volToUse ? volToUse : targetNodeParam.value; // i.e. gain.gain.value 31 | 32 | // you want to keep the value changes from the envelope steady throughout even if the note duration is not long enough to use the whole envelope 33 | // NOTE: cancelAndHoldAtTime is not implemented in Firefox :( 34 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1308431, https://github.com/WebAudio/web-audio-api/issues/2437 35 | if(targetNodeParam.cancelAndHoldAtTime){ 36 | targetNodeParam.cancelAndHoldAtTime(time); 37 | } 38 | 39 | targetNodeParam.linearRampToValueAtTime(0.0, time); 40 | targetNodeParam.linearRampToValueAtTime(baseParamVal, time + this.attack); 41 | targetNodeParam.linearRampToValueAtTime(baseParamVal * this.sustainLevel, time + this.attack + this.decay); 42 | targetNodeParam.linearRampToValueAtTime(baseParamVal * this.sustainLevel, time + this.attack + this.decay + this.sustain); 43 | targetNodeParam.linearRampToValueAtTime(0.0, time + duration + this.attack + this.decay + this.sustain + this.release); 44 | 45 | return targetNodeParam; 46 | } 47 | } 48 | 49 | // sets up all the audio nodes needed for this instrument preset 50 | function createPresetInstrument(data, audioCtx){ 51 | 52 | const nodeTypes = { 53 | 54 | "GainNode": function(params){ 55 | const gain = new GainNode(audioCtx, params); 56 | 57 | // need to add the gain value as an extra property so we don't lose it 58 | // when changing the actual value of the gain. this is needed when scaling volume 59 | // for custom instruments with multiple gain nodes. 60 | gain.baseGainValue = gain.gain.value; 61 | return gain; 62 | }, 63 | 64 | "OscillatorNode": function(params){ 65 | return new OscillatorNode(audioCtx, params); 66 | }, 67 | 68 | "ADSREnvelope": function(params){ 69 | const newADSREnv = new ADSREnvelope(); 70 | newADSREnv.updateParams(params); 71 | return newADSREnv; 72 | }, 73 | 74 | "AudioBufferSourceNode": function(params){ 75 | 76 | if(params["buffer"].channelData){ 77 | const bufferData = new Float32Array([...Object.values(params["buffer"].channelData)]); 78 | 79 | //delete params["buffer"]['channelData']; // not a real param we can use for the constructor 80 | delete params["buffer"]['duration']; // duration param not supported for constructor apparently 81 | 82 | const buffer = new AudioBuffer(params["buffer"]); 83 | buffer.copyToChannel(bufferData, 0); // only one channel 84 | params["buffer"] = buffer; 85 | } 86 | 87 | const newAudioBuffSource = new AudioBufferSourceNode(audioCtx, params); 88 | newAudioBuffSource.loop = true; 89 | return newAudioBuffSource; 90 | }, 91 | 92 | "BiquadFilterNode": function(params){ 93 | return new BiquadFilterNode(audioCtx, params); 94 | } 95 | }; 96 | 97 | // set up all our nodes first 98 | const nodeMap = {}; // map our node names to their node instances 99 | for(const nodeName in data){ 100 | const nodeInfo = data[nodeName]; 101 | for(const type in nodeTypes){ 102 | if(nodeName.indexOf(type) >= 0){ 103 | // find the right node function to call based on nodeName 104 | const audioNode = nodeTypes[type](nodeInfo.node); // create new node 105 | nodeMap[nodeName] = audioNode; // add it to the map 106 | audioNode.id = nodeInfo.id; 107 | break; 108 | } 109 | } 110 | } 111 | 112 | // attach any envelopes as needed to the gain nodes 113 | const gainNodes = [...Object.keys(nodeMap)].filter((key) => key.indexOf("Gain") >= 0); 114 | gainNodes.forEach((gain) => { 115 | // TODO: for now we're assuming only 1 envelope for gain nodes (and for their gain prop) 116 | const feed = data[gain].feedsFrom.filter((nodeName) => nodeName.indexOf("ADSR") >= 0); 117 | if(feed.length >= 0){ 118 | const envelope = nodeMap[feed[0]]; // get the ADSREnvelope object ref 119 | nodeMap[gain].envelope = envelope; // attach it to this gain node for easy access as a prop called envelope 120 | } 121 | }); 122 | 123 | // then link them up properly based on feedsInto and feedsFrom for each node given in the data 124 | const oscNodes = [...Object.keys(nodeMap)].filter((key) => key.indexOf("Oscillator") >= 0 || key.indexOf("AudioBuffer") >= 0); 125 | 126 | const nodesToStart = []; 127 | oscNodes.forEach((osc) => { 128 | 129 | const newOsc = nodeMap[osc]; 130 | 131 | // need to go down all the way to each node and make connections 132 | // gain nodes don't need to be touched as they're already attached to the context dest by default 133 | const connections = data[osc].feedsInto; 134 | 135 | connections.forEach((conn) => { 136 | // connect the new osc node to this connection 137 | const sinkNode = nodeMap[conn]; 138 | 139 | // make connection 140 | newOsc.connect(sinkNode); 141 | //console.log("connecting: " + newOsc.constructor.name + " to: " + sinkNode.constructor.name); 142 | 143 | // if sink is a gain node, no need to go further 144 | if(sinkNode.id.indexOf("Gain") < 0){ 145 | let stack = data[sinkNode.id]["feedsInto"]; 146 | let newSource = sinkNode; 147 | 148 | while(stack.length > 0){ 149 | const next = stack.pop(); 150 | const currSink = nodeMap[next]; 151 | //console.log("connecting: " + newSource.constructor.name + " to: " + currSink.constructor.name); 152 | newSource.connect(currSink); 153 | newSource = currSink; 154 | nextConnections = data[next]["feedsInto"].filter((name) => name.indexOf("Destination") < 0); 155 | stack = stack.concat(nextConnections); 156 | } 157 | } 158 | }); 159 | }); 160 | 161 | //console.log(nodeMap); 162 | return nodeMap; 163 | } 164 | 165 | // get the gain nodes and the osc nodes objects that need to be played given a custom preset 166 | // TODO: what about ADSR envelopes!? 167 | function getNodeNamesFromCustomPreset(currPreset){ 168 | //console.log(currPreset); 169 | const nodes = [...Object.keys(currPreset)]; 170 | const oscNodes = nodes.filter((nodeName) => { 171 | return nodeName.indexOf("Osc") >= 0 || nodeName.indexOf("AudioBuffer") >= 0; 172 | }); 173 | 174 | const gainNodes = nodes.filter((nodeName) => { 175 | return nodeName.indexOf("Gain") >= 0; 176 | }); 177 | 178 | return { 179 | "gainNodes": gainNodes, 180 | "oscNodes": oscNodes 181 | }; 182 | } 183 | 184 | function getNodesCustomPreset(customPreset){ 185 | const nodes = getNodeNamesFromCustomPreset(customPreset); 186 | 187 | for(const nodeType in nodes){ 188 | // remap so that instead of a list of names, we get a list of nodes 189 | nodes[nodeType] = nodes[nodeType].map((node) => customPreset[node]); 190 | } 191 | 192 | return nodes; 193 | } 194 | 195 | 196 | // handling a custom preset when clicking on a note 197 | function onClickCustomPreset(pianoRollObject, waveType, volume, parent){ 198 | 199 | const audioCtx = pianoRollObject.audioContext; 200 | const presetData = pianoRollObject.instrumentPresets[waveType]; 201 | const currPreset = createPresetInstrument(presetData, audioCtx); 202 | 203 | const nodes = getNodeNamesFromCustomPreset(currPreset); 204 | const oscNodes = nodes.oscNodes; 205 | const gainNodes = nodes.gainNodes; 206 | 207 | const now = audioCtx.currentTime; 208 | 209 | oscNodes.forEach((oscName) => { 210 | const osc = currPreset[oscName]; 211 | if(osc.frequency){ 212 | osc.frequency.value = pianoRollObject.noteFrequencies[parent]; 213 | } 214 | osc.start(0); 215 | if(osc.stop){ 216 | osc.stop(now + .200); 217 | } 218 | }); 219 | 220 | let gainValueSum = 0; 221 | gainNodes.forEach((name) => { 222 | gainValueSum += currPreset[name].gain.value; 223 | }); 224 | 225 | gainNodes.forEach((gainName) => { 226 | // let's scale our gain nodes' gain values appropriately based on the instrument's current volume value. 227 | // divide this gain value from the sum of all the gain values for this preset, 228 | // then multiply it by the curr. instrument's volume to get the equivalent proportion of gain values in the context of this instrument's volume. 229 | const gainNode = currPreset[gainName]; 230 | 231 | //gainNode.gain.value = pianoRollObject.currentInstrument.volume; 232 | gainNode.gain.value = ((gainNode.gain.value / gainValueSum) * volume); 233 | 234 | gainNode.connect(pianoRollObject.audioContextDestOriginal); 235 | 236 | // apply any ADSR envelopes that feed into this gainNode 237 | presetData[gainName].feedsFrom.forEach((feed) => { 238 | if(feed.indexOf("ADSR") >= 0){ 239 | const adsr = feed; 240 | const envelope = currPreset[adsr]; 241 | envelope.applyADSR(gainNode.gain, now, .200); 242 | }else{ 243 | gainNode.gain.setTargetAtTime(gainNode.gain.value, now, 0.002); 244 | } 245 | }); 246 | }); 247 | } -------------------------------------------------------------------------------- /src/mmpGenerator.js: -------------------------------------------------------------------------------- 1 | // export html piano roll to .mmp (LMMS) file 2 | 3 | /* example .mmp file 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 |

Put down your project notes here.

]]>
32 | 33 | 34 |
35 |
36 | */ 37 | 38 | const projectTemplate = ` 39 | 40 | 41 | 42 | 43 | 44 | %tracks 45 | 46 | 47 | 48 | 49 | 50 | 53 |

Put down your project notes here.

]]>
54 | 55 | 56 |
57 |
58 | `; 59 | 60 | const trackTemplate = ` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | %notes 72 | 73 | 74 | `; 75 | 76 | const noteTemplate = ``; 77 | 78 | function interpolateValues(template, key, value){ 79 | return template.replaceAll(key, value); 80 | } 81 | 82 | // convert px lengths of notes in html piano roll to the lengths used in LMMS/.mmp files 83 | function convertNoteLengthHtmlToLmms(htmlLength){ 84 | return Math.floor((htmlLength / 40) * 24); // 1 eighth note in html piano roll is 40px. 1 eighth note in LMMS is 24 (I dunno the units lol). 85 | } 86 | 87 | function generateInstrumentTrack(instrumentData, pianoKeys){ 88 | // sort the notes first based on position 89 | const notes = Array.from(Object.keys(instrumentData.notes)); 90 | const sortedNotes = notes.sort((a, b) => parseInt(a.split('_')[1]) < parseInt(b.split('_')[1])); // e.g. Fs4col_454 vs Eb6col_450 91 | 92 | // then collect xml of notes 93 | const notesXml = []; 94 | 95 | let currLength = 0; 96 | sortedNotes.forEach(noteName => { 97 | const n = instrumentData.notes[noteName]; // this is an array :/. tbh I don't remember why I designed it this way -__-. but there could be multiple notes in the same note cell, e.g. 2 16th notes 98 | const noteKey = pianoKeys[noteName.split('col')[0].replace('s', '#')] - 1; // my piano key numbering is off by one? :/ 99 | 100 | if(noteKey === undefined){ 101 | console.log(noteName); 102 | } 103 | 104 | n.forEach(note => { 105 | // convert note length 106 | const len = convertNoteLengthHtmlToLmms(parseInt(note.width)); 107 | 108 | // convert volume 109 | // LMMS is from 0-100 whereas html piano roll is 0-0.5 110 | const vol = Math.round(parseFloat(note.volume) * 100 / 0.5); 111 | 112 | // convert position 113 | // html piano roll notes have an offset of 60px (so pos 0 in LMMS would be 60px in html piano roll) 114 | const pos = convertNoteLengthHtmlToLmms((parseInt(note.left) - 60)); 115 | 116 | const addLen = interpolateValues(noteTemplate, '%length', len); 117 | const addVol = interpolateValues(addLen, '%volume', vol); 118 | const addPos = interpolateValues(addVol, '%position', pos); 119 | const addKey = interpolateValues(addPos, '%key', noteKey); 120 | 121 | notesXml.push(addKey); 122 | }); 123 | 124 | // find the note with the longest width in the array and add it to the running length total 125 | // so we know how long to set the track pattern to be 126 | currLength += convertNoteLengthHtmlToLmms(Math.max(...n.map(x => parseInt(x.width)))); 127 | }); 128 | 129 | // create a new track for this instrument 130 | // replace instrument name 131 | // replace pan 132 | const name = instrumentData.name; 133 | const pan = instrumentData.pan; 134 | 135 | const addName = interpolateValues(trackTemplate, '%instrumentName', name); 136 | const addPan = interpolateValues(addName, '%pan', Math.round(pan * 100)); // LMMS panning and volume range from 0-100 whereas html piano roll is 0-1. 137 | 138 | // replace total length of LMMS pattern based on total length of instrument notes 139 | const addLength = interpolateValues(addPan, '%length', currLength); 140 | 141 | // then add the notes 142 | const track = interpolateValues(addLength, '%notes', notesXml.join('\n')); 143 | 144 | return track; 145 | } 146 | 147 | function exportMMPFile(projectData){ 148 | // we need to know the index of the correpsonding piano key of a note 149 | const pianoKeys = { 150 | "C8": 97, 151 | "B7": 96, 152 | "Bb7": 95, 153 | "A#7": 95, 154 | "A7": 94, 155 | "Ab7": 93, 156 | "G#7": 93, 157 | "G7": 92, 158 | "F#7": 91, 159 | "F7": 90, 160 | "E7": 89, 161 | "Eb7": 88, 162 | "D#7": 88, 163 | "D7": 87, 164 | "C#7": 86, 165 | "C7": 85, 166 | "B6": 84, 167 | "Bb6": 83, 168 | "A#6": 83, 169 | "A6": 82, 170 | "Ab6": 81, 171 | "G#6": 81, 172 | "G6": 80, 173 | "F#6": 79, 174 | "F6": 78, 175 | "E6": 77, 176 | "Eb6": 76, 177 | "D#6": 76, 178 | "D6": 75, 179 | "C#6": 74, 180 | "C6": 73, 181 | "B5": 72, 182 | "Bb5": 71, 183 | "A#5": 71, 184 | "A5": 70, 185 | "Ab5": 69, 186 | "G#5": 69, 187 | "G5": 68, 188 | "F#5": 67, 189 | "F5": 66, 190 | "E5": 65, 191 | "Eb5": 64, 192 | "D#5": 64, 193 | "D5": 63, 194 | "C#5": 62, 195 | "C5": 61, 196 | "B4": 60, 197 | "Bb4": 59, 198 | "A#4": 59, 199 | "A4": 58, 200 | "Ab4": 57, 201 | "G#4": 57, 202 | "G4": 56, 203 | "F#4": 55, 204 | "F4": 54, 205 | "E4": 53, 206 | "Eb4": 52, 207 | "D#4": 52, 208 | "D4": 51, 209 | "C#4": 50, 210 | "C4": 49, 211 | "B3": 48, 212 | "Bb3": 47, 213 | "A#3": 47, 214 | "A3": 46, 215 | "Ab3": 45, 216 | "G#3": 45, 217 | "G3": 44, 218 | "F#3": 43, 219 | "F3": 42, 220 | "E3": 41, 221 | "Eb3": 40, 222 | "D#3": 40, 223 | "D3": 39, 224 | "C#3": 38, 225 | "C3": 37, 226 | "B2": 36, 227 | "Bb2": 35, 228 | "A#2": 35, 229 | "A2": 34, 230 | "Ab2": 33, 231 | "G#2": 33, 232 | "G2": 32, 233 | "F#2": 31, 234 | "F2": 30, 235 | "E2": 29, 236 | "Eb2": 28, 237 | "D#2": 28, 238 | "D2": 27, 239 | "C#2": 26, 240 | "C2": 25 241 | }; 242 | 243 | // get some metadata to fill in 244 | // %bpm 245 | const bpm = projectData.tempo; 246 | const addBpm = interpolateValues(projectTemplate, '%bpm', bpm); 247 | 248 | // %timesig 249 | const timeSigNumerator = parseInt(projectData.timeSignature.split('/')[0]); 250 | const addTimeSig = interpolateValues(addBpm, '%timesig', timeSigNumerator); 251 | 252 | // then get instruments 253 | const instruments = projectData.instruments; 254 | const instrumentXml = []; 255 | instruments.forEach(inst => { 256 | instrumentXml.push(generateInstrumentTrack(inst, pianoKeys)); 257 | }); 258 | 259 | const mmp = interpolateValues(addTimeSig, '%tracks', instrumentXml.join('\n')); 260 | 261 | // export mmp file 262 | const blob = new Blob([mmp], {type: "text/xml"}); 263 | const url = URL.createObjectURL(blob); 264 | const link = document.createElement('a'); 265 | link.href = url; 266 | link.download = `${projectData.title}.mmp`; 267 | link.click(); 268 | } -------------------------------------------------------------------------------- /icons/clearGridAlt.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 25 | 31 | 37 | 38 | 57 | 59 | 60 | 62 | image/svg+xml 63 | 65 | 66 | 67 | 68 | 69 | 74 | 81 | 85 | 96 | 100 | 111 | 115 | 119 | 123 | 127 | 131 | 135 | 140 | 144 | 148 | 152 | 156 | 160 | 164 | 168 | 172 | 176 | 181 | 186 | 191 | 196 | 200 | 204 | 205 | 209 | 214 | 219 | 224 | 225 | 226 | -------------------------------------------------------------------------------- /src/classes.js: -------------------------------------------------------------------------------- 1 | /****** 2 | 3 | PIANO ROLL CLASS 4 | this will hold all the data relevant to the piano roll, such as number of measures, subdivisions, time signature, etc. 5 | 6 | *******/ 7 | function PianoRoll(){ 8 | this.numberOfMeasures = 4; // 4 measures by default 9 | this.subdivision = 8; // number of eighth notes per measure (8 for 4 quarter notes per measure, 6 for 3/4) 10 | this.currentTempo = 250; // hold the current tempo (this is time in milliseconds per 8th note). 250 ms seems about right for 120 bpm (and with the length of 8th notes as 40px) 11 | this.timeSignature = "4/4"; // options are 4/4 or 3/4 12 | this.instruments = []; // list of instruments will be an array 13 | this.timers = []; // keep track of setTimeouts so all can be ended at once 14 | this.currentInstrument; // need to keep track of what current instrument is! 15 | this.audioContext; // associate an AudioContext with this PianoRoll 16 | this.audioContextDestOriginal; // the original audio context destination 17 | this.audioContextDestMediaStream; // a media stream destination for the audio context (to be used when recording is desired) 18 | this.audioDataChunks = []; 19 | this.lastTime = 0; // the time the last note was supposed to be played 20 | this.isPlaying = false; // a boolean flag to easily quit playing 21 | this.loopFlag = false; // if playback should be looped or not 22 | this.recording = false; // if recording. note that if looping, recording should not be possible. 23 | this.recorder; // a MediaRecorder instance 24 | this.playMarker; // the id of a column header indicating where to start playing 25 | this.lastNoteColumn; // html element of the column header of the last note that was played 26 | this.autoScroll = false; // if auto scroll when playing 27 | 28 | this.lockNoteSize = "16th"; // the note-size increment to be used when moving/placing notes 29 | this.addNoteSize = "last selected"; // note-size to use when adding notes (changes based on last selected/resize by default) 30 | this.lastNoteSize = 40; // last clicked-on note size in px as integer 31 | this.noteIdNum = 0; // use this to create a unique number for each added note's id 32 | 33 | this.instrumentPresets = {}; // a dictionary to keep track of imported instrument presets 34 | this.noiseBuffer; // for percussion 35 | 36 | // stuff needed for the visualizer 37 | this.selectedVizualizer = null; 38 | this.analyserNode = null; 39 | this.visualizerCanvas = null; 40 | this.visualizerOffscreenCanvas = null; 41 | this.visualizerWebWorker = null; 42 | this.visualizerRequestAnimationFrameId = null; 43 | 44 | // colors 45 | this.playMarkerColor = "rgb(50, 205, 50)"; 46 | this.highlightColor = "#FFFF99"; // yellow 47 | this.measureNumberColor = "#2980B9"; // blue 48 | this.instrumentTableColor = 'rgb(188, 223, 70)'; // green 49 | this.currNotePlayingColor = 'rgb(112, 155, 224)'; // light blue 50 | 51 | // default instrument sounds and note styles 52 | this.defaultInstrumentSounds = { 53 | 1: "square", 54 | 2: "sine", 55 | 3: "sawtooth", 56 | 4: "triangle", 57 | 5: "percussion", 58 | 6: "piano" 59 | }; 60 | 61 | /****** 62 | default note styles 63 | TODO: can we reorganize this so that we can map 64 | a style to a function so that we don't we have do that in 65 | the scheduler function? 66 | but we also need to note that default, legato and staccato 67 | affect duration, whereas glide affects oscillator freq. 68 | or maybe make this its own class? 69 | ******/ 70 | this.defaultNoteStyles = { 71 | 1: "default", 72 | 2: "legato", 73 | 3: "staccato", 74 | 4: "glide", 75 | }; 76 | 77 | this.noteSizeMap = { 78 | "8th": 40, 79 | "16th": 20, 80 | "32nd": 10, 81 | }; 82 | 83 | this.noteFrequencies = { 84 | "C8": 4186.01, 85 | "B7": 3951.07, 86 | "Bb7": 3729.31, 87 | "A#7": 3729.31, 88 | "A7": 3520.00, 89 | "Ab7": 3322.44, 90 | "G#7": 3322.44, 91 | "G7": 3135.96, 92 | "F#7": 2959.96, 93 | "F7": 2793.83, 94 | "E7": 2637.02, 95 | "Eb7": 2489.02, 96 | "D#7": 2489.02, 97 | "D7": 2349.32, 98 | "C#7": 2217.46, 99 | 100 | "C7": 2093.00, 101 | "B6": 1975.53, 102 | "Bb6": 1864.66, 103 | "A#6": 1864.66, 104 | "A6": 1760.00, 105 | "Ab6": 1661.22, 106 | "G#6": 1661.22, 107 | "G6": 1567.98, 108 | "F#6": 1479.98, 109 | "F6": 1396.91, 110 | "E6": 1318.51, 111 | "Eb6": 1244.51, 112 | "D#6": 1244.51, 113 | "D6": 1174.66, 114 | "C#6": 1108.73, 115 | "C6": 1046.50, 116 | 117 | "B5": 987.77, 118 | "Bb5": 932.33, 119 | "A#5": 932.33, 120 | "A5": 880.00, 121 | "Ab5": 830.61, 122 | "G#5": 830.61, 123 | "G5": 783.99, 124 | "F#5": 739.99, 125 | "F5": 698.46, 126 | "E5": 659.25, 127 | "Eb5": 622.25, 128 | "D#5": 622.25, 129 | "D5": 587.33, 130 | "C#5": 554.37, 131 | "C5": 523.25, 132 | 133 | "B4": 493.88, 134 | "Bb4": 466.16, 135 | "A#4": 466.16, 136 | "A4": 440.00, 137 | "Ab4": 415.30, 138 | "G#4": 415.30, 139 | "G4": 392.00, 140 | "F#4": 369.99, 141 | "F4": 349.23, 142 | "E4": 329.63, 143 | "Eb4": 311.13, 144 | "D#4": 311.13, 145 | "D4": 293.66, 146 | "C#4": 277.18, 147 | "C4": 261.63, 148 | 149 | "B3": 246.94, 150 | "Bb3": 233.08, 151 | "A#3": 233.08, 152 | "A3": 220.00, 153 | "Ab3": 207.63, 154 | "G#3": 207.63, 155 | "G3": 196.00, 156 | "F#3": 185.00, 157 | "F3": 174.61, 158 | "E3": 164.81, 159 | "Eb3": 155.56, 160 | "D#3": 155.56, 161 | "D3": 146.83, 162 | "C#3": 138.59, 163 | "C3": 130.81, 164 | 165 | "B2": 123.47, 166 | "Bb2": 116.54, 167 | "A#2": 116.54, 168 | "A2": 110.00, 169 | "Ab2": 103.83, 170 | "G#2": 103.83, 171 | "G2": 98.00, 172 | "F#2": 92.50, 173 | "F2": 87.31, 174 | "E2": 82.41, 175 | "Eb2": 77.78, 176 | "D#2": 77.78, 177 | "D2": 73.42, 178 | "C#2": 69.30, 179 | "C2": 65.41 180 | }; 181 | 182 | this.init = function(){ 183 | const context = new AudioContext(); 184 | this.audioContext = context; 185 | 186 | // suspend the context (M70 update (Chrome)) 187 | context.suspend(); 188 | 189 | // save a reference to the original audio destination 190 | this.audioContextDestOriginal = context.destination; 191 | 192 | // make a recorder and set it up for recording 193 | const audioStream = context.createMediaStreamDestination(); 194 | this.audioContextDestMediaStream = audioStream; 195 | this.recorder = new MediaRecorder(audioStream.stream); 196 | 197 | this.recorder.ondataavailable = (function(pianoRoll){ 198 | return function(evt){ 199 | pianoRoll.audioDataChunks.push(evt.data); 200 | }; 201 | })(this); 202 | 203 | this.recorder.onstop = (function(pianoRoll){ 204 | return function(evt){ 205 | const blob = new Blob(pianoRoll.audioDataChunks, {'type': 'audio/ogg; codecs=opus'}); 206 | console.log(blob); 207 | const url = URL.createObjectURL(blob); 208 | const link = document.createElement('a'); 209 | link.href = url; 210 | 211 | // duration for output file will be set to infinity on Chrome 212 | // I don't think I can edit the file's duration unless you do some crazy annoying stuff. it's a chrome bug :/ 213 | 214 | // note this is specific to my page html 215 | link.download = document.getElementById('pieceTitle').textContent + "_pianorollfun"; 216 | link.click(); 217 | 218 | // reset audio data array 219 | while(pianoRoll.audioDataChunks.length){ 220 | pianoRoll.audioDataChunks.pop(); 221 | } 222 | }; 223 | })(this); 224 | 225 | const analyser = context.createAnalyser(); 226 | analyser.connect(context.destination); 227 | analyser.fftSize = 2048; 228 | this.analyserNode = analyser; 229 | 230 | this.PercussionManager = new PercussionManager(this); 231 | this.PianoManager = new PianoManager(this); 232 | }; 233 | 234 | } 235 | 236 | 237 | /****** INSTRUMENT CLASS ********/ 238 | function Instrument(name, gain, notesArray){ 239 | this.name = name; 240 | this.gain = gain; // assign a gain node object 241 | this.notes = notesArray; // array of arrays of Note objects (notes that occur at the same time get grouped in the same array) 242 | this.activeNotes = {}; // 243 | this.waveType = "sine"; // sine wave by default 244 | this.volume = 0.2; 245 | this.pan = 0.0; 246 | this.isMute = false; 247 | this.onionSkinOn = true; 248 | 249 | // note color is a gradient 250 | this.noteColorStart = "rgb(0,158,52)"; 251 | this.noteColorEnd = "rgb(52,208,0)"; 252 | } 253 | 254 | function updateNoteColors(instrument){ 255 | for(const noteName in instrument.activeNotes){ 256 | // should be something like: linear-gradient(90deg, rgb(0, 158, 52) 90%, rgb(52, 208, 0) 99%) 257 | instrument.activeNotes[noteName].style.background = `linear-gradient(90deg, ${instrument.noteColorStart} 90%, ${instrument.noteColorEnd} 99%)`; 258 | } 259 | } 260 | 261 | /***** NOTE CLASS ******/ 262 | // this class will hold a note's frequency, duration, and div element 263 | // duration is in milliseconds (i.e. 600, 300, 1000) 264 | function Note(freq, duration, block){ 265 | this.freq = freq; 266 | this.duration = duration; 267 | this.block = new ElementNode(block); 268 | } 269 | 270 | /****** CUSTOM DOM ELEMENT NODE CLASS *********/ 271 | // This class will take a DOM element node and just extract some important info from it, 272 | // such as the id and custom attributes I've assigned, such as "length", "volume", etc. 273 | 274 | // the id is very important in keeping track of which columns to subdivide or rejoin when 275 | // switching instruments. 276 | 277 | // pass in a dom element node and the object will extract the information 278 | function ElementNode(domElement){ 279 | this.id = domElement.id; 280 | this.volume = domElement.dataset.volume; 281 | 282 | // indicates whether note is regular, legato, staccato, or glide 283 | this.style = domElement.dataset.type; 284 | } 285 | 286 | /***** PERCUSSION CLASS ******/ 287 | // thanks to: https://dev.opera.com/articles/drum-sounds-webaudio/ 288 | function PercussionManager(pianoRollObject){ 289 | // set up a noise buffer 290 | // used in hihat and snare drum 291 | this.context = pianoRollObject.audioContext; 292 | const bufSize = this.context.sampleRate; 293 | const buffer = this.context.createBuffer(1, bufSize, bufSize); 294 | const output = buffer.getChannelData(0); 295 | for(let i = 0; i < bufSize; i++){ 296 | output[i] = Math.random() * 2 - 1; 297 | } 298 | 299 | this.noiseBuffer = buffer; 300 | 301 | // note that each oscillator needs its own gain node! 302 | this.kickDrumNote = function(frequency, volume, time, returnBool){ 303 | const context = this.context; 304 | const osc = context.createOscillator(); 305 | const gain = context.createGain(); 306 | osc.connect(gain); 307 | 308 | osc.frequency.setValueAtTime(frequency, time); 309 | gain.gain.setValueAtTime(volume, time); 310 | 311 | osc.frequency.exponentialRampToValueAtTime(0.01, time + 0.1); 312 | gain.gain.exponentialRampToValueAtTime(0.01, time + 0.1); 313 | 314 | if(pianoRollObject.recording){ 315 | gain.connect(pianoRoll.audioContextDestMediaStream); 316 | } 317 | gain.connect(pianoRoll.audioContextDestOriginal); 318 | 319 | if(!returnBool){ 320 | // this is just for clicking on a note 321 | osc.start(0); 322 | osc.stop(time + 0.1); 323 | }else{ 324 | // this is for a note that needs to be played. 325 | // return the oscillator node in an array 326 | return [osc]; 327 | } 328 | }; 329 | 330 | this.snareDrumNote = function(frequency, volume, time, returnBool){ 331 | const context = this.context; 332 | const noise = context.createBufferSource(); 333 | noise.buffer = this.noiseBuffer; 334 | const noiseFilter = context.createBiquadFilter(); 335 | noiseFilter.type = 'highpass'; 336 | noiseFilter.frequency.value = 1800; 337 | noise.connect(noiseFilter); 338 | 339 | // add gain to the noise filter 340 | const noiseEnvelope = context.createGain(); 341 | noiseFilter.connect(noiseEnvelope); 342 | //noiseEnvelope.connect(context.destination); 343 | 344 | // the pianoRollObject should have the noise buffer and envelope set up for the snare 345 | // we just need to trigger it 346 | // here we add the snappy part of the drum sound 347 | const snapOsc = context.createOscillator(); 348 | snapOsc.type = 'triangle'; 349 | 350 | const snapOscEnv = context.createGain(); //gainNode; 351 | snapOsc.connect(snapOscEnv); 352 | //snapOscEnv.connect(context.destination); 353 | 354 | noiseEnvelope.gain.setValueAtTime(volume, time); 355 | noiseEnvelope.gain.exponentialRampToValueAtTime(0.01, time + 0.2); 356 | 357 | snapOsc.frequency.setValueAtTime(100, time); 358 | snapOscEnv.gain.setValueAtTime(0.7, time); 359 | snapOscEnv.gain.exponentialRampToValueAtTime(0.01, time + 0.1); 360 | 361 | if(pianoRollObject.recording){ 362 | noiseEnvelope.connect(pianoRoll.audioContextDestMediaStream); 363 | snapOscEnv.connect(pianoRoll.audioContextDestMediaStream); 364 | } 365 | noiseEnvelope.connect(pianoRoll.audioContextDestOriginal); 366 | snapOscEnv.connect(pianoRoll.audioContextDestOriginal); 367 | 368 | if(!returnBool){ 369 | // this is for clicking a note (not setting up a note for playback) 370 | // filter the noise buffer 371 | noise.start(time); 372 | snapOsc.start(time); 373 | snapOsc.stop(time + 0.2); 374 | noise.stop(time + 0.2); 375 | }else{ 376 | return [noise, snapOsc]; 377 | } 378 | }; 379 | 380 | this.hihatNote = function(volume, time, returnBool){ 381 | const context = this.context; 382 | const noise = context.createBufferSource(); 383 | noise.buffer = this.noiseBuffer; 384 | const noiseFilter = context.createBiquadFilter(); 385 | noiseFilter.type = 'highpass'; 386 | noiseFilter.frequency.value = 1200; 387 | noise.connect(noiseFilter); 388 | 389 | // add gain to the noise filter 390 | const noiseEnvelope = context.createGain(); 391 | noiseFilter.connect(noiseEnvelope); 392 | 393 | if(pianoRollObject.recording){ 394 | noiseEnvelope.connect(pianoRoll.audioContextDestMediaStream); 395 | } 396 | noiseEnvelope.connect(pianoRoll.audioContextDestOriginal); 397 | 398 | noiseEnvelope.gain.setValueAtTime(volume, time); 399 | noiseEnvelope.gain.exponentialRampToValueAtTime(0.01, time + 0.2); 400 | 401 | if(!returnBool){ 402 | // this is for clicking a note (not setting up a note for playback) 403 | noise.start(time); 404 | noise.stop(time + 0.2); 405 | }else{ 406 | return [noise]; 407 | } 408 | }; 409 | } 410 | 411 | // until I think or learn of a better way to do this, let's try getting realistic piano sounds 412 | // via loading in .ogg files of each note on the piano roll and using AudioBufferSourceNodes :D 413 | function PianoManager(pianoRollObject) { 414 | this.audioCtx = pianoRollObject.audioContext; 415 | this.noteMap = {}; 416 | 417 | for(const note in pianoRollObject.noteFrequencies){ 418 | this.noteMap[note.replace('#', 's')] = ""; 419 | } 420 | 421 | this.getAudioBufferForNote = function(note){ 422 | return this.noteMap[note].buffer; 423 | }; 424 | 425 | // load in the notes 426 | this.loadPianoNotes = function(pElement){ 427 | let totalNotes = Object.keys(this.noteMap).length; 428 | pElement.textContent = "loading in piano notes..."; 429 | 430 | for(const note in this.noteMap){ 431 | const fileToFetch = "example_presets/piano/piano-" + note + '.ogg'; 432 | const newSource = this.audioCtx.createBufferSource(); 433 | 434 | // https://developer.mozilla.org/en-US/docs/Web/API/Body/arrayBuffer 435 | const req = new Request(fileToFetch); 436 | 437 | fetch(req).then((res) => { 438 | return res.arrayBuffer(); 439 | }).then((buffer) => { 440 | this.audioCtx.decodeAudioData(buffer, (decodedData) => { 441 | newSource.buffer = decodedData; // newSource will be a buffer source node that will be a reference node that we'll use to create the nodes for playing the notes 442 | this.noteMap[note] = newSource; 443 | 444 | totalNotes--; 445 | if(totalNotes === 0){ 446 | pElement.textContent = ""; 447 | } 448 | }); 449 | }); 450 | } 451 | }; 452 | } 453 | 454 | // a priority queue (min-heap) for getting the minimum number of nodes needed for an instrument 455 | // no prototype because there should only be one instance of these at a time 456 | function PriorityQueue(){ 457 | this.array = []; 458 | this.size = 0; 459 | this.lastIndex = 0; 460 | 461 | this.swap = function(idx1, idx2){ 462 | const temp = this.array[idx1]; 463 | this.array[idx1] = this.array[idx2]; 464 | this.array[idx2] = temp; 465 | }; 466 | 467 | this.add = function(num){ 468 | this.array[this.lastIndex++] = num; 469 | 470 | // bubble-up 471 | let currIdx = this.lastIndex - 1; 472 | let parentIdx = (currIdx - 1) / 2; 473 | 474 | while(this.array[parentIdx] > this.array[currIdx]){ 475 | this.swap(currIdx, parentIdx); 476 | currIdx = parentIdx; 477 | parentIdx = (currIdx - 1) / 2; 478 | } 479 | 480 | this.size++; 481 | }; 482 | 483 | this.remove = function(){ 484 | if(this.size === 0){ 485 | return null; 486 | } 487 | 488 | const root = this.array[0]; 489 | 490 | this.array[0] = this.array[this.lastIndex - 1]; // move last node to root 491 | this.lastIndex--; 492 | 493 | // bubble-down 494 | for(let i = 0; (2*i + 1) < this.array.length; i++){ 495 | let smallestChildIdx = 2*i + 1; 496 | const rightChildIdx = 2*i + 2; 497 | 498 | if(rightChildIdx < this.array.length){ 499 | // compare against right child since it exists 500 | if(this.array[smallestChildIdx] > this.array[rightChildIdx]){ 501 | smallestChildIdx = rightChildIdx; 502 | } 503 | } 504 | 505 | if(this.array[i] > this.array[smallestChildIdx]){ 506 | this.swap(i, smallestChildIdx); 507 | } 508 | } 509 | 510 | this.size--; 511 | 512 | return root; 513 | }; 514 | 515 | this.peek = function(){ 516 | return this.array[0]; 517 | }; 518 | } 519 | 520 | 521 | try{ 522 | module.exports = { 523 | PianoRoll, 524 | Instrument, 525 | Note, 526 | ElementNode, 527 | PriorityQueue, 528 | }; 529 | }catch(e){ 530 | // ignore 531 | } --------------------------------------------------------------------------------