├── .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 | 
58 |
59 | ---
60 |
61 | Instrument editor:
62 | 
63 |
64 | ---
65 |
66 | Pattern editor:
67 | 
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 | File Name:
76 |
77 |
78 | .wav
79 |
80 | {this.state.errorMessage}
81 |
82 | {this.state.isDownloadInProgress && }
83 | Download
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 ;
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 | Name:
33 | Tips and Tricks
34 |
35 | {(this.state.tipsAndTricksVisible === true) &&
36 |
37 | To enter a note, select a note box, and play a note on the on-screen keyboard or MIDI keyboard, or type the note name.
38 | A note is a letter between A and G plus an octave between 0 and 7. For example: A3 , C♯4 , E♭2
39 | Use ‘#’ to enter a sharp, and ‘@’ to enter a flat. Press twice to double sharp/flat, thrice to remove the sharp/flat.
40 | Use — to lengthen a note. For example, ‘A4 — — —’ will last for 4 steps, while ‘A4 —’ will last for two, and ‘A4’ will last for one.
41 | Press SPACE
, DELETE
, or BACKSPACE
to clear the current note.
42 | Use the left/right arrow keys to move between notes, and the up/down arrow keys to move between rows.
43 |
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 |
92 |
93 | {this.props.rows.map((patternRow, rowIndex) =>
94 |
95 |
96 |
97 | )}
98 |
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
114 |
115 |
116 | {Array(this.props.stepCount).fill(undefined).map((_, stepIndex) =>
117 | {stepIndex + this.props.startStep + 1}
118 | )}
119 |
120 |
121 | {this.props.rows.map((patternRow, rowIndex) =>
122 |
123 |
124 | {patternRow.slice(0, this.props.stepCount).map((note, stepIndex) =>
125 |
126 |
138 |
139 | )}
140 |
141 |
142 | )}
143 | ;
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 X ;
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 | Add Row
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 |
← Sequencer
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 | Tempo
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 | Volume
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 | Rewind
41 | Play
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 |
--------------------------------------------------------------------------------