├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── html └── index.html ├── images ├── sequencer_row_background.png └── sequencer_timeline_ticks.png ├── js-130-instrument-editor.png ├── js-130-pattern-editor.png ├── js-130-sequencer.png ├── lib └── AudioContextMonkeyPatch.js ├── package.json ├── sass ├── controls.scss ├── main.scss └── utilities.scss ├── sounds ├── bass.wav ├── hihat.wav └── snare.wav ├── src ├── app.js ├── buffer_generator.js ├── components │ ├── download_button.js │ ├── instrument_editor.js │ ├── keyboard.js │ ├── pattern_editor.js │ ├── sequencer.js │ └── transport.js ├── constants.js ├── default_song.js ├── id_generator.js ├── midi_controller.js ├── serializer.js ├── synth_core.js └── synth_core │ ├── audio_context_builder.js │ ├── buffer_collection.js │ ├── envelope.js │ ├── instrument.js │ ├── instrument_note.js │ ├── mixer.js │ ├── note.js │ ├── note_player.js │ ├── offline_transport.js │ ├── score.js │ ├── sequence_parser.js │ ├── song_player.js │ ├── transport.js │ └── wave_writer.js ├── test └── synth_core │ ├── envelope.test.js │ ├── note.test.js │ └── sequence_parser.test.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | // For Jest 2 | { 3 | "presets": ["@babel/preset-env", "@babel/preset-react"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | jssynth.js 3 | jssynth.js.map 4 | jssynth.css 5 | .sass-cache 6 | .DS_Store 7 | node_modules/ 8 | dist/ 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-20 Joel Strait 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JS-130 2 | 3 | A synthesizer and sequencer that runs in your browser, using the WebAudio API. 4 | 5 | Try it out here: [https://www.joelstrait.com/jssynth/](https://www.joelstrait.com/jssynth/) 6 | 7 | ## Example Song 8 | 9 | The song below is an example of what you can create with the JS-130, along with [Beats Drum Machine](https://beatsdrummachine.com) and GarageBand. 10 | 11 | [JS-130 Example Song](https://www.joelstrait.com/jssynth/js-130-demo.m4a) 12 | 13 | ## Features 14 | 15 | * Oscillator Instruments 16 | * Base oscillator with sine/square/saw/triangle wave 17 | * Secondary oscillator with same wave types, and optional detune from primary oscillator 18 | * White or pink noise 19 | * Adjustable volume for each noise source (oscillator 1, oscillator 2, noise) 20 | * LFO to control oscillator pitch (i.e. "pitch wobble") 21 | * Filter, with LFO and ADSR envelope to control filter cutoff frequency 22 | * ADSR Envelope to control loudness 23 | * Feedback delay and reverb effects 24 | * Sampler Instruments 25 | * Use a sound file (*.wav, *.mp3, etc.) as an instrument 26 | * Filter, with LFO and ADSR envelope to control filter cutoff frequency 27 | * ADSR Envelope to control loudness 28 | * Feedback delay and reverb effects 29 | * Sequencer 30 | * Multiple tracks, each with its own instrument and set of patterns 31 | * Enter notes in patterns via on-screen piano keyboard, MIDI keyboard, or computer's keyboard 32 | * Full songs 1-99 patterns long 33 | * Volume control + mute for each track 34 | * Tempo control 35 | * Master volume control 36 | * On-screen keyboard to enter notes and try out sounds 37 | * MIDI keyboard support (only in browsers that support Web MIDI, such as Chrome) 38 | * Download sequencer output to a *.wav file 39 | 40 | ## Running Locally 41 | 42 | * If running the app locally for the first time, run `yarn install` 43 | * Run `yarn serve`, which will build the app and start a local development server 44 | * Open the `localhost` URL listed in the command-line output in your browser 45 | * If a source file is changed while the server is running the app will automatically be rebuilt. However, you'll need to manually refresh the page in your browser to see the changes. 46 | 47 | ## Building For Production 48 | 49 | * If building the app for the first time, run `yarn install` 50 | * Run `yarn build` 51 | * The `dist/` folder will contain the files that should be deployed to production 52 | 53 | 54 | ## Screenshots 55 | 56 | Sequencer: 57 | ![JS-130 Sequencer](js-130-sequencer.png) 58 | 59 | --- 60 | 61 | Instrument editor: 62 | ![JS-130 Instrument Editor](js-130-instrument-editor.png) 63 | 64 | --- 65 | 66 | Pattern editor: 67 | ![JS-130 Pattern Editor](js-130-pattern-editor.png) 68 | -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JS-140 7 | 8 | 9 | 10 | 11 |
12 |
13 |

JS-140

14 | Web Synthesizer 15 | Loading... 16 |
17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /images/sequencer_row_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrait/jssynth/a019d4803506758ffaa425a822d32c624d59bd62/images/sequencer_row_background.png -------------------------------------------------------------------------------- /images/sequencer_timeline_ticks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrait/jssynth/a019d4803506758ffaa425a822d32c624d59bd62/images/sequencer_timeline_ticks.png -------------------------------------------------------------------------------- /js-130-instrument-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrait/jssynth/a019d4803506758ffaa425a822d32c624d59bd62/js-130-instrument-editor.png -------------------------------------------------------------------------------- /js-130-pattern-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrait/jssynth/a019d4803506758ffaa425a822d32c624d59bd62/js-130-pattern-editor.png -------------------------------------------------------------------------------- /js-130-sequencer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrait/jssynth/a019d4803506758ffaa425a822d32c624d59bd62/js-130-sequencer.png -------------------------------------------------------------------------------- /lib/AudioContextMonkeyPatch.js: -------------------------------------------------------------------------------- 1 | /* Copyright 2013 Chris Wilson 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | http://www.apache.org/licenses/LICENSE-2.0 6 | Unless required by applicable law or agreed to in writing, software 7 | distributed under the License is distributed on an "AS IS" BASIS, 8 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 9 | See the License for the specific language governing permissions and 10 | limitations under the License. 11 | */ 12 | 13 | /* 14 | This monkeypatch library is intended to be included in projects that are 15 | written to the proper AudioContext spec (instead of webkitAudioContext), 16 | and that use the new naming and proper bits of the Web Audio API (e.g. 17 | using BufferSourceNode.start() instead of BufferSourceNode.noteOn()), but may 18 | have to run on systems that only support the deprecated bits. 19 | This library should be harmless to include if the browser supports 20 | unprefixed "AudioContext", and/or if it supports the new names. 21 | The patches this library handles: 22 | if window.AudioContext is unsupported, it will be aliased to webkitAudioContext(). 23 | if AudioBufferSourceNode.start() is unimplemented, it will be routed to noteOn() or 24 | noteGrainOn(), depending on parameters. 25 | The following aliases only take effect if the new names are not already in place: 26 | AudioBufferSourceNode.stop() is aliased to noteOff() 27 | AudioContext.createGain() is aliased to createGainNode() 28 | AudioContext.createDelay() is aliased to createDelayNode() 29 | AudioContext.createScriptProcessor() is aliased to createJavaScriptNode() 30 | AudioContext.createPeriodicWave() is aliased to createWaveTable() 31 | OscillatorNode.start() is aliased to noteOn() 32 | OscillatorNode.stop() is aliased to noteOff() 33 | OscillatorNode.setPeriodicWave() is aliased to setWaveTable() 34 | AudioParam.setTargetAtTime() is aliased to setTargetValueAtTime() 35 | This library does NOT patch the enumerated type changes, as it is 36 | recommended in the specification that implementations support both integer 37 | and string types for AudioPannerNode.panningModel, AudioPannerNode.distanceModel 38 | BiquadFilterNode.type and OscillatorNode.type. 39 | */ 40 | (function (global, exports, perf) { 41 | 'use strict'; 42 | 43 | function fixSetTarget(param) { 44 | if (!param) // if NYI, just return 45 | return; 46 | if (!param.setTargetAtTime) 47 | param.setTargetAtTime = param.setTargetValueAtTime; 48 | } 49 | 50 | if (window.hasOwnProperty('webkitAudioContext') && 51 | !window.hasOwnProperty('AudioContext')) { 52 | window.AudioContext = webkitAudioContext; 53 | 54 | if (!AudioContext.prototype.hasOwnProperty('createGain')) 55 | AudioContext.prototype.createGain = AudioContext.prototype.createGainNode; 56 | if (!AudioContext.prototype.hasOwnProperty('createDelay')) 57 | AudioContext.prototype.createDelay = AudioContext.prototype.createDelayNode; 58 | if (!AudioContext.prototype.hasOwnProperty('createScriptProcessor')) 59 | AudioContext.prototype.createScriptProcessor = AudioContext.prototype.createJavaScriptNode; 60 | if (!AudioContext.prototype.hasOwnProperty('createPeriodicWave')) 61 | AudioContext.prototype.createPeriodicWave = AudioContext.prototype.createWaveTable; 62 | 63 | 64 | AudioContext.prototype.internal_createGain = AudioContext.prototype.createGain; 65 | AudioContext.prototype.createGain = function() { 66 | var node = this.internal_createGain(); 67 | fixSetTarget(node.gain); 68 | return node; 69 | }; 70 | 71 | AudioContext.prototype.internal_createDelay = AudioContext.prototype.createDelay; 72 | AudioContext.prototype.createDelay = function(maxDelayTime) { 73 | var node = maxDelayTime ? this.internal_createDelay(maxDelayTime) : this.internal_createDelay(); 74 | fixSetTarget(node.delayTime); 75 | return node; 76 | }; 77 | 78 | AudioContext.prototype.internal_createBufferSource = AudioContext.prototype.createBufferSource; 79 | AudioContext.prototype.createBufferSource = function() { 80 | var node = this.internal_createBufferSource(); 81 | if (!node.start) { 82 | node.start = function ( when, offset, duration ) { 83 | if ( offset || duration ) 84 | this.noteGrainOn( when || 0, offset, duration ); 85 | else 86 | this.noteOn( when || 0 ); 87 | }; 88 | } else { 89 | node.internal_start = node.start; 90 | node.start = function( when, offset, duration ) { 91 | if( typeof duration !== 'undefined' ) 92 | node.internal_start( when || 0, offset, duration ); 93 | else 94 | node.internal_start( when || 0, offset || 0 ); 95 | }; 96 | } 97 | if (!node.stop) { 98 | node.stop = function ( when ) { 99 | this.noteOff( when || 0 ); 100 | }; 101 | } else { 102 | node.internal_stop = node.stop; 103 | node.stop = function( when ) { 104 | node.internal_stop( when || 0 ); 105 | }; 106 | } 107 | fixSetTarget(node.playbackRate); 108 | return node; 109 | }; 110 | 111 | AudioContext.prototype.internal_createDynamicsCompressor = AudioContext.prototype.createDynamicsCompressor; 112 | AudioContext.prototype.createDynamicsCompressor = function() { 113 | var node = this.internal_createDynamicsCompressor(); 114 | fixSetTarget(node.threshold); 115 | fixSetTarget(node.knee); 116 | fixSetTarget(node.ratio); 117 | fixSetTarget(node.reduction); 118 | fixSetTarget(node.attack); 119 | fixSetTarget(node.release); 120 | return node; 121 | }; 122 | 123 | AudioContext.prototype.internal_createBiquadFilter = AudioContext.prototype.createBiquadFilter; 124 | AudioContext.prototype.createBiquadFilter = function() { 125 | var node = this.internal_createBiquadFilter(); 126 | fixSetTarget(node.frequency); 127 | fixSetTarget(node.detune); 128 | fixSetTarget(node.Q); 129 | fixSetTarget(node.gain); 130 | return node; 131 | }; 132 | 133 | if (AudioContext.prototype.hasOwnProperty( 'createOscillator' )) { 134 | AudioContext.prototype.internal_createOscillator = AudioContext.prototype.createOscillator; 135 | AudioContext.prototype.createOscillator = function() { 136 | var node = this.internal_createOscillator(); 137 | if (!node.start) { 138 | node.start = function ( when ) { 139 | this.noteOn( when || 0 ); 140 | }; 141 | } else { 142 | node.internal_start = node.start; 143 | node.start = function ( when ) { 144 | node.internal_start( when || 0); 145 | }; 146 | } 147 | if (!node.stop) { 148 | node.stop = function ( when ) { 149 | this.noteOff( when || 0 ); 150 | }; 151 | } else { 152 | node.internal_stop = node.stop; 153 | node.stop = function( when ) { 154 | node.internal_stop( when || 0 ); 155 | }; 156 | } 157 | if (!node.setPeriodicWave) 158 | node.setPeriodicWave = node.setWaveTable; 159 | fixSetTarget(node.frequency); 160 | fixSetTarget(node.detune); 161 | return node; 162 | }; 163 | } 164 | } 165 | 166 | if (window.hasOwnProperty('webkitOfflineAudioContext') && 167 | !window.hasOwnProperty('OfflineAudioContext')) { 168 | window.OfflineAudioContext = webkitOfflineAudioContext; 169 | } 170 | 171 | }(window)); 172 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jssynth", 3 | "version": "1.3.0", 4 | "description": "A synthesizer and sequencer for your browser", 5 | "private": true, 6 | "repository": "https://github.com/jstrait/jssynth.git", 7 | "author": "Joel Strait ", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "webpack --config webpack.config.js --mode production", 11 | "serve": "webpack serve --config webpack.config.js --mode production --compress --no-bonjour --no-hot --no-live-reload --no-static --no-web-socket-server", 12 | "test": "jest" 13 | }, 14 | "browserslist": [ 15 | "supports es6" 16 | ], 17 | "dependencies": { 18 | "react": "18.3.1", 19 | "react-dom": "18.3.1" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "7.24.8", 23 | "@babel/preset-env": "7.24.8", 24 | "@babel/preset-react": "7.24.7", 25 | "babel-loader": "9.1.3", 26 | "copy-webpack-plugin": "12.0.2", 27 | "css-loader": "7.1.2", 28 | "css-minimizer-webpack-plugin": "7.0.0", 29 | "jest": "29.7.0", 30 | "mini-css-extract-plugin": "2.9.0", 31 | "sass": "1.77.7", 32 | "sass-loader": "14.2.1", 33 | "webpack": "5.93.0", 34 | "webpack-cli": "5.1.4", 35 | "webpack-dev-server": "5.0.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /sass/controls.scss: -------------------------------------------------------------------------------- 1 | input[type="text"] { 2 | font-family: $font-family; 3 | font-size: 1.20rem; 4 | font-weight: 300; 5 | color: $black; 6 | padding: 0; 7 | } 8 | 9 | input[type="text"]:disabled { 10 | color: $border-color; 11 | background: $white; /* Prevent gray background in Firefox */ 12 | } 13 | 14 | input[type="range"] { 15 | -webkit-appearance: none; 16 | height: 1.5rem; 17 | margin: 0; 18 | padding: 0; 19 | outline: none; 20 | background-color: transparent; 21 | } 22 | 23 | input[type="range"]::-webkit-slider-runnable-track { 24 | height: 2px; 25 | background-color: #ccc; 26 | } 27 | 28 | input[type="range"]::-moz-range-track { 29 | height: 2px; 30 | background-color: #ccc; 31 | } 32 | 33 | input[type="range"]::-ms-track { 34 | height: 2px; 35 | background-color: #ccc; 36 | } 37 | 38 | input[type="range"]::-webkit-slider-thumb { 39 | -webkit-appearance: none; 40 | height: 1.5rem; 41 | width: 1.5rem; 42 | margin-top: calc(-0.75rem + 1px); 43 | border-radius: 50%; 44 | background: $black; 45 | border: 0; 46 | box-shadow: none; /* Remove shadows added in iOS/iPadOS 15 */ 47 | } 48 | 49 | input[type="range"]::-moz-range-thumb { 50 | height: 1.5rem; 51 | width: 1.5rem; 52 | border-radius: 50%; 53 | background: $black; 54 | border: 0; 55 | } 56 | 57 | input[type="range"]::-ms-thumb { 58 | height: 1.5rem; 59 | width: 1.5rem; 60 | border-radius: 50%; 61 | background: $black; 62 | border: 0; 63 | } 64 | 65 | input[type="range"]::-webkit-slider-thumb:active { 66 | background: $orange; 67 | } 68 | 69 | input[type="range"]::-moz-range-thumb:active { 70 | background: $orange; 71 | } 72 | 73 | input[type="range"]::-ms-thumb:active { 74 | background: $orange; 75 | } 76 | 77 | @media (hover) { 78 | input[type="range"]::-webkit-slider-thumb:hover { 79 | background: $orange; 80 | } 81 | 82 | input[type="range"]::-moz-range-thumb:hover { 83 | background: $orange; 84 | } 85 | 86 | input[type="range"]::-ms-thumb:hover { 87 | background: $orange; 88 | } 89 | } 90 | 91 | input[type="range"]::-moz-focus-outer { 92 | border: 0; 93 | } 94 | 95 | button { 96 | outline: none; 97 | font-size: 1.0rem; 98 | font-family: $font-family; 99 | cursor: pointer; 100 | margin: 0; 101 | } 102 | 103 | button:disabled { 104 | border-color: $border-color; 105 | color: $border-color; 106 | cursor: default; 107 | } 108 | 109 | .button-link { 110 | background: none; 111 | color: $link-color; 112 | border: 0; 113 | font-weight: 300; 114 | text-decoration: underline; 115 | padding-left: 0; 116 | padding-right: 0; 117 | } 118 | 119 | .button-standard { 120 | background: $white; 121 | color: $black; 122 | border-style: solid; 123 | border-color: #ccc; 124 | } 125 | 126 | @media (hover) { 127 | .button-standard:enabled:hover { 128 | background: $light-orange; 129 | border-color: $orange; 130 | color: $orange; 131 | } 132 | } 133 | 134 | .button-standard-tiny { 135 | border-radius: 0.25rem; 136 | border-width: 2px; 137 | padding: 0.0rem 0.5rem; 138 | } 139 | 140 | .button-standard-small { 141 | border-radius: 0.5rem; 142 | border-width: 2px; 143 | padding: 0.25rem 0.5rem; 144 | } 145 | 146 | .button-standard-full { 147 | border-radius: 0.75rem; 148 | border-width: 3px; 149 | padding: $size-half; 150 | } 151 | 152 | .button-standard-toggled { 153 | background: $orange; 154 | border-color: $orange; 155 | color: $white; 156 | } 157 | 158 | button.button-standard-error:disabled { 159 | background: $white; 160 | border-style: solid; 161 | border-color: $light-red; 162 | color: $light-red; 163 | } 164 | 165 | .button-standard-tab-list { 166 | border-width: 3px 1.5px; 167 | padding: $size-half; 168 | } 169 | 170 | .button-standard-tab-list:first-child { 171 | border-radius: 0.75rem 0 0 0.75rem; 172 | border-left-width: 3px; 173 | } 174 | 175 | .button-standard-tab-list:last-child { 176 | border-radius: 0 0.75rem 0.75rem 0; 177 | border-right-width: 3px; 178 | } 179 | 180 | .input-underlined { 181 | outline: none; 182 | border: solid $black; 183 | border-width: 0 0 1px 0; 184 | border-radius: 0; /* For iOS */ 185 | } 186 | 187 | .input-underlined:disabled { 188 | border-color: $border-color; 189 | } 190 | 191 | .control { 192 | display: flex; 193 | flex-direction: row; 194 | align-items: center; 195 | } 196 | 197 | .control-label { 198 | width: 30%; 199 | box-sizing: border-box; 200 | } 201 | 202 | .control input[type="range"] { 203 | flex: 1; 204 | } 205 | 206 | .control-value { 207 | width: 5.0rem; 208 | padding-left: 0.5rem; 209 | box-sizing: border-box; 210 | } 211 | 212 | @media (max-width: 768px) { 213 | .control { 214 | align-items: flex-start; 215 | } 216 | 217 | .control-label { 218 | width: 35%; 219 | } 220 | 221 | .control input[type="range"] { 222 | margin-bottom: calc(0.75rem - 1px); 223 | } 224 | } 225 | 226 | @media (max-width: 480px) { 227 | .control { 228 | flex-wrap: wrap; 229 | } 230 | 231 | .control-label { 232 | order: 0; 233 | width: auto; 234 | } 235 | 236 | .control-value { 237 | order: 1; 238 | } 239 | 240 | .control input[type="range"] { 241 | order: 2; 242 | flex: 0 0 100%; 243 | margin-bottom: calc(1.0rem - 1px); 244 | } 245 | } 246 | 247 | 248 | 249 | 250 | /* Keyboard */ 251 | .keyboard-outer-container { 252 | height: 11.0rem; 253 | } 254 | 255 | .keyboard-scroll-button { 256 | background: #c1c1c1; 257 | flex: 0 0 40px; 258 | } 259 | 260 | @media (hover) { 261 | .keyboard-scroll-button:hover { 262 | background: #a1a1a1; 263 | } 264 | } 265 | 266 | .keyboard-keys-container { 267 | display: flex; 268 | overflow-x: hidden; 269 | background: $white; 270 | } 271 | 272 | .keyboard-key { 273 | flex-shrink: 0; 274 | display: flex; 275 | flex-direction: column; 276 | justify-content: flex-end; 277 | align-items: center; /* Center white key labels */ 278 | font-size: 1.0rem; 279 | line-height: 1.5rem; 280 | text-align: center; /* Center black key labels */ 281 | cursor: default; /* Prevent I-beam cursor in Safari when hovering over key label */ 282 | box-sizing: border-box; 283 | border-bottom-left-radius: 5px; 284 | border-bottom-right-radius: 5px; 285 | } 286 | 287 | .keyboard-white-key { 288 | width: 3.5rem; 289 | border: solid $black; 290 | border-width: 2px 1px; 291 | background: $white; 292 | color: $black; 293 | } 294 | 295 | .keyboard-white-key:first-child { 296 | border-left-width: 2px; 297 | } 298 | 299 | .keyboard-white-key:last-child { 300 | border-right-width: 2px; 301 | } 302 | 303 | .keyboard-black-key { 304 | background: $black; 305 | height: 60%; 306 | width: 2.5rem; 307 | margin-left: -1.25rem; 308 | margin-right: -1.25rem; 309 | z-index: 2; 310 | color: $white; 311 | border: 2px solid $black; 312 | } 313 | 314 | .keyboard-key-root-indicator { 315 | border-radius: 6px; 316 | padding: 0.0rem 0.25rem; 317 | background: $red; 318 | color: $white; 319 | font-size: 0.75rem; 320 | pointer-events: none; 321 | } 322 | 323 | .keyboard-key.pressed { 324 | background: $orange; 325 | } 326 | .keyboard-scroll-button.pressed { 327 | background: $orange; 328 | } 329 | 330 | @media (min-width: 768px) { 331 | .keyboard-outer-container { 332 | height: 15.0rem; 333 | margin-left: 0; 334 | margin-right: 0; 335 | } 336 | 337 | .keyboard-keys-container { 338 | background: none; 339 | } 340 | 341 | .keyboard-white-key { 342 | width: 4.0rem; 343 | } 344 | 345 | .keyboard-black-key { 346 | width: 3.0rem; 347 | margin-left: -1.5rem; 348 | margin-right: -1.5rem; 349 | } 350 | 351 | .keyboard-key-root-indicator { 352 | font-size: 1.0rem; 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /sass/main.scss: -------------------------------------------------------------------------------- 1 | $font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", helvetica, arial, sans-serif; 2 | $orange: orange; 3 | $light-orange: papayawhip; 4 | $dark-orange: darkorange; 5 | $red: #c00; 6 | $light-red: #faa; 7 | $black: #130f30; 8 | $white: #fff; 9 | $border-color: #ddd; 10 | $light-gray: #f1f1f1; 11 | $lighter-gray: #fafafa; 12 | $link-color: #0af; 13 | 14 | $size-half: 0.75rem; 15 | $size-1: 1.5rem; 16 | $size-2: 3.0rem; 17 | $size-3: 4.5rem; 18 | 19 | @import "controls.scss"; 20 | @import "utilities.scss"; 21 | 22 | html { 23 | font-size: 16px; 24 | -webkit-text-size-adjust: none; 25 | -ms-text-size-adjust: none; 26 | text-size-adjust: none; 27 | overscroll-behavior: none; 28 | } 29 | 30 | body { 31 | margin: 0; 32 | padding: 0; 33 | font-family: $font-family; 34 | font-size: 20px; 35 | background: $white; 36 | color: $black; 37 | line-height: 1.5em; 38 | font-weight: 300; 39 | } 40 | 41 | a { 42 | color: $link-color; 43 | } 44 | 45 | /* Header */ 46 | 47 | #transport { 48 | justify-content: center; 49 | } 50 | 51 | .play-icon { 52 | display: block; 53 | width: 0; 54 | height: 0; 55 | overflow: hidden; 56 | border-style: solid; 57 | border-width: 0.5rem 0.0rem 0.5rem 1.0rem; 58 | border-color: transparent transparent transparent $black; 59 | } 60 | 61 | .play-icon-enabled { 62 | border-color: $white; 63 | } 64 | 65 | .rewind-icon { 66 | display: block; 67 | width: 1.0rem; 68 | height: 1.0rem; 69 | overflow: hidden; 70 | border-style: solid; 71 | border-width: 0 0 0 3px; 72 | border-color: $black; 73 | box-sizing: border-box; 74 | } 75 | 76 | .rewind-icon::before { 77 | content: "\00a0"; /* non-breaking space character */ 78 | display: block; 79 | width: 0px; 80 | height: 0px; 81 | margin-left: -3px; 82 | border-style: solid; 83 | border-width: 0.5rem 1.0rem 0.5rem 0.0rem; 84 | border-color: transparent $black transparent transparent; 85 | } 86 | 87 | @media (hover) { 88 | .button-standard-tab-list:hover .play-icon { 89 | border-color: transparent transparent transparent $orange; 90 | } 91 | 92 | .button-standard-tab-list:hover .play-icon-enabled { 93 | border-color: $orange; 94 | } 95 | 96 | .button-standard-tab-list:hover .rewind-icon { 97 | border-color: $orange; 98 | } 99 | 100 | .button-standard-tab-list:hover .rewind-icon::before { 101 | border-color: transparent $orange transparent transparent; 102 | } 103 | } 104 | 105 | .popup-box { 106 | background: $white; 107 | position: absolute; 108 | right: 0; 109 | border: 2px solid $border-color; 110 | border-radius: 10px; 111 | width: 15.0rem; 112 | z-index: 1000; 113 | } 114 | 115 | .popup-box::before { 116 | content: ""; 117 | position: absolute; 118 | bottom: 100%; 119 | right: 1.5rem; 120 | border-width: 0 1.25rem 1.25rem 1.25rem; 121 | border-style: solid; 122 | border-color: transparent transparent $border-color transparent; 123 | } 124 | 125 | .popup-box::after { 126 | content: ""; 127 | position: absolute; 128 | bottom: 100%; 129 | right: 1.7rem; 130 | padding-top: 0.1875rem; /* Prevent artifact when menu is hidden in Safari */ 131 | border-width: 0 1.0625rem 1.0625rem 1.0625rem; 132 | border-style: solid; 133 | border-color: transparent transparent $white transparent; 134 | } 135 | 136 | .spinner-icon { 137 | width: 1.0rem; 138 | height: 1.0rem; 139 | border-radius: 50%; 140 | border-style: solid; 141 | border-width: 2px; 142 | border-color: $black $black $black transparent; 143 | animation: spinner 1s linear infinite; 144 | } 145 | @keyframes spinner { 100% { transform: rotate(360deg); } } 146 | 147 | .tab-strip { 148 | overflow: hidden; 149 | border-radius: 5px; 150 | flex: 1; 151 | } 152 | 153 | .tab-strip-item { 154 | border-right: 1px solid $border-color; 155 | } 156 | 157 | .tab-strip-item:last-child { 158 | border-right: 0; 159 | } 160 | 161 | @media (max-width: 480px) { 162 | body { 163 | font-size: 16px; 164 | } 165 | 166 | .tab-strip { 167 | flex: 0 0 100%; 168 | order: 1; 169 | } 170 | } 171 | 172 | @media (max-width: 666px) { 173 | #header { 174 | flex-flow: row wrap; 175 | } 176 | 177 | #logo-container { 178 | order: 1; 179 | flex: 1 50%; 180 | } 181 | 182 | #transport { 183 | order: 3; 184 | flex: 1 100%; 185 | padding-top: 1.5rem; 186 | justify-content: space-between; 187 | } 188 | 189 | .transport-inner { 190 | flex: 1; 191 | } 192 | 193 | #download-container { 194 | order: 2; 195 | flex: 1 50%; 196 | } 197 | } 198 | 199 | 200 | /* Sequencer */ 201 | 202 | .sequencer-step-timeline { 203 | width: 100%; 204 | height: 1.5rem; 205 | position: absolute; 206 | bottom: 1px; 207 | } 208 | 209 | input[type="range"].sequencer-playback-header::-webkit-slider-runnable-track { 210 | height: 0px; 211 | background-color: red; 212 | } 213 | 214 | input[type="range"].sequencer-playback-header::-moz-range-track { 215 | height: 0px; 216 | background-color: red; 217 | } 218 | 219 | input[type="range"].sequencer-playback-header::-ms-track { 220 | height: 0px; 221 | background-color: red; 222 | } 223 | 224 | input[type="range"].sequencer-playback-header::-webkit-slider-thumb { 225 | background: #aaaaaaaa; 226 | border: 2px solid black; 227 | box-sizing: border-box; 228 | margin-top: -0.75rem; 229 | } 230 | 231 | input[type="range"].sequencer-playback-header::-moz-range-thumb { 232 | background: #aaaaaaaa; 233 | border: 2px solid black; 234 | box-sizing: border-box; 235 | } 236 | 237 | input[type="range"].sequencer-playback-header::-ms-thumb { 238 | background: #aaaaaaaa; 239 | border: 2px solid black; 240 | box-sizing: border-box; 241 | } 242 | 243 | input[type="range"].sequencer-playback-header::-webkit-slider-thumb:active { 244 | background: #666666aa; 245 | } 246 | 247 | input[type="range"].sequencer-playback-header::-moz-range-thumb:active { 248 | background: #666666aa; 249 | } 250 | 251 | input[type="range"].sequencer-playback-header::-ms-thumb:active { 252 | background: #666666aa; 253 | } 254 | 255 | @media (hover) { 256 | input[type="range"].sequencer-playback-header::-webkit-slider-thumb:hover { 257 | background: #666666aa; 258 | } 259 | 260 | input[type="range"].sequencer-playback-header::-moz-range-thumb:hover { 261 | background: #666666aa; 262 | } 263 | 264 | input[type="range"].sequencer-playback-header::-ms-thumb:hover { 265 | background: #666666aa; 266 | } 267 | } 268 | 269 | 270 | .sequencer-playback-line { 271 | position: absolute; 272 | top: calc(3.0rem - 1px); 273 | bottom: 0; 274 | width: 5px; 275 | background: $orange; 276 | opacity: 0.5; 277 | } 278 | 279 | .sequencer-cell { 280 | flex: 0 0 9.0rem; 281 | } 282 | 283 | .sequencer-body-left-padding { 284 | flex: 0 0 1.0rem; 285 | background-image: url(images/sequencer_row_background.png); 286 | background-size: 144px 72px; 287 | background-position: bottom right; 288 | image-rendering: pixelated; 289 | } 290 | 291 | .sequencer-body { 292 | background-image: url(images/sequencer_row_background.png); 293 | background-size: 144px 72px; 294 | background-position: bottom right; 295 | image-rendering: pixelated; 296 | } 297 | 298 | .sequencer-body-right-padding { 299 | flex: 1; 300 | min-width: 1.0rem; 301 | background-image: url(images/sequencer_row_background.png); 302 | background-size: 1px 72px; 303 | background-position: bottom; 304 | image-rendering: pixelated; /* Prevent blurry/hidden image in Chrome */ 305 | } 306 | 307 | .sequencer-cell-header { 308 | background-image: url(images/sequencer_timeline_ticks.png); 309 | background-repeat: repeat-x; 310 | background-position: bottom left; 311 | background-size: 36px 20px; 312 | } 313 | 314 | .timeline-pattern { 315 | position: absolute; 316 | display: block; 317 | height: calc(100% - 1px); 318 | box-sizing: border-box; 319 | border: 1px solid $black; 320 | border-radius: 5px; 321 | background: $light-gray; 322 | } 323 | 324 | .timeline-pattern-transparent { 325 | opacity: 0.5; 326 | } 327 | 328 | .timeline-pattern-selected { 329 | background: $light-orange; 330 | border-color: $orange; 331 | } 332 | 333 | .timeline-pattern-error { 334 | background: $light-red; 335 | border-color: $red; 336 | } 337 | 338 | .timeline-pattern-divider { 339 | display: block; 340 | background: $light-gray; 341 | height: calc(100% - (5px * 2)); 342 | width: 0; 343 | margin-top: 5px; 344 | border-width: 0 0 0 1px; 345 | border-color: $light-gray; 346 | border-style: solid; 347 | } 348 | 349 | .timeline-pattern-divider-selected { 350 | background: $light-orange; 351 | border-color: $light-orange; 352 | } 353 | 354 | .timeline-pattern-divider-error { 355 | background: $light-red; 356 | border-color: $light-red; 357 | } 358 | 359 | .timeline-pattern-name { 360 | position: absolute; 361 | background: $light-gray; 362 | margin-left: 0.25rem; 363 | margin-top: 1px; 364 | line-height: 1.25rem; 365 | max-width: calc(100% - 1.5rem - 0.5rem - 2px); 366 | overflow: hidden; 367 | font-size: 1.0rem; 368 | text-overflow: ellipsis; 369 | } 370 | 371 | .timeline-pattern-name-selected { 372 | background: $light-orange; 373 | } 374 | 375 | .timeline-pattern-name-error { 376 | background: $light-red; 377 | } 378 | 379 | .timeline-pattern-overlay { 380 | opacity: 0.8; 381 | } 382 | 383 | .timeline-sidebar-button { 384 | color: #aaa; 385 | } 386 | 387 | .timeline-sidebar-button-enabled { 388 | color: $black; 389 | } 390 | 391 | .timeline-sidebar-button-enabled:active { 392 | color: $white; 393 | background: $dark-orange; 394 | } 395 | 396 | .timeline-pattern-menu { 397 | display: block; 398 | border: 2px solid $border-color; 399 | border-radius: 0.75rem; 400 | padding: 0.5rem; 401 | box-sizing: border-box; 402 | background: $lighter-gray; 403 | pointer-events: auto; 404 | } 405 | 406 | .timeline-pattern-menu-arrow-container { 407 | position: relative; 408 | display: block; 409 | width: 2.0rem; 410 | height: 1.0rem; 411 | margin-top: -2px; 412 | } 413 | 414 | .timeline-pattern-menu-arrow-outline { 415 | position: absolute; 416 | bottom: 0; 417 | width: 0; 418 | border-width: 1.0rem 1.0rem 0 1.0rem; 419 | border-style: solid; 420 | border-color: $border-color transparent transparent transparent; 421 | pointer-events: auto; 422 | clip-path: polygon(0 0, 100% 0, 50% 100%, 0 0); 423 | } 424 | 425 | .timeline-pattern-menu-arrow-fill { 426 | position: absolute; 427 | top: 0; 428 | left: 3px; 429 | width: 0; 430 | border-width: 0.8125rem 0.8125rem 0 0.8125rem; 431 | border-style: solid; 432 | border-color: $lighter-gray transparent transparent transparent; 433 | } 434 | 435 | .expanded { 436 | width: 200px; 437 | } 438 | 439 | .expanded > li > .short-name { 440 | display: none; 441 | } 442 | 443 | .contracted > li > input { 444 | display: none; 445 | } 446 | 447 | .contracted > li > .sequencer-name-container, 448 | .contracted > li > .sequencer-volume-container { 449 | display: none; 450 | } 451 | 452 | 453 | /* Instrument editor */ 454 | 455 | .instrument-panel { 456 | min-width: 600px; 457 | } 458 | 459 | @media (max-width: 768px) { 460 | .instrument-panel-container { 461 | flex-direction: column; 462 | align-items: stretch; 463 | width: 100%; 464 | } 465 | 466 | .instrument-panel { 467 | flex: 1; 468 | min-width: 0; 469 | margin: 0; 470 | padding-left: 0; 471 | padding-right: 0; 472 | border-left: 0; 473 | border-right: 0; 474 | } 475 | } 476 | 477 | 478 | /* Pattern editor */ 479 | 480 | .pane-tab-unselected { 481 | padding-bottom: 2px; 482 | border-bottom: 2px solid $border-color; 483 | } 484 | 485 | .pane-tab-selected { 486 | border-bottom: 4px solid $orange; 487 | } 488 | 489 | .note-container { 490 | flex: 1; 491 | min-width: 3.0rem; 492 | box-sizing: border-box; 493 | padding: 1px; 494 | } 495 | 496 | .note-column-header { 497 | color: #ccc; 498 | } 499 | 500 | .note-box { 501 | display: block; 502 | height: 2.0rem; 503 | width: calc(100% - 4px); 504 | text-align: center; 505 | border: 1px solid $border-color; 506 | margin: 1px; 507 | font-size: 1.0rem; 508 | font-weight: normal; 509 | line-height: 2.0rem; 510 | outline: none; 511 | } 512 | 513 | .note-box-focused { 514 | background: $light-orange; 515 | border: 2px solid $orange; 516 | margin: 0; 517 | } 518 | 519 | .note-box-invalid { 520 | background: $light-red; 521 | border: 2px solid $red; 522 | margin: 0; 523 | } 524 | 525 | 526 | /* Other */ 527 | 528 | .indented { 529 | padding-left: 1.0rem; 530 | } 531 | 532 | .lightText { color: #aaa; } 533 | 534 | @media (max-width: 480px) { 535 | .indented { 536 | padding-left: 0; 537 | } 538 | } 539 | -------------------------------------------------------------------------------- /sass/utilities.scss: -------------------------------------------------------------------------------- 1 | .list-style-none { list-style-type: none; } 2 | 3 | .h2 { font-size: 3.0rem; } 4 | .h3 { font-size: 1.5rem; } 5 | .h4 { font-size: 1.0rem; } 6 | 7 | .lh-2 { line-height: 3.0rem; } 8 | .lh-3 { line-height: 1.5rem; } 9 | .lh-4 { line-height: 1.0rem; } 10 | 11 | .m0 { margin: 0; } 12 | .mt0 { margin-top: 0; } 13 | .mb0 { margin-bottom: 0; } 14 | .ml0 { margin-left: 0; } 15 | .mr-half { margin-right: $size-half; } 16 | .mt1 { margin-top: $size-1; } 17 | .mr1 { margin-right: $size-1; } 18 | .mb1 { margin-bottom: $size-1; } 19 | .ml1 { margin-left: $size-1; } 20 | .mt3 { margin-top: $size-3; } 21 | 22 | .pl0 { padding-left: 0; } 23 | .pt-half { padding-top: $size-half; } 24 | .pb-half { padding-bottom: $size-half; } 25 | .pl-half { padding-left: $size-half; } 26 | .pr-half { padding-right: $size-half; } 27 | .p1 { padding: $size-1; } 28 | .pl1 { padding-left: $size-1; } 29 | .pr1 { padding-right: $size-1; } 30 | .pt1 { padding-top: $size-1; } 31 | .pb1 { padding-bottom: $size-1; } 32 | 33 | .pr-half-safe { padding-right: $size-half; } 34 | .pl1-safe { padding-left: $size-1; } 35 | .pr1-safe { padding-right: $size-1; } 36 | 37 | @supports(padding: max(0px)) { 38 | .pr-half-safe { padding-right: max(0.75rem, env(safe-area-inset-right)); } 39 | .pl1-safe { padding-left: max(1.5rem, env(safe-area-inset-left)); } 40 | .pr1-safe { padding-right: max(1.5rem, env(safe-area-inset-right)); } 41 | } 42 | 43 | .relative { position: relative; } 44 | .absolute { position: absolute; } 45 | 46 | .t0 { top: 0; } 47 | .b0 { bottom: 0; } 48 | .r0 { right: 0; } 49 | 50 | .b-all { border: 1px solid $border-color; } 51 | .bb { border-bottom: 1px solid $border-color; } 52 | .bl { border-left: 1px solid $border-color; } 53 | .br { border-right: 1px solid $border-color; } 54 | 55 | .bt-thick { border-top: 2px solid $border-color; } 56 | 57 | .outline-none { outline: none; } 58 | 59 | .center { text-align: center; } 60 | .vertical-top { vertical-align: top; } 61 | 62 | .round { border-radius: 50%; } 63 | 64 | .bold { font-weight: bold; } 65 | 66 | .flex { display: flex; } 67 | .block { display: block; } 68 | .inline-block { display: inline-block; } 69 | .display-none { display: none; } 70 | 71 | .flex-uniform-size { flex: 1; } 72 | .flex-column { flex-direction: column; } 73 | .flex-align-center { align-items: center; } 74 | .flex-align-end { align-items: flex-end; } 75 | .flex-justify-center { justify-content: center; } 76 | .flex-justify-end { justify-content: flex-end; } 77 | .flex-justify-space-between { justify-content: space-between; } 78 | 79 | .overflow-hidden { overflow: hidden; } 80 | .overflow-scroll-x { overflow-x: scroll; } 81 | .overflow-ellipsis { text-overflow: ellipsis; } 82 | 83 | .border-box { box-sizing: border-box; } 84 | 85 | .whitespace-wrap-none { white-space: nowrap; } 86 | 87 | .cursor-default { cursor: default; } 88 | .cursor-pointer { cursor: pointer; } 89 | 90 | .pointer-events-none { pointer-events: none; } 91 | 92 | .user-select-none { 93 | -moz-user-select: none; 94 | -webkit-user-select: none; 95 | -ms-user-select: none; 96 | user-select: none; 97 | } 98 | 99 | .width-1 { width: $size-1; } 100 | .width-2 { width: $size-2; } 101 | .width-3 { width: $size-3; } 102 | .full-width { width: 100%; } 103 | .full-height { height: 100%; } 104 | .height-1 { height: $size-1; } 105 | .height-2 { height: $size-2; } 106 | .height-3 { height: $size-3; } 107 | 108 | .bg-black { background: $black; } 109 | .bg-orange { background: $orange; } 110 | .bg-light-orange { background: $light-orange; } 111 | .red { color: $red; } 112 | .bg-light-red { background: $light-red; } 113 | .bg-red { background: $red; } 114 | .bg-light-gray { background: $light-gray; } 115 | .bg-lighter-gray { background: $lighter-gray; } 116 | .white { color: $white; } 117 | 118 | @media (min-width: 769px) { 119 | .block-l { display: block; } 120 | .inline-l { display: inline; } 121 | .display-none-l { display: none; } 122 | 123 | .ml-half-l { margin-left: $size-half; } 124 | .mr1-l { margin-right: $size-1; } 125 | 126 | .pr1-l { padding-right: $size-1; } 127 | 128 | .br-l { border-right: 1px solid $border-color; } 129 | } 130 | -------------------------------------------------------------------------------- /sounds/bass.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrait/jssynth/a019d4803506758ffaa425a822d32c624d59bd62/sounds/bass.wav -------------------------------------------------------------------------------- /sounds/hihat.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrait/jssynth/a019d4803506758ffaa425a822d32c624d59bd62/sounds/hihat.wav -------------------------------------------------------------------------------- /sounds/snare.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrait/jssynth/a019d4803506758ffaa425a822d32c624d59bd62/sounds/snare.wav -------------------------------------------------------------------------------- /src/buffer_generator.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export class BufferGenerator { 4 | static generateWhiteNoise(audioContext) { 5 | var noiseBuffer = audioContext.createBuffer(1, audioContext.sampleRate, audioContext.sampleRate); 6 | var noiseChannel = noiseBuffer.getChannelData(0); 7 | var i; 8 | 9 | for (i = 0; i < noiseChannel.length; i++) { 10 | noiseChannel[i] = (Math.random() * 2.0) - 1.0; 11 | } 12 | 13 | return noiseBuffer; 14 | }; 15 | 16 | static generatePinkNoise(audioContext) { 17 | var noiseBuffer = audioContext.createBuffer(1, audioContext.sampleRate, audioContext.sampleRate); 18 | var noiseChannel = noiseBuffer.getChannelData(0); 19 | var white; 20 | var i; 21 | 22 | // Adapted from https://noisehack.com/generate-noise-web-audio-api/, https://github.com/zacharydenton/noise.js 23 | var b0, b1, b2, b3, b4, b5, b6; 24 | b0 = b1 = b2 = b3 = b4 = b5 = b6 = 0.0; 25 | for (i = 0; i < noiseChannel.length; i++) { 26 | white = Math.random() * 2 - 1; 27 | b0 = 0.99886 * b0 + white * 0.0555179; 28 | b1 = 0.99332 * b1 + white * 0.0750759; 29 | b2 = 0.96900 * b2 + white * 0.1538520; 30 | b3 = 0.86650 * b3 + white * 0.3104856; 31 | b4 = 0.55000 * b4 + white * 0.5329522; 32 | b5 = -0.7616 * b5 - white * 0.0168980; 33 | noiseChannel[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362; 34 | noiseChannel[i] *= 0.11; // (roughly) compensate for gain 35 | b6 = white * 0.115926; 36 | } 37 | 38 | return noiseBuffer; 39 | }; 40 | 41 | static generateReverbImpulseResponse(audioContext) { 42 | var impulseResponseBuffer = audioContext.createBuffer(1, audioContext.sampleRate, audioContext.sampleRate); 43 | var channelData = impulseResponseBuffer.getChannelData(0); 44 | var sampleCount = impulseResponseBuffer.sampleRate; 45 | var i; 46 | 47 | for (i = 0; i < channelData.length; i++) { 48 | channelData[i] = ((Math.random() * 2) - 1) * ((sampleCount - i) / sampleCount); 49 | } 50 | 51 | return impulseResponseBuffer; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/components/download_button.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | 5 | export class DownloadButton extends React.PureComponent { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | isPopupVisible: false, 11 | isDownloadInProgress: false, 12 | errorMessage: "", 13 | fileName: "js-140", 14 | }; 15 | 16 | this.togglePopup = this.togglePopup.bind(this); 17 | this.setFileName = this.setFileName.bind(this); 18 | this.beginDownload = this.beginDownload.bind(this); 19 | this.downloadCompleteCallback = this.downloadCompleteCallback.bind(this); 20 | this.onDownloadError = this.onDownloadError.bind(this); 21 | }; 22 | 23 | togglePopup(e) { 24 | this.setState((prevState, props) => ({ 25 | isPopupVisible: !prevState.isPopupVisible, 26 | })); 27 | }; 28 | 29 | setFileName(e) { 30 | this.setState({ fileName: e.target.value }); 31 | }; 32 | 33 | beginDownload(e) { 34 | if (this.state.fileName === "") { 35 | this.setState({ errorMessage: "Please give a file name", }); 36 | return; 37 | } 38 | 39 | this.setState({ 40 | errorMessage: "", 41 | isDownloadInProgress: true, 42 | }); 43 | 44 | try { 45 | this.props.onRequestDownload(this.downloadCompleteCallback); 46 | } 47 | catch(e) { 48 | this.onDownloadError(); 49 | } 50 | }; 51 | 52 | downloadCompleteCallback(blob) { 53 | let url = window.URL.createObjectURL(blob); 54 | 55 | this.hiddenDownloadLink.href = url; 56 | this.hiddenDownloadLink.click(); 57 | 58 | window.URL.revokeObjectURL(blob); 59 | 60 | this.setState({isDownloadInProgress: false}); 61 | }; 62 | 63 | onDownloadError() { 64 | this.setState({ 65 | errorMessage: "An error occurred", 66 | isDownloadInProgress: false, 67 | }); 68 | }; 69 | 70 | render() { 71 | let bodyContent; 72 | 73 | if (this.props.isEnabled === true) { 74 | bodyContent = 75 | 76 | 77 | 78 | .wav 79 | 80 | {this.state.errorMessage} 81 |
82 | {this.state.isDownloadInProgress && } 83 | 84 |
85 |
; 86 | } 87 | else { 88 | bodyContent = Downloading to *.wav is not supported in your browser. If using a mobile device, try using a desktop browser instead.; 89 | } 90 | 91 | return
92 | 95 | { this.hiddenDownloadLink = el; }} className="display-none" download={this.state.fileName + ".wav"} href="#"> 96 |
97 | {bodyContent} 98 |
99 |
; 100 | }; 101 | }; 102 | -------------------------------------------------------------------------------- /src/components/keyboard.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import { flushSync } from "react-dom"; 5 | 6 | const NOTE_NAMES = ["A", "B", "C", "D", "E", "F", "G"]; 7 | 8 | class Key extends React.PureComponent { 9 | constructor(props) { 10 | super(props); 11 | }; 12 | 13 | render() { 14 | let noteString = this.props.noteName + this.props.octave; 15 | let isWhiteKey = NOTE_NAMES.includes(this.props.noteName); 16 | let keyColorClass = (isWhiteKey) ? "keyboard-white-key" : "keyboard-black-key"; 17 | let pressedClass = this.props.isActive ? "pressed" : ""; 18 | 19 | let rootNoteIndicator; 20 | if (this.props.rootNote === noteString) { 21 | rootNoteIndicator = Root; 22 | } 23 | 24 | return 25 | {rootNoteIndicator}{this.props.label} 26 | ; 27 | }; 28 | }; 29 | 30 | class Keyboard extends React.PureComponent { 31 | constructor(props) { 32 | super(props); 33 | 34 | this.state = { 35 | isScrollLeftActive: false, 36 | isScrollRightActive: false, 37 | scrollTimeoutID: null, 38 | }; 39 | 40 | this.touchHandler = this.touchHandler.bind(this); 41 | this.onMouseDown = this.onMouseDown.bind(this); 42 | this.onMouseUp = this.onMouseUp.bind(this); 43 | this.onMouseMove = this.onMouseMove.bind(this); 44 | this.onMouseOut = this.onMouseOut.bind(this); 45 | this.onMouseOver = this.onMouseOver.bind(this); 46 | this.onTouchStart = this.onTouchStart.bind(this); 47 | this.onTouchEndOrCancel = this.onTouchEndOrCancel.bind(this); 48 | this.onTouchMove = this.onTouchMove.bind(this); 49 | this.onGestureStart = this.onGestureStart.bind(this); 50 | this.onScroll = this.onScroll.bind(this); 51 | 52 | this.touches = {}; 53 | }; 54 | 55 | touchHandler(touches) { 56 | let key; 57 | let newIsScrollLeftActive = false; 58 | let newIsScrollRightActive = false; 59 | let newScrollTimeoutID = this.state.scrollTimeoutID; 60 | let activeNotes = []; 61 | 62 | for (key in touches) { 63 | let elementUnderCursor = document.elementFromPoint(touches[key].x, touches[key].y); 64 | 65 | if (elementUnderCursor !== null) { 66 | if (elementUnderCursor.classList.contains("js-keyboard-scroll-left")) { 67 | newIsScrollLeftActive = true; 68 | } 69 | else if (elementUnderCursor.classList.contains("js-keyboard-scroll-right")) { 70 | newIsScrollRightActive = true; 71 | } 72 | else if (elementUnderCursor.classList.contains("keyboard-key")) { 73 | activeNotes.push(elementUnderCursor.dataset.note); 74 | } 75 | } 76 | } 77 | 78 | if (newIsScrollLeftActive === true || newIsScrollRightActive === true) { 79 | if (this.state.scrollTimeoutID === null) { 80 | newScrollTimeoutID = setInterval(() => this.onScroll(), 15); 81 | } 82 | } 83 | else if (this.state.scrollTimeoutID !== null) { 84 | clearInterval(this.state.scrollTimeoutID); 85 | newScrollTimeoutID = null; 86 | } 87 | 88 | if (newIsScrollLeftActive !== this.state.isScrollLeftActive || 89 | newIsScrollRightActive !== this.state.isScrollRightActive || 90 | newScrollTimeoutID !== this.state.scrollTimeoutID) { 91 | flushSync(() => { 92 | this.setState({ 93 | isScrollLeftActive: newIsScrollLeftActive, 94 | isScrollRightActive: newIsScrollRightActive, 95 | scrollTimeoutID: newScrollTimeoutID, 96 | }) 97 | }); 98 | } 99 | 100 | this.props.setNotes(activeNotes); 101 | }; 102 | 103 | onMouseDown(e) { 104 | const RIGHT_MOUSE_BUTTON = 2; 105 | 106 | if (e.button === RIGHT_MOUSE_BUTTON) { 107 | return; 108 | } 109 | 110 | this.props.activate(); 111 | this.touches[-1] = { x: e.clientX, y: e.clientY }; 112 | this.touchHandler(this.touches); 113 | }; 114 | 115 | onMouseUp(e) { 116 | this.props.deactivate(); 117 | delete this.touches[-1]; 118 | this.touchHandler(this.touches); 119 | }; 120 | 121 | onMouseMove(e) { 122 | if (this.props.isActive) { 123 | this.touches[-1] = { x: e.clientX, y: e.clientY }; 124 | this.touchHandler(this.touches); 125 | } 126 | }; 127 | 128 | onMouseOut(e) { 129 | if (this.props.isActive) { 130 | this.touches[-1] = { x: e.clientX, y: e.clientY }; 131 | this.touchHandler(this.touches); 132 | } 133 | }; 134 | 135 | onMouseOver(e) { 136 | let noMouseButtonsPressed = false; 137 | 138 | if (e.buttons !== undefined && e.buttons === 0) { 139 | noMouseButtonsPressed = true; 140 | } 141 | // Safari, as of v11, doesn't support `buttons`, but it does support the non-standard `which` 142 | else if (e.nativeEvent.which !== undefined && e.nativeEvent.which === 0) { 143 | noMouseButtonsPressed = true; 144 | } 145 | 146 | if (noMouseButtonsPressed === true) { 147 | // Only deactivate if current active, to avoid performing a 148 | // no-op state change and re-rendering of unrelated components. 149 | if (this.props.isActive) { 150 | this.props.deactivate(); 151 | } 152 | } 153 | }; 154 | 155 | onTouchStart(e) { 156 | let i; 157 | let touch; 158 | let newTouches = e.changedTouches; 159 | 160 | // Fix for Safari to prevent text on the rest of the page from being selected during a 161 | // long press on iOS, or when the mouse is moved out of the timeline grid during the drag. 162 | document.body.classList.add("user-select-none"); 163 | 164 | this.props.activate(); 165 | 166 | for (i = 0; i < newTouches.length; i++) { 167 | touch = newTouches.item(i); 168 | this.touches[touch.identifier] = { x: touch.clientX, y: touch.clientY }; 169 | } 170 | 171 | this.touchHandler(this.touches); 172 | }; 173 | 174 | onTouchEndOrCancel(e) { 175 | let i; 176 | let removedTouches = e.changedTouches; 177 | 178 | for (i = 0; i < removedTouches.length; i++) { 179 | delete this.touches[removedTouches.item(i).identifier]; 180 | } 181 | 182 | this.touchHandler(this.touches); 183 | if (Object.keys(this.touches).length === 0) { 184 | document.body.classList.remove("user-select-none"); 185 | this.props.deactivate(); 186 | } 187 | 188 | // Prevent page zoom from double tap 189 | e.preventDefault(); 190 | }; 191 | 192 | onTouchMove(e) { 193 | let i; 194 | let touch; 195 | let newTouches = e.changedTouches; 196 | 197 | for (i = 0; i < newTouches.length; i++) { 198 | touch = newTouches.item(i); 199 | this.touches[touch.identifier] = { x: touch.clientX, y: touch.clientY }; 200 | } 201 | 202 | this.touchHandler(this.touches); 203 | 204 | // Prevent page from scrolling vertically or zooming in while dragging on keyboard 205 | e.preventDefault(); 206 | }; 207 | 208 | onGestureStart(e) { 209 | e.preventDefault(); 210 | }; 211 | 212 | onScroll() { 213 | const SCROLL_AMOUNT = 10; 214 | let scrollDelta = 0; 215 | 216 | if (this.state.isScrollLeftActive === true) { 217 | scrollDelta -= SCROLL_AMOUNT; 218 | } 219 | 220 | if (this.state.isScrollRightActive === true) { 221 | scrollDelta += SCROLL_AMOUNT; 222 | } 223 | 224 | this.keyboardKeysContainer.scrollLeft += scrollDelta; 225 | }; 226 | 227 | componentDidMount() { 228 | this.keyboardKeysContainer.scrollLeft = (this.keyboardKeysContainer.scrollWidth / 2) - (this.keyboardKeysContainer.clientWidth / 2); 229 | 230 | // This event handler is added manually to the actual DOM element, instead of using the 231 | // normal React way of attaching events because React seems to have a bug that prevents 232 | // preventDefault() from working correctly in a "touchmove" handler (as of v18.2.0). 233 | // The preventDefault() is needed to prevent the "pinch zoom into page" gesture from 234 | // activating when using the keyboard on iOS. 235 | // See https://medium.com/@ericclemmons/react-event-preventdefault-78c28c950e46 and 236 | // https://github.com/facebook/react/issues/9809. 237 | this.keyboardOuterContainer.addEventListener("touchmove", this.onTouchMove, false); 238 | 239 | // This prevents the "three finger page zoom" gesture on iOS while using the piano keyboard, 240 | // because it makes it very difficult to play chords without accidentally changing the page zoom. 241 | // React doesn't support the "gesturestart" event, so adding it manually. 242 | this.keyboardOuterContainer.addEventListener("gesturestart", this.onGestureStart, false); 243 | }; 244 | 245 | componentWillUnmount() { 246 | this.keyboardOuterContainer.removeEventListener("touchmove", this.onTouchMove); 247 | this.keyboardOuterContainer.removeEventListener("gesturestart", this.onGestureStart); 248 | }; 249 | 250 | render() { 251 | let rootNote = this.props.rootNoteName + this.props.rootNoteOctave; 252 | 253 | return
{ this.keyboardOuterContainer = el; }} 255 | className="keyboard-outer-container flex user-select-none" 256 | onMouseDown={this.onMouseDown} 257 | onMouseUp={this.onMouseUp} 258 | onMouseMove={this.onMouseMove} 259 | onMouseOut={this.onMouseOut} 260 | onMouseOver={this.onMouseOver} 261 | onTouchStart={this.onTouchStart} 262 | onTouchEnd={this.onTouchEndOrCancel} 263 | onTouchCancel={this.onTouchEndOrCancel} 264 | > 265 |
266 |
{ this.keyboardKeysContainer = div; }}> 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 |
371 |
372 |
; 373 | }; 374 | }; 375 | 376 | export { Keyboard }; 377 | -------------------------------------------------------------------------------- /src/components/pattern_editor.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | 5 | import { STEPS_PER_MEASURE } from "./../constants"; 6 | 7 | class PatternHeader extends React.PureComponent { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.state = { 12 | tipsAndTricksVisible: false, 13 | }; 14 | 15 | this.setPatternName = this.setPatternName.bind(this); 16 | this.setTipsAndTricksVisible = this.setTipsAndTricksVisible.bind(this); 17 | }; 18 | 19 | setPatternName(e) { 20 | this.props.setPatternName(this.props.patternID, e.target.value); 21 | }; 22 | 23 | setTipsAndTricksVisible(e) { 24 | this.setState((prevState, props) => ({ 25 | tipsAndTricksVisible: !prevState.tipsAndTricksVisible, 26 | })); 27 | }; 28 | 29 | render() { 30 | return 31 |
32 |   33 | 34 |
35 | {(this.state.tipsAndTricksVisible === true) && 36 | 44 | } 45 |
; 46 | }; 47 | }; 48 | 49 | class PatternNotes extends React.Component { 50 | constructor(props) { 51 | super(props); 52 | }; 53 | 54 | render() { 55 | const measureCount = Math.ceil(this.props.stepCount / STEPS_PER_MEASURE); 56 | 57 | let measures = []; 58 | let measure = undefined; 59 | let i, j; 60 | let startStep; 61 | 62 | for (i = 0; i < measureCount; i++) { 63 | measure = []; 64 | startStep = i * STEPS_PER_MEASURE; 65 | 66 | for (j = 0; j < this.props.rows.length; j++) { 67 | measure.push(this.props.rows[j].notes.slice(startStep, startStep + STEPS_PER_MEASURE)); 68 | } 69 | 70 | measures.push(measure); 71 | } 72 | 73 | return
74 |
75 | {measures.map((measure, measureIndex) => 76 | 89 | )} 90 |
91 | 99 |
; 100 | }; 101 | }; 102 | 103 | class PatternMeasure extends React.Component { 104 | constructor(props) { 105 | super(props); 106 | }; 107 | 108 | render() { 109 | const leftPaddingStyle = (this.props.startStep === 0) ? " pl0" : " pl-half"; 110 | const leftBorderStyle = (this.props.startStep === 0) ? "" : " bl"; 111 | const rightPaddingStyle = ((this.props.startStep + this.props.stepCount - 1) === this.props.maxStep) ? "" : "pr-half"; 112 | 113 | return ; 144 | }; 145 | }; 146 | 147 | class NoteBox extends React.PureComponent { 148 | constructor(props) { 149 | super(props); 150 | 151 | this.onKeyDown = this.onKeyDown.bind(this); 152 | this.onMouseDown = this.onMouseDown.bind(this); 153 | this.onTouchStart = this.onTouchStart.bind(this); 154 | this.onFocus = this.onFocus.bind(this); 155 | this.onBlur = this.onBlur.bind(this); 156 | this.setNoteValue = this.setNoteValue.bind(this); 157 | this.extractNoteParts = this.extractNoteParts.bind(this); 158 | this.isNoteValid = this.isNoteValid.bind(this); 159 | this.formatNote = this.formatNote.bind(this); 160 | }; 161 | 162 | onKeyDown(e) { 163 | const SPACE = 32; 164 | const DELETE = 46; 165 | const BACKSPACE = 8; 166 | const ZERO = 48; 167 | const TWO = 50; 168 | const THREE = 51; 169 | const FOUR = 52; 170 | const SIX = 54; 171 | const SEVEN = 55; 172 | const A = 65; 173 | const G = 71; 174 | const DASH = 189; 175 | const DASH_FIREFOX = 173; // Firefox has a different keycode from the "-" key than other browsers 176 | const LEFT_ARROW = 37; 177 | const RIGHT_ARROW = 39; 178 | const UP_ARROW = 38; 179 | const DOWN_ARROW = 40; 180 | 181 | let noteParts; 182 | 183 | if (e.keyCode === SPACE || e.keyCode === DELETE || e.keyCode === BACKSPACE) { 184 | this.setNoteValue(""); 185 | } 186 | else if (e.keyCode === TWO && e.shiftKey) { 187 | noteParts = this.extractNoteParts(this.props.note.name); 188 | 189 | if (noteParts.modifier === "" || noteParts.modifier === "@") { 190 | noteParts.modifier += "@"; 191 | } 192 | else if (noteParts.modifier === "@@") { 193 | noteParts.modifier = ""; 194 | } 195 | else { 196 | noteParts.modifier = "@"; 197 | } 198 | 199 | this.setNoteValue(noteParts.noteName + noteParts.modifier + noteParts.octave); 200 | } 201 | else if (e.keyCode === THREE && e.shiftKey) { 202 | noteParts = this.extractNoteParts(this.props.note.name); 203 | 204 | if (noteParts.modifier === "" || noteParts.modifier === "#") { 205 | noteParts.modifier += "#"; 206 | } 207 | else if (noteParts.modifier === "##") { 208 | noteParts.modifier = ""; 209 | } 210 | else { 211 | noteParts.modifier = "#"; 212 | } 213 | 214 | this.setNoteValue(noteParts.noteName + noteParts.modifier + noteParts.octave); 215 | } 216 | else if (e.keyCode === FOUR && e.shiftKey) { 217 | this.props.setSelectedStepIndex(this.props.rowIndex, this.props.maxStep); 218 | } 219 | else if (e.keyCode === SIX && e.shiftKey) { 220 | this.props.setSelectedStepIndex(this.props.rowIndex, 0); 221 | } 222 | else if (e.keyCode >= ZERO && e.keyCode <= SEVEN && !e.shiftKey) { 223 | noteParts = this.extractNoteParts(this.props.note.name); 224 | 225 | if (noteParts.modifier === "-") { 226 | noteParts.modifier = ""; 227 | } 228 | 229 | this.setNoteValue(noteParts.noteName + noteParts.modifier + String.fromCharCode(e.keyCode)); 230 | } 231 | else if (e.keyCode >= A && e.keyCode <= G) { 232 | noteParts = this.extractNoteParts(this.props.note.name); 233 | 234 | if (noteParts.modifier === "-") { 235 | noteParts.modifier = ""; 236 | } 237 | 238 | this.setNoteValue(String.fromCharCode(e.keyCode) + noteParts.modifier + noteParts.octave); 239 | } 240 | else if ((e.keyCode === DASH || e.keyCode === DASH_FIREFOX) && !e.shiftKey) { 241 | this.setNoteValue("-"); 242 | } 243 | else if (e.keyCode === LEFT_ARROW) { 244 | if (this.props.stepIndex > 0) { 245 | this.props.setSelectedStepIndex(this.props.rowIndex, this.props.stepIndex - 1); 246 | } 247 | } 248 | else if (e.keyCode === RIGHT_ARROW) { 249 | if (this.props.stepIndex < this.props.maxStep) { 250 | this.props.setSelectedStepIndex(this.props.rowIndex, this.props.stepIndex + 1); 251 | } 252 | } 253 | else if (e.keyCode === UP_ARROW) { 254 | if (this.props.rowIndex > 0) { 255 | this.props.setSelectedStepIndex(this.props.rowIndex - 1, this.props.stepIndex); 256 | } 257 | } 258 | else if (e.keyCode === DOWN_ARROW) { 259 | if (this.props.rowIndex < this.props.maxRow) { 260 | this.props.setSelectedStepIndex(this.props.rowIndex + 1, this.props.stepIndex); 261 | } 262 | } 263 | 264 | e.preventDefault(); 265 | }; 266 | 267 | onMouseDown(e) { 268 | this.el.focus(); 269 | }; 270 | 271 | onTouchStart(e) { 272 | this.el.focus(); 273 | }; 274 | 275 | onFocus(e) { 276 | this.props.setSelectedStepIndex(this.props.rowIndex, this.props.stepIndex); 277 | }; 278 | 279 | onBlur(e) { 280 | if (!this.props.isKeyboardActive) { 281 | this.props.setSelectedStepIndex(undefined, undefined); 282 | } 283 | }; 284 | 285 | setNoteValue(newNoteValue) { 286 | this.props.setNoteValue(newNoteValue, this.props.patternID, this.props.rowIndex, this.props.stepIndex); 287 | }; 288 | 289 | extractNoteParts(noteString) { 290 | let noteNameMatches = noteString.match(/^[A-G]/); 291 | let noteName = (noteNameMatches === null) ? "" : noteNameMatches[0]; 292 | let octaveMatches = noteString.match(/[0-7]$/); 293 | let octave = (octaveMatches === null) ? "" : octaveMatches[0]; 294 | 295 | let modifier = noteString; 296 | if (noteName !== "") { 297 | modifier = noteString.slice(1); 298 | } 299 | if (octave !== "") { 300 | modifier = modifier.slice(0, modifier.length - 1); 301 | } 302 | 303 | return {noteName: noteName, modifier: modifier, octave: octave}; 304 | }; 305 | 306 | isNoteValid(rawNoteString) { 307 | return /^$|^-$|^ $|(^[A-G](@|@@|#|##){0,1}[0-7]$)/.test(rawNoteString); 308 | }; 309 | 310 | formatNote(rawNoteString) { 311 | let formattedNoteName = rawNoteString; 312 | 313 | formattedNoteName = formattedNoteName.toUpperCase(); 314 | formattedNoteName = formattedNoteName.replace("##", "𝄪"); 315 | formattedNoteName = formattedNoteName.replace("#", "♯"); 316 | formattedNoteName = formattedNoteName.replace("@@", "𝄫"); 317 | formattedNoteName = formattedNoteName.replace("@", "♭"); 318 | formattedNoteName = formattedNoteName.replace("-", "—"); 319 | 320 | return formattedNoteName; 321 | }; 322 | 323 | componentDidUpdate() { 324 | if (this.props.isSelected === true) { 325 | this.el.focus(); 326 | } 327 | }; 328 | 329 | render() { 330 | let formattedNoteName = this.formatNote(this.props.note.name); 331 | let isNoteValid = (this.props.isSelected === true) || this.isNoteValid(this.props.note.name); 332 | 333 | return { this.el = el; }} 335 | tabIndex="-1" 336 | className={"note-box" + (isNoteValid ? "" : " note-box-invalid") + ((this.props.isSelected === true) ? " note-box-focused" : "")} 337 | onKeyDown={this.onKeyDown} 338 | onMouseDown={this.onMouseDown} 339 | onTouchStart={this.onTouchStart} 340 | onFocus={this.onFocus} 341 | onBlur={this.onBlur} 342 | > 343 | {formattedNoteName} 344 | ; 345 | }; 346 | }; 347 | 348 | class PatternRowRemoveButton extends React.PureComponent { 349 | constructor(props) { 350 | super(props); 351 | 352 | this.removePatternRow = this.removePatternRow.bind(this); 353 | } 354 | 355 | removePatternRow(e) { 356 | this.props.removePatternRow(this.props.patternID, this.props.rowIndex); 357 | }; 358 | 359 | render() { 360 | return ; 361 | }; 362 | }; 363 | 364 | class PatternFooter extends React.PureComponent { 365 | constructor(props) { 366 | super(props); 367 | 368 | this.addPatternRow = this.addPatternRow.bind(this); 369 | this.eraseNote = this.eraseNote.bind(this); 370 | this.setNoteAsDash = this.setNoteAsDash.bind(this); 371 | }; 372 | 373 | addPatternRow(e) { 374 | this.props.addPatternRow(this.props.patternID); 375 | }; 376 | 377 | eraseNote(e) { 378 | if (this.props.selectedRowIndex !== undefined && this.props.selectedStepIndex !== undefined) { 379 | this.props.setNoteValue("", this.props.patternID, this.props.selectedRowIndex, this.props.selectedStepIndex); 380 | 381 | // Prevent the currently selected note input from losing focus, 382 | // which will prevent the note from being set properly. 383 | e.preventDefault(); 384 | } 385 | }; 386 | 387 | setNoteAsDash(e) { 388 | if (this.props.selectedRowIndex !== undefined && this.props.selectedStepIndex !== undefined) { 389 | this.props.setNoteValue("-", this.props.patternID, this.props.selectedRowIndex, this.props.selectedStepIndex); 390 | 391 | // Prevent the currently selected note input from losing focus, 392 | // which will prevent the note from being set properly. 393 | e.preventDefault(); 394 | } 395 | }; 396 | 397 | render() { 398 | return
399 | 400 | 401 | 402 | 403 | 404 |
; 405 | }; 406 | }; 407 | 408 | class PatternEditor extends React.Component { 409 | constructor(props) { 410 | super(props); 411 | }; 412 | 413 | componentDidMount() { 414 | window.scrollTo(0, 0); 415 | }; 416 | 417 | render() { 418 | return
419 | 420 | 425 | 436 | 443 |
; 444 | }; 445 | }; 446 | 447 | export { PatternEditor }; 448 | -------------------------------------------------------------------------------- /src/components/transport.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | 5 | class TempoSlider extends React.PureComponent { 6 | constructor(props) { 7 | super(props); 8 | }; 9 | 10 | render() { 11 | return 12 | 13 | 14 |  {this.props.tempo} 15 | ; 16 | }; 17 | }; 18 | 19 | class AmplitudeSlider extends React.PureComponent { 20 | constructor(props) { 21 | super(props); 22 | }; 23 | 24 | render() { 25 | return 26 | 27 | 28 |  {(this.props.amplitude * 100).toFixed(0)}% 29 | ; 30 | }; 31 | }; 32 | 33 | class Controls extends React.PureComponent { 34 | constructor(props) { 35 | super(props); 36 | }; 37 | 38 | render() { 39 | return 40 | 41 | 42 | ; 43 | }; 44 | }; 45 | 46 | class Transport extends React.PureComponent { 47 | constructor(props) { 48 | super(props); 49 | }; 50 | 51 | render() { 52 | return
53 |
54 | 55 | 56 | 57 | 58 | 59 |
60 |
; 61 | }; 62 | }; 63 | 64 | export { Transport }; 65 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export const STEPS_PER_MEASURE = 16; 4 | export const STEP_WIDTH_IN_PIXELS = 9; 5 | export const TRACK_HEIGHT_IN_PIXELS = 72; 6 | -------------------------------------------------------------------------------- /src/default_song.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const instruments = [ 4 | { 5 | id: 1, 6 | type: "synth", 7 | name: "Bass Synth", 8 | oscillator1Waveform: "sawtooth", 9 | oscillator1Octave: 0, 10 | oscillator1Amplitude: 1.0, 11 | oscillator2Waveform: "sawtooth", 12 | oscillator2Detune: 0, 13 | oscillator2Octave: 0, 14 | oscillator2Amplitude: 1.0, 15 | noiseAmplitude: 0.0, 16 | noiseType: "pink", 17 | lfoWaveform: "sine", 18 | lfoFrequency: 7.1, 19 | lfoAmplitude: 2, 20 | filterCutoff: 150, 21 | filterResonance: 4, 22 | filterLFOWaveform: "sine", 23 | filterLFOFrequency: 2.1, 24 | filterLFOAmplitude: 0, 25 | filterEnvelopeAmount: 2000, 26 | filterEnvelopeAttackTime: 0, 27 | filterEnvelopeDecayTime: 0.1, 28 | filterEnvelopeSustainPercentage: 0.09, 29 | filterEnvelopeReleaseTime: 0, 30 | envelopeAttackTime: 0, 31 | envelopeDecayTime: 0, 32 | envelopeSustainPercentage: 1, 33 | envelopeReleaseTime: 0, 34 | delayTime: 0.0, 35 | delayFeedback: 0.0, 36 | reverbWetPercentage: 0.0, 37 | }, 38 | { 39 | id: 2, 40 | type: "synth", 41 | name: "Arpeggio", 42 | oscillator1Waveform: "sawtooth", 43 | oscillator1Octave: 1, 44 | oscillator1Amplitude: 1.0, 45 | oscillator2Waveform: "square", 46 | oscillator2Detune: 7, 47 | oscillator2Octave: 1, 48 | oscillator2Amplitude: 1.0, 49 | noiseAmplitude: 0.0, 50 | noiseType: "pink", 51 | lfoWaveform: "sine", 52 | lfoFrequency: 5, 53 | lfoAmplitude: 0, 54 | filterCutoff: 9950, 55 | filterResonance: 0, 56 | filterLFOWaveform: "sine", 57 | filterLFOFrequency: 5, 58 | filterLFOAmplitude: 0, 59 | filterEnvelopeAmount: 1500, 60 | filterEnvelopeAttackTime: 0, 61 | filterEnvelopeDecayTime: 0, 62 | filterEnvelopeSustainPercentage: 1, 63 | filterEnvelopeReleaseTime: 0, 64 | envelopeAttackTime: 0, 65 | envelopeDecayTime: 0.09, 66 | envelopeSustainPercentage: 0.16, 67 | envelopeReleaseTime: 0.99, 68 | delayTime: 0.4, 69 | delayFeedback: 0.5, 70 | reverbWetPercentage: 0.0, 71 | }, 72 | { 73 | id: 3, 74 | type: "synth", 75 | name: "Squeal", 76 | oscillator1Waveform: "square", 77 | oscillator1Octave: 0, 78 | oscillator1Amplitude: 1.0, 79 | oscillator2Waveform: "sawtooth", 80 | oscillator2Detune: 6, 81 | oscillator2Octave: 0, 82 | oscillator2Amplitude: 1.0, 83 | noiseAmplitude: 0.0, 84 | noiseType: "pink", 85 | lfoWaveform: "sine", 86 | lfoFrequency: 6, 87 | lfoAmplitude: 57, 88 | filterCutoff: 2800, 89 | filterResonance: 0, 90 | filterLFOWaveform: "sine", 91 | filterLFOFrequency: 5, 92 | filterLFOAmplitude: 0, 93 | filterEnvelopeAmount: 1500, 94 | filterEnvelopeAttackTime: 0.48, 95 | filterEnvelopeDecayTime: 0.17, 96 | filterEnvelopeSustainPercentage: 0.17, 97 | filterEnvelopeReleaseTime: 0, 98 | envelopeAttackTime: 0.13, 99 | envelopeDecayTime: 0, 100 | envelopeSustainPercentage: 1, 101 | envelopeReleaseTime: 0, 102 | delayTime: 0.3, 103 | delayFeedback: 0.4, 104 | reverbWetPercentage: 0.4, 105 | }, 106 | { 107 | id: 4, 108 | type: "synth", 109 | name: "Flute", 110 | oscillator1Waveform: "sine", 111 | oscillator1Octave: 0.0, 112 | oscillator1Amplitude: 1.0, 113 | oscillator2Waveform: "triangle", 114 | oscillator2Detune: 0.0, 115 | oscillator2Octave: 1.0, 116 | oscillator2Amplitude: 1.0, 117 | noiseAmplitude: 0.0, 118 | noiseType: "pink", 119 | lfoWaveform: "sine", 120 | lfoFrequency: 5, 121 | lfoAmplitude: 6.666666666666667, 122 | filterCutoff: 4300, 123 | filterResonance: 3, 124 | filterLFOWaveform: "sine", 125 | filterLFOFrequency: 5, 126 | filterLFOAmplitude: 0.0, 127 | filterEnvelopeAmount: 0, 128 | filterEnvelopeAttackTime: 0.0, 129 | filterEnvelopeDecayTime: 0.0, 130 | filterEnvelopeSustainPercentage: 1.0, 131 | filterEnvelopeReleaseTime: 0.0, 132 | envelopeAttackTime: 0.0, 133 | envelopeDecayTime: 0.0, 134 | envelopeSustainPercentage: 1.0, 135 | envelopeReleaseTime: 0.0, 136 | delayTime: 0.0, 137 | delayFeedback: 0.0, 138 | reverbWetPercentage: 0.5, 139 | }, 140 | { 141 | id: 5, 142 | type: "synth", 143 | name: "Swell", 144 | oscillator1Waveform: "sawtooth", 145 | oscillator1Octave: -1.0, 146 | oscillator1Amplitude: 1.0, 147 | oscillator2Waveform: "sawtooth", 148 | oscillator2Detune: 10.285714285714292, 149 | oscillator2Octave: 0.0, 150 | oscillator2Amplitude: 1.0, 151 | noiseAmplitude: 0.0, 152 | noiseType: "pink", 153 | lfoWaveform: "sine", 154 | lfoFrequency: 5, 155 | lfoAmplitude: 0.0, 156 | filterCutoff: 50, 157 | filterResonance: 10, 158 | filterLFOWaveform: "sine", 159 | filterLFOFrequency: 5, 160 | filterLFOAmplitude: 0.0, 161 | filterEnvelopeAmount: 6450, 162 | filterEnvelopeAttackTime: 2.52, 163 | filterEnvelopeDecayTime: 0.0, 164 | filterEnvelopeSustainPercentage: 1.0, 165 | filterEnvelopeReleaseTime: 5.0, 166 | envelopeAttackTime: 0.0, 167 | envelopeDecayTime: 0.0, 168 | envelopeSustainPercentage: 1.0, 169 | envelopeReleaseTime: 5.0, 170 | delayTime: 0.0, 171 | delayFeedback: 0.0, 172 | reverbWetPercentage: 0.2, 173 | }, 174 | { 175 | id: 6, 176 | type: "sample", 177 | name: "Bass Drum", 178 | filename: "sounds/bass.wav", 179 | loop: false, 180 | rootNoteName: "A", 181 | rootNoteOctave: 4, 182 | filterCutoff: 9950, 183 | filterResonance: 0, 184 | filterLFOWaveform: "sine", 185 | filterLFOFrequency: 5, 186 | filterLFOAmplitude: 0, 187 | filterEnvelopeAmount: 1500, 188 | filterEnvelopeAttackTime: 0, 189 | filterEnvelopeDecayTime: 0, 190 | filterEnvelopeSustainPercentage: 1, 191 | filterEnvelopeReleaseTime: 0, 192 | envelopeAttackTime: 0, 193 | envelopeDecayTime: 0, 194 | envelopeSustainPercentage: 1, 195 | envelopeReleaseTime: 0, 196 | delayTime: 0.0, 197 | delayFeedback: 0.0, 198 | reverbWetPercentage: 0.0, 199 | }, 200 | { 201 | id: 7, 202 | type: "sample", 203 | name: "Snare Drum", 204 | filename: "sounds/snare.wav", 205 | loop: false, 206 | rootNoteName: "A", 207 | rootNoteOctave: 4, 208 | filterCutoff: 9950, 209 | filterResonance: 0, 210 | filterLFOWaveform: "sine", 211 | filterLFOFrequency: 5, 212 | filterLFOAmplitude: 0, 213 | filterEnvelopeAmount: 1500, 214 | filterEnvelopeAttackTime: 0, 215 | filterEnvelopeDecayTime: 0, 216 | filterEnvelopeSustainPercentage: 1, 217 | filterEnvelopeReleaseTime: 0, 218 | envelopeAttackTime: 0, 219 | envelopeDecayTime: 0, 220 | envelopeSustainPercentage: 1, 221 | envelopeReleaseTime: 0, 222 | delayTime: 0.0, 223 | delayFeedback: 0.0, 224 | reverbWetPercentage: 0.0, 225 | }, 226 | { 227 | id: 8, 228 | type: "sample", 229 | name: "Hi-Hat", 230 | filename: "sounds/hihat.wav", 231 | loop: false, 232 | rootNoteName: "A", 233 | rootNoteOctave: 4, 234 | filterCutoff: 9950, 235 | filterResonance: 0, 236 | filterLFOWaveform: "sine", 237 | filterLFOFrequency: 5, 238 | filterLFOAmplitude: 0, 239 | filterEnvelopeAmount: 1500, 240 | filterEnvelopeAttackTime: 0, 241 | filterEnvelopeDecayTime: 0, 242 | filterEnvelopeSustainPercentage: 1, 243 | filterEnvelopeReleaseTime: 0, 244 | envelopeAttackTime: 0, 245 | envelopeDecayTime: 0, 246 | envelopeSustainPercentage: 1, 247 | envelopeReleaseTime: 0, 248 | delayTime: 0.0, 249 | delayFeedback: 0.0, 250 | reverbWetPercentage: 0.0, 251 | }, 252 | ]; 253 | 254 | const patterns = [ 255 | { 256 | id: 1, 257 | name: "", 258 | trackID: 1, 259 | startStep: 0, 260 | stepCount: 32, 261 | playbackStepCount: 64, 262 | rows: [ 263 | { 264 | notes: [{name: "G2"}, 265 | {name: "A2"}, 266 | {name: "A1"}, 267 | {name: ""}, 268 | {name: "A1"}, 269 | {name: ""}, 270 | {name: ""}, 271 | {name: "A1"}, 272 | {name: ""}, 273 | {name: "A1"}, 274 | {name: "A1"}, 275 | {name: ""}, 276 | {name: "C2"}, 277 | {name: "A1"}, 278 | {name: "G2"}, 279 | {name: "-"}, 280 | {name: "G2"}, 281 | {name: "A2"}, 282 | {name: "A1"}, 283 | {name: ""}, 284 | {name: "A1"}, 285 | {name: ""}, 286 | {name: ""}, 287 | {name: "A1"}, 288 | {name: ""}, 289 | {name: "A1"}, 290 | {name: "C2"}, 291 | {name: ""}, 292 | {name: "C2"}, 293 | {name: "A1"}, 294 | {name: "G1"}, 295 | {name: "-"},], 296 | }, 297 | ], 298 | }, 299 | { 300 | id: 2, 301 | name: "", 302 | trackID: 1, 303 | startStep: 64, 304 | stepCount: 32, 305 | playbackStepCount: 64, 306 | rows: [ 307 | { 308 | notes: [{name: "A1"}, 309 | {name: "A1"}, 310 | {name: ""}, 311 | {name: "A1"}, 312 | {name: "A1"}, 313 | {name: ""}, 314 | {name: "A1"}, 315 | {name: "A1"}, 316 | {name: "-"}, 317 | {name: "-"}, 318 | {name: "-"}, 319 | {name: "-"}, 320 | {name: "C2"}, 321 | {name: "-"}, 322 | {name: "G2"}, 323 | {name: "-"}, 324 | {name: "A1"}, 325 | {name: "A1"}, 326 | {name: ""}, 327 | {name: "A1"}, 328 | {name: "A1"}, 329 | {name: ""}, 330 | {name: "A1"}, 331 | {name: "A1"}, 332 | {name: "-"}, 333 | {name: "-"}, 334 | {name: "C2"}, 335 | {name: "-"}, 336 | {name: "C2"}, 337 | {name: "A1"}, 338 | {name: "G1"}, 339 | {name: "-"},], 340 | }, 341 | ], 342 | }, 343 | { 344 | id: 3, 345 | name: "", 346 | trackID: 1, 347 | startStep: 128, 348 | stepCount: 32, 349 | playbackStepCount: 64, 350 | rows: [ 351 | { 352 | notes: [{name: "G2"}, 353 | {name: "A2"}, 354 | {name: "A1"}, 355 | {name: ""}, 356 | {name: "A1"}, 357 | {name: ""}, 358 | {name: ""}, 359 | {name: "A1"}, 360 | {name: ""}, 361 | {name: "A1"}, 362 | {name: "A1"}, 363 | {name: ""}, 364 | {name: "C2"}, 365 | {name: "A1"}, 366 | {name: "G2"}, 367 | {name: "-"}, 368 | {name: "G2"}, 369 | {name: "A2"}, 370 | {name: "A1"}, 371 | {name: ""}, 372 | {name: "A1"}, 373 | {name: ""}, 374 | {name: ""}, 375 | {name: "A1"}, 376 | {name: ""}, 377 | {name: "A1"}, 378 | {name: "C2"}, 379 | {name: ""}, 380 | {name: "C2"}, 381 | {name: "A1"}, 382 | {name: "G1"}, 383 | {name: "-"},], 384 | }, 385 | ], 386 | }, 387 | { 388 | id: 4, 389 | name: "", 390 | trackID: 1, 391 | startStep: 192, 392 | stepCount: 32, 393 | playbackStepCount: 64, 394 | rows: [ 395 | { 396 | notes: [{name: "A1"}, 397 | {name: "A1"}, 398 | {name: ""}, 399 | {name: "A1"}, 400 | {name: "A1"}, 401 | {name: ""}, 402 | {name: "A1"}, 403 | {name: "A1"}, 404 | {name: "-"}, 405 | {name: "-"}, 406 | {name: "-"}, 407 | {name: "-"}, 408 | {name: "C2"}, 409 | {name: "-"}, 410 | {name: "G2"}, 411 | {name: "-"}, 412 | {name: "A1"}, 413 | {name: "A1"}, 414 | {name: ""}, 415 | {name: "A1"}, 416 | {name: "A1"}, 417 | {name: ""}, 418 | {name: "A1"}, 419 | {name: "A1"}, 420 | {name: "-"}, 421 | {name: "-"}, 422 | {name: "C2"}, 423 | {name: "-"}, 424 | {name: "C2"}, 425 | {name: "A1"}, 426 | {name: "G1"}, 427 | {name: "-"},], 428 | }, 429 | ], 430 | }, 431 | { 432 | id: 5, 433 | name: "", 434 | trackID: 2, 435 | startStep: 64, 436 | stepCount: 16, 437 | playbackStepCount: 16, 438 | rows: [ 439 | { 440 | notes: [{name: "A3"}, 441 | {name: "E4"}, 442 | {name: "A4"}, 443 | {name: "A3"}, 444 | {name: "E4"}, 445 | {name: "A4"}, 446 | {name: "A3"}, 447 | {name: "E4"}, 448 | {name: "C4"}, 449 | {name: "A3"}, 450 | {name: "A4"}, 451 | {name: "E4"}, 452 | {name: "A3"}, 453 | {name: "A2"}, 454 | {name: ""}, 455 | {name: ""},], 456 | }, 457 | ], 458 | }, 459 | { 460 | id: 6, 461 | name: "", 462 | trackID: 2, 463 | startStep: 96, 464 | stepCount: 16, 465 | playbackStepCount: 16, 466 | rows: [ 467 | { 468 | notes: [{name: "C4"}, 469 | {name: "E4"}, 470 | {name: "C5"}, 471 | {name: "C4"}, 472 | {name: "E4"}, 473 | {name: "C5"}, 474 | {name: "C4"}, 475 | {name: "E4"}, 476 | {name: "C4"}, 477 | {name: "C3"}, 478 | {name: "C5"}, 479 | {name: "E4"}, 480 | {name: "C4"}, 481 | {name: "C3"}, 482 | {name: ""}, 483 | {name: ""},], 484 | }, 485 | ], 486 | }, 487 | { 488 | id: 7, 489 | name: "", 490 | trackID: 2, 491 | startStep: 192, 492 | stepCount: 16, 493 | playbackStepCount: 16, 494 | rows: [ 495 | { 496 | notes: [{name: "A3"}, 497 | {name: "E4"}, 498 | {name: "A4"}, 499 | {name: "A3"}, 500 | {name: "E4"}, 501 | {name: "A4"}, 502 | {name: "A3"}, 503 | {name: "E4"}, 504 | {name: "C4"}, 505 | {name: "A3"}, 506 | {name: "A4"}, 507 | {name: "E4"}, 508 | {name: "A3"}, 509 | {name: "A2"}, 510 | {name: ""}, 511 | {name: ""},], 512 | }, 513 | ], 514 | }, 515 | { 516 | id: 8, 517 | name: "", 518 | trackID: 2, 519 | startStep: 224, 520 | stepCount: 16, 521 | playbackStepCount: 16, 522 | rows: [ 523 | { 524 | notes: [{name: "C4"}, 525 | {name: "E4"}, 526 | {name: "C5"}, 527 | {name: "C4"}, 528 | {name: "E4"}, 529 | {name: "C5"}, 530 | {name: "C4"}, 531 | {name: "E4"}, 532 | {name: "C4"}, 533 | {name: "C3"}, 534 | {name: "C5"}, 535 | {name: "E4"}, 536 | {name: "C4"}, 537 | {name: "C3"}, 538 | {name: ""}, 539 | {name: ""},], 540 | }, 541 | ], 542 | }, 543 | { 544 | id: 9, 545 | name: "", 546 | trackID: 3, 547 | startStep: 112, 548 | stepCount: 16, 549 | playbackStepCount: 16, 550 | rows: [ 551 | { 552 | notes: [{name: "C5"}, 553 | {name: "-"}, 554 | {name: "-"}, 555 | {name: "-"}, 556 | {name: "B4"}, 557 | {name: "-"}, 558 | {name: "-"}, 559 | {name: "-"}, 560 | {name: "G4"}, 561 | {name: "-"}, 562 | {name: "-"}, 563 | {name: "-"}, 564 | {name: "E4"}, 565 | {name: "-"}, 566 | {name: "-"}, 567 | {name: "-"},], 568 | }, 569 | ], 570 | }, 571 | { 572 | id: 10, 573 | name: "", 574 | trackID: 3, 575 | startStep: 240, 576 | stepCount: 16, 577 | playbackStepCount: 16, 578 | rows: [ 579 | { 580 | notes: [{name: "C5"}, 581 | {name: "-"}, 582 | {name: "-"}, 583 | {name: "-"}, 584 | {name: "B4"}, 585 | {name: "-"}, 586 | {name: "-"}, 587 | {name: "-"}, 588 | {name: "G4"}, 589 | {name: "-"}, 590 | {name: "-"}, 591 | {name: "-"}, 592 | {name: "E4"}, 593 | {name: "-"}, 594 | {name: "-"}, 595 | {name: "-"},], 596 | }, 597 | ], 598 | }, 599 | { 600 | id: 11, 601 | name: "", 602 | trackID: 4, 603 | startStep: 128, 604 | stepCount: 32, 605 | playbackStepCount: 64, 606 | rows: [ 607 | { 608 | notes: [{name: "C5"}, 609 | {name: "A4"}, 610 | {name: ""}, 611 | {name: "E4"}, 612 | {name: "G4"}, 613 | {name: "A4"}, 614 | {name: ""}, 615 | {name: ""}, 616 | {name: ""}, 617 | {name: ""}, 618 | {name: ""}, 619 | {name: ""}, 620 | {name: ""}, 621 | {name: ""}, 622 | {name: ""}, 623 | {name: ""}, 624 | {name: "G4"}, 625 | {name: "A4"}, 626 | {name: ""}, 627 | {name: "E4"}, 628 | {name: "G4"}, 629 | {name: "A4"}, 630 | {name: ""}, 631 | {name: ""}, 632 | {name: ""}, 633 | {name: ""}, 634 | {name: ""}, 635 | {name: "E4"}, 636 | {name: "G4"}, 637 | {name: "A4"}, 638 | {name: "G4"}, 639 | {name: "A4"},], 640 | }, 641 | ], 642 | }, 643 | { 644 | id: 12, 645 | name: "", 646 | trackID: 5, 647 | startStep: 128, 648 | stepCount: 32, 649 | playbackStepCount: 64, 650 | rows: [ 651 | { 652 | notes: [{name: ""}, 653 | {name: ""}, 654 | {name: ""}, 655 | {name: ""}, 656 | {name: ""}, 657 | {name: ""}, 658 | {name: ""}, 659 | {name: ""}, 660 | {name: "A3"}, 661 | {name: "-"}, 662 | {name: "-"}, 663 | {name: "-"}, 664 | {name: "-"}, 665 | {name: "-"}, 666 | {name: "-"}, 667 | {name: "-"}, 668 | {name: "-"}, 669 | {name: ""}, 670 | {name: ""}, 671 | {name: ""}, 672 | {name: ""}, 673 | {name: ""}, 674 | {name: ""}, 675 | {name: ""}, 676 | {name: ""}, 677 | {name: ""}, 678 | {name: ""}, 679 | {name: ""}, 680 | {name: ""}, 681 | {name: ""}, 682 | {name: ""}, 683 | {name: ""},], 684 | }, 685 | { 686 | notes: [{name: ""}, 687 | {name: ""}, 688 | {name: ""}, 689 | {name: ""}, 690 | {name: ""}, 691 | {name: ""}, 692 | {name: ""}, 693 | {name: ""}, 694 | {name: "A4"}, 695 | {name: "-"}, 696 | {name: "-"}, 697 | {name: "-"}, 698 | {name: "-"}, 699 | {name: "-"}, 700 | {name: "-"}, 701 | {name: "-"}, 702 | {name: "-"}, 703 | {name: ""}, 704 | {name: ""}, 705 | {name: ""}, 706 | {name: ""}, 707 | {name: ""}, 708 | {name: ""}, 709 | {name: ""}, 710 | {name: ""}, 711 | {name: ""}, 712 | {name: ""}, 713 | {name: ""}, 714 | {name: ""}, 715 | {name: ""}, 716 | {name: ""}, 717 | {name: ""},], 718 | }, 719 | ], 720 | }, 721 | { 722 | id: 13, 723 | name: "", 724 | trackID: 6, 725 | startStep: 0, 726 | stepCount: 16, 727 | playbackStepCount: 64, 728 | rows: [ 729 | { 730 | notes: [{name: "A4"}, 731 | {name: "-"}, 732 | {name: "-"}, 733 | {name: "-"}, 734 | {name: "A4"}, 735 | {name: "-"}, 736 | {name: "-"}, 737 | {name: "-"}, 738 | {name: "A4"}, 739 | {name: "-"}, 740 | {name: "-"}, 741 | {name: "-"}, 742 | {name: "A4"}, 743 | {name: "-"}, 744 | {name: "-"}, 745 | {name: "-"},], 746 | }, 747 | ], 748 | }, 749 | { 750 | id: 14, 751 | name: "", 752 | trackID: 6, 753 | startStep: 64, 754 | stepCount: 16, 755 | playbackStepCount: 64, 756 | rows: [ 757 | { 758 | notes: [{name: "A4"}, 759 | {name: "-"}, 760 | {name: "-"}, 761 | {name: "-"}, 762 | {name: "A4"}, 763 | {name: "-"}, 764 | {name: "-"}, 765 | {name: "-"}, 766 | {name: "A4"}, 767 | {name: "A4"}, 768 | {name: "-"}, 769 | {name: "-"}, 770 | {name: "A4"}, 771 | {name: "-"}, 772 | {name: "-"}, 773 | {name: "-"},], 774 | }, 775 | ], 776 | }, 777 | { 778 | id: 15, 779 | name: "", 780 | trackID: 6, 781 | startStep: 128, 782 | stepCount: 16, 783 | playbackStepCount: 64, 784 | rows: [ 785 | { 786 | notes: [{name: "A4"}, 787 | {name: "-"}, 788 | {name: "-"}, 789 | {name: "-"}, 790 | {name: "A4"}, 791 | {name: "-"}, 792 | {name: "-"}, 793 | {name: "-"}, 794 | {name: "A4"}, 795 | {name: "-"}, 796 | {name: "-"}, 797 | {name: "-"}, 798 | {name: "A4"}, 799 | {name: "-"}, 800 | {name: "-"}, 801 | {name: "-"},], 802 | }, 803 | ], 804 | }, 805 | { 806 | id: 16, 807 | name: "", 808 | trackID: 6, 809 | startStep: 192, 810 | stepCount: 16, 811 | playbackStepCount: 64, 812 | rows: [ 813 | { 814 | notes: [{name: "A4"}, 815 | {name: "-"}, 816 | {name: "-"}, 817 | {name: "-"}, 818 | {name: "A4"}, 819 | {name: "-"}, 820 | {name: "-"}, 821 | {name: "-"}, 822 | {name: "A4"}, 823 | {name: "A4"}, 824 | {name: "-"}, 825 | {name: "-"}, 826 | {name: "A4"}, 827 | {name: "-"}, 828 | {name: "-"}, 829 | {name: "-"},], 830 | }, 831 | ], 832 | }, 833 | { 834 | id: 17, 835 | name: "", 836 | trackID: 7, 837 | startStep: 64, 838 | stepCount: 16, 839 | playbackStepCount: 64, 840 | rows: [ 841 | { 842 | notes: [{name: ""}, 843 | {name: ""}, 844 | {name: ""}, 845 | {name: ""}, 846 | {name: "A4"}, 847 | {name: "-"}, 848 | {name: "-"}, 849 | {name: "-"}, 850 | {name: ""}, 851 | {name: ""}, 852 | {name: ""}, 853 | {name: ""}, 854 | {name: "A4"}, 855 | {name: "-"}, 856 | {name: "-"}, 857 | {name: "-"},], 858 | }, 859 | ], 860 | }, 861 | { 862 | id: 18, 863 | name: "", 864 | trackID: 7, 865 | startStep: 192, 866 | stepCount: 16, 867 | playbackStepCount: 64, 868 | rows: [ 869 | { 870 | notes: [{name: ""}, 871 | {name: ""}, 872 | {name: ""}, 873 | {name: ""}, 874 | {name: "A4"}, 875 | {name: "-"}, 876 | {name: "-"}, 877 | {name: "-"}, 878 | {name: ""}, 879 | {name: ""}, 880 | {name: ""}, 881 | {name: ""}, 882 | {name: "A4"}, 883 | {name: "-"}, 884 | {name: "-"}, 885 | {name: "-"},], 886 | }, 887 | ], 888 | }, 889 | { 890 | id: 19, 891 | name: "", 892 | trackID: 8, 893 | startStep: 0, 894 | stepCount: 16, 895 | playbackStepCount: 64, 896 | rows: [ 897 | { 898 | notes: [{name: "A4"}, 899 | {name: "A4"}, 900 | {name: "A4"}, 901 | {name: "-"}, 902 | {name: "A4"}, 903 | {name: "-"}, 904 | {name: "A3"}, 905 | {name: "F4"}, 906 | {name: "A4"}, 907 | {name: ""}, 908 | {name: "C5"}, 909 | {name: "A4"}, 910 | {name: "A4"}, 911 | {name: "A4"}, 912 | {name: "-"}, 913 | {name: ""},], 914 | }, 915 | ], 916 | }, 917 | { 918 | id: 20, 919 | name: "", 920 | trackID: 8, 921 | startStep: 64, 922 | stepCount: 16, 923 | playbackStepCount: 64, 924 | rows: [ 925 | { 926 | notes: [{name: "A4"}, 927 | {name: "A4"}, 928 | {name: "A4"}, 929 | {name: "B4"}, 930 | {name: "A4"}, 931 | {name: "A4"}, 932 | {name: "A3"}, 933 | {name: "A4"}, 934 | {name: "A4"}, 935 | {name: "F3"}, 936 | {name: "A4"}, 937 | {name: "C5"}, 938 | {name: "A4"}, 939 | {name: "A4"}, 940 | {name: "A2"}, 941 | {name: "F3"},], 942 | }, 943 | ], 944 | }, 945 | { 946 | id: 21, 947 | name: "", 948 | trackID: 8, 949 | startStep: 128, 950 | stepCount: 16, 951 | playbackStepCount: 64, 952 | rows: [ 953 | { 954 | notes: [{name: "A4"}, 955 | {name: "A4"}, 956 | {name: "A4"}, 957 | {name: "-"}, 958 | {name: "A4"}, 959 | {name: "-"}, 960 | {name: "A3"}, 961 | {name: "F4"}, 962 | {name: "A4"}, 963 | {name: ""}, 964 | {name: "C5"}, 965 | {name: "A4"}, 966 | {name: "A4"}, 967 | {name: "A4"}, 968 | {name: "-"}, 969 | {name: ""},], 970 | }, 971 | ], 972 | }, 973 | { 974 | id: 22, 975 | name: "", 976 | trackID: 8, 977 | startStep: 192, 978 | stepCount: 16, 979 | playbackStepCount: 64, 980 | rows: [ 981 | { 982 | notes: [{name: "A4"}, 983 | {name: "A4"}, 984 | {name: "A4"}, 985 | {name: "B4"}, 986 | {name: "A4"}, 987 | {name: "A4"}, 988 | {name: "A3"}, 989 | {name: "A4"}, 990 | {name: "A4"}, 991 | {name: "F3"}, 992 | {name: "A4"}, 993 | {name: "C5"}, 994 | {name: "A4"}, 995 | {name: "A4"}, 996 | {name: "A2"}, 997 | {name: "F3"},], 998 | }, 999 | ], 1000 | }, 1001 | ]; 1002 | 1003 | const tracks = [ 1004 | { 1005 | id: 1, 1006 | name: "Bass Synth", 1007 | instrumentID: 1, 1008 | isMuted: false, 1009 | volume: 0.8, 1010 | }, 1011 | { 1012 | id: 2, 1013 | name: "Arpeggio", 1014 | instrumentID: 2, 1015 | isMuted: false, 1016 | volume: 0.5, 1017 | }, 1018 | { 1019 | id: 3, 1020 | name: "Squeal", 1021 | instrumentID: 3, 1022 | isMuted: false, 1023 | volume: 0.6, 1024 | }, 1025 | { 1026 | id: 4, 1027 | name: "Flute", 1028 | instrumentID: 4, 1029 | isMuted: false, 1030 | volume: 0.8, 1031 | }, 1032 | { 1033 | id: 5, 1034 | name: "Swell", 1035 | instrumentID: 5, 1036 | isMuted: false, 1037 | volume: 0.6, 1038 | }, 1039 | { 1040 | id: 6, 1041 | name: "Bass Drum", 1042 | instrumentID: 6, 1043 | isMuted: false, 1044 | volume: 1.0, 1045 | }, 1046 | { 1047 | id: 7, 1048 | name: "Snare Drum", 1049 | instrumentID: 7, 1050 | isMuted: false, 1051 | volume: 0.8, 1052 | }, 1053 | { 1054 | id: 8, 1055 | name: "Hi-Hat", 1056 | instrumentID: 8, 1057 | isMuted: false, 1058 | volume: 0.8, 1059 | }, 1060 | ]; 1061 | 1062 | const measureCount = 16; 1063 | const tempo = 114; 1064 | 1065 | export { measureCount, tempo, instruments, patterns, tracks }; 1066 | -------------------------------------------------------------------------------- /src/id_generator.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export class IDGenerator { 4 | constructor(initialNextID) { 5 | this.nextID = initialNextID; 6 | }; 7 | 8 | next() { 9 | this.nextID += 1; 10 | return this.nextID; 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/midi_controller.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Adapted from https://webaudiodemos.appspot.com/slides/webmidi.html 4 | export const MidiController = function(onStateChange, onMessage, onError) { 5 | const NOTE_ON_COMMAND = 9; 6 | const NOTE_OFF_COMMAND = 8; 7 | const CONTROLLER_COMMAND = 11; 8 | 9 | const onMIDIInit = function(midiAccess) { 10 | let input; 11 | inputs = []; 12 | 13 | for (input of midiAccess.inputs.values()) { 14 | inputs.push(input); 15 | input.onmidimessage = onMidiMessage; 16 | } 17 | 18 | midiAccess.onstatechange = function(e) { 19 | onMIDIInit(e.currentTarget); 20 | onStateChange({port: e.port.name, connection: e.port.connection, state: e.port.state}); 21 | }; 22 | }; 23 | 24 | const onMIDISystemError = function(error) { 25 | onError(error); 26 | }; 27 | 28 | const onMidiMessage = function(e) { 29 | let command = e.data[0] >> 4; 30 | let channel = e.data[0] & 0xf; 31 | let noteNumber = e.data[1]; 32 | let velocity = (e.data.length > 2) ? e.data[2] : 0; 33 | 34 | // MIDI noteon with velocity=0 is the same as noteoff 35 | if (command === NOTE_OFF_COMMAND || ((command === NOTE_ON_COMMAND) && (velocity === 0)) ) { 36 | onMessage("noteoff", { noteNumber: noteNumber }); 37 | } 38 | else if (command === NOTE_ON_COMMAND) { 39 | onMessage("noteon", { noteNumber: noteNumber, velocity: velocity }); 40 | } 41 | else if (command === CONTROLLER_COMMAND) { 42 | onMessage("controller", { noteNumber: noteNumber, velocity: velocity }); 43 | } 44 | else { 45 | // probably sysex! 46 | } 47 | }; 48 | 49 | 50 | let inputs = []; 51 | let isEnabled = false; 52 | 53 | if (navigator.requestMIDIAccess) { 54 | navigator.requestMIDIAccess().then(onMIDIInit, onMIDISystemError); 55 | isEnabled = true; 56 | } 57 | else { 58 | isEnabled = false; 59 | } 60 | 61 | return { 62 | isEnabled: function() { return isEnabled; }, 63 | inputs: function() { return inputs; }, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /src/serializer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { STEPS_PER_MEASURE } from "./constants"; 4 | import * as SynthCore from "./synth_core"; 5 | 6 | export class Serializer { 7 | constructor() {}; 8 | 9 | static serializeSynthInstrument(instrument, bufferCollection) { 10 | let noiseAudioBuffer; 11 | 12 | if (instrument.noiseType === "white") { 13 | noiseAudioBuffer = bufferCollection.getBuffer("white-noise"); 14 | } 15 | else if (instrument.noiseType === "pink") { 16 | noiseAudioBuffer = bufferCollection.getBuffer("pink-noise"); 17 | } 18 | else { 19 | console.log("Error: Invalid noise type '" + instrument.noiseType + "'"); 20 | } 21 | 22 | let serializedConfig = { 23 | oscillators: [ 24 | { 25 | waveform: instrument.oscillator1Waveform, 26 | octave: instrument.oscillator1Octave, 27 | detune: 0, 28 | amplitude: instrument.oscillator1Amplitude, 29 | }, 30 | { 31 | waveform: instrument.oscillator2Waveform, 32 | octave: instrument.oscillator2Octave, 33 | detune: instrument.oscillator2Detune, 34 | amplitude: instrument.oscillator2Amplitude, 35 | } 36 | ], 37 | noise: { 38 | audioBuffer: noiseAudioBuffer, 39 | amplitude: instrument.noiseAmplitude, 40 | }, 41 | lfo: { 42 | waveform: instrument.lfoWaveform, 43 | frequency: instrument.lfoFrequency, 44 | amplitude: instrument.lfoAmplitude, 45 | }, 46 | filter: { 47 | cutoff: instrument.filterCutoff, 48 | resonance: instrument.filterResonance, 49 | lfo: { 50 | waveform: instrument.filterLFOWaveform, 51 | frequency: instrument.filterLFOFrequency, 52 | amplitude: instrument.filterLFOAmplitude, 53 | }, 54 | envelope: { 55 | amount: instrument.filterEnvelopeAmount, 56 | attackTime: instrument.filterEnvelopeAttackTime, 57 | decayTime: instrument.filterEnvelopeDecayTime, 58 | sustainPercentage: instrument.filterEnvelopeSustainPercentage, 59 | releaseTime: instrument.filterEnvelopeReleaseTime, 60 | }, 61 | }, 62 | envelope: { 63 | attackTime: instrument.envelopeAttackTime, 64 | decayTime: instrument.envelopeDecayTime, 65 | sustainPercentage: instrument.envelopeSustainPercentage, 66 | releaseTime: instrument.envelopeReleaseTime, 67 | }, 68 | }; 69 | 70 | return new SynthCore.SynthInstrument(serializedConfig); 71 | }; 72 | 73 | static serializeSampleInstrument(instrument, bufferCollection) { 74 | let serializedConfig = { 75 | audioBuffer: bufferCollection.getBuffer(instrument.bufferID), 76 | loop: instrument.loop, 77 | rootNoteName: instrument.rootNoteName, 78 | rootNoteOctave: instrument.rootNoteOctave, 79 | filter: { 80 | cutoff: instrument.filterCutoff, 81 | resonance: instrument.filterResonance, 82 | lfo: { 83 | waveform: instrument.filterLFOWaveform, 84 | frequency: instrument.filterLFOFrequency, 85 | amplitude: instrument.filterLFOAmplitude, 86 | }, 87 | envelope: { 88 | amount: instrument.filterEnvelopeAmount, 89 | attackTime: instrument.filterEnvelopeAttackTime, 90 | decayTime: instrument.filterEnvelopeDecayTime, 91 | sustainPercentage: instrument.filterEnvelopeSustainPercentage, 92 | releaseTime: instrument.filterEnvelopeReleaseTime, 93 | }, 94 | }, 95 | envelope: { 96 | attackTime: instrument.envelopeAttackTime, 97 | decayTime: instrument.envelopeDecayTime, 98 | sustainPercentage: instrument.envelopeSustainPercentage, 99 | releaseTime: instrument.envelopeReleaseTime, 100 | }, 101 | }; 102 | 103 | return new SynthCore.SampleInstrument(serializedConfig); 104 | }; 105 | 106 | static serializeInstrument(instrument, bufferCollection) { 107 | if (instrument.type === "synth") { 108 | return Serializer.serializeSynthInstrument(instrument, bufferCollection); 109 | } 110 | else if (instrument.type === "sample") { 111 | return Serializer.serializeSampleInstrument(instrument, bufferCollection); 112 | } 113 | else { 114 | return undefined; 115 | } 116 | }; 117 | 118 | static serializePatternRows(stepCount, playbackStepCount, rows) { 119 | let serializedRows = []; 120 | 121 | rows.forEach(function(row) { 122 | let baseNoteSequence = row.notes.slice(0, stepCount).map(function(note) { return note.name; }); 123 | let fullNoteSequence = []; 124 | let i; 125 | let baseNoteIndex = 0; 126 | 127 | for (i = 0; i < playbackStepCount; i++) { 128 | fullNoteSequence.push(baseNoteSequence[baseNoteIndex]); 129 | 130 | baseNoteIndex += 1; 131 | if (baseNoteIndex >= baseNoteSequence.length) { 132 | baseNoteIndex = 0; 133 | } 134 | } 135 | 136 | serializedRows.push(SynthCore.SequenceParser.parse(fullNoteSequence)); 137 | }); 138 | 139 | return serializedRows; 140 | }; 141 | 142 | static patternsByTrackID(allPatterns, trackID) { 143 | let i; 144 | let patterns = []; 145 | 146 | for (i = 0; i < allPatterns.length; i++) { 147 | if (allPatterns[i].trackID === trackID) { 148 | patterns.push(allPatterns[i]); 149 | } 150 | } 151 | 152 | return patterns; 153 | }; 154 | 155 | static serializeScore(measureCount, tracks, patterns) { 156 | const TOTAL_STEPS = measureCount * STEPS_PER_MEASURE; 157 | 158 | let i, j, k; 159 | let serializedNotes = []; 160 | 161 | for (i = 0; i < TOTAL_STEPS; i++) { 162 | serializedNotes[i] = []; 163 | } 164 | 165 | tracks.forEach(function(track) { 166 | let trackPatterns = Serializer.patternsByTrackID(patterns, track.id); 167 | let patternRows, patternRow; 168 | let startStep; 169 | 170 | for (i = 0; i < trackPatterns.length; i++) { 171 | startStep = trackPatterns[i].startStep; 172 | patternRows = Serializer.serializePatternRows(trackPatterns[i].stepCount, trackPatterns[i].playbackStepCount, trackPatterns[i].rows); 173 | 174 | for (j = 0; j < patternRows.length; j++) { 175 | patternRow = patternRows[j]; 176 | 177 | for (k = 0; k < patternRow.length; k++) { 178 | if (patternRow[k] !== undefined) { 179 | serializedNotes[startStep + k].push(new SynthCore.InstrumentNote(patternRow[k], track.id)); 180 | } 181 | } 182 | } 183 | } 184 | }); 185 | 186 | return SynthCore.Score(serializedNotes); 187 | }; 188 | }; 189 | -------------------------------------------------------------------------------- /src/synth_core.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export { AudioContextBuilder } from "./synth_core/audio_context_builder"; 4 | export { BufferCollection } from "./synth_core/buffer_collection"; 5 | export { Envelope } from "./synth_core/envelope"; 6 | export { InstrumentNote } from "./synth_core/instrument_note"; 7 | export { Mixer } from "./synth_core/mixer"; 8 | export { Note } from "./synth_core/note"; 9 | export { NotePlayer } from "./synth_core/note_player"; 10 | export { OfflineTransport } from "./synth_core/offline_transport"; 11 | export { SampleInstrument } from "./synth_core/instrument"; 12 | export { Score } from "./synth_core/score"; 13 | export { SequenceParser } from "./synth_core/sequence_parser"; 14 | export { SongPlayer } from "./synth_core/song_player"; 15 | export { SynthInstrument } from "./synth_core/instrument"; 16 | export { Transport } from "./synth_core/transport"; 17 | export { WaveWriter } from "./synth_core/wave_writer"; 18 | -------------------------------------------------------------------------------- /src/synth_core/audio_context_builder.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export var AudioContextBuilder = (function() { 4 | var buildAudioContext = function() { 5 | var audioContext; 6 | 7 | if (window.AudioContext) { 8 | // Why create an AudioContext, immediately close it, and then recreate 9 | // another one? Good question. 10 | // 11 | // The reason is that in iOS, there is a bug in which an AudioContext 12 | // can be created with a sample rate of 48,000Hz, which for reasons 13 | // causes audio playback to be distorted. If you re-load the page, 14 | // the sample rate will be set to 44,100Hz instead, and playback 15 | // will sound normal. 16 | // 17 | // Creating an AudioContext, closing it, and recreating another 18 | // one works around this issue, I _think_ by basically simulating 19 | // the page re-load behavior, causing the sample rate of the 2nd 20 | // AudioContext to be 44,100Hz. 21 | // 22 | // This fix was figured out by searching Google, which returned 23 | // this GitHub issue: https://github.com/photonstorm/phaser/issues/2373 24 | audioContext = new AudioContext(); 25 | if (audioContext.close) { 26 | audioContext.close(); 27 | audioContext = new AudioContext(); 28 | } 29 | } 30 | 31 | return audioContext; 32 | }; 33 | 34 | var buildOfflineAudioContext = function(channelCount, sampleCount, sampleRate) { 35 | var offlineAudioContext; 36 | 37 | if (window.OfflineAudioContext) { 38 | offlineAudioContext = new OfflineAudioContext(channelCount, sampleCount, sampleRate); 39 | } 40 | else if (window.webkitOfflineAudioContext) { 41 | offlineAudioContext = new webkitOfflineAudioContext(channelCount, sampleCount, sampleRate); 42 | } 43 | 44 | return offlineAudioContext; 45 | }; 46 | 47 | return { 48 | buildAudioContext: buildAudioContext, 49 | buildOfflineAudioContext: buildOfflineAudioContext, 50 | }; 51 | })(); 52 | -------------------------------------------------------------------------------- /src/synth_core/buffer_collection.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export function BufferCollection(audioContext) { 4 | var buffers = {}; 5 | 6 | var addBuffer = function(label, buffer) { 7 | buffers[label] = buffer; 8 | }; 9 | 10 | var addBufferFromURL = function(label, url, onSuccess, onError) { 11 | var onDecodeSuccess = function(buffer) { 12 | buffers[label] = buffer; 13 | onSuccess(); 14 | }; 15 | 16 | var onDecodeError = function(e) { 17 | var errorMessage = "Error decoding audio data for URL `" + url + "`"; 18 | 19 | if (e) { // The error object seems to be null in Safari (as of v11) 20 | errorMessage += ": " + e.message; 21 | } 22 | 23 | console.log(errorMessage); 24 | onError(); 25 | }; 26 | 27 | var request = new XMLHttpRequest(); 28 | 29 | request.open("GET", url, true); 30 | request.responseType = "arraybuffer"; 31 | 32 | request.onload = function() { 33 | audioContext.decodeAudioData(request.response, onDecodeSuccess, onDecodeError); 34 | }; 35 | 36 | request.send(); 37 | }; 38 | 39 | var addBuffersFromURLs = function(bufferConfig, onAllBuffersLoaded, onLoadError) { 40 | var loadedBufferCount = 0; 41 | var allBuffersCount = bufferConfig.length; 42 | var i; 43 | 44 | var onBufferLoaded = function() { 45 | loadedBufferCount += 1; 46 | 47 | if (loadedBufferCount === allBuffersCount) { 48 | onAllBuffersLoaded(); 49 | } 50 | }; 51 | 52 | for (i = 0; i < bufferConfig.length; i++) { 53 | addBufferFromURL(bufferConfig[i].label, bufferConfig[i].url, onBufferLoaded, onLoadError); 54 | } 55 | }; 56 | 57 | var addBufferFromFile = function(label, file, onSuccess) { 58 | var onDecodeSuccess = function(buffer) { 59 | buffers[label] = buffer; 60 | onSuccess(); 61 | }; 62 | 63 | var onDecodeError = function(e) { 64 | alert(`${file.name} is not a valid sound file`); 65 | }; 66 | 67 | var reader = new FileReader(); 68 | 69 | reader.onload = function(e) { 70 | audioContext.decodeAudioData(e.target.result, onDecodeSuccess, onDecodeError); 71 | }; 72 | 73 | reader.readAsArrayBuffer(file); 74 | }; 75 | 76 | var getBuffer = function(label) { 77 | return buffers[label]; 78 | }; 79 | 80 | var removeBuffer = function(label) { 81 | delete buffers[label]; 82 | }; 83 | 84 | 85 | return { 86 | addBuffer: addBuffer, 87 | addBuffersFromURLs: addBuffersFromURLs, 88 | addBufferFromFile: addBufferFromFile, 89 | getBuffer: getBuffer, 90 | removeBuffer: removeBuffer, 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /src/synth_core/envelope.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export function Envelope(targetAttackAmplitude, envelopeConfig, gateOnTime, gateOffTime) { 4 | var attackEndTime = gateOnTime + envelopeConfig.attackTime; 5 | var attackEndAmplitude, attackEndAmplitudePercentage; 6 | var decayEndTime, decayEndAmplitude, decayEndAmplitudePercentage; 7 | var sustainAmplitude; 8 | var delta; 9 | 10 | var valueAtTime = function(rawTime, rawGateOffTime) { 11 | var time = rawTime - gateOnTime; 12 | var gateOffTime = rawGateOffTime - gateOnTime; 13 | var sustainAmplitude = targetAttackAmplitude * envelopeConfig.sustainPercentage; 14 | 15 | if (time < 0.0) { 16 | return 0.0; 17 | } 18 | else if (time > gateOffTime) { 19 | // In release portion 20 | if (time >= (gateOffTime + envelopeConfig.releaseTime)) { 21 | return 0.0; 22 | } 23 | else { 24 | return (1.0 - ((time - gateOffTime) / envelopeConfig.releaseTime)) * sustainAmplitude; 25 | } 26 | } 27 | else if (time <= envelopeConfig.attackTime) { 28 | // In attack portion 29 | if (envelopeConfig.attackTime === 0) { 30 | return targetAttackAmplitude; 31 | } 32 | else { 33 | return (time / envelopeConfig.attackTime) * targetAttackAmplitude; 34 | } 35 | } 36 | else if (time <= (envelopeConfig.attackTime + envelopeConfig.decayTime)) { 37 | // In decay portion 38 | return ((1.0 - ((time - envelopeConfig.attackTime) / envelopeConfig.decayTime)) * (targetAttackAmplitude - sustainAmplitude)) + sustainAmplitude; 39 | } 40 | else { 41 | // In sustain portion 42 | return sustainAmplitude; 43 | } 44 | }; 45 | 46 | if (attackEndTime < gateOffTime) { 47 | attackEndAmplitude = targetAttackAmplitude; 48 | } 49 | else { 50 | attackEndAmplitudePercentage = ((gateOffTime - gateOnTime) / (attackEndTime - gateOnTime)); 51 | attackEndAmplitude = targetAttackAmplitude * attackEndAmplitudePercentage; 52 | attackEndTime = gateOffTime; 53 | } 54 | 55 | decayEndTime = attackEndTime + Math.max(envelopeConfig.decayTime, 0.001); 56 | sustainAmplitude = targetAttackAmplitude * envelopeConfig.sustainPercentage; 57 | if (gateOffTime > decayEndTime) { 58 | decayEndAmplitude = sustainAmplitude; 59 | } 60 | else { 61 | decayEndAmplitudePercentage = ((gateOffTime - attackEndTime) / (decayEndTime - attackEndTime)); 62 | decayEndTime = gateOffTime; 63 | 64 | delta = attackEndAmplitude - sustainAmplitude; 65 | decayEndAmplitude = attackEndAmplitude - (delta * decayEndAmplitudePercentage); 66 | } 67 | 68 | return { 69 | attackEndTime: attackEndTime, 70 | attackEndAmplitude: attackEndAmplitude, 71 | decayEndTime: decayEndTime, 72 | decayEndAmplitude: decayEndAmplitude, 73 | valueAtTime: valueAtTime, 74 | }; 75 | }; 76 | -------------------------------------------------------------------------------- /src/synth_core/instrument.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { Envelope } from "./envelope"; 4 | import { Note } from "./note"; 5 | 6 | var BaseInstrument = function(config) { 7 | var buildOscillator = function(audioContext, waveform, frequency, detune) { 8 | var oscillator = audioContext.createOscillator(); 9 | oscillator.type = waveform; 10 | oscillator.frequency.value = frequency; 11 | oscillator.detune.value = detune; 12 | 13 | return oscillator; 14 | }; 15 | 16 | var buildGain = function(audioContext, amplitude) { 17 | var gain = audioContext.createGain(); 18 | gain.gain.value = amplitude; 19 | 20 | return gain; 21 | }; 22 | 23 | var buildFilter = function(audioContext, frequency, resonance) { 24 | var filter = audioContext.createBiquadFilter(); 25 | filter.frequency.value = frequency; 26 | filter.Q.value = resonance; 27 | 28 | return filter; 29 | }; 30 | 31 | var scheduleNote = function(audioContext, audioDestination, note, gateOnTime, gateOffTime) { 32 | var noteContext = baseInstrument.gateOn(audioContext, audioDestination, note, gateOnTime, gateOffTime); 33 | baseInstrument.gateOff(noteContext, gateOffTime, false); 34 | }; 35 | 36 | var gateOff = function(noteContext, gateOffTime, isInteractive) { 37 | var MINIMUM_RELEASE_TIME = 0.005; 38 | var cutoffFrequencyAtReleaseStart, masterGainAtReleaseStart, safeMasterGainRelease, gainReleaseEndTime, releaseEndTime; 39 | var safeFilterRelease; 40 | 41 | // Filter Envelope Release 42 | safeFilterRelease = Math.max(MINIMUM_RELEASE_TIME, config.filter.envelope.releaseTime); 43 | if (isInteractive === true) { 44 | // Simulate `cancelAndHoldAtTime()`, which is not present in all browsers. 45 | // See comment below for the master gain node for more info. 46 | noteContext.filter.frequency.cancelScheduledValues(gateOffTime); 47 | cutoffFrequencyAtReleaseStart = config.filter.cutoff + Envelope(config.filter.envelope.amount, config.filter.envelope, noteContext.gateOnTime, gateOffTime).valueAtTime(gateOffTime, gateOffTime); 48 | noteContext.filter.frequency.setValueAtTime(cutoffFrequencyAtReleaseStart, gateOffTime); 49 | } 50 | noteContext.filter.frequency.setTargetAtTime(config.filter.cutoff, gateOffTime, safeFilterRelease / 5); 51 | 52 | // Gain Envelope Release 53 | safeMasterGainRelease = Math.max(MINIMUM_RELEASE_TIME, config.envelope.releaseTime); 54 | gainReleaseEndTime = gateOffTime + safeMasterGainRelease; 55 | 56 | if (isInteractive === true) { 57 | // Simulate `cancelAndHoldAtTime()`, which is not present in all browsers. 58 | // The gain value is manually set to the current gain value because `cancelScheduledValues()` 59 | // seems to (sometimes? all the time?) reset the gain value at 0. If the gain is 0, the 60 | // release portion of the envelope will have no effect, and cause notes that are played 61 | // for a shorter amount of time than the attack+decay time to be suddenly cut off, instead 62 | // of having a release fade. As mentioned above, using `cancelAndHoldAtTime()` would be 63 | // another way to solve this problem. 64 | noteContext.masterGain.gain.cancelScheduledValues(gateOffTime); 65 | masterGainAtReleaseStart = Envelope(noteContext.amplitude, config.envelope, noteContext.gateOnTime, gateOffTime).valueAtTime(gateOffTime, gateOffTime); 66 | noteContext.masterGain.gain.setValueAtTime(masterGainAtReleaseStart, gateOffTime); 67 | } 68 | 69 | noteContext.masterGain.gain.setTargetAtTime(0.0, gateOffTime, safeMasterGainRelease / 5); 70 | 71 | if (noteContext.audioBufferSourceNode !== undefined) { 72 | noteContext.audioBufferSourceNode.stop(gainReleaseEndTime); 73 | } 74 | if (noteContext.oscillator1 !== undefined) { 75 | noteContext.oscillator1.stop(gainReleaseEndTime); 76 | noteContext.oscillator2.stop(gainReleaseEndTime); 77 | noteContext.noise.stop(gainReleaseEndTime); 78 | } 79 | if (noteContext.pitchLfoOscillator !== undefined) { 80 | noteContext.pitchLfoOscillator.stop(gainReleaseEndTime); 81 | } 82 | if (noteContext.filterLfoOscillator !== undefined) { 83 | noteContext.filterLfoOscillator.stop(gainReleaseEndTime); 84 | } 85 | }; 86 | 87 | 88 | var baseInstrument = { 89 | gateOn: function() {}, 90 | gateOff: gateOff, 91 | buildOscillator: buildOscillator, 92 | buildGain: buildGain, 93 | buildFilter: buildFilter, 94 | scheduleNote: scheduleNote, 95 | config: function() { return config; }, 96 | }; 97 | 98 | return baseInstrument; 99 | }; 100 | 101 | 102 | function SampleInstrument(config) { 103 | var BASE_FREQUENCY = Note(config.rootNoteName, config.rootNoteOctave, 1.0, 1).frequency(); 104 | var sampleInstrument = BaseInstrument(config); 105 | 106 | var buildBufferSourceNode = function(audioContext, target, note) { 107 | var audioBufferSourceNode = audioContext.createBufferSource(); 108 | audioBufferSourceNode.buffer = config.audioBuffer; 109 | audioBufferSourceNode.playbackRate.value = note.frequency() / BASE_FREQUENCY; 110 | audioBufferSourceNode.loop = config.loop; 111 | audioBufferSourceNode.connect(target); 112 | 113 | return audioBufferSourceNode; 114 | }; 115 | 116 | sampleInstrument.gateOn = function(audioContext, audioDestination, note, gateOnTime, gateOffTime) { 117 | var masterGain, calculatedMasterGainEnvelope; 118 | var filter, filterLfoGain, filterLfoOscillator, calculatedFilterEnvelope; 119 | var envelopeAttackStartTime = Math.max(0.0, gateOnTime - 0.001); 120 | var audioBufferSourceNode; 121 | 122 | // Master Gain 123 | masterGain = audioContext.createGain(); 124 | masterGain.connect(audioDestination); 125 | 126 | calculatedMasterGainEnvelope = Envelope(note.amplitude(), config.envelope, gateOnTime, gateOffTime); 127 | 128 | // Master Gain Envelope Attack 129 | masterGain.gain.setValueAtTime(0.0, envelopeAttackStartTime); 130 | masterGain.gain.linearRampToValueAtTime(calculatedMasterGainEnvelope.attackEndAmplitude, calculatedMasterGainEnvelope.attackEndTime); 131 | 132 | // Master Gain Envelope Decay/Sustain 133 | if (calculatedMasterGainEnvelope.attackEndTime < gateOffTime) { 134 | masterGain.gain.linearRampToValueAtTime(calculatedMasterGainEnvelope.decayEndAmplitude, calculatedMasterGainEnvelope.decayEndTime); 135 | } 136 | 137 | masterGain.connect(audioDestination); 138 | 139 | // Filter 140 | filter = sampleInstrument.buildFilter(audioContext, config.filter.cutoff, config.filter.resonance); 141 | 142 | filterLfoGain = sampleInstrument.buildGain(audioContext, config.filter.lfo.amplitude); 143 | filterLfoGain.connect(filter.detune); 144 | 145 | filterLfoOscillator = sampleInstrument.buildOscillator(audioContext, config.filter.lfo.waveform, config.filter.lfo.frequency, 0); 146 | filterLfoOscillator.connect(filterLfoGain); 147 | filterLfoOscillator.start(gateOnTime); 148 | 149 | calculatedFilterEnvelope = Envelope(config.filter.envelope.amount, config.filter.envelope, gateOnTime, gateOffTime); 150 | 151 | // Envelope Attack 152 | filter.frequency.setValueAtTime(config.filter.cutoff, envelopeAttackStartTime); 153 | filter.frequency.linearRampToValueAtTime(config.filter.cutoff + calculatedFilterEnvelope.attackEndAmplitude, calculatedFilterEnvelope.attackEndTime); 154 | 155 | // Envelope Decay/Sustain 156 | if (calculatedFilterEnvelope.attackEndTime < gateOffTime) { 157 | filter.frequency.linearRampToValueAtTime(config.filter.cutoff + calculatedFilterEnvelope.decayEndAmplitude, calculatedFilterEnvelope.decayEndTime); 158 | } 159 | 160 | filter.connect(masterGain); 161 | 162 | // Audio Buffer 163 | audioBufferSourceNode = buildBufferSourceNode(audioContext, filter, note); 164 | audioBufferSourceNode.start(gateOnTime); 165 | 166 | return { 167 | gateOnTime: gateOnTime, 168 | amplitude: note.amplitude(), 169 | audioBufferSourceNode: audioBufferSourceNode, 170 | masterGain: masterGain, 171 | filter: filter, 172 | filterLfoOscillator: filterLfoOscillator, 173 | }; 174 | }; 175 | 176 | 177 | return sampleInstrument; 178 | }; 179 | 180 | function SynthInstrument(config) { 181 | var synthInstrument = BaseInstrument(config); 182 | 183 | synthInstrument.gateOn = function(audioContext, audioDestination, note, gateOnTime, gateOffTime) { 184 | var masterGainAmplitude, masterGain, calculatedMasterGainEnvelope; 185 | var filter, filterLfoGain, filterLfoOscillator, calculatedFilterEnvelope; 186 | var oscillator1, oscillator1Gain, oscillator2, oscillator2Gain, noise, noiseGain; 187 | var pitchLfoOscillator, pitchLfoGain; 188 | 189 | var envelopeAttackStartTime = Math.max(0.0, gateOnTime - 0.001); 190 | 191 | // Master Gain 192 | masterGain = audioContext.createGain(); 193 | masterGain.connect(audioDestination); 194 | 195 | masterGainAmplitude = note.amplitude() / (config.oscillators.length + 1); 196 | calculatedMasterGainEnvelope = Envelope(masterGainAmplitude, config.envelope, gateOnTime, gateOffTime); 197 | 198 | // Master Gain Envelope Attack 199 | masterGain.gain.setValueAtTime(0.0, envelopeAttackStartTime); 200 | masterGain.gain.linearRampToValueAtTime(calculatedMasterGainEnvelope.attackEndAmplitude, calculatedMasterGainEnvelope.attackEndTime); 201 | 202 | // Master Gain Envelope Decay/Sustain 203 | if (calculatedMasterGainEnvelope.attackEndTime < gateOffTime) { 204 | masterGain.gain.linearRampToValueAtTime(calculatedMasterGainEnvelope.decayEndAmplitude, calculatedMasterGainEnvelope.decayEndTime); 205 | } 206 | 207 | 208 | // Filter 209 | filter = synthInstrument.buildFilter(audioContext, config.filter.cutoff, config.filter.resonance); 210 | 211 | filterLfoGain = synthInstrument.buildGain(audioContext, config.filter.lfo.amplitude); 212 | filterLfoGain.connect(filter.detune); 213 | 214 | filterLfoOscillator = synthInstrument.buildOscillator(audioContext, config.filter.lfo.waveform, config.filter.lfo.frequency, 0); 215 | filterLfoOscillator.connect(filterLfoGain); 216 | filterLfoOscillator.start(gateOnTime); 217 | 218 | calculatedFilterEnvelope = Envelope(config.filter.envelope.amount, config.filter.envelope, gateOnTime, gateOffTime); 219 | 220 | // Envelope Attack 221 | filter.frequency.setValueAtTime(config.filter.cutoff, envelopeAttackStartTime); 222 | filter.frequency.linearRampToValueAtTime(config.filter.cutoff + calculatedFilterEnvelope.attackEndAmplitude, calculatedFilterEnvelope.attackEndTime); 223 | 224 | // Envelope Decay/Sustain 225 | if (calculatedFilterEnvelope.attackEndTime < gateOffTime) { 226 | filter.frequency.linearRampToValueAtTime(config.filter.cutoff + calculatedFilterEnvelope.decayEndAmplitude, calculatedFilterEnvelope.decayEndTime); 227 | } 228 | 229 | filter.connect(masterGain); 230 | 231 | 232 | // Base sound generator 233 | oscillator1Gain = synthInstrument.buildGain(audioContext, config.oscillators[0].amplitude); 234 | oscillator1 = synthInstrument.buildOscillator(audioContext, 235 | config.oscillators[0].waveform, 236 | note.frequency() * Math.pow(2, config.oscillators[0].octave), 237 | config.oscillators[0].detune); 238 | oscillator1.connect(oscillator1Gain); 239 | oscillator1Gain.connect(filter); 240 | oscillator1.start(gateOnTime); 241 | 242 | // Secondary sound generator 243 | oscillator2Gain = synthInstrument.buildGain(audioContext, config.oscillators[1].amplitude); 244 | oscillator2 = synthInstrument.buildOscillator(audioContext, 245 | config.oscillators[1].waveform, 246 | note.frequency() * Math.pow(2, config.oscillators[1].octave), 247 | config.oscillators[1].detune); 248 | oscillator2.connect(oscillator2Gain); 249 | oscillator2Gain.connect(filter); 250 | oscillator2.start(gateOnTime); 251 | 252 | // Noise 253 | noiseGain = synthInstrument.buildGain(audioContext, config.noise.amplitude); 254 | noise = audioContext.createBufferSource(); 255 | noise.buffer = config.noise.audioBuffer; 256 | noise.loop = true; 257 | noise.connect(noiseGain); 258 | noiseGain.connect(filter); 259 | noise.start(gateOnTime); 260 | 261 | // LFO for base sound 262 | if (config.lfo.frequency > 0.0 && config.lfo.amplitude > 0.0) { 263 | pitchLfoOscillator = synthInstrument.buildOscillator(audioContext, config.lfo.waveform, config.lfo.frequency, 0); 264 | pitchLfoGain = synthInstrument.buildGain(audioContext, config.lfo.amplitude); 265 | pitchLfoOscillator.connect(pitchLfoGain); 266 | pitchLfoGain.connect(oscillator1.detune); 267 | pitchLfoGain.connect(oscillator2.detune); 268 | pitchLfoOscillator.start(gateOnTime); 269 | } 270 | 271 | return { 272 | gateOnTime: gateOnTime, 273 | amplitude: masterGainAmplitude, 274 | oscillator1: oscillator1, 275 | oscillator2: oscillator2, 276 | noise: noise, 277 | filter: filter, 278 | masterGain: masterGain, 279 | pitchLfoOscillator: pitchLfoOscillator, 280 | filterLfoOscillator: filterLfoOscillator, 281 | }; 282 | }; 283 | 284 | 285 | return synthInstrument; 286 | }; 287 | 288 | 289 | export { SynthInstrument, SampleInstrument }; 290 | -------------------------------------------------------------------------------- /src/synth_core/instrument_note.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export function InstrumentNote(note, channelID) { 4 | return { 5 | note: function() { return note; }, 6 | channelID: function() { return channelID; }, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/synth_core/mixer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function MixerChannel(audioContext, audioDestination, initialAmplitude, initialMultiplier, initialIsMuted, reverbBuffer, initialReverbWetPercentage, delayTime, delayFeedback) { 4 | var amplitude = initialAmplitude; 5 | var multiplier = initialMultiplier; 6 | var isMuted = initialIsMuted; 7 | 8 | var inputNode = audioContext.createGain(); 9 | var reverb = audioContext.createConvolver(); 10 | var reverbDryGain = audioContext.createGain(); 11 | var reverbWetGain = audioContext.createGain(); 12 | var delay = audioContext.createDelay(); 13 | var delayGain = audioContext.createGain(); 14 | var feedback = audioContext.createGain(); 15 | var gain = audioContext.createGain(); 16 | 17 | var setAmplitude = function(newAmplitude) { 18 | amplitude = newAmplitude; 19 | syncGain(); 20 | }; 21 | 22 | var setMultiplier = function(newMultiplier) { 23 | multiplier = newMultiplier; 24 | syncGain(); 25 | }; 26 | 27 | var setIsMuted = function(newIsMuted) { 28 | isMuted = newIsMuted; 29 | syncGain(); 30 | }; 31 | 32 | var setDelay = function(delayTime, delayFeedback) { 33 | delay.delayTime.value = delayTime; 34 | feedback.gain.value = delayFeedback; 35 | 36 | if (delayTime === 0.0) { 37 | // Turn off the delay, to avoid doubling the base sound, 38 | // and causing the track to be artificially loud. 39 | delayGain.gain.value = 0.0; 40 | } 41 | else { 42 | delayGain.gain.value = 1.0; 43 | } 44 | }; 45 | 46 | var setReverb = function(newReverbWetPercentage) { 47 | reverbWetGain.gain.value = newReverbWetPercentage * 3.0; 48 | }; 49 | 50 | var input = function() { 51 | return inputNode; 52 | }; 53 | 54 | var destroy = function() { 55 | gain.disconnect(audioDestination); 56 | }; 57 | 58 | var syncGain = function() { 59 | if (isMuted === true) { 60 | gain.gain.value = 0.0; 61 | } 62 | else { 63 | gain.gain.value = amplitude * multiplier; 64 | } 65 | }; 66 | 67 | setAmplitude(initialAmplitude); 68 | 69 | inputNode.gain.value = 1.0; 70 | reverb.buffer = reverbBuffer; 71 | setReverb(initialReverbWetPercentage); 72 | setDelay(delayTime, delayFeedback); 73 | 74 | inputNode.connect(reverbDryGain); 75 | inputNode.connect(reverb); 76 | inputNode.connect(delay); 77 | delay.connect(feedback); 78 | feedback.connect(delay); 79 | delay.connect(delayGain); 80 | delayGain.connect(reverbDryGain); 81 | delayGain.connect(reverb); 82 | reverb.connect(reverbWetGain); 83 | reverbDryGain.connect(gain); 84 | reverbWetGain.connect(gain); 85 | gain.connect(audioDestination); 86 | 87 | return { 88 | setAmplitude: setAmplitude, 89 | setMultiplier: setMultiplier, 90 | setIsMuted: setIsMuted, 91 | setDelay: setDelay, 92 | setReverb: setReverb, 93 | input: input, 94 | destroy: destroy, 95 | }; 96 | }; 97 | 98 | function MixerChannelCollection(audioContext, audioDestination) { 99 | var channels = {}; 100 | var count = 0; 101 | 102 | var channel = function(id) { 103 | return channels[id]; 104 | }; 105 | 106 | var add = function(id, amplitude, isMuted, reverbBuffer, reverbWetPercentage, delayTime, delayFeedback) { 107 | channels[id] = MixerChannel(audioContext, audioDestination, amplitude, 1.0, isMuted, reverbBuffer, reverbWetPercentage, delayTime, delayFeedback); 108 | count += 1; 109 | 110 | setMultipliers(); 111 | }; 112 | 113 | var remove = function(id) { 114 | channels[id].destroy(); 115 | delete channels[id]; 116 | count -= 1; 117 | 118 | setMultipliers(); 119 | }; 120 | 121 | var setMultipliers = function() { 122 | var id; 123 | var newMultiplier = 1 / Math.max(8, count); 124 | 125 | for (id in channels) { 126 | channels[id].setMultiplier(newMultiplier); 127 | } 128 | }; 129 | 130 | return { 131 | channel: channel, 132 | add: add, 133 | remove: remove, 134 | count: function() { return count; }, 135 | }; 136 | }; 137 | 138 | function Mixer(audioContext) { 139 | var clipDetector; 140 | var masterGain; 141 | var channelCollection; 142 | 143 | var detectClipping = function(e) { 144 | var i; 145 | var samples = e.inputBuffer.getChannelData(0); 146 | var numSamples = samples.length; 147 | 148 | for (i = 0; i < numSamples; i++) { 149 | if (Math.abs(samples[i]) > 1.0) { 150 | console.log("Clipping! " + samples[i]); 151 | break; 152 | } 153 | } 154 | }; 155 | 156 | var addChannel = function(id, amplitude, isMuted, reverbBuffer, reverbWetPercentage, delayTime, delayFeedback) { 157 | channelCollection.add(id, amplitude, isMuted, reverbBuffer, reverbWetPercentage, delayTime, delayFeedback); 158 | }; 159 | 160 | var removeChannel = function(id) { 161 | channelCollection.remove(id); 162 | }; 163 | 164 | var setChannelAmplitude = function(id, newAmplitude) { 165 | var channel = channelCollection.channel(id); 166 | channel.setAmplitude(newAmplitude); 167 | }; 168 | 169 | var setChannelIsMuted = function(id, newIsMuted) { 170 | var channel = channelCollection.channel(id); 171 | channel.setIsMuted(newIsMuted); 172 | }; 173 | 174 | var setChannelDelay = function(channelID, delayTime, delayFeedback) { 175 | var channel = channelCollection.channel(channelID); 176 | channel.setDelay(delayTime, delayFeedback); 177 | }; 178 | 179 | var setChannelReverb = function(channelID, reverbWetPercentage) { 180 | var channel = channelCollection.channel(channelID); 181 | channel.setReverb(reverbWetPercentage); 182 | }; 183 | 184 | var setMasterAmplitude = function(newAmplitude) { 185 | masterGain.gain.value = newAmplitude; 186 | }; 187 | 188 | var destination = function(id) { 189 | var channel = channelCollection.channel(id); 190 | 191 | if (channel === undefined) { 192 | return undefined; 193 | } 194 | 195 | return channel.input(); 196 | }; 197 | 198 | var setClipDetectionEnabled = function(isEnabled) { 199 | // This following if/else is set up to handle differences between Chrome, Safari 200 | // and Firefox. 201 | // 202 | // Chrome 80: For clip detection to work, the master gain has to be connected to 203 | // clip detector, and the clip detector must be connected to audio context 204 | // destination. However, `detectClipping()` will continuously fire if the clip 205 | // detector is connected to audio context destination outside of song playback. 206 | // 207 | // Safari 13: Same as Chrome, but master gain must be re-connected to the audio 208 | // context destination after the clip detector is disconnected, or there will 209 | // be no audio. 210 | // 211 | // Firefox 74: Only the master gain needs to be connected to clip detector for 212 | // clip detection to work, but `detectClipping()` will fire continuously if it 213 | // is connected outside of song playback. 214 | if (isEnabled === true) { 215 | masterGain.connect(clipDetector); 216 | clipDetector.connect(audioContext.destination); 217 | } 218 | else { 219 | masterGain.disconnect(); 220 | masterGain.connect(audioContext.destination); 221 | clipDetector.disconnect(audioContext.destination); 222 | } 223 | }; 224 | 225 | 226 | if (audioContext !== undefined) { 227 | clipDetector = audioContext.createScriptProcessor(512); 228 | clipDetector.onaudioprocess = detectClipping; 229 | 230 | masterGain = audioContext.createGain(); 231 | masterGain.connect(audioContext.destination); 232 | 233 | channelCollection = MixerChannelCollection(audioContext, masterGain); 234 | } 235 | 236 | return { 237 | audioContext: function() { return audioContext; }, 238 | addChannel: addChannel, 239 | removeChannel: removeChannel, 240 | setChannelAmplitude: setChannelAmplitude, 241 | setChannelIsMuted: setChannelIsMuted, 242 | setChannelDelay: setChannelDelay, 243 | setChannelReverb: setChannelReverb, 244 | masterGainNode: function() { return masterGain; }, 245 | setMasterAmplitude: setMasterAmplitude, 246 | destination: destination, 247 | setClipDetectionEnabled: setClipDetectionEnabled, 248 | }; 249 | }; 250 | 251 | export { Mixer }; 252 | -------------------------------------------------------------------------------- /src/synth_core/note.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; 4 | const NOTES_IN_OCTAVE = 12; 5 | const MIDDLE_A_MIDI_NOTE_NUMBER = 69; 6 | const MIDDLE_A_FREQUENCY = 440.0; 7 | 8 | const ENHARMONIC_EQUIVALENTS = { 9 | "A" : "A", 10 | "G##" : "A", 11 | "B@@" : "A", 12 | 13 | "A#" : "A#", 14 | "B@" : "A#", 15 | "C@@" : "A#", 16 | 17 | "B" : "B", 18 | "A##" : "B", 19 | "C@" : "B", 20 | 21 | "C" : "C", 22 | "B#" : "C", 23 | "D@@" : "C", 24 | 25 | "C#" : "C#", 26 | "B##" : "C#", 27 | "D@" : "C#", 28 | 29 | "D" : "D", 30 | "C##" : "D", 31 | "E@@" : "D", 32 | 33 | "D#" : "D#", 34 | "E@" : "D#", 35 | "F@@" : "D#", 36 | 37 | "E" : "E", 38 | "D##" : "E", 39 | "F@" : "E", 40 | 41 | "F" : "F", 42 | "E#" : "F", 43 | "G@@" : "F", 44 | 45 | "F#" : "F#", 46 | "E##" : "F#", 47 | "G@" : "F#", 48 | 49 | "G" : "G", 50 | "F##" : "G", 51 | "A@@" : "G", 52 | 53 | "G#" : "G#", 54 | "A@" : "G#", 55 | }; 56 | 57 | 58 | export function Note(noteName, octave, amplitude, stepCount) { 59 | var normalizedNoteName = ENHARMONIC_EQUIVALENTS[noteName]; 60 | var midiNote; 61 | var frequency; 62 | 63 | if (normalizedNoteName === undefined) { 64 | throw TypeError("Invalid note name: \"" + noteName + "\""); 65 | } 66 | else if (!(Number.isInteger(octave) && (octave >= 0 ) && (octave <= 7))) { 67 | throw TypeError("Invalid octave: \"" + octave + "\""); 68 | } 69 | else if (!((typeof amplitude === "number") && (amplitude >= 0.0) && (amplitude <= 1.0))) { 70 | throw TypeError("Invalid amplitude: \"" + amplitude + "\""); 71 | } 72 | else if (!(Number.isInteger(stepCount) && (stepCount >= 0 ))) { 73 | throw TypeError("Invalid step count: \"" + stepCount + "\""); 74 | } 75 | 76 | midiNote = 12 + (octave * NOTES_IN_OCTAVE) + NOTE_NAMES.indexOf(normalizedNoteName); 77 | frequency = (2 ** ((midiNote - MIDDLE_A_MIDI_NOTE_NUMBER) / 12)) * MIDDLE_A_FREQUENCY; 78 | 79 | return { 80 | name: function() { return noteName; }, 81 | octave: function() { return octave; }, 82 | midiNote: function() { return midiNote; }, 83 | amplitude: function() { return amplitude; }, 84 | stepCount: function() { return stepCount; }, 85 | frequency: function() { return frequency; }, 86 | }; 87 | }; 88 | -------------------------------------------------------------------------------- /src/synth_core/note_player.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export function NotePlayer() { 4 | var channels = {}; 5 | 6 | var scheduleNote = function(channelID, audioContext, audioDestination, note, gateOnTime, gateOffTime) { 7 | channels[channelID].instrument.scheduleNote(audioContext, audioDestination, note, gateOnTime, gateOffTime); 8 | }; 9 | 10 | var playImmediateNote = function(channelID, audioContext, audioDestination, note) { 11 | return channels[channelID].instrument.gateOn(audioContext, audioDestination, note, audioContext.currentTime, Number.POSITIVE_INFINITY); 12 | }; 13 | 14 | var stopNote = function(channelID, audioContext, noteContext) { 15 | channels[channelID].instrument.gateOff(noteContext, audioContext.currentTime, true); 16 | }; 17 | 18 | var addChannel = function(channelID, instrument) { 19 | channels[channelID] = {instrument: instrument}; 20 | }; 21 | 22 | var removeChannel = function(channelID) { 23 | delete channels[channelID]; 24 | }; 25 | 26 | var noteDuration = function(channelID, stepCount, stepDuration) { 27 | var noteTimeDuration = stepDuration * stepCount; 28 | return noteTimeDuration + channels[channelID].instrument.config().envelope.releaseTime; 29 | }; 30 | 31 | 32 | return { 33 | scheduleNote: scheduleNote, 34 | playImmediateNote: playImmediateNote, 35 | stopNote: stopNote, 36 | addChannel: addChannel, 37 | removeChannel: removeChannel, 38 | noteDuration: noteDuration, 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/synth_core/offline_transport.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { AudioContextBuilder } from "./audio_context_builder"; 4 | import { Mixer } from "./mixer"; 5 | import { NotePlayer } from "./note_player"; 6 | import { WaveWriter } from "./wave_writer"; 7 | 8 | export function OfflineTransport(tracks, songPlayer, notePlayer, tempo, masterAmplitude, sampleRate, completeCallback) { 9 | const NUM_CHANNELS = 1; 10 | const SIXTEENTHS_PER_MINUTE = tempo * 4; 11 | const STEP_INTERVAL_IN_SECONDS = 60.0 / SIXTEENTHS_PER_MINUTE; 12 | const FADE_OUT_TIME_IN_SECONDS = 1.5; 13 | 14 | var calculatePlaybackTimeInSeconds = function() { 15 | var minimumPlaybackTime = songPlayer.stepCount() * STEP_INTERVAL_IN_SECONDS; 16 | var actualPlaybackTime = songPlayer.playbackTime(notePlayer, STEP_INTERVAL_IN_SECONDS); 17 | 18 | return Math.max(minimumPlaybackTime, actualPlaybackTime) + FADE_OUT_TIME_IN_SECONDS; 19 | }; 20 | 21 | var buildOfflineAudioContext = function(playbackTimeInSeconds) { 22 | var sampleCount = sampleRate * playbackTimeInSeconds; 23 | var offlineAudioContext = AudioContextBuilder.buildOfflineAudioContext(NUM_CHANNELS, sampleCount, sampleRate); 24 | 25 | offlineAudioContext.oncomplete = function(e) { 26 | var waveWriter = WaveWriter(sampleRate); 27 | 28 | var sampleData = e.renderedBuffer.getChannelData(0); 29 | var outputView = waveWriter.write(sampleData); 30 | var blob = new Blob([outputView], { type: "audio/wav" }); 31 | 32 | completeCallback(blob); 33 | }; 34 | 35 | return offlineAudioContext; 36 | }; 37 | 38 | var buildOfflineMixer = function(offlineAudioContext) { 39 | var i; 40 | var track; 41 | var offlineMixer = Mixer(offlineAudioContext); 42 | 43 | offlineMixer.setMasterAmplitude(masterAmplitude); 44 | 45 | for (i = 0; i < tracks.length; i++) { 46 | track = tracks[i]; 47 | offlineMixer.addChannel(track.id, track.volume, track.isMuted, track.reverbBuffer, track.reverbWetPercentage, track.delayTime, track.delayFeedback); 48 | } 49 | 50 | offlineMixer.masterGainNode().gain.setValueAtTime(masterAmplitude, playbackTimeInSeconds - FADE_OUT_TIME_IN_SECONDS); 51 | offlineMixer.masterGainNode().gain.linearRampToValueAtTime(0.0, playbackTimeInSeconds); 52 | 53 | return offlineMixer; 54 | }; 55 | 56 | var tick = function() { 57 | var scheduleAheadTimeInSeconds = songPlayer.stepCount() * STEP_INTERVAL_IN_SECONDS; 58 | var startTimeInSeconds = offlineAudioContext.currentTime; 59 | var finalTimeInSeconds = startTimeInSeconds + scheduleAheadTimeInSeconds; 60 | 61 | songPlayer.reset(startTimeInSeconds, 0); 62 | songPlayer.tick(offlineMixer, notePlayer, finalTimeInSeconds, STEP_INTERVAL_IN_SECONDS, false); 63 | 64 | offlineAudioContext.startRendering(); 65 | }; 66 | 67 | var playbackTimeInSeconds = calculatePlaybackTimeInSeconds(); 68 | var offlineAudioContext = buildOfflineAudioContext(playbackTimeInSeconds); 69 | var offlineMixer = buildOfflineMixer(offlineAudioContext); 70 | 71 | 72 | return { 73 | tick: tick, 74 | }; 75 | }; 76 | -------------------------------------------------------------------------------- /src/synth_core/score.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export function Score(notes) { 4 | return { 5 | notesAtStepIndex: function(stepIndex) { return notes[stepIndex]; }, 6 | stepCount: function() { return notes.length; }, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/synth_core/sequence_parser.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { Note } from "./note"; 4 | 5 | export var SequenceParser = { 6 | parse: function(noteStrings) { 7 | var sequence = []; 8 | var noteString; 9 | var i; 10 | var noteName; 11 | var octave; 12 | var noteDuration = 1; 13 | 14 | for (i = noteStrings.length - 1; i >= 0; i--) { 15 | noteString = noteStrings[i]; 16 | 17 | if (noteString === "-") { 18 | noteDuration += 1; 19 | } 20 | else if (noteString === "") { 21 | noteDuration = 1; 22 | } 23 | else { 24 | noteName = noteString.slice(0, -1); 25 | octave = parseInt(noteString.slice(-1), 10); 26 | 27 | try { 28 | sequence[i] = Note(noteName, octave, 1.0, noteDuration); 29 | } 30 | catch (e) { 31 | // If the note is invalid, we want to skip it without causing 32 | // things to crash, and also avoid adding a console log message 33 | // because invalid notes are expected as a normal possibility 34 | // of user input. Therefore, in case of an error, skip it and 35 | // do nothing. 36 | } 37 | 38 | noteDuration = 1; 39 | } 40 | } 41 | 42 | return sequence; 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /src/synth_core/song_player.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { Score } from "./score"; 4 | 5 | export function SongPlayer() { 6 | var score = Score([]); 7 | 8 | var nextStepToSchedule = 0; 9 | var isFinishedPlaying = false; 10 | var currentTime = 0.0; 11 | 12 | var reset = function(newCurrentTime, newNextStepToSchedule) { 13 | nextStepToSchedule = newNextStepToSchedule; 14 | isFinishedPlaying = false; 15 | currentTime = newCurrentTime; 16 | }; 17 | 18 | var replaceScore = function(newScore) { 19 | score = newScore; 20 | }; 21 | 22 | var tick = function(mixer, notePlayer, endTime, stepDuration, loop) { 23 | var scheduledSteps = []; 24 | var noteTimeDuration; 25 | var incomingNotes; 26 | 27 | while (currentTime < endTime) { 28 | incomingNotes = score.notesAtStepIndex(nextStepToSchedule); 29 | incomingNotes.forEach(function(note) { 30 | noteTimeDuration = stepDuration * note.note().stepCount(); 31 | notePlayer.scheduleNote(note.channelID(), 32 | mixer.audioContext(), 33 | mixer.destination(note.channelID()), 34 | note.note(), 35 | currentTime, 36 | currentTime + noteTimeDuration); 37 | }); 38 | 39 | scheduledSteps.push({ step: nextStepToSchedule, time: currentTime }); 40 | 41 | nextStepToSchedule += 1; 42 | if (nextStepToSchedule >= score.stepCount()) { 43 | if (loop === true) { 44 | nextStepToSchedule = 0; 45 | } 46 | else { 47 | isFinishedPlaying = true; 48 | return; 49 | } 50 | } 51 | 52 | currentTime += stepDuration; 53 | } 54 | 55 | return scheduledSteps; 56 | }; 57 | 58 | var playbackTime = function(notePlayer, stepDuration) { 59 | var notesAtStepIndex; 60 | var note, noteEndTime; 61 | var i, j; 62 | 63 | var noteStartTime = 0.0; 64 | var maxNoteEndTime = 0.0; 65 | 66 | for (i = 0; i < score.stepCount(); i++) { 67 | notesAtStepIndex = score.notesAtStepIndex(i); 68 | 69 | for(j = 0; j < notesAtStepIndex.length; j++) { 70 | note = notesAtStepIndex[j]; 71 | noteEndTime = noteStartTime + notePlayer.noteDuration(note.channelID(), note.note().stepCount(), stepDuration); 72 | if (noteEndTime > maxNoteEndTime) { 73 | maxNoteEndTime = noteEndTime; 74 | } 75 | } 76 | 77 | noteStartTime += stepDuration; 78 | } 79 | 80 | return maxNoteEndTime; 81 | }; 82 | 83 | 84 | return { 85 | reset: reset, 86 | stepCount: function() { return score.stepCount(); }, 87 | isFinishedPlaying: function() { return isFinishedPlaying; }, 88 | replaceScore: replaceScore, 89 | tick: tick, 90 | playbackTime: playbackTime, 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /src/synth_core/transport.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const SCHEDULE_AHEAD_TIME = 0.2; // in seconds 4 | const TICK_INTERVAL = 50; // in milliseconds 5 | 6 | export function Transport(mixer, songPlayer, notePlayer) { 7 | var LOOP = true; 8 | 9 | var currentStep = 0; 10 | var scheduledSteps; 11 | var stepInterval; 12 | var timeoutId; 13 | var isPlaying = false; 14 | 15 | var tick = function() { 16 | var currentTime = mixer.audioContext().currentTime; 17 | var finalTime = currentTime + SCHEDULE_AHEAD_TIME; 18 | var i; 19 | 20 | var newScheduledSteps = songPlayer.tick(mixer, notePlayer, finalTime, stepInterval, LOOP); 21 | scheduledSteps = scheduledSteps.concat(newScheduledSteps); 22 | 23 | i = 0; 24 | while (i < scheduledSteps.length && scheduledSteps[i].time <= currentTime) { 25 | currentStep = scheduledSteps[i].step; 26 | scheduledSteps.splice(0, 1); 27 | 28 | i++; 29 | } 30 | 31 | if (songPlayer.isFinishedPlaying() === true) { 32 | stop(); 33 | } 34 | }; 35 | 36 | var start = function() { 37 | var audioContext = mixer.audioContext(); 38 | 39 | scheduledSteps = []; 40 | songPlayer.reset(audioContext.currentTime, currentStep); 41 | 42 | // Fix for Safari 9.1 (and maybe 9?) 43 | // For some reason, the AudioContext on a new page load is in suspended state 44 | // in this version of Safari, which means that no audio playback will occur. 45 | // If you re-load the same page, it will no longer be in suspended state 46 | // and audio playback will occur. 47 | // 48 | // This fixes this by detecting if the AudioContext is in suspended state, 49 | // and manually forcing it to resume. 50 | if (audioContext.state === "suspended") { 51 | if (audioContext.resume) { 52 | audioContext.resume(); 53 | } 54 | } 55 | 56 | mixer.setClipDetectionEnabled(true); 57 | 58 | tick(); 59 | timeoutId = window.setInterval(tick, TICK_INTERVAL); 60 | isPlaying = true; 61 | }; 62 | 63 | var stop = function() { 64 | window.clearInterval(timeoutId); 65 | mixer.setClipDetectionEnabled(false); 66 | isPlaying = false; 67 | }; 68 | 69 | var setTempo = function(newTempo) { 70 | var sixteenthsPerMinute = newTempo * 4; 71 | stepInterval = 60.0 / sixteenthsPerMinute; 72 | }; 73 | 74 | var setCurrentStep = function(newCurrentStep) { 75 | if (currentStep === newCurrentStep) { 76 | return; 77 | } 78 | 79 | currentStep = newCurrentStep; 80 | 81 | if (isPlaying === true) { 82 | songPlayer.reset(mixer.audioContext().currentTime, currentStep); 83 | scheduledSteps = []; 84 | } 85 | }; 86 | 87 | var toggle = function() { 88 | if (isPlaying === true) { 89 | stop(); 90 | } 91 | else { 92 | start(); 93 | } 94 | }; 95 | 96 | 97 | setTempo(100); 98 | 99 | 100 | return { 101 | setTempo: setTempo, 102 | toggle: toggle, 103 | currentStep: function() { return currentStep; }, 104 | setCurrentStep: setCurrentStep, 105 | }; 106 | }; 107 | -------------------------------------------------------------------------------- /src/synth_core/wave_writer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const LITTLE_ENDIAN = true; 4 | const AUDIO_FORMAT_CODE = 1; // I.e., PCM 5 | const NUM_CHANNELS = 1; 6 | const BITS_PER_SAMPLE = 16; 7 | const BYTES_PER_SAMPLE = 2; 8 | const MAX_SAMPLE_VALUE = 32767; 9 | const BLOCK_ALIGN = BYTES_PER_SAMPLE * NUM_CHANNELS; 10 | const WAVEFILE_HEADER_BYTE_COUNT = 44; 11 | const RIFF_CHUNK_BODY_BYTE_COUNT_MINIMUM = 36; 12 | const FORMAT_CHUNK_BODY_BYTE_COUNT = 16; 13 | 14 | export function WaveWriter(sampleRate) { 15 | const BYTE_RATE = BLOCK_ALIGN * sampleRate; 16 | 17 | var write = function(rawFloat32SampleData) { 18 | const sampleDataByteCount = rawFloat32SampleData.length * BYTES_PER_SAMPLE; 19 | const fileLength = WAVEFILE_HEADER_BYTE_COUNT + sampleDataByteCount; 20 | var outputView = new DataView(new ArrayBuffer(fileLength)); 21 | var sampleByteOffset; 22 | var clampedSample; 23 | var i; 24 | 25 | outputView.setUint8( 0, "R".charCodeAt(0), LITTLE_ENDIAN); 26 | outputView.setUint8( 1, "I".charCodeAt(0), LITTLE_ENDIAN); 27 | outputView.setUint8( 2, "F".charCodeAt(0), LITTLE_ENDIAN); 28 | outputView.setUint8( 3, "F".charCodeAt(0), LITTLE_ENDIAN); 29 | outputView.setUint32( 4, RIFF_CHUNK_BODY_BYTE_COUNT_MINIMUM + sampleDataByteCount, LITTLE_ENDIAN); 30 | outputView.setUint8( 8, "W".charCodeAt(0), LITTLE_ENDIAN); 31 | outputView.setUint8( 9, "A".charCodeAt(0), LITTLE_ENDIAN); 32 | outputView.setUint8( 10, "V".charCodeAt(0), LITTLE_ENDIAN); 33 | outputView.setUint8( 11, "E".charCodeAt(0), LITTLE_ENDIAN); 34 | outputView.setUint8( 12, "f".charCodeAt(0), LITTLE_ENDIAN); 35 | outputView.setUint8( 13, "m".charCodeAt(0), LITTLE_ENDIAN); 36 | outputView.setUint8( 14, "t".charCodeAt(0), LITTLE_ENDIAN); 37 | outputView.setUint8( 15, " ".charCodeAt(0), LITTLE_ENDIAN); 38 | outputView.setUint32(16, FORMAT_CHUNK_BODY_BYTE_COUNT, LITTLE_ENDIAN); 39 | outputView.setUint16(20, AUDIO_FORMAT_CODE, LITTLE_ENDIAN); 40 | outputView.setUint16(22, NUM_CHANNELS, LITTLE_ENDIAN); 41 | outputView.setUint32(24, sampleRate, LITTLE_ENDIAN); 42 | outputView.setUint32(28, BYTE_RATE, LITTLE_ENDIAN); 43 | outputView.setUint16(32, BLOCK_ALIGN, LITTLE_ENDIAN); 44 | outputView.setUint16(34, BITS_PER_SAMPLE, LITTLE_ENDIAN); 45 | outputView.setUint8( 36, "d".charCodeAt(0), LITTLE_ENDIAN); 46 | outputView.setUint8( 37, "a".charCodeAt(0), LITTLE_ENDIAN); 47 | outputView.setUint8( 38, "t".charCodeAt(0), LITTLE_ENDIAN); 48 | outputView.setUint8( 39, "a".charCodeAt(0), LITTLE_ENDIAN); 49 | outputView.setUint32(40, sampleDataByteCount, LITTLE_ENDIAN); 50 | 51 | sampleByteOffset = WAVEFILE_HEADER_BYTE_COUNT; 52 | 53 | // Float32Array doesn't appear to support forEach() in Safari 9 54 | for (i = 0; i < rawFloat32SampleData.length; i++) { 55 | // Should this round? 56 | clampedSample = Math.max(Math.min(rawFloat32SampleData[i], 1.0), -1.0); 57 | outputView.setInt16(sampleByteOffset, clampedSample * MAX_SAMPLE_VALUE, LITTLE_ENDIAN); 58 | 59 | sampleByteOffset += BYTES_PER_SAMPLE; 60 | } 61 | 62 | return outputView; 63 | }; 64 | 65 | 66 | return { 67 | write: write, 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /test/synth_core/envelope.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { Envelope } from "./../../src/synth_core/envelope"; 4 | 5 | describe("Envelope", () => { 6 | test("calculates correctly when envelope is effectively a no-op", () => { 7 | var envelopeConfig = { 8 | attackTime: 0.0, 9 | decayTime: 0.0, 10 | sustainPercentage: 1.0, 11 | releaseTime: 0.0, 12 | }; 13 | 14 | var calculatedEnvelope = Envelope(0.5, envelopeConfig, 1.0, 1.1); 15 | 16 | expect(calculatedEnvelope.attackEndTime).toEqual(1.0); 17 | expect(calculatedEnvelope.attackEndAmplitude).toEqual(0.5); 18 | expect(calculatedEnvelope.decayEndTime).toEqual(1.001); 19 | expect(calculatedEnvelope.decayEndAmplitude).toEqual(0.5); 20 | 21 | expect(calculatedEnvelope.valueAtTime(0.5, 1.1)).toEqual(0.0); 22 | expect(calculatedEnvelope.valueAtTime(1.0, 1.1)).toEqual(0.5); 23 | expect(calculatedEnvelope.valueAtTime(1.025, 1.1)).toEqual(0.5); 24 | expect(calculatedEnvelope.valueAtTime(1.05, 1.1)).toEqual(0.5); 25 | expect(calculatedEnvelope.valueAtTime(1.075, 1.1)).toEqual(0.5); 26 | expect(calculatedEnvelope.valueAtTime(1.1, 1.1)).toEqual(0.5); 27 | expect(calculatedEnvelope.valueAtTime(1.11, 1.1)).toEqual(0.0); 28 | }); 29 | 30 | test("calculates correctly when attack time is longer than note duration", () => { 31 | var envelopeConfig = { 32 | attackTime: 0.2, 33 | decayTime: 0.0, 34 | sustainPercentage: 1.0, 35 | releaseTime: 0.0, 36 | }; 37 | 38 | var calculatedEnvelope = Envelope(0.5, envelopeConfig, 1.0, 1.1); 39 | 40 | expect(calculatedEnvelope.attackEndTime).toEqual(1.1); 41 | expect(calculatedEnvelope.attackEndAmplitude).toBeCloseTo(0.25); 42 | expect(calculatedEnvelope.decayEndTime).toEqual(1.1); 43 | expect(calculatedEnvelope.decayEndAmplitude).toEqual(0.2500000000000003); 44 | 45 | expect(calculatedEnvelope.valueAtTime(0.5, 1.1)).toEqual(0.0); 46 | expect(calculatedEnvelope.valueAtTime(1.0, 1.1)).toEqual(0.0); 47 | expect(calculatedEnvelope.valueAtTime(1.025, 1.1)).toBeCloseTo(0.0625); 48 | expect(calculatedEnvelope.valueAtTime(1.05, 1.1)).toBeCloseTo(0.125); 49 | expect(calculatedEnvelope.valueAtTime(1.075, 1.1)).toBeCloseTo(0.1875); 50 | expect(calculatedEnvelope.valueAtTime(1.1, 1.1)).toBeCloseTo(0.25); 51 | expect(calculatedEnvelope.valueAtTime(1.11, 1.1)).toEqual(0.0); 52 | }); 53 | 54 | test("calculates correctly when attack time is shorter than note duration ", () => { 55 | var envelopeConfig = { 56 | attackTime: 0.5, 57 | decayTime: 0.0, 58 | sustainPercentage: 1.0, 59 | releaseTime: 0.0, 60 | }; 61 | 62 | var calculatedEnvelope = Envelope(0.5, envelopeConfig, 1.0, 2.0); 63 | 64 | expect(calculatedEnvelope.attackEndTime).toEqual(1.5); 65 | expect(calculatedEnvelope.attackEndAmplitude).toEqual(0.5); 66 | expect(calculatedEnvelope.decayEndTime).toEqual(1.501); 67 | expect(calculatedEnvelope.decayEndAmplitude).toEqual(0.5); 68 | 69 | expect(calculatedEnvelope.valueAtTime(0.5, 2.0)).toEqual(0.0); 70 | expect(calculatedEnvelope.valueAtTime(1.0, 2.0)).toEqual(0.0); // Attack start 71 | expect(calculatedEnvelope.valueAtTime(1.125, 2.0)).toEqual(0.125); 72 | expect(calculatedEnvelope.valueAtTime(1.25, 2.0)).toEqual(0.25); 73 | expect(calculatedEnvelope.valueAtTime(1.375, 2.0)).toEqual(0.375); 74 | expect(calculatedEnvelope.valueAtTime(1.5, 2.0)).toEqual(0.5); // Attack end, decay start 75 | expect(calculatedEnvelope.valueAtTime(1.75, 2.0)).toEqual(0.5); 76 | expect(calculatedEnvelope.valueAtTime(2.0, 2.0)).toEqual(0.5); // Release start 77 | expect(calculatedEnvelope.valueAtTime(2.01, 2.0)).toEqual(0.0); 78 | }); 79 | 80 | test("calculates correctly when decay ends before note ends", () => { 81 | var envelopeConfig = { 82 | attackTime: 0.5, 83 | decayTime: 0.25, 84 | sustainPercentage: 0.5, 85 | releaseTime: 0.0, 86 | }; 87 | 88 | var calculatedEnvelope = Envelope(0.5, envelopeConfig, 1.0, 2.0); 89 | 90 | expect(calculatedEnvelope.attackEndTime).toEqual(1.5); 91 | expect(calculatedEnvelope.attackEndAmplitude).toEqual(0.5); 92 | expect(calculatedEnvelope.decayEndTime).toEqual(1.75); 93 | expect(calculatedEnvelope.decayEndAmplitude).toEqual(0.25); 94 | 95 | expect(calculatedEnvelope.valueAtTime(0.5, 2.0)).toEqual(0.0); 96 | expect(calculatedEnvelope.valueAtTime(1.0, 2.0)).toEqual(0.0); // Attack start 97 | expect(calculatedEnvelope.valueAtTime(1.25, 2.0)).toEqual(0.25); 98 | expect(calculatedEnvelope.valueAtTime(1.5, 2.0)).toEqual(0.5); // Attack end, decay start 99 | expect(calculatedEnvelope.valueAtTime(1.5625, 2.0)).toEqual(0.4375); 100 | expect(calculatedEnvelope.valueAtTime(1.625, 2.0)).toEqual(0.375); 101 | expect(calculatedEnvelope.valueAtTime(1.6875, 2.0)).toEqual(0.3125); 102 | expect(calculatedEnvelope.valueAtTime(1.75, 2.0)).toEqual(0.25); // Decay end 103 | expect(calculatedEnvelope.valueAtTime(1.875, 2.0)).toEqual(0.25); 104 | expect(calculatedEnvelope.valueAtTime(2.0, 2.0)).toEqual(0.25); // Release start 105 | expect(calculatedEnvelope.valueAtTime(2.01, 2.0)).toEqual(0.0); 106 | }); 107 | 108 | test("calculates correctly when decay is a no-op because sustain is 100%", () => { 109 | var envelopeConfig = { 110 | attackTime: 0.5, 111 | decayTime: 1.0, 112 | sustainPercentage: 1.0, 113 | releaseTime: 0.0, 114 | }; 115 | 116 | var calculatedEnvelope = Envelope(0.5, envelopeConfig, 1.0, 2.0); 117 | 118 | expect(calculatedEnvelope.attackEndTime).toEqual(1.5); 119 | expect(calculatedEnvelope.attackEndAmplitude).toEqual(0.5); 120 | expect(calculatedEnvelope.decayEndTime).toEqual(2.0); 121 | expect(calculatedEnvelope.decayEndAmplitude).toEqual(0.5); 122 | 123 | expect(calculatedEnvelope.valueAtTime(0.5, 2.0)).toEqual(0.0); 124 | expect(calculatedEnvelope.valueAtTime(1.0, 2.0)).toEqual(0.0); // Attack start 125 | expect(calculatedEnvelope.valueAtTime(1.25, 2.0)).toEqual(0.25); 126 | expect(calculatedEnvelope.valueAtTime(1.5, 2.0)).toEqual(0.5); // Attack end, decay start 127 | expect(calculatedEnvelope.valueAtTime(1.75, 2.0)).toEqual(0.5); 128 | expect(calculatedEnvelope.valueAtTime(2.0, 2.0)).toEqual(0.5); // Release start 129 | expect(calculatedEnvelope.valueAtTime(2.01, 2.0)).toEqual(0.0); 130 | }); 131 | 132 | test("calculates correctly when decay ends before gate off, but is a no-op due to sustain volume", () => { 133 | var envelopeConfig = { 134 | attackTime: 0.0, 135 | decayTime: 0.5, 136 | sustainPercentage: 1.0, 137 | releaseTime: 0.0, 138 | }; 139 | 140 | var calculatedEnvelope = Envelope(0.5, envelopeConfig, 1.0, 2.0); 141 | 142 | expect(calculatedEnvelope.attackEndTime).toEqual(1.0); 143 | expect(calculatedEnvelope.attackEndAmplitude).toBeCloseTo(0.5); 144 | expect(calculatedEnvelope.decayEndTime).toEqual(1.5); 145 | expect(calculatedEnvelope.decayEndAmplitude).toEqual(0.5); 146 | 147 | expect(calculatedEnvelope.valueAtTime(0.5, 2.0)).toEqual(0.0); 148 | expect(calculatedEnvelope.valueAtTime(1.0, 2.0)).toEqual(0.5); // Attack start 149 | expect(calculatedEnvelope.valueAtTime(1.125, 2.0)).toEqual(0.5); 150 | expect(calculatedEnvelope.valueAtTime(1.25, 2.0)).toEqual(0.5); 151 | expect(calculatedEnvelope.valueAtTime(1.375, 2.0)).toEqual(0.5); 152 | expect(calculatedEnvelope.valueAtTime(1.5, 2.0)).toEqual(0.5); // Decay end 153 | expect(calculatedEnvelope.valueAtTime(1.625, 2.0)).toEqual(0.5); 154 | expect(calculatedEnvelope.valueAtTime(1.75, 2.0)).toEqual(0.5); 155 | expect(calculatedEnvelope.valueAtTime(1.875, 2.0)).toEqual(0.5); 156 | expect(calculatedEnvelope.valueAtTime(2.0, 2.0)).toEqual(0.5); // Release start 157 | expect(calculatedEnvelope.valueAtTime(2.01, 2.0)).toEqual(0.0); 158 | }); 159 | 160 | test("calculates correctly when decay ends after gate off, but is a no-op due to sustain volume", () => { 161 | var envelopeConfig = { 162 | attackTime: 0.0, 163 | decayTime:1.5, 164 | sustainPercentage: 1.0, 165 | releaseTime: 0.0, 166 | }; 167 | 168 | var calculatedEnvelope = Envelope(0.5, envelopeConfig, 1.0, 2.0); 169 | 170 | expect(calculatedEnvelope.attackEndTime).toEqual(1.0); 171 | expect(calculatedEnvelope.attackEndAmplitude).toBeCloseTo(0.5); 172 | expect(calculatedEnvelope.decayEndTime).toEqual(2.0); 173 | expect(calculatedEnvelope.decayEndAmplitude).toEqual(0.5); 174 | 175 | expect(calculatedEnvelope.valueAtTime(0.5, 2.0)).toEqual(0.0); 176 | expect(calculatedEnvelope.valueAtTime(1.0, 2.0)).toEqual(0.5); // Attack start 177 | expect(calculatedEnvelope.valueAtTime(1.125, 2.0)).toEqual(0.5); 178 | expect(calculatedEnvelope.valueAtTime(1.25, 2.0)).toEqual(0.5); 179 | expect(calculatedEnvelope.valueAtTime(1.375, 2.0)).toEqual(0.5); 180 | expect(calculatedEnvelope.valueAtTime(1.5, 2.0)).toEqual(0.5); // Decay end 181 | expect(calculatedEnvelope.valueAtTime(1.625, 2.0)).toEqual(0.5); 182 | expect(calculatedEnvelope.valueAtTime(1.75, 2.0)).toEqual(0.5); 183 | expect(calculatedEnvelope.valueAtTime(1.875, 2.0)).toEqual(0.5); 184 | expect(calculatedEnvelope.valueAtTime(2.0, 2.0)).toEqual(0.5); // Release start 185 | expect(calculatedEnvelope.valueAtTime(2.01, 2.0)).toEqual(0.0); 186 | }); 187 | 188 | test("calculates correctly when there is a release portion of the envelope", () => { 189 | var envelopeConfig = { 190 | attackTime: 0.0, 191 | decayTime: 0.0, 192 | sustainPercentage: 1.0, 193 | releaseTime: 0.5, 194 | }; 195 | 196 | var calculatedEnvelope = Envelope(0.5, envelopeConfig, 1.0, 2.0); 197 | 198 | expect(calculatedEnvelope.attackEndTime).toEqual(1.0); 199 | expect(calculatedEnvelope.attackEndAmplitude).toBe(0.5); 200 | expect(calculatedEnvelope.decayEndTime).toEqual(1.001); 201 | expect(calculatedEnvelope.decayEndAmplitude).toEqual(0.5); 202 | 203 | expect(calculatedEnvelope.valueAtTime(0.5, 2.0)).toEqual(0.0); 204 | expect(calculatedEnvelope.valueAtTime(1.0, 2.0)).toEqual(0.5); // Attack start 205 | expect(calculatedEnvelope.valueAtTime(1.5, 2.0)).toEqual(0.5); 206 | expect(calculatedEnvelope.valueAtTime(2.0, 2.0)).toEqual(0.5); // Release start 207 | expect(calculatedEnvelope.valueAtTime(2.125, 2.0)).toEqual(0.375); 208 | expect(calculatedEnvelope.valueAtTime(2.25, 2.0)).toEqual(0.25); 209 | expect(calculatedEnvelope.valueAtTime(2.375, 2.0)).toEqual(0.125); 210 | expect(calculatedEnvelope.valueAtTime(2.5, 2.0)).toEqual(0.0); 211 | expect(calculatedEnvelope.valueAtTime(2.6, 2.0)).toEqual(0.0); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /test/synth_core/note.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { Note } from "./../../src/synth_core/note"; 4 | 5 | describe("Note", () => { 6 | test("returns correct object when constructed with valid argument values", () => { 7 | var note = Note("A", 3, 0.75, 1); 8 | 9 | expect(note.name()).toEqual("A"); 10 | expect(note.octave()).toEqual(3); 11 | expect(note.amplitude()).toEqual(0.75); 12 | expect(note.stepCount()).toEqual(1); 13 | expect(note.frequency()).toEqual(220.0); 14 | expect(note.midiNote()).toEqual(57); 15 | }); 16 | 17 | test("raises an error if constructed with an invalid argument value", () => { 18 | // Name 19 | expect(() => Note("a", 3, 1.0, 1)).toThrowError(TypeError); 20 | expect(() => Note("V", 3, 1.0, 1)).toThrowError(TypeError); 21 | expect(() => Note("A!", 3, 1.0, 1)).toThrowError(TypeError); 22 | expect(() => Note("@", 3, 1.0, 1)).toThrowError(TypeError); 23 | expect(() => Note("A@@@", 3, 1.0, 1)).toThrowError(TypeError); 24 | expect(() => Note("A###", 3, 1.0, 1)).toThrowError(TypeError); 25 | expect(() => Note("A@#", 3, 1.0, 1)).toThrowError(TypeError); 26 | expect(() => Note(" ", 3, 1.0, 1)).toThrowError(TypeError); 27 | expect(() => Note("", 3, 1.0, 1)).toThrowError(TypeError); 28 | expect(() => Note(0, 3, 1.0, 1)).toThrowError(TypeError); 29 | expect(() => Note(undefined, 3, 1.0, 1)).toThrowError(TypeError); 30 | expect(() => Note(null, 3, 1.0, 1)).toThrowError(TypeError); 31 | expect(() => Note(true, 3, 1.0, 1)).toThrowError(TypeError); 32 | 33 | // Octave 34 | expect(() => Note("A", "", 1.0, 1)).toThrowError(TypeError); 35 | expect(() => Note("A", "Q", 1.0, 1)).toThrowError(TypeError); 36 | expect(() => Note("A", "3", 1.0, 1)).toThrowError(TypeError); 37 | expect(() => Note("A", -1, 1.0, 1)).toThrowError(TypeError); 38 | expect(() => Note("A", 8, 1.0, 1)).toThrowError(TypeError); 39 | expect(() => Note("A", 2.1, 1.0, 1)).toThrowError(TypeError); 40 | expect(() => Note("A", NaN, 1.0, 1)).toThrowError(TypeError); 41 | expect(() => Note("A", undefined, 1.0, 1)).toThrowError(TypeError); 42 | expect(() => Note("A", null, 1.0, 1)).toThrowError(TypeError); 43 | expect(() => Note("A", true, 1.0, 1)).toThrowError(TypeError); 44 | 45 | // Amplitude 46 | expect(() => Note("A", 3, "", 1)).toThrowError(TypeError); 47 | expect(() => Note("A", 3, "1.0", 1)).toThrowError(TypeError); 48 | expect(() => Note("A", 3, "A", 1)).toThrowError(TypeError); 49 | expect(() => Note("A", 3, -1, 1)).toThrowError(TypeError); 50 | expect(() => Note("A", 3, 1.2, 1)).toThrowError(TypeError); 51 | expect(() => Note("A", 3, NaN, 1)).toThrowError(TypeError); 52 | expect(() => Note("A", 3, undefined, 1)).toThrowError(TypeError); 53 | expect(() => Note("A", 3, null, 1)).toThrowError(TypeError); 54 | expect(() => Note("A", 3, true, 1)).toThrowError(TypeError); 55 | 56 | // Step count 57 | expect(() => Note("A", 3, 1.0, "")).toThrowError(TypeError); 58 | expect(() => Note("A", 3, 1.0, "1")).toThrowError(TypeError); 59 | expect(() => Note("A", 3, 1.0, "A")).toThrowError(TypeError); 60 | expect(() => Note("A", 3, 1.0, -1)).toThrowError(TypeError); 61 | expect(() => Note("A", 3, 1.0, 1.2)).toThrowError(TypeError); 62 | expect(() => Note("A", 3, 1.0, NaN)).toThrowError(TypeError); 63 | expect(() => Note("A", 3, 1.0, undefined)).toThrowError(TypeError); 64 | expect(() => Note("A", 3, 1.0, null)).toThrowError(TypeError); 65 | expect(() => Note("A", 3, 1.0, true)).toThrowError(TypeError); 66 | }); 67 | 68 | test("handles enharmonic equivalents properly", () => { 69 | var note1 = Note("D#", 3, 1.0, 1); 70 | var note2 = Note("E@", 3, 1.0, 1); 71 | var note3 = Note("F@@", 3, 1.0, 1); 72 | 73 | expect(note1.name()).toEqual("D#"); 74 | expect(note1.octave()).toEqual(3); 75 | expect(note1.amplitude()).toEqual(1.0); 76 | expect(note1.stepCount()).toEqual(1); 77 | expect(note1.frequency()).toEqual(155.56349186104043); 78 | expect(note1.midiNote()).toEqual(51); 79 | 80 | expect(note2.name()).toEqual("E@"); 81 | expect(note2.octave()).toEqual(3); 82 | expect(note2.amplitude()).toEqual(1.0); 83 | expect(note2.stepCount()).toEqual(1); 84 | expect(note2.frequency()).toEqual(155.56349186104043); 85 | expect(note2.midiNote()).toEqual(51); 86 | 87 | expect(note3.name()).toEqual("F@@"); 88 | expect(note3.octave()).toEqual(3); 89 | expect(note3.amplitude()).toEqual(1.0); 90 | expect(note3.stepCount()).toEqual(1); 91 | expect(note3.frequency()).toEqual(155.56349186104043); 92 | expect(note3.midiNote()).toEqual(51); 93 | }); 94 | 95 | test("handles notes at start/end of valid range properly", () => { 96 | var note = Note("C", 0, 1.0, 1); 97 | 98 | expect(note.name()).toEqual("C"); 99 | expect(note.octave()).toEqual(0); 100 | expect(note.amplitude()).toEqual(1.0); 101 | expect(note.stepCount()).toEqual(1); 102 | expect(note.frequency()).toEqual(16.351597831287414); 103 | expect(note.midiNote()).toEqual(12); 104 | 105 | note = Note("A", 0, 1.0, 1); 106 | 107 | expect(note.name()).toEqual("A"); 108 | expect(note.octave()).toEqual(0); 109 | expect(note.amplitude()).toEqual(1.0); 110 | expect(note.stepCount()).toEqual(1); 111 | expect(note.frequency()).toEqual(27.5); 112 | expect(note.midiNote()).toEqual(21); 113 | 114 | note = Note("B", 7, 1.0, 1); 115 | 116 | expect(note.name()).toEqual("B"); 117 | expect(note.octave()).toEqual(7); 118 | expect(note.amplitude()).toEqual(1.0); 119 | expect(note.stepCount()).toEqual(1); 120 | expect(note.frequency()).toEqual(3951.066410048992); 121 | expect(note.midiNote()).toEqual(107); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/synth_core/sequence_parser.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { SequenceParser } from "./../../src/synth_core/sequence_parser"; 4 | 5 | describe("SequenceParser", () => { 6 | test("properly parses a valid sequence", () => { 7 | var rawSequence = ["A4", "B@2", "", "C#5", ""]; 8 | var parsedSequence = SequenceParser.parse(rawSequence); 9 | 10 | expect(parsedSequence.length).toEqual(4); 11 | expect(Object.keys(parsedSequence)).toEqual(["0", "1", "3"]); 12 | 13 | expect(parsedSequence[0].name()).toEqual("A"); 14 | expect(parsedSequence[0].octave()).toEqual(4); 15 | expect(parsedSequence[0].amplitude()).toEqual(1.0); 16 | expect(parsedSequence[0].stepCount()).toEqual(1); 17 | 18 | expect(parsedSequence[1].name()).toEqual("B@"); 19 | expect(parsedSequence[1].octave()).toEqual(2); 20 | expect(parsedSequence[1].amplitude()).toEqual(1.0); 21 | expect(parsedSequence[1].stepCount()).toEqual(1); 22 | 23 | expect(parsedSequence[2]).toBe(undefined); 24 | 25 | expect(parsedSequence[3].name()).toEqual("C#"); 26 | expect(parsedSequence[3].octave()).toEqual(5); 27 | expect(parsedSequence[3].amplitude()).toEqual(1.0); 28 | expect(parsedSequence[3].stepCount()).toEqual(1); 29 | }); 30 | 31 | test("properly parses a sequence containing ties", () => { 32 | var rawSequence = ["A4", "-", "-", "-", "C2", "-", "D4", "G3", "-", "-"]; 33 | var parsedSequence = SequenceParser.parse(rawSequence); 34 | 35 | expect(parsedSequence.length).toEqual(8); 36 | expect(Object.keys(parsedSequence)).toEqual(["0", "4", "6", "7"]); 37 | 38 | expect(parsedSequence[0].name()).toEqual("A"); 39 | expect(parsedSequence[0].octave()).toEqual(4); 40 | expect(parsedSequence[0].amplitude()).toEqual(1.0); 41 | expect(parsedSequence[0].stepCount()).toEqual(4); 42 | 43 | expect(parsedSequence[1]).toBe(undefined); 44 | expect(parsedSequence[2]).toBe(undefined); 45 | expect(parsedSequence[3]).toBe(undefined); 46 | 47 | expect(parsedSequence[4].name()).toEqual("C"); 48 | expect(parsedSequence[4].octave()).toEqual(2); 49 | expect(parsedSequence[4].amplitude()).toEqual(1.0); 50 | expect(parsedSequence[4].stepCount()).toEqual(2); 51 | 52 | expect(parsedSequence[5]).toBe(undefined); 53 | 54 | expect(parsedSequence[6].name()).toEqual("D"); 55 | expect(parsedSequence[6].octave()).toEqual(4); 56 | expect(parsedSequence[6].amplitude()).toEqual(1.0); 57 | expect(parsedSequence[6].stepCount()).toEqual(1); 58 | 59 | expect(parsedSequence[7].name()).toEqual("G"); 60 | expect(parsedSequence[7].octave()).toEqual(3); 61 | expect(parsedSequence[7].amplitude()).toEqual(1.0); 62 | expect(parsedSequence[7].stepCount()).toEqual(3); 63 | }); 64 | 65 | test("properly parses a sequence with invalid note names", () => { 66 | var rawSequence = ["V3", "-", "-", "-", "4", "A", "@5", "3A", "C2"]; 67 | var parsedSequence = SequenceParser.parse(rawSequence); 68 | 69 | expect(parsedSequence.length).toEqual(9); 70 | expect(Object.keys(parsedSequence)).toEqual(["8"]); 71 | 72 | expect(parsedSequence[0]).toBe(undefined); 73 | expect(parsedSequence[1]).toBe(undefined); 74 | expect(parsedSequence[2]).toBe(undefined); 75 | expect(parsedSequence[3]).toBe(undefined); 76 | expect(parsedSequence[4]).toBe(undefined); 77 | expect(parsedSequence[5]).toBe(undefined); 78 | expect(parsedSequence[6]).toBe(undefined); 79 | expect(parsedSequence[7]).toBe(undefined); 80 | 81 | expect(parsedSequence[8].name()).toEqual("C"); 82 | expect(parsedSequence[8].octave()).toBe(2); 83 | expect(parsedSequence[8].amplitude()).toEqual(1.0); 84 | expect(parsedSequence[8].stepCount()).toEqual(1); 85 | }); 86 | 87 | test("properly parses a sequence containing trailing spaces", () => { 88 | var rawSequence = ["A4", "-", "-", "-", "", "", ""]; 89 | var parsedSequence = SequenceParser.parse(rawSequence); 90 | 91 | expect(parsedSequence.length).toBe(1); 92 | expect(Object.keys(parsedSequence)).toEqual(["0"]); 93 | 94 | expect(parsedSequence[0].name()).toEqual("A"); 95 | expect(parsedSequence[0].octave()).toEqual(4); 96 | expect(parsedSequence[0].amplitude()).toEqual(1.0); 97 | expect(parsedSequence[0].stepCount()).toEqual(4); 98 | }); 99 | 100 | test("properly parses a sequence with unattached sustain characters ('-')", () => { 101 | var rawSequence = ["A4", "-", "", "-", "-", "C2"]; 102 | var parsedSequence = SequenceParser.parse(rawSequence); 103 | 104 | expect(parsedSequence.length).toEqual(6); 105 | expect(Object.keys(parsedSequence)).toEqual(["0", "5"]); 106 | 107 | expect(parsedSequence[0].name()).toEqual("A"); 108 | expect(parsedSequence[0].octave()).toEqual(4); 109 | expect(parsedSequence[0].amplitude()).toEqual(1.0); 110 | expect(parsedSequence[0].stepCount()).toEqual(2); 111 | 112 | expect(parsedSequence[1]).toBe(undefined); 113 | expect(parsedSequence[2]).toBe(undefined); 114 | expect(parsedSequence[3]).toBe(undefined); 115 | expect(parsedSequence[4]).toBe(undefined); 116 | 117 | expect(parsedSequence[5].name()).toEqual("C"); 118 | expect(parsedSequence[5].octave()).toEqual(2); 119 | expect(parsedSequence[5].amplitude()).toEqual(1.0); 120 | expect(parsedSequence[5].stepCount()).toEqual(1); 121 | }); 122 | 123 | test("properly parses a sequence with leading sustain characters ('-')", () => { 124 | var rawSequence = ["-", "-", "-", "-"]; 125 | var parsedSequence = SequenceParser.parse(rawSequence); 126 | 127 | expect(parsedSequence).toEqual([]); 128 | expect(parsedSequence.length).toEqual(0); 129 | expect(Object.keys(parsedSequence)).toEqual([]); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 3 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 5 | const TerserPlugin = require("terser-webpack-plugin"); 6 | 7 | module.exports = { 8 | mode: process.env.NODE_ENV, 9 | entry: ["./src/app.js", "./sass/main.scss"], 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | use: { 16 | loader: "babel-loader", 17 | options: { 18 | presets: ["@babel/preset-env", "@babel/preset-react"], 19 | }, 20 | } 21 | }, 22 | { 23 | test: /\.scss$/, 24 | use: [ 25 | MiniCssExtractPlugin.loader, 26 | { 27 | loader: "css-loader", 28 | options: { 29 | url: false, 30 | }, 31 | }, 32 | { 33 | loader: "sass-loader", 34 | options: { 35 | implementation: require("sass"), 36 | }, 37 | }, 38 | ], 39 | } 40 | ] 41 | }, 42 | optimization: { 43 | minimizer: [ 44 | new CssMinimizerPlugin(), 45 | ], 46 | }, 47 | plugins: [ 48 | new CopyWebpackPlugin({ 49 | patterns: [ 50 | { from: "html/index.html" }, 51 | { from: "sounds/*.wav" }, 52 | { from: "images/*.png" }, 53 | { from: "lib/*.js" }, 54 | ], 55 | }), 56 | new MiniCssExtractPlugin({ 57 | filename: "jssynth.css", 58 | }), 59 | new TerserPlugin({ 60 | terserOptions: { 61 | compress: true, 62 | mangle: true, 63 | }, 64 | exclude: "lib/", 65 | extractComments: false, 66 | }), 67 | ], 68 | output: { 69 | filename: "jssynth.js", 70 | path: path.resolve(__dirname, "./dist"), 71 | clean: true, 72 | } 73 | }; 74 | --------------------------------------------------------------------------------