├── .gitignore ├── README.md ├── css ├── controls.css ├── keyboard.css └── page.css ├── img ├── adsr.png ├── input.png ├── key-black.png ├── key.png ├── sawtooth.png ├── sine.png └── square.png ├── index.html ├── js ├── Controls.js ├── Keyboard.js ├── KeyboardListener.js ├── MediatorMixin.js ├── MidiListener.js ├── Note.js ├── SineModulator.js ├── Synth.js ├── app.js ├── effects │ └── Delay.js ├── prod │ └── app.js ├── synthMixins │ ├── ADSR.js │ ├── PitchShifter.js │ └── WaveForm.js └── waveforms │ ├── sawtooth.js │ ├── sine.js │ └── square.js ├── package.json ├── spec ├── DelaySpec.js ├── KeyboardListenerSpec.js ├── KeyboardSpec.js ├── MediatorMixinSpec.js ├── MidiListenerSpec.js ├── NoteSpec.js ├── SineModulatorSpec.js ├── SynthMixinsSpec.js ├── SynthSpec.js ├── WaveFormSpec.js └── support │ └── jasmine.json └── tpl └── controls.html /.gitignore: -------------------------------------------------------------------------------- 1 | log 2 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Build: 2 | 3 | `npm run-script build` 4 | 5 | Test: 6 | 7 | `npm test` 8 | 9 | Demo: 10 | 11 | http://miroshko.github.io/Synzer 12 | -------------------------------------------------------------------------------- /css/controls.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Indie+Flower); 2 | 3 | .controls { 4 | width: 790px; 5 | font-family: 'Indie Flower'; 6 | font-size: 22px; 7 | } 8 | 9 | .col2-container, .col3-container, .col5-container, .col6-container { 10 | display: inline-block; 11 | margin-top: 10px; 12 | text-align: right; 13 | vertical-align: top; 14 | } 15 | 16 | .col2-container { 17 | width: 100px; 18 | } 19 | 20 | .col3-container { 21 | width: 150px; 22 | } 23 | 24 | .col5-container { 25 | width: 250px; 26 | } 27 | 28 | .col6-container { 29 | width: 300px; 30 | } 31 | 32 | 33 | 34 | .adsr-image { 35 | width: 120px; 36 | height: 90px; 37 | } 38 | 39 | .wave-form { 40 | display: inline-block; 41 | width: 80px; 42 | height: 80px; 43 | text-align: center; 44 | } 45 | 46 | .wave-form .img { 47 | display: inline-block; 48 | width: 50px; 49 | height: 50px; 50 | background-size: 100%; 51 | } 52 | 53 | .sine .img { 54 | background-image: url(../img/sine.png); 55 | } 56 | 57 | .square .img { 58 | background-image: url(../img/square.png); 59 | } 60 | 61 | .sawtooth .img { 62 | background-image: url(../img/sawtooth.png); 63 | } 64 | 65 | [type="number"] { 66 | width: 45px; 67 | padding: 5px; 68 | border: 0; 69 | background: url(../img/input.png) 50% 50% no-repeat; 70 | background-size: 100%; 71 | } 72 | 73 | h1, h2, h3 { 74 | margin: 0; 75 | } 76 | 77 | /* hide outline on focus */ 78 | input[type=text]:focus, 79 | input[type=number]:focus { 80 | outline: none; 81 | } 82 | 83 | /* hide radio input, not for wave-form inputs, yet */ 84 | label:not(.wave-form) input[type=radio] { 85 | opacity: 0; /* display:none breaks all */ 86 | } 87 | 88 | label span { 89 | cursor: pointer; 90 | } 91 | 92 | label:not(.wave-form) input[type=radio]:checked + span { 93 | font-weight: bold; 94 | } 95 | -------------------------------------------------------------------------------- /css/keyboard.css: -------------------------------------------------------------------------------- 1 | .key { 2 | display: inline-block; 3 | position: relative; 4 | vertical-align: top; 5 | width: 40px; 6 | height: 120px; 7 | margin: 0 -1px; 8 | background: url(../img/key.png); 9 | user-drag: none; 10 | -moz-user-select: none; 11 | -webkit-user-drag: none; 12 | } 13 | 14 | .key-black { 15 | z-index: 10; 16 | height: 72px; 17 | margin: 0 -20px 0 -20px; 18 | background: url(../img/key-black.png); 19 | } 20 | 21 | .pressed { 22 | top: 4px; 23 | } -------------------------------------------------------------------------------- /css/page.css: -------------------------------------------------------------------------------- 1 | .synzer { 2 | position: relative; 3 | top: 100px; 4 | width: 900px; 5 | margin: auto; 6 | } 7 | -------------------------------------------------------------------------------- /img/adsr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miroshko/Synzer/0161ccd100d41b4dc695c15929cd6eb4763dd6d9/img/adsr.png -------------------------------------------------------------------------------- /img/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miroshko/Synzer/0161ccd100d41b4dc695c15929cd6eb4763dd6d9/img/input.png -------------------------------------------------------------------------------- /img/key-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miroshko/Synzer/0161ccd100d41b4dc695c15929cd6eb4763dd6d9/img/key-black.png -------------------------------------------------------------------------------- /img/key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miroshko/Synzer/0161ccd100d41b4dc695c15929cd6eb4763dd6d9/img/key.png -------------------------------------------------------------------------------- /img/sawtooth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miroshko/Synzer/0161ccd100d41b4dc695c15929cd6eb4763dd6d9/img/sawtooth.png -------------------------------------------------------------------------------- /img/sine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miroshko/Synzer/0161ccd100d41b4dc695c15929cd6eb4763dd6d9/img/sine.png -------------------------------------------------------------------------------- /img/square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miroshko/Synzer/0161ccd100d41b4dc695c15929cd6eb4763dd6d9/img/square.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Synz. Javascript synthesizer 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 |
15 | 16 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /js/Controls.js: -------------------------------------------------------------------------------- 1 | var MediatorMixin = require('./MediatorMixin'); 2 | var template = require('../tpl/controls.html'); 3 | 4 | function Controls(el) { 5 | this.el = el; 6 | MediatorMixin.call(this); 7 | 8 | this.el.innerHTML = template; 9 | } 10 | 11 | Controls.prototype.activate = function() { 12 | var this_ = this; 13 | this.el.addEventListener('change', function(e) { 14 | this_.emit(e.target.name + '-change', e.target.value); 15 | }); 16 | 17 | // senging out initial values 18 | Array.prototype.forEach.call(this.el.querySelectorAll('[name]'), function(el) { 19 | if (el.type == 'radio' && el.checked === false) { 20 | return; 21 | } 22 | this_.emit(el.name + '-change', el.value); 23 | }); 24 | } 25 | 26 | module.exports = Controls; -------------------------------------------------------------------------------- /js/Keyboard.js: -------------------------------------------------------------------------------- 1 | var Note = require('./Note'); 2 | var MediatorMixin = require('./MediatorMixin'); 3 | 4 | function Keyboard(el) { 5 | this.el = el; 6 | this._keysPressed = {}; 7 | MediatorMixin.call(this); 8 | } 9 | 10 | Keyboard.prototype.draw = function(lowestNote, highestNote) { 11 | var key; 12 | this._keyEls = {}; 13 | for(var i = lowestNote; i <= highestNote; i++) { 14 | this._keyEls[i] = key = document.createElement('div'); 15 | key.dataset.pitch = i; 16 | key.classList.add('key'); 17 | if (['C#', 'D#', 'F#', 'G#', 'B'].indexOf(new Note(i).letter) > -1) { 18 | key.classList.add('key-black'); 19 | } 20 | this.el.appendChild(key); 21 | } 22 | }; 23 | 24 | Keyboard.prototype.press = function(pitch) { 25 | var el = this._keyEls[pitch]; 26 | el.classList.add('pressed'); 27 | this.emit('notePressed', new Note(el.dataset.pitch)); 28 | }; 29 | 30 | Keyboard.prototype.release = function(pitch) { 31 | var el = this._keyEls[pitch]; 32 | el.classList.remove('pressed'); 33 | this.emit('noteReleased', new Note(el.dataset.pitch)); 34 | }; 35 | 36 | Keyboard.prototype.startMouseListening = function() { 37 | this.el.addEventListener('mousedown', (e) => { 38 | if (!e.target.classList.contains('key')) 39 | return; 40 | this._mouseDown = true; 41 | this.press(e.target.dataset.pitch); 42 | }); 43 | 44 | this.el.addEventListener('mouseover', (e) => { 45 | if (!e.target.classList.contains('key')) 46 | return; 47 | if (this._mouseDown) { 48 | this.press(e.target.dataset.pitch); 49 | } 50 | }); 51 | 52 | this.el.addEventListener('mouseleave', (e) => { 53 | if (!e.target.classList.contains('key')) 54 | return; 55 | this._mouseDown = false; 56 | }); 57 | 58 | this.el.addEventListener('mouseout', (e) => { 59 | if (!e.target.classList.contains('key')) 60 | return; 61 | if (this._mouseDown) { 62 | this.release(e.target.dataset.pitch); 63 | } 64 | }); 65 | 66 | this.el.addEventListener('mouseup', (e) => { 67 | if (!e.target.classList.contains('key')) 68 | return; 69 | if (this._mouseDown) { 70 | this.release(e.target.dataset.pitch); 71 | } 72 | this._mouseDown = false; 73 | }); 74 | }; 75 | 76 | module.exports = Keyboard; 77 | -------------------------------------------------------------------------------- /js/KeyboardListener.js: -------------------------------------------------------------------------------- 1 | var MediatorMixin = require('./MediatorMixin'); 2 | 3 | const KEYCODE_TO_PITCH_MAP = { 4 | 81: 48, 5 | 50: 49, 6 | 87: 50, 7 | 51: 51, 8 | 69: 52, 9 | 82: 53, 10 | 53: 54, 11 | 84: 55, 12 | 54: 56, 13 | 90: 57, 14 | 55: 58, 15 | 85: 59, 16 | 73: 60, 17 | 57: 61, 18 | 79: 62, 19 | 48: 63, 20 | 80: 64, 21 | 186: 65, 22 | 65: 66, 23 | 89: 67, 24 | 83: 68, 25 | 88: 69, 26 | 68: 70, 27 | 67: 71, 28 | 86: 72, 29 | 71: 73, 30 | 66: 74, 31 | 72: 75, 32 | 78: 76, 33 | 77: 77, 34 | 75: 78, 35 | 188: 79, 36 | 76: 80, 37 | 190: 81, 38 | 192: 82, 39 | 189: 83 40 | }; 41 | 42 | KeyboardListener.prototype.KEYCODE_TO_PITCH_MAP; 43 | 44 | function KeyboardListener (options) { 45 | options = Object.assign({}, options); 46 | MediatorMixin.call(this); 47 | 48 | if (!options.startNote || options.startNote < 48) { 49 | throw new Error('startNote must be a number greater or equal than 48'); 50 | } 51 | 52 | if (!options.endNote || options.endNote > 83) { 53 | throw new Error('endNote must be a number less or equal than 83'); 54 | } 55 | 56 | this._options = options; 57 | this._buttonStatuses = {}; 58 | 59 | var emitPitch = (name) => (e) => { 60 | var pitch = KEYCODE_TO_PITCH_MAP[e.keyCode]; 61 | if (pitch && pitch >= this._options.startNote && pitch <= this._options.endNote && this._buttonStatuses[pitch] != name) { 62 | this.emit(name, pitch); 63 | } 64 | this._buttonStatuses[pitch] = name; 65 | } 66 | 67 | global.window.addEventListener('keydown', emitPitch('keyPressed')); 68 | global.window.addEventListener('keyup', emitPitch('keyReleased')); 69 | } 70 | 71 | module.exports = KeyboardListener; 72 | -------------------------------------------------------------------------------- /js/MediatorMixin.js: -------------------------------------------------------------------------------- 1 | function MediatorMixin() { 2 | this._events = {}; 3 | this.on = function(eventName, callback) { 4 | this._events[eventName] = this._events[eventName] || []; 5 | this._events[eventName].push(callback); 6 | }; 7 | 8 | this.emit = function(eventName) { 9 | var args = Array.prototype.slice.call(arguments, 1); 10 | 11 | if (this._events[eventName]) { 12 | this._events[eventName].forEach(function(callback) { 13 | callback.apply(null, args); 14 | }); 15 | } 16 | }; 17 | }; 18 | 19 | module.exports = MediatorMixin; -------------------------------------------------------------------------------- /js/MidiListener.js: -------------------------------------------------------------------------------- 1 | var MediatorMixin = require('./MediatorMixin'); 2 | 3 | MidiListener.prototype._emitMidiMessage = function(event) { 4 | var eventName = { 5 | 148: 'keyPressed', 6 | 132: 'keyReleased' 7 | }[event.data[0]]; 8 | this.emit(eventName, event.data[1]); 9 | }; 10 | 11 | function MidiListener () { 12 | MediatorMixin.call(this); 13 | 14 | if (window.navigator.requestMIDIAccess) { 15 | window.navigator.requestMIDIAccess({sysex:false}) 16 | .then((midiAccess) => { 17 | var midiInputs = midiAccess.inputs.values(); 18 | var input = midiInputs.next(); 19 | do { 20 | input.value.onmidimessage = (event) => this._emitMidiMessage(event); 21 | input = midiInputs.next(); 22 | } while(!input.done) 23 | }, () => console.log('Failed to get access to the MIDI device')); 24 | } else { 25 | console.log('MIDI API is not available in the browser'); 26 | } 27 | } 28 | 29 | module.exports = MidiListener; 30 | -------------------------------------------------------------------------------- /js/Note.js: -------------------------------------------------------------------------------- 1 | function Note(letterWithOctaveOrPitch) { 2 | if (!this._parsePitch(letterWithOctaveOrPitch) && !this._parseLetter(letterWithOctaveOrPitch)) { 3 | throw new Error('Can not parse ' + letterWithOctaveOrPitch); 4 | } 5 | } 6 | 7 | Note.prototype._NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'B', 'H']; 8 | 9 | Note.prototype._parsePitch = function(pitch) { 10 | // 21 == A0 11 | pitch = parseInt(pitch); 12 | if (isNaN(pitch) || pitch < 21 || pitch > 108) 13 | return false; 14 | 15 | this.letter = this._NOTES[(pitch - 21 + 9) % 12]; 16 | this.octave = Math.floor((pitch - 12) / 12); 17 | this.pitch = pitch; 18 | this.frequency = this._freq(this.pitch); 19 | return true; 20 | }; 21 | 22 | Note.prototype._parseLetter = function(letterOctave) { 23 | var match = letterOctave.match(/([ABCDEFGH]#?)(\d+)/); 24 | if (!match.length) 25 | return false; 26 | this.letter = match[1]; 27 | this.octave = parseInt(match[2]); 28 | this.pitch = this._NOTES.indexOf(this.letter) + 12 * (this.octave + 1); 29 | this.frequency = this._freq(this.pitch); 30 | return true; 31 | }; 32 | 33 | Note.prototype._freq = function(pitch) { 34 | return Math.pow(2, (pitch - 20 - 49) / 12) * 440; 35 | }; 36 | 37 | module.exports = Note; -------------------------------------------------------------------------------- /js/SineModulator.js: -------------------------------------------------------------------------------- 1 | function SineModulator (options) { 2 | options = options || {}; 3 | this._frequency = options.frequency || 0; 4 | this._phaseOffset = 0; 5 | this._startedAt = 0; 6 | this._interval = null; 7 | this._prevValue = 0; 8 | this.depth = options.depth || 0; 9 | 10 | Object.defineProperty(this, "frequency", { 11 | set: function (frequency) { 12 | // the offset is needed in order to have seamless 13 | // transition between different frequencies 14 | frequency = parseFloat(frequency); 15 | this._phaseOffset = this._phaseNow(); 16 | this._startedAt = Date.now(); 17 | this._frequency = frequency; 18 | }, 19 | get: function() { 20 | return this._frequency; 21 | } 22 | }); 23 | } 24 | 25 | SineModulator.prototype.modulate = function(object, property) { 26 | this._objToModulate = object; 27 | this._propertyToModulate = property; 28 | return this; 29 | }; 30 | 31 | SineModulator.prototype.start = function() { 32 | this._startedAt = Date.now(); 33 | var this_ = this; 34 | this._interval = setInterval(function() { 35 | var value = this_._modValueNow(); 36 | var diff = value - this_._prevValue; 37 | this_._objToModulate[this_._propertyToModulate] += diff; 38 | this_._prevValue = value; 39 | }, 10); 40 | }; 41 | 42 | SineModulator.prototype._phaseNow = function() { 43 | var timeDiff = (Date.now() - this._startedAt) / 1000; 44 | var phase = this._phaseOffset + timeDiff * this.frequency % 1; 45 | return phase; 46 | }; 47 | 48 | SineModulator.prototype._modValueNow = function() { 49 | var phase = this._phaseNow(); 50 | return Math.sin((phase) * 2 * Math.PI) * this.depth; 51 | }; 52 | 53 | SineModulator.prototype.stop = function() { 54 | clearInterval(this._interval); 55 | } 56 | 57 | module.exports = SineModulator; 58 | -------------------------------------------------------------------------------- /js/Synth.js: -------------------------------------------------------------------------------- 1 | var WaveForm = require('./synthMixins/WaveForm') 2 | var PitchShifter = require('./synthMixins/PitchShifter') 3 | var ADSR = require('./synthMixins/ADSR') 4 | 5 | function Synth(context) { 6 | this.audioContext = context; 7 | this.output = context.createGain(); 8 | 9 | this._oscillators = {}; 10 | 11 | WaveForm.apply(this, arguments); 12 | PitchShifter.apply(this, arguments); 13 | ADSR.apply(this, arguments); 14 | } 15 | 16 | Synth.prototype.play = function(note) { 17 | var oscillator; 18 | 19 | oscillator = this._oscillators[note.pitch]; 20 | if (oscillator) { 21 | this.stop(note); 22 | } 23 | 24 | oscillator = this._oscillators[note.pitch] = this.audioContext.createOscillator(); 25 | oscillator.frequency.value = note.frequency; 26 | oscillator.connect(this.output); 27 | oscillator.start(0); 28 | return oscillator; 29 | }; 30 | 31 | Synth.prototype.stop = function(note) { 32 | this._oscillators[note.pitch].disconnect(this.output); 33 | this._oscillators[note.pitch].stop(0); 34 | delete this._oscillators[note.pitch]; 35 | }; 36 | 37 | Synth.prototype.connect = function(output) { 38 | this.output.connect(output); 39 | }; 40 | 41 | module.exports = Synth; 42 | -------------------------------------------------------------------------------- /js/app.js: -------------------------------------------------------------------------------- 1 | var ScreenKeyboard = require('./Keyboard'); 2 | var KeyboardListener = require('./KeyboardListener'); 3 | var MidiListener = require('./MidiListener'); 4 | var Controls = require('./Controls'); 5 | var Synth = require('./Synth'); 6 | var Delay = require('./effects/Delay'); 7 | var SineModulator = require('./SineModulator'); 8 | 9 | var audioCtx = new global.AudioContext(); 10 | var synth = new Synth(audioCtx); 11 | var volume = audioCtx.createGain(); 12 | var pan = audioCtx.createStereoPanner(); 13 | var delay = new Delay(audioCtx); 14 | 15 | synth.connect(volume); 16 | volume.connect(delay.input); 17 | delay.connect(pan); 18 | pan.connect(audioCtx.destination); 19 | 20 | var tremolo = new SineModulator().modulate(volume.gain, 'value'); 21 | var vibrato = new SineModulator().modulate(synth, 'pitchShift'); 22 | var controls = new Controls(document.querySelector('.controls')); 23 | 24 | controls.on('wave-form-change', function(type) { 25 | synth.waveForm = type; 26 | }); 27 | 28 | controls.on('volume-change', function(value) { 29 | volume.gain.value = value; 30 | }); 31 | 32 | controls.on('pan-change', function(value) { 33 | pan.pan.value = value; 34 | }); 35 | 36 | controls.on('tremolo-on-change', function(value) { 37 | parseInt(value) ? tremolo.start() : tremolo.stop(); 38 | }); 39 | 40 | controls.on('tremolo-depth-change', function(value) { 41 | tremolo.depth = value; 42 | }); 43 | 44 | controls.on('tremolo-freq-change', function(value) { 45 | tremolo.frequency = value; 46 | }); 47 | 48 | controls.on('vibrato-on-change', function(value) { 49 | parseInt(value) ? vibrato.start() : vibrato.stop(); 50 | }); 51 | 52 | controls.on('vibrato-depth-change', function(value) { 53 | vibrato.depth = value; 54 | }); 55 | 56 | controls.on('vibrato-freq-change', function(value) { 57 | vibrato.frequency = value; 58 | }); 59 | 60 | controls.on('delay-on-change', function(value) { 61 | parseInt(value) ? delay.start() : delay.stop(); 62 | }); 63 | 64 | controls.on('delay-feedback-change', function(value) { 65 | delay.feedback = value; 66 | }); 67 | 68 | controls.on('delay-taps-change', function(value) { 69 | delay.taps = value; 70 | }); 71 | 72 | controls.on('delay-latency-change', function(value) { 73 | delay.latency = value; 74 | }); 75 | 76 | controls.on('delay-latency-change', function(value) { 77 | delay.latency = value; 78 | }); 79 | 80 | controls.on('adsr-a-change', function(value) { 81 | synth.ADSR.A = value; 82 | }); 83 | 84 | controls.on('adsr-d-change', function(value) { 85 | synth.ADSR.D = value; 86 | }); 87 | 88 | controls.on('adsr-s-change', function(value) { 89 | synth.ADSR.S = value; 90 | }); 91 | 92 | controls.on('adsr-r-change', function(value) { 93 | synth.ADSR.R = value; 94 | }); 95 | 96 | controls.activate(); 97 | 98 | var screenKeyboard = new ScreenKeyboard(document.querySelector('.keyboard')); 99 | screenKeyboard.draw(48, 83); 100 | screenKeyboard.startMouseListening(); 101 | 102 | screenKeyboard.on('notePressed', function(note) { 103 | synth.play(note); 104 | }); 105 | 106 | screenKeyboard.on('noteReleased', function(note) { 107 | synth.stop(note); 108 | }); 109 | 110 | var keyboardListener = new KeyboardListener({startNote: 48, endNote: 83}); 111 | keyboardListener.on('keyPressed', (pitch) => screenKeyboard.press(pitch)); 112 | keyboardListener.on('keyReleased', (pitch) => screenKeyboard.release(pitch)); 113 | 114 | var midiListener = new MidiListener({startNote: 48, endNote: 83}); 115 | midiListener.on('keyPressed', (pitch) => screenKeyboard.press(pitch)); 116 | midiListener.on('keyReleased', (pitch) => screenKeyboard.release(pitch)); 117 | -------------------------------------------------------------------------------- /js/effects/Delay.js: -------------------------------------------------------------------------------- 1 | function Delay(audioCtx) { 2 | this._audioCtx = audioCtx; 3 | this.input = audioCtx.createGain(); 4 | this._delayLines = []; 5 | this._gainNodes = []; 6 | this._delayLinesInput = audioCtx.createGain(); 7 | this._output = audioCtx.createGain(); 8 | 9 | this._taps = 0; 10 | this._latency = 0; 11 | this._feedback = 0; 12 | 13 | Object.defineProperty(this, "feedback", { 14 | set: function (freq) { 15 | this._feedback = freq; 16 | this._applyParams(); 17 | }, 18 | get: function() { 19 | return this._feedback; 20 | } 21 | }); 22 | 23 | Object.defineProperty(this, "latency", { 24 | set: function (freq) { 25 | this._latency = freq; 26 | this._applyParams(); 27 | }, 28 | get: function() { 29 | return this._latency; 30 | } 31 | }); 32 | 33 | Object.defineProperty(this, "taps", { 34 | set: function (value) { 35 | var prevTaps = this._taps; 36 | var diff = value - this._taps; 37 | for(var i = 0; i < diff; i++) { 38 | diff < 0 ? this._popTap() : this._pushTap(); 39 | } 40 | this._taps = value; 41 | }, 42 | get: function() { 43 | return this._taps; 44 | } 45 | }); 46 | 47 | this.input.connect(this._output); 48 | } 49 | 50 | Delay.prototype._applyParams = function() { 51 | for(var i = 0; i < this._delayLines.length; i++) { 52 | this._delayLines[i].delayTime.value = this._latency / 1000 * (i + 1); 53 | this._gainNodes[i].gain.value = Math.pow(this._feedback, (1 + i)) 54 | } 55 | }; 56 | 57 | Delay.prototype._pushTap = function() { 58 | var delay = this._audioCtx.createDelay(10.0); 59 | this._delayLines.push(delay); 60 | 61 | var gainNode = this._audioCtx.createGain(); 62 | this._gainNodes.push(gainNode); 63 | 64 | gainNode.connect(this._output); 65 | delay.connect(gainNode); 66 | this._delayLinesInput.connect(delay); 67 | }; 68 | 69 | Delay.prototype._popTap = function() { 70 | var lastDelayLine = this._delayLines.pop(); 71 | var lastGainNode = this._gainNodes.pop(); 72 | 73 | lastDelayLine.disconnect(lastGainNode); 74 | lastGainNode.disconnect(this._output); 75 | this._delayLinesInput.disconnect(lastDelayLine); 76 | }; 77 | 78 | Delay.prototype.start = function() { 79 | if (!this._started) { 80 | this.input.connect(this._delayLinesInput); 81 | this._started = true; 82 | } 83 | } 84 | 85 | Delay.prototype.stop = function() { 86 | if (this._started) { 87 | this.input.disconnect(this._delayLinesInput); 88 | this._started = false; 89 | } 90 | }; 91 | 92 | Delay.prototype.connect = function(target) { 93 | this._output.connect(target); 94 | }; 95 | 96 | module.exports = Delay; 97 | -------------------------------------------------------------------------------- /js/prod/app.js: -------------------------------------------------------------------------------- 1 | (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o -1) { 46 | key.classList.add('key-black'); 47 | } 48 | this.el.appendChild(key); 49 | } 50 | }; 51 | 52 | Keyboard.prototype.press = function(pitch) { 53 | var el = this._keyEls[pitch]; 54 | el.classList.add('pressed'); 55 | this.emit('notePressed', new Note(el.dataset.pitch)); 56 | }; 57 | 58 | Keyboard.prototype.release = function(pitch) { 59 | var el = this._keyEls[pitch]; 60 | el.classList.remove('pressed'); 61 | this.emit('noteReleased', new Note(el.dataset.pitch)); 62 | }; 63 | 64 | Keyboard.prototype.startMouseListening = function() { 65 | this.el.addEventListener('mousedown', (e) => { 66 | if (!e.target.classList.contains('key')) 67 | return; 68 | this._mouseDown = true; 69 | this.press(e.target.dataset.pitch); 70 | }); 71 | 72 | this.el.addEventListener('mouseover', (e) => { 73 | if (!e.target.classList.contains('key')) 74 | return; 75 | if (this._mouseDown) { 76 | this.press(e.target.dataset.pitch); 77 | } 78 | }); 79 | 80 | this.el.addEventListener('mouseleave', (e) => { 81 | if (!e.target.classList.contains('key')) 82 | return; 83 | this._mouseDown = false; 84 | }); 85 | 86 | this.el.addEventListener('mouseout', (e) => { 87 | if (!e.target.classList.contains('key')) 88 | return; 89 | if (this._mouseDown) { 90 | this.release(e.target.dataset.pitch); 91 | } 92 | }); 93 | 94 | this.el.addEventListener('mouseup', (e) => { 95 | if (!e.target.classList.contains('key')) 96 | return; 97 | if (this._mouseDown) { 98 | this.release(e.target.dataset.pitch); 99 | } 100 | this._mouseDown = false; 101 | }); 102 | }; 103 | 104 | module.exports = Keyboard; 105 | 106 | },{"./MediatorMixin":4,"./Note":6}],3:[function(require,module,exports){ 107 | (function (global){ 108 | var MediatorMixin = require('./MediatorMixin'); 109 | 110 | const KEYCODE_TO_PITCH_MAP = { 111 | 81: 48, 112 | 50: 49, 113 | 87: 50, 114 | 51: 51, 115 | 69: 52, 116 | 82: 53, 117 | 53: 54, 118 | 84: 55, 119 | 54: 56, 120 | 90: 57, 121 | 55: 58, 122 | 85: 59, 123 | 73: 60, 124 | 57: 61, 125 | 79: 62, 126 | 48: 63, 127 | 80: 64, 128 | 186: 65, 129 | 65: 66, 130 | 89: 67, 131 | 83: 68, 132 | 88: 69, 133 | 68: 70, 134 | 67: 71, 135 | 86: 72, 136 | 71: 73, 137 | 66: 74, 138 | 72: 75, 139 | 78: 76, 140 | 77: 77, 141 | 75: 78, 142 | 188: 79, 143 | 76: 80, 144 | 190: 81, 145 | 192: 82, 146 | 189: 83 147 | }; 148 | 149 | KeyboardListener.prototype.KEYCODE_TO_PITCH_MAP; 150 | 151 | function KeyboardListener (options) { 152 | options = Object.assign({}, options); 153 | MediatorMixin.call(this); 154 | 155 | if (!options.startNote || options.startNote < 48) { 156 | throw new Error('startNote must be a number greater or equal than 48'); 157 | } 158 | 159 | if (!options.endNote || options.endNote > 83) { 160 | throw new Error('endNote must be a number less or equal than 83'); 161 | } 162 | 163 | this._options = options; 164 | this._buttonStatuses = {}; 165 | 166 | var emitPitch = (name) => (e) => { 167 | var pitch = KEYCODE_TO_PITCH_MAP[e.keyCode]; 168 | if (pitch && pitch >= this._options.startNote && pitch <= this._options.endNote && this._buttonStatuses[pitch] != name) { 169 | this.emit(name, pitch); 170 | } 171 | this._buttonStatuses[pitch] = name; 172 | } 173 | 174 | global.window.addEventListener('keydown', emitPitch('keyPressed')); 175 | global.window.addEventListener('keyup', emitPitch('keyReleased')); 176 | } 177 | 178 | module.exports = KeyboardListener; 179 | 180 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 181 | },{"./MediatorMixin":4}],4:[function(require,module,exports){ 182 | function MediatorMixin() { 183 | this._events = {}; 184 | this.on = function(eventName, callback) { 185 | this._events[eventName] = this._events[eventName] || []; 186 | this._events[eventName].push(callback); 187 | }; 188 | 189 | this.emit = function(eventName) { 190 | var args = Array.prototype.slice.call(arguments, 1); 191 | 192 | if (this._events[eventName]) { 193 | this._events[eventName].forEach(function(callback) { 194 | callback.apply(null, args); 195 | }); 196 | } 197 | }; 198 | }; 199 | 200 | module.exports = MediatorMixin; 201 | },{}],5:[function(require,module,exports){ 202 | var MediatorMixin = require('./MediatorMixin'); 203 | 204 | MidiListener.prototype._emitMidiMessage = function(event) { 205 | var eventName = { 206 | 148: 'keyPressed', 207 | 132: 'keyReleased' 208 | }[event.data[0]]; 209 | this.emit(eventName, event.data[1]); 210 | }; 211 | 212 | function MidiListener () { 213 | MediatorMixin.call(this); 214 | 215 | if (window.navigator.requestMIDIAccess) { 216 | window.navigator.requestMIDIAccess({sysex:false}) 217 | .then((midiAccess) => { 218 | var midiInputs = midiAccess.inputs.values(); 219 | var input = midiInputs.next(); 220 | do { 221 | input.value.onmidimessage = (event) => this._emitMidiMessage(event); 222 | input = midiInputs.next(); 223 | } while(!input.done) 224 | }, () => console.log('Failed to get access to the MIDI device')); 225 | } else { 226 | console.log('MIDI API is not available in the browser'); 227 | } 228 | } 229 | 230 | module.exports = MidiListener; 231 | 232 | },{"./MediatorMixin":4}],6:[function(require,module,exports){ 233 | function Note(letterWithOctaveOrPitch) { 234 | if (!this._parsePitch(letterWithOctaveOrPitch) && !this._parseLetter(letterWithOctaveOrPitch)) { 235 | throw new Error('Can not parse ' + letterWithOctaveOrPitch); 236 | } 237 | } 238 | 239 | Note.prototype._NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'B', 'H']; 240 | 241 | Note.prototype._parsePitch = function(pitch) { 242 | // 21 == A0 243 | pitch = parseInt(pitch); 244 | if (isNaN(pitch) || pitch < 21 || pitch > 108) 245 | return false; 246 | 247 | this.letter = this._NOTES[(pitch - 21 + 9) % 12]; 248 | this.octave = Math.floor((pitch - 12) / 12); 249 | this.pitch = pitch; 250 | this.frequency = this._freq(this.pitch); 251 | return true; 252 | }; 253 | 254 | Note.prototype._parseLetter = function(letterOctave) { 255 | var match = letterOctave.match(/([ABCDEFGH]#?)(\d+)/); 256 | if (!match.length) 257 | return false; 258 | this.letter = match[1]; 259 | this.octave = parseInt(match[2]); 260 | this.pitch = this._NOTES.indexOf(this.letter) + 12 * (this.octave + 1); 261 | this.frequency = this._freq(this.pitch); 262 | return true; 263 | }; 264 | 265 | Note.prototype._freq = function(pitch) { 266 | return Math.pow(2, (pitch - 20 - 49) / 12) * 440; 267 | }; 268 | 269 | module.exports = Note; 270 | },{}],7:[function(require,module,exports){ 271 | function SineModulator (options) { 272 | options = options || {}; 273 | this._frequency = options.frequency || 0; 274 | this._phaseOffset = 0; 275 | this._startedAt = 0; 276 | this._interval = null; 277 | this._prevValue = 0; 278 | this.depth = options.depth || 0; 279 | 280 | Object.defineProperty(this, "frequency", { 281 | set: function (frequency) { 282 | // the offset is needed in order to have seamless 283 | // transition between different frequencies 284 | frequency = parseFloat(frequency); 285 | this._phaseOffset = this._phaseNow(); 286 | this._startedAt = Date.now(); 287 | this._frequency = frequency; 288 | }, 289 | get: function() { 290 | return this._frequency; 291 | } 292 | }); 293 | } 294 | 295 | SineModulator.prototype.modulate = function(object, property) { 296 | this._objToModulate = object; 297 | this._propertyToModulate = property; 298 | return this; 299 | }; 300 | 301 | SineModulator.prototype.start = function() { 302 | this._startedAt = Date.now(); 303 | var this_ = this; 304 | this._interval = setInterval(function() { 305 | var value = this_._modValueNow(); 306 | var diff = value - this_._prevValue; 307 | this_._objToModulate[this_._propertyToModulate] += diff; 308 | this_._prevValue = value; 309 | }, 10); 310 | }; 311 | 312 | SineModulator.prototype._phaseNow = function() { 313 | var timeDiff = (Date.now() - this._startedAt) / 1000; 314 | var phase = this._phaseOffset + timeDiff * this.frequency % 1; 315 | return phase; 316 | }; 317 | 318 | SineModulator.prototype._modValueNow = function() { 319 | var phase = this._phaseNow(); 320 | return Math.sin((phase) * 2 * Math.PI) * this.depth; 321 | }; 322 | 323 | SineModulator.prototype.stop = function() { 324 | clearInterval(this._interval); 325 | } 326 | 327 | module.exports = SineModulator; 328 | 329 | },{}],8:[function(require,module,exports){ 330 | var WaveForm = require('./synthMixins/WaveForm') 331 | var PitchShifter = require('./synthMixins/PitchShifter') 332 | var ADSR = require('./synthMixins/ADSR') 333 | 334 | function Synth(context) { 335 | this.audioContext = context; 336 | this.output = context.createGain(); 337 | 338 | this._oscillators = {}; 339 | 340 | WaveForm.apply(this, arguments); 341 | PitchShifter.apply(this, arguments); 342 | ADSR.apply(this, arguments); 343 | } 344 | 345 | Synth.prototype.play = function(note) { 346 | var oscillator; 347 | 348 | oscillator = this._oscillators[note.pitch]; 349 | if (oscillator) { 350 | this.stop(note); 351 | } 352 | 353 | oscillator = this._oscillators[note.pitch] = this.audioContext.createOscillator(); 354 | oscillator.frequency.value = note.frequency; 355 | oscillator.connect(this.output); 356 | oscillator.start(0); 357 | return oscillator; 358 | }; 359 | 360 | Synth.prototype.stop = function(note) { 361 | this._oscillators[note.pitch].disconnect(this.output); 362 | this._oscillators[note.pitch].stop(0); 363 | delete this._oscillators[note.pitch]; 364 | }; 365 | 366 | Synth.prototype.connect = function(output) { 367 | this.output.connect(output); 368 | }; 369 | 370 | module.exports = Synth; 371 | 372 | },{"./synthMixins/ADSR":11,"./synthMixins/PitchShifter":12,"./synthMixins/WaveForm":13}],9:[function(require,module,exports){ 373 | (function (global){ 374 | var ScreenKeyboard = require('./Keyboard'); 375 | var KeyboardListener = require('./KeyboardListener'); 376 | var MidiListener = require('./MidiListener'); 377 | var Controls = require('./Controls'); 378 | var Synth = require('./Synth'); 379 | var Delay = require('./effects/Delay'); 380 | var SineModulator = require('./SineModulator'); 381 | 382 | var audioCtx = new global.AudioContext(); 383 | var synth = new Synth(audioCtx); 384 | var volume = audioCtx.createGain(); 385 | var pan = audioCtx.createStereoPanner(); 386 | var delay = new Delay(audioCtx); 387 | 388 | synth.connect(volume); 389 | volume.connect(delay.input); 390 | delay.connect(pan); 391 | pan.connect(audioCtx.destination); 392 | 393 | var tremolo = new SineModulator().modulate(volume.gain, 'value'); 394 | var vibrato = new SineModulator().modulate(synth, 'pitchShift'); 395 | var controls = new Controls(document.querySelector('.controls')); 396 | 397 | controls.on('wave-form-change', function(type) { 398 | synth.waveForm = type; 399 | }); 400 | 401 | controls.on('volume-change', function(value) { 402 | volume.gain.value = value; 403 | }); 404 | 405 | controls.on('pan-change', function(value) { 406 | pan.pan.value = value; 407 | }); 408 | 409 | controls.on('tremolo-on-change', function(value) { 410 | parseInt(value) ? tremolo.start() : tremolo.stop(); 411 | }); 412 | 413 | controls.on('tremolo-depth-change', function(value) { 414 | tremolo.depth = value; 415 | }); 416 | 417 | controls.on('tremolo-freq-change', function(value) { 418 | tremolo.frequency = value; 419 | }); 420 | 421 | controls.on('vibrato-on-change', function(value) { 422 | parseInt(value) ? vibrato.start() : vibrato.stop(); 423 | }); 424 | 425 | controls.on('vibrato-depth-change', function(value) { 426 | vibrato.depth = value; 427 | }); 428 | 429 | controls.on('vibrato-freq-change', function(value) { 430 | vibrato.frequency = value; 431 | }); 432 | 433 | controls.on('delay-on-change', function(value) { 434 | parseInt(value) ? delay.start() : delay.stop(); 435 | }); 436 | 437 | controls.on('delay-feedback-change', function(value) { 438 | delay.feedback = value; 439 | }); 440 | 441 | controls.on('delay-taps-change', function(value) { 442 | delay.taps = value; 443 | }); 444 | 445 | controls.on('delay-latency-change', function(value) { 446 | delay.latency = value; 447 | }); 448 | 449 | controls.on('delay-latency-change', function(value) { 450 | delay.latency = value; 451 | }); 452 | 453 | controls.on('adsr-a-change', function(value) { 454 | synth.ADSR.A = value; 455 | }); 456 | 457 | controls.on('adsr-d-change', function(value) { 458 | synth.ADSR.D = value; 459 | }); 460 | 461 | controls.on('adsr-s-change', function(value) { 462 | synth.ADSR.S = value; 463 | }); 464 | 465 | controls.on('adsr-r-change', function(value) { 466 | synth.ADSR.R = value; 467 | }); 468 | 469 | controls.activate(); 470 | 471 | var screenKeyboard = new ScreenKeyboard(document.querySelector('.keyboard')); 472 | screenKeyboard.draw(48, 83); 473 | screenKeyboard.startMouseListening(); 474 | 475 | screenKeyboard.on('notePressed', function(note) { 476 | synth.play(note); 477 | }); 478 | 479 | screenKeyboard.on('noteReleased', function(note) { 480 | synth.stop(note); 481 | }); 482 | 483 | var keyboardListener = new KeyboardListener({startNote: 48, endNote: 83}); 484 | keyboardListener.on('keyPressed', (pitch) => screenKeyboard.press(pitch)); 485 | keyboardListener.on('keyReleased', (pitch) => screenKeyboard.release(pitch)); 486 | 487 | var midiListener = new MidiListener({startNote: 48, endNote: 83}); 488 | midiListener.on('keyPressed', (pitch) => screenKeyboard.press(pitch)); 489 | midiListener.on('keyReleased', (pitch) => screenKeyboard.release(pitch)); 490 | 491 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 492 | },{"./Controls":1,"./Keyboard":2,"./KeyboardListener":3,"./MidiListener":5,"./SineModulator":7,"./Synth":8,"./effects/Delay":10}],10:[function(require,module,exports){ 493 | function Delay(audioCtx) { 494 | this._audioCtx = audioCtx; 495 | this.input = audioCtx.createGain(); 496 | this._delayLines = []; 497 | this._gainNodes = []; 498 | this._delayLinesInput = audioCtx.createGain(); 499 | this._output = audioCtx.createGain(); 500 | 501 | this._taps = 0; 502 | this._latency = 0; 503 | this._feedback = 0; 504 | 505 | Object.defineProperty(this, "feedback", { 506 | set: function (freq) { 507 | this._feedback = freq; 508 | this._applyParams(); 509 | }, 510 | get: function() { 511 | return this._feedback; 512 | } 513 | }); 514 | 515 | Object.defineProperty(this, "latency", { 516 | set: function (freq) { 517 | this._latency = freq; 518 | this._applyParams(); 519 | }, 520 | get: function() { 521 | return this._latency; 522 | } 523 | }); 524 | 525 | Object.defineProperty(this, "taps", { 526 | set: function (value) { 527 | var prevTaps = this._taps; 528 | var diff = value - this._taps; 529 | for(var i = 0; i < diff; i++) { 530 | diff < 0 ? this._popTap() : this._pushTap(); 531 | } 532 | this._taps = value; 533 | }, 534 | get: function() { 535 | return this._taps; 536 | } 537 | }); 538 | 539 | this.input.connect(this._output); 540 | } 541 | 542 | Delay.prototype._applyParams = function() { 543 | for(var i = 0; i < this._delayLines.length; i++) { 544 | this._delayLines[i].delayTime.value = this._latency / 1000 * (i + 1); 545 | this._gainNodes[i].gain.value = Math.pow(this._feedback, (1 + i)) 546 | } 547 | }; 548 | 549 | Delay.prototype._pushTap = function() { 550 | var delay = this._audioCtx.createDelay(10.0); 551 | this._delayLines.push(delay); 552 | 553 | var gainNode = this._audioCtx.createGain(); 554 | this._gainNodes.push(gainNode); 555 | 556 | gainNode.connect(this._output); 557 | delay.connect(gainNode); 558 | this._delayLinesInput.connect(delay); 559 | }; 560 | 561 | Delay.prototype._popTap = function() { 562 | var lastDelayLine = this._delayLines.pop(); 563 | var lastGainNode = this._gainNodes.pop(); 564 | 565 | lastDelayLine.disconnect(lastGainNode); 566 | lastGainNode.disconnect(this._output); 567 | this._delayLinesInput.disconnect(lastDelayLine); 568 | }; 569 | 570 | Delay.prototype.start = function() { 571 | if (!this._started) { 572 | this.input.connect(this._delayLinesInput); 573 | this._started = true; 574 | } 575 | } 576 | 577 | Delay.prototype.stop = function() { 578 | if (this._started) { 579 | this.input.disconnect(this._delayLinesInput); 580 | this._started = false; 581 | } 582 | }; 583 | 584 | Delay.prototype.connect = function(target) { 585 | this._output.connect(target); 586 | }; 587 | 588 | module.exports = Delay; 589 | 590 | },{}],11:[function(require,module,exports){ 591 | function ADSR() { 592 | this.ADSR = { 593 | A: null, 594 | D: null, 595 | S: null, 596 | R: null 597 | }; 598 | 599 | var oscillators = {}; 600 | var gainNodes = {}; 601 | var asdIntervals = {}; 602 | var rIntervals = {}; 603 | 604 | var old = { 605 | play: this.play, 606 | stop: this.stop 607 | }; 608 | 609 | this.play = function(note) { 610 | var gain = gainNodes[note.pitch]; 611 | if (!gain) { 612 | gain = gainNodes[note.pitch] = this.audioContext.createGain(); 613 | gain.connect(this.output); 614 | gain.gain.value = 0; 615 | } 616 | 617 | var startedAt = Date.now(); 618 | var startedAtGain = gain.gain.value; 619 | 620 | if (oscillators[note.pitch]) { 621 | this._finalize(note); 622 | } 623 | 624 | var osc = oscillators[note.pitch] = old.play.call(this, note); 625 | osc.disconnect(this.output); 626 | osc.connect(gain); 627 | 628 | this.ADSR.A = parseInt(this.ADSR.A); 629 | this.ADSR.D = parseInt(this.ADSR.D); 630 | this.ADSR.S = parseFloat(this.ADSR.S); 631 | this.ADSR.R = parseInt(this.ADSR.R); 632 | 633 | asdIntervals[note.pitch] = setInterval(() => { 634 | var diff = Date.now() - startedAt; 635 | if (diff < this.ADSR.A) { 636 | gain.gain.value = startedAtGain + (1 - startedAtGain) * (diff / this.ADSR.A); 637 | } else if (diff < this.ADSR.A + this.ADSR.D) { 638 | gain.gain.value = 1 - (diff - this.ADSR.A) / (this.ADSR.D / (1 - this.ADSR.S)); 639 | } else { 640 | gain.gain.value = this.ADSR.S; 641 | clearInterval(asdIntervals[note.pitch]); 642 | } 643 | }, 10); 644 | 645 | return osc; 646 | }; 647 | 648 | this._finalize = function(note) { 649 | var osc = oscillators[note.pitch]; 650 | var gain = gainNodes[note.pitch]; 651 | clearInterval(rIntervals[note.pitch]); 652 | gain.gain.value = 0; 653 | osc.disconnect(gain); 654 | osc.connect(this.output); 655 | delete oscillators[note.pitch]; 656 | old.stop.apply(this, arguments); 657 | } 658 | 659 | this.stop = function(note) { 660 | var releasedAt = Date.now(); 661 | var this_ = this; 662 | var arguments_ = arguments; 663 | var gain = gainNodes[note.pitch]; 664 | var gainOnRelease = gain.gain.value; 665 | rIntervals[note.pitch] = setInterval(() => { 666 | var diff = Date.now() - releasedAt; 667 | if (diff < this_.ADSR.R) { 668 | gain.gain.value = gainOnRelease * (1 - diff / this_.ADSR.R); 669 | } else { 670 | this._finalize(note); 671 | } 672 | }, 10); 673 | }; 674 | } 675 | 676 | module.exports = ADSR; 677 | 678 | },{}],12:[function(require,module,exports){ 679 | function PitchShifter() { 680 | this._pitchShift = 0; 681 | var oscillators = {}; 682 | 683 | Object.defineProperty(this, "pitchShift", { 684 | set: function (ps) { 685 | this._pitchShift = ps; 686 | for(var pitch in oscillators) { 687 | oscillators[pitch].frequency.value = 688 | oscillators[pitch].baseFrequency * Math.pow(2, this._pitchShift/1200); 689 | } 690 | }, 691 | get: function() { 692 | return this._pitchShift; 693 | } 694 | }); 695 | 696 | var old = { 697 | play: this.play, 698 | stop: this.stop 699 | }; 700 | 701 | this.play = function(note) { 702 | var osc = oscillators[note.pitch] = old.play.call(this, note); 703 | osc.baseFrequency = note.frequency; 704 | osc.frequency.value = osc.baseFrequency * Math.pow(2, this._pitchShift/1200); 705 | return osc; 706 | }; 707 | 708 | this.stop = function(note) { 709 | delete oscillators[note.pitch]; 710 | old.stop.apply(this, arguments); 711 | }; 712 | 713 | } 714 | 715 | module.exports = PitchShifter; 716 | },{}],13:[function(require,module,exports){ 717 | var sawtooth = require('../waveforms/sawtooth'); 718 | var square = require('../waveforms/square'); 719 | var sine = require('../waveforms/sine'); 720 | 721 | function WaveForm() { 722 | Object.defineProperty(this, "waveForm", { 723 | set: function(waveForm) { 724 | this._waveForm = { 725 | 'sawtooth': sawtooth, 726 | 'square': square, 727 | 'sine': sine 728 | }[waveForm]; 729 | }, 730 | get: function() { 731 | return this._waveForm; 732 | } 733 | }); 734 | 735 | var old = { 736 | play: this.play, 737 | stop: this.stop 738 | }; 739 | 740 | this.play = function() { 741 | var osc = old.play.apply(this, arguments); 742 | osc.setPeriodicWave(this._waveForm || sine); 743 | return osc; 744 | } 745 | 746 | this.stop = function() { 747 | old.stop.apply(this, arguments); 748 | } 749 | } 750 | 751 | module.exports = WaveForm; 752 | },{"../waveforms/sawtooth":14,"../waveforms/sine":15,"../waveforms/square":16}],14:[function(require,module,exports){ 753 | (function (global){ 754 | var context = new global.AudioContext(); 755 | var steps = 128; 756 | var real = new global.Float32Array(steps); 757 | var imag = new global.Float32Array(steps); 758 | 759 | for (var i = 1; i < steps; i++) { 760 | imag[i] = 1 / (i * Math.PI); 761 | } 762 | 763 | var wave = context.createPeriodicWave(real, imag); 764 | 765 | module.exports = wave; 766 | 767 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 768 | },{}],15:[function(require,module,exports){ 769 | (function (global){ 770 | var context = new global.AudioContext(); 771 | var realCoeffs = new global.Float32Array([0,0]); 772 | var imagCoeffs = new global.Float32Array([0,1]); 773 | var wave = context.createPeriodicWave(realCoeffs, imagCoeffs); 774 | 775 | module.exports = wave; 776 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 777 | },{}],16:[function(require,module,exports){ 778 | (function (global){ 779 | var context = new global.AudioContext(); 780 | var approaches = 128; 781 | var real = new global.Float32Array(approaches); 782 | var imag = new global.Float32Array(approaches); 783 | 784 | real[0] = 0; 785 | for (var i = 1; i < approaches; i++) { 786 | imag[i] = i % 2 == 0 ? 0 : 4 / (i * Math.PI); 787 | } 788 | 789 | var wave = context.createPeriodicWave(real, imag); 790 | 791 | module.exports = wave; 792 | 793 | }).call(this,typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) 794 | },{}],17:[function(require,module,exports){ 795 | module.exports = "\n\n
\n

Tremolo

\n \n \n \n \n
\n\n
\n

Vibrato

\n \n \n \n \n
\n\n
\n

Delay

\n \n \n \n \n \n
\n\n
\n

ADSR envelope

\n
\n
\n
\n
\n
\n
\n \"ADSR\"\n
\n\n
\n \n \n
\n\n
\n \n \n \n
\n"; 796 | 797 | },{}]},{},[9]); 798 | -------------------------------------------------------------------------------- /js/synthMixins/ADSR.js: -------------------------------------------------------------------------------- 1 | function ADSR() { 2 | this.ADSR = { 3 | A: null, 4 | D: null, 5 | S: null, 6 | R: null 7 | }; 8 | 9 | var oscillators = {}; 10 | var gainNodes = {}; 11 | var asdIntervals = {}; 12 | var rIntervals = {}; 13 | 14 | var old = { 15 | play: this.play, 16 | stop: this.stop 17 | }; 18 | 19 | this.play = function(note) { 20 | var gain = gainNodes[note.pitch]; 21 | if (!gain) { 22 | gain = gainNodes[note.pitch] = this.audioContext.createGain(); 23 | gain.connect(this.output); 24 | gain.gain.value = 0; 25 | } 26 | 27 | var startedAt = Date.now(); 28 | var startedAtGain = gain.gain.value; 29 | 30 | if (oscillators[note.pitch]) { 31 | this._finalize(note); 32 | } 33 | 34 | var osc = oscillators[note.pitch] = old.play.call(this, note); 35 | osc.disconnect(this.output); 36 | osc.connect(gain); 37 | 38 | this.ADSR.A = parseInt(this.ADSR.A); 39 | this.ADSR.D = parseInt(this.ADSR.D); 40 | this.ADSR.S = parseFloat(this.ADSR.S); 41 | this.ADSR.R = parseInt(this.ADSR.R); 42 | 43 | asdIntervals[note.pitch] = setInterval(() => { 44 | var diff = Date.now() - startedAt; 45 | if (diff < this.ADSR.A) { 46 | gain.gain.value = startedAtGain + (1 - startedAtGain) * (diff / this.ADSR.A); 47 | } else if (diff < this.ADSR.A + this.ADSR.D) { 48 | gain.gain.value = 1 - (diff - this.ADSR.A) / (this.ADSR.D / (1 - this.ADSR.S)); 49 | } else { 50 | gain.gain.value = this.ADSR.S; 51 | clearInterval(asdIntervals[note.pitch]); 52 | } 53 | }, 10); 54 | 55 | return osc; 56 | }; 57 | 58 | this._finalize = function(note) { 59 | var osc = oscillators[note.pitch]; 60 | var gain = gainNodes[note.pitch]; 61 | clearInterval(rIntervals[note.pitch]); 62 | gain.gain.value = 0; 63 | osc.disconnect(gain); 64 | osc.connect(this.output); 65 | delete oscillators[note.pitch]; 66 | old.stop.apply(this, arguments); 67 | } 68 | 69 | this.stop = function(note) { 70 | var releasedAt = Date.now(); 71 | var this_ = this; 72 | var arguments_ = arguments; 73 | var gain = gainNodes[note.pitch]; 74 | var gainOnRelease = gain.gain.value; 75 | rIntervals[note.pitch] = setInterval(() => { 76 | var diff = Date.now() - releasedAt; 77 | if (diff < this_.ADSR.R) { 78 | gain.gain.value = gainOnRelease * (1 - diff / this_.ADSR.R); 79 | } else { 80 | this._finalize(note); 81 | } 82 | }, 10); 83 | }; 84 | } 85 | 86 | module.exports = ADSR; 87 | -------------------------------------------------------------------------------- /js/synthMixins/PitchShifter.js: -------------------------------------------------------------------------------- 1 | function PitchShifter() { 2 | this._pitchShift = 0; 3 | var oscillators = {}; 4 | 5 | Object.defineProperty(this, "pitchShift", { 6 | set: function (ps) { 7 | this._pitchShift = ps; 8 | for(var pitch in oscillators) { 9 | oscillators[pitch].frequency.value = 10 | oscillators[pitch].baseFrequency * Math.pow(2, this._pitchShift/1200); 11 | } 12 | }, 13 | get: function() { 14 | return this._pitchShift; 15 | } 16 | }); 17 | 18 | var old = { 19 | play: this.play, 20 | stop: this.stop 21 | }; 22 | 23 | this.play = function(note) { 24 | var osc = oscillators[note.pitch] = old.play.call(this, note); 25 | osc.baseFrequency = note.frequency; 26 | osc.frequency.value = osc.baseFrequency * Math.pow(2, this._pitchShift/1200); 27 | return osc; 28 | }; 29 | 30 | this.stop = function(note) { 31 | delete oscillators[note.pitch]; 32 | old.stop.apply(this, arguments); 33 | }; 34 | 35 | } 36 | 37 | module.exports = PitchShifter; -------------------------------------------------------------------------------- /js/synthMixins/WaveForm.js: -------------------------------------------------------------------------------- 1 | var sawtooth = require('../waveforms/sawtooth'); 2 | var square = require('../waveforms/square'); 3 | var sine = require('../waveforms/sine'); 4 | 5 | function WaveForm() { 6 | Object.defineProperty(this, "waveForm", { 7 | set: function(waveForm) { 8 | this._waveForm = { 9 | 'sawtooth': sawtooth, 10 | 'square': square, 11 | 'sine': sine 12 | }[waveForm]; 13 | }, 14 | get: function() { 15 | return this._waveForm; 16 | } 17 | }); 18 | 19 | var old = { 20 | play: this.play, 21 | stop: this.stop 22 | }; 23 | 24 | this.play = function() { 25 | var osc = old.play.apply(this, arguments); 26 | osc.setPeriodicWave(this._waveForm || sine); 27 | return osc; 28 | } 29 | 30 | this.stop = function() { 31 | old.stop.apply(this, arguments); 32 | } 33 | } 34 | 35 | module.exports = WaveForm; -------------------------------------------------------------------------------- /js/waveforms/sawtooth.js: -------------------------------------------------------------------------------- 1 | var context = new global.AudioContext(); 2 | var steps = 128; 3 | var real = new global.Float32Array(steps); 4 | var imag = new global.Float32Array(steps); 5 | 6 | for (var i = 1; i < steps; i++) { 7 | imag[i] = 1 / (i * Math.PI); 8 | } 9 | 10 | var wave = context.createPeriodicWave(real, imag); 11 | 12 | module.exports = wave; 13 | -------------------------------------------------------------------------------- /js/waveforms/sine.js: -------------------------------------------------------------------------------- 1 | var context = new global.AudioContext(); 2 | var realCoeffs = new global.Float32Array([0,0]); 3 | var imagCoeffs = new global.Float32Array([0,1]); 4 | var wave = context.createPeriodicWave(realCoeffs, imagCoeffs); 5 | 6 | module.exports = wave; -------------------------------------------------------------------------------- /js/waveforms/square.js: -------------------------------------------------------------------------------- 1 | var context = new global.AudioContext(); 2 | var approaches = 128; 3 | var real = new global.Float32Array(approaches); 4 | var imag = new global.Float32Array(approaches); 5 | 6 | real[0] = 0; 7 | for (var i = 1; i < approaches; i++) { 8 | imag[i] = i % 2 == 0 ? 0 : 4 / (i * Math.PI); 9 | } 10 | 11 | var wave = context.createPeriodicWave(real, imag); 12 | 13 | module.exports = wave; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Synzer", 3 | "version": "0.0.0", 4 | "devDependencies": { 5 | "atob": "^1.1.2", 6 | "browserify": "^11.1.0", 7 | "btoa": "^1.1.2", 8 | "jasmine": "*", 9 | "node-dom": "^0.1.0", 10 | "proxyquire": "^1.7.2", 11 | "stringify": "^3.1.0" 12 | }, 13 | "scripts": { 14 | "test": "node_modules/.bin/jasmine", 15 | "build": "node_modules/.bin/browserify js/app.js -t stringify -o js/prod/app.js" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /spec/DelaySpec.js: -------------------------------------------------------------------------------- 1 | var Delay = require('../js/effects/Delay'); 2 | 3 | describe("Delay", function() { 4 | var delay, delayNodes, audioContext; 5 | 6 | beforeEach(function() { 7 | audioContext = { 8 | createDelay: function() {}, 9 | createGain: function() {}, 10 | destination: {} 11 | }; 12 | delayNodes = []; 13 | gainNodes = []; 14 | 15 | for(var i = 0; i < 10; i++) { 16 | var delayNode = jasmine.createSpyObj('delayNode' + i, ['connect', 'disconnect']); 17 | delayNode.delayTime = {}; 18 | delayNodes.push(delayNode); 19 | 20 | var gainNode = jasmine.createSpyObj('gainNode' + i, ['connect', 'disconnect']); 21 | gainNode.gain = {}; 22 | gainNode.i = i; 23 | gainNodes.push(gainNode); 24 | } 25 | 26 | var delayNodeCounter = 0; 27 | spyOn(audioContext, 'createDelay').and.callFake(function() { 28 | return delayNodes[delayNodeCounter++]; 29 | }); 30 | 31 | var gainNodeCounter = 0; 32 | spyOn(audioContext, 'createGain').and.callFake(function() { 33 | return gainNodes[gainNodeCounter++]; 34 | }); 35 | 36 | delay = new Delay(audioContext); 37 | }); 38 | 39 | it('connects input node to delays on start', function() { 40 | delay.start(); 41 | expect(gainNodes[0].connect).toHaveBeenCalled(); 42 | }); 43 | 44 | it('disconnects input note from delays on stop', function() { 45 | delay.start(); 46 | delay.stop(); 47 | expect(gainNodes[0].disconnect).toHaveBeenCalled(); 48 | }); 49 | 50 | it('changes delays on frequency change', function() { 51 | delay.taps = 3; 52 | delay.latency = 200; 53 | expect(delayNodes[0].delayTime.value).toBeCloseTo(0.2); 54 | expect(delayNodes[1].delayTime.value).toBeCloseTo(0.4); 55 | expect(delayNodes[2].delayTime.value).toBeCloseTo(0.6); 56 | }); 57 | 58 | it('changes delay lines, gain nodes amount on taps change', function() { 59 | delay.taps = 3; 60 | expect(delayNodes[0].connect).toHaveBeenCalled(); 61 | expect(delayNodes[1].connect).toHaveBeenCalled(); 62 | expect(delayNodes[2].connect).toHaveBeenCalled(); 63 | expect(delayNodes[3].connect).not.toHaveBeenCalled(); 64 | 65 | expect(gainNodes[3].connect).toHaveBeenCalled(); 66 | expect(gainNodes[4].connect).toHaveBeenCalled(); 67 | expect(gainNodes[5].connect).toHaveBeenCalled(); 68 | expect(gainNodes[6].connect).not.toHaveBeenCalled(); 69 | 70 | }); 71 | 72 | it('changes feedback', function() { 73 | delay.taps = 3; 74 | delay.feedback = 0.5; 75 | expect(gainNodes[3].gain.value).toBeCloseTo(0.5); 76 | expect(gainNodes[4].gain.value).toBeCloseTo(0.25); 77 | expect(gainNodes[5].gain.value).toBeCloseTo(0.125); 78 | }); 79 | }); -------------------------------------------------------------------------------- /spec/KeyboardListenerSpec.js: -------------------------------------------------------------------------------- 1 | var KeyboardListener = require('../js/KeyboardListener'); 2 | 3 | describe('KeyboardListener', function() { 4 | beforeEach(function() { 5 | global.window = { 6 | addEventListener: jasmine.createSpy('window.addEventListener') 7 | }; 8 | }); 9 | 10 | afterEach(function() { 11 | delete global.window; 12 | }); 13 | 14 | it('throws if specified notes range is out of allowed range (48 - 83)', function() { 15 | expect(function() { 16 | new KeyboardListener({startNote: 47, endNote: 81}); 17 | }).toThrow(); 18 | expect(function() { 19 | new KeyboardListener({startNote: 51, endNote: 85}); 20 | }).toThrow(); 21 | }); 22 | 23 | it('does not throw when notes are within 48 - 83 range', function() { 24 | expect(function() { 25 | new KeyboardListener({startNote: 48, endNote: 83}) 26 | }).not.toThrow(); 27 | expect(function() { 28 | new KeyboardListener({startNote: 52, endNote: 63}) 29 | }).not.toThrow(); 30 | }); 31 | 32 | describe('', function() { 33 | var keyboardListener; 34 | var keypressCallback; 35 | var keyupCallback; 36 | var onKeyPress; 37 | var onKeyRelease; 38 | 39 | beforeEach(function() { 40 | global.window.addEventListener.and.callFake(function (name, fn) { 41 | if (name == 'keydown') { 42 | keypressCallback = fn; 43 | } 44 | if (name == 'keyup') { 45 | keyupCallback = fn; 46 | } 47 | }); 48 | keyboardListener = new KeyboardListener({startNote: 60, endNote: 72}); 49 | onKeyPress = jasmine.createSpy('keyPress'); 50 | onKeyRelease = jasmine.createSpy('keyRelease'); 51 | keyboardListener.on('keyPressed', onKeyPress); 52 | keyboardListener.on('keyReleased', onKeyRelease); 53 | }); 54 | 55 | it('emits keyPressed and keyReleased for keys within range', function() { 56 | keypressCallback({ 57 | keyCode: 73 58 | }); 59 | expect(onKeyPress).toHaveBeenCalledWith(60); 60 | expect(onKeyRelease).not.toHaveBeenCalled(); 61 | keyupCallback({ 62 | keyCode: 73 63 | }); 64 | expect(onKeyRelease).toHaveBeenCalledWith(60); 65 | }); 66 | 67 | it('does not emit keyPressed and keyReleased for keys outside the range', function() { 68 | keypressCallback({ 69 | keyCode: 53 70 | }); 71 | expect(onKeyPress).not.toHaveBeenCalled(); 72 | keyupCallback({ 73 | keyCode: 53 74 | }); 75 | expect(onKeyRelease).not.toHaveBeenCalled(); 76 | }); 77 | 78 | it('does emit after second keyPressed with the same keyCode', function() { 79 | keypressCallback({ 80 | keyCode: 73 81 | }); 82 | onKeyPress.calls.reset(); 83 | keypressCallback({ 84 | keyCode: 73 85 | }); 86 | expect(onKeyPress).not.toHaveBeenCalled(); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /spec/KeyboardSpec.js: -------------------------------------------------------------------------------- 1 | var Keyboard = require('../js/Keyboard'); 2 | 3 | describe('Keyboard', function() { 4 | var keyboard; 5 | 6 | beforeEach(function() { 7 | keyboard = new Keyboard; 8 | }); 9 | 10 | afterEach(function() { 11 | }); 12 | }); -------------------------------------------------------------------------------- /spec/MediatorMixinSpec.js: -------------------------------------------------------------------------------- 1 | var MediatorMixin = require('../js/MediatorMixin') 2 | 3 | describe('Mediator', function() { 4 | var obj = {}; 5 | 6 | beforeEach(function() { 7 | MediatorMixin.call(obj); 8 | }); 9 | 10 | it('subscribes', function() { 11 | expect(function() { 12 | obj.on('event1'); 13 | }).not.toThrow(); 14 | }); 15 | 16 | it('reaches listener if emitted', function(done) { 17 | obj.on('ev1', function(value) { 18 | expect(value).toBe(1); 19 | done(); 20 | }); 21 | obj.emit('ev1', 1) 22 | }); 23 | 24 | it('reaches multiple listeners', function(done) { 25 | var counter = 0; 26 | 27 | obj.on('ev1', function(a, b) { 28 | expect(a).toBe(1); 29 | expect(b).toBe(2); 30 | if (++counter == 2) done(); 31 | }); 32 | 33 | obj.on('ev1', function(a, b, c, d) { 34 | expect(a).toBe(1); 35 | expect(b).toBe(2); 36 | expect(c).toBe(3); 37 | expect(d).toBe(4); 38 | if (++counter == 2) done(); 39 | }); 40 | 41 | obj.emit('ev1', 1, 2, 3, 4); 42 | }); 43 | 44 | it('does not reach not corresponding listeners', function(done) { 45 | obj.on('ev1', function() { 46 | done.fail(); 47 | }); 48 | obj.emit('ev2'); 49 | setTimeout(done); 50 | }); 51 | 52 | it('does not throw if no listener', function() { 53 | expect(function() { obj.emit('ev1'); }).not.toThrow(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /spec/MidiListenerSpec.js: -------------------------------------------------------------------------------- 1 | var MidiListener = require('../js/MidiListener'); 2 | 3 | describe('MidiListener', function() { 4 | it('', function() { 5 | 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /spec/NoteSpec.js: -------------------------------------------------------------------------------- 1 | var Note = require('../js/Note'); 2 | 3 | describe('Note', function() { 4 | it('can be created with pitch', function() { 5 | var note1 = new Note(21); 6 | expect(note1.letter).toEqual("A"); 7 | expect(note1.pitch).toEqual(21); 8 | expect(note1.octave).toEqual(0); 9 | }); 10 | 11 | it('can be created with letter notation', function() { 12 | var note1 = new Note("C#4"); 13 | expect(note1.letter).toEqual("C#"); 14 | expect(note1.pitch).toEqual(61); 15 | expect(note1.octave).toEqual(4); 16 | }); 17 | 18 | it('throws if invalid input', function() { 19 | expect(function() {new Note();}).toThrow(); 20 | expect(function() {new Note(2);}).toThrow(); 21 | expect(function() {new Note("nn");}).toThrow(); 22 | expect(function() {new Note("N#6");}).toThrow(); 23 | }); 24 | 25 | it('calculates frequencies correctly', function() { 26 | expect(new Note('A4').frequency).toEqual(440); 27 | expect(new Note('F6').frequency).toBeCloseTo(1396.91); 28 | }); 29 | }); -------------------------------------------------------------------------------- /spec/SineModulatorSpec.js: -------------------------------------------------------------------------------- 1 | var SineModulator = require('../js/SineModulator'); 2 | 3 | describe('Sine Modulator', function() { 4 | var sineModulator; 5 | var modulatedObj; 6 | var prevVal; 7 | 8 | beforeEach(function() { 9 | modulatedObj = { 10 | param1: 100 11 | }; 12 | 13 | sineModulator = new SineModulator({ 14 | depth: 0.5, 15 | frequency: 1 16 | }); 17 | 18 | prevVal = modulatedObj.param1; 19 | 20 | sineModulator.modulate(modulatedObj, 'param1'); 21 | jasmine.clock().install(); 22 | jasmine.clock().mockDate(); 23 | sineModulator.start(); 24 | }); 25 | 26 | afterEach(function() { 27 | jasmine.clock().uninstall(); 28 | }); 29 | 30 | it('modulate returns the object', function() { 31 | expect(sineModulator.modulate(modulatedObj, 'param1')).toBe(sineModulator); 32 | }); 33 | 34 | it('starts modulating', function() { 35 | jasmine.clock().tick(250); 36 | expect(modulatedObj.param1).toBeCloseTo(prevVal + 0.5); 37 | jasmine.clock().tick(250); 38 | expect(modulatedObj.param1).toBeCloseTo(prevVal); 39 | jasmine.clock().tick(250); 40 | expect(modulatedObj.param1).toBeCloseTo(prevVal - 0.5); 41 | jasmine.clock().tick(250); 42 | expect(modulatedObj.param1).toBeCloseTo(prevVal); 43 | }); 44 | 45 | it('depth can be changed in runtime', function() { 46 | jasmine.clock().tick(250); 47 | expect(modulatedObj.param1).toBeCloseTo(prevVal + 0.5); 48 | jasmine.clock().tick(250); 49 | sineModulator.depth = 0.8; 50 | jasmine.clock().tick(250); 51 | expect(modulatedObj.param1).toBeCloseTo(prevVal - 0.8); 52 | }); 53 | 54 | it('frequency can be changed in runtime', function() { 55 | jasmine.clock().tick(250); 56 | expect(modulatedObj.param1).toBeCloseTo(prevVal + 0.5); 57 | jasmine.clock().tick(250); 58 | sineModulator.frequency = 2; 59 | jasmine.clock().tick(125); 60 | expect(modulatedObj.param1).toBeCloseTo(prevVal - 0.5); 61 | jasmine.clock().tick(125); 62 | expect(modulatedObj.param1).toBeCloseTo(prevVal); 63 | }); 64 | 65 | it('doesn\'t block the param from being changed by something else', function() { 66 | jasmine.clock().tick(500); 67 | modulatedObj.param1 += 50; 68 | jasmine.clock().tick(250); 69 | expect(modulatedObj.param1).toBeCloseTo(prevVal + 50 - 0.5); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /spec/SynthMixinsSpec.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire').noCallThru(); 2 | 3 | var WaveForm = proxyquire('../js/synthMixins/WaveForm', { 4 | '../waveforms/sine': {periodicWave: true, sine: true}, 5 | '../waveforms/sawtooth': {periodicWave: true, sawtooth: true}, 6 | '../waveforms/square': {periodicWave: true, square: true} 7 | }); 8 | var PitchShifter = require('../js/synthMixins/PitchShifter'); 9 | var ADSR = require('../js/synthMixins/ADSR'); 10 | 11 | describe('synthMixins', function() { 12 | var oscillator, audioContext, note, note2; 13 | beforeEach(function() { 14 | oscillator = jasmine.createSpyObj('oscillator', ['setPeriodicWave', 'connect', 'disconnect', 'start', 'stop']); 15 | oscillator.frequency = {}; 16 | gainNode = jasmine.createSpyObj('gainNode', ['connect', 'disconnect']); 17 | gainNode.gain = {}; 18 | 19 | audioContext = { 20 | createOscillator: function() {}, 21 | createGain: function() {}, 22 | }; 23 | spyOn(audioContext, 'createOscillator').and.returnValue(oscillator); 24 | spyOn(audioContext, 'createGain').and.returnValue(gainNode); 25 | 26 | synth = { 27 | audioContext: audioContext, 28 | output: gainNode, 29 | play: function(note) { 30 | oscillator.frequency.value = note.frequency; 31 | return oscillator; 32 | }, 33 | stop: function() {} 34 | }; 35 | note = {pitch:44, frequency:100}; 36 | note2 = {pitch:46, frequency:121}; 37 | }); 38 | 39 | describe("WaveForm", function() { 40 | beforeEach(function() { 41 | WaveForm.call(synth); 42 | }); 43 | 44 | it('play method returns oscillator', function() { 45 | expect(synth.play(note)).toBe(oscillator); 46 | }); 47 | 48 | it('waveForm property can be set', function() { 49 | synth.waveForm = 'square'; 50 | synth.play(note); 51 | expect(oscillator.setPeriodicWave).toHaveBeenCalledWith( 52 | jasmine.objectContaining({periodicWave: true, square: true}) 53 | ); 54 | }); 55 | 56 | it('uses sine if no wave form is set', function() { 57 | synth.play(note); 58 | expect(oscillator.setPeriodicWave).toHaveBeenCalledWith( 59 | jasmine.objectContaining({periodicWave: true, sine: true}) 60 | ); 61 | }); 62 | }); 63 | 64 | describe('Pitch shift', function() { 65 | beforeEach(function() { 66 | PitchShifter.call(synth); 67 | }); 68 | 69 | it('play method returns oscillator', function() { 70 | expect(synth.play(note)).toBe(oscillator); 71 | }); 72 | 73 | it('pitchShift property can be set', function() { 74 | synth.pitchShift = 1; 75 | synth.play(note); 76 | expect(oscillator.frequency.value).toBeCloseTo(note.frequency * (Math.pow(2, 1/1200))); 77 | 78 | synth.pitchShift = 10; 79 | expect(oscillator.frequency.value).toBeCloseTo(note.frequency * (Math.pow(2, 10/1200))); 80 | 81 | synth.pitchShift = 1200; 82 | expect(oscillator.frequency.value).toBeCloseTo(note.frequency * 2); 83 | 84 | synth.pitchShift = -1200; 85 | expect(oscillator.frequency.value).toBeCloseTo(note.frequency / 2); 86 | }); 87 | }); 88 | 89 | describe('ADSR', function () { 90 | beforeEach(function() { 91 | ADSR.call(synth); 92 | synth.ADSR.A = 100; 93 | synth.ADSR.D = 100; 94 | synth.ADSR.S = 0.5; 95 | synth.ADSR.R = 100; 96 | jasmine.clock().install(); 97 | jasmine.clock().mockDate(); 98 | }); 99 | 100 | afterEach(function() { 101 | jasmine.clock().uninstall(); 102 | }) 103 | 104 | it('play method returns an oscillator', function() { 105 | expect(synth.play(note)).toBe(oscillator); 106 | }); 107 | 108 | it('wires in a gain node between oscillator and output', function() { 109 | synth.play(note); 110 | expect(gainNode.connect).toHaveBeenCalledWith(gainNode); 111 | expect(oscillator.disconnect).toHaveBeenCalledWith(gainNode); 112 | expect(oscillator.connect).toHaveBeenCalledWith(gainNode); 113 | }); 114 | 115 | it('A (Attack) property sets the duration of the A linear gain phase', function() { 116 | synth.ADSR.A = 500; 117 | synth.play(note); 118 | expect(gainNode.gain.value).toBe(0); 119 | jasmine.clock().tick(250); 120 | expect(gainNode.gain.value).toBe(0.5); 121 | jasmine.clock().tick(250) 122 | expect(gainNode.gain.value).toBe(1); 123 | }); 124 | 125 | it('D (Decay) property can be set', function() { 126 | synth.ADSR.S = 0.2; 127 | synth.ADSR.A = 100; 128 | synth.ADSR.D = 500; 129 | synth.play(note); 130 | jasmine.clock().tick(100); 131 | expect(gainNode.gain.value).toBeCloseTo(1); 132 | jasmine.clock().tick(250); 133 | expect(gainNode.gain.value).toBeCloseTo(0.6); 134 | jasmine.clock().tick(250); 135 | expect(gainNode.gain.value).toBeCloseTo(0.2); 136 | }); 137 | 138 | it('S (Sustain) property can be set', function() { 139 | synth.ADSR.S = 0.4; 140 | synth.ADSR.A = 200; 141 | synth.ADSR.D = 300; 142 | synth.play(note); 143 | jasmine.clock().tick(500); 144 | expect(gainNode.gain.value).toBeCloseTo(0.4); 145 | jasmine.clock().tick(1000); 146 | expect(gainNode.gain.value).toBeCloseTo(0.4); 147 | }); 148 | 149 | it('R (Release) property can be set', function() { 150 | synth.ADSR.A = 100; 151 | synth.ADSR.D = 100; 152 | synth.ADSR.S = 0.1; 153 | synth.ADSR.R = 400; 154 | synth.play(note); 155 | jasmine.clock().tick(1000); 156 | synth.stop(note); 157 | expect(gainNode.gain.value).toBeCloseTo(0.1); 158 | jasmine.clock().tick(200); 159 | expect(gainNode.gain.value).toBeCloseTo(0.05); 160 | jasmine.clock().tick(200); 161 | expect(gainNode.gain.value).toBeCloseTo(0); 162 | }); 163 | 164 | it('Release before A or D is finished cases decrease from the current level', function() { 165 | synth.ADSR.A = 100; 166 | synth.ADSR.D = 100; 167 | synth.ADSR.S = 0.6; 168 | synth.ADSR.R = 600; 169 | synth.play(note); 170 | jasmine.clock().tick(80); 171 | synth.stop(note); 172 | expect(gainNode.gain.value).toBeCloseTo(0.8); 173 | jasmine.clock().tick(300); 174 | expect(gainNode.gain.value).toBeCloseTo(0.4); 175 | jasmine.clock().tick(300); 176 | expect(gainNode.gain.value).toBeCloseTo(0); 177 | }); 178 | 179 | it('Starting a new note during the R phase cancels R', function() { 180 | synth.ADSR.A = 100; 181 | synth.ADSR.D = 100; 182 | synth.ADSR.S = 0.6; 183 | synth.ADSR.R = 600; 184 | synth.play(note); 185 | jasmine.clock().tick(300); 186 | synth.stop(note); 187 | jasmine.clock().tick(300); 188 | synth.play(note); 189 | jasmine.clock().tick(50); 190 | expect(gainNode.gain.value).toBeCloseTo(0.65); 191 | jasmine.clock().tick(50); 192 | expect(gainNode.gain.value).toBeCloseTo(1); 193 | }); 194 | 195 | it('Plays second note as expected when first if completely finished', function() { 196 | synth.ADSR.A = 100; 197 | synth.ADSR.D = 100; 198 | synth.ADSR.S = 0.6; 199 | synth.ADSR.R = 600; 200 | synth.play(note); 201 | jasmine.clock().tick(500); 202 | synth.stop(note); 203 | jasmine.clock().tick(800); 204 | synth.play(note); 205 | jasmine.clock().tick(50); 206 | expect(gainNode.gain.value).toBeCloseTo(0.5); 207 | jasmine.clock().tick(50); 208 | expect(gainNode.gain.value).toBeCloseTo(1); 209 | jasmine.clock().tick(50); 210 | expect(gainNode.gain.value).toBeCloseTo(0.8); 211 | jasmine.clock().tick(50); 212 | expect(gainNode.gain.value).toBeCloseTo(0.6); 213 | jasmine.clock().tick(50); 214 | expect(gainNode.gain.value).toBeCloseTo(0.6); 215 | synth.stop(note); 216 | jasmine.clock().tick(300); 217 | expect(gainNode.gain.value).toBeCloseTo(0.3); 218 | jasmine.clock().tick(300); 219 | expect(gainNode.gain.value).toBeCloseTo(0); 220 | }); 221 | }); 222 | }); 223 | -------------------------------------------------------------------------------- /spec/SynthSpec.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire').noCallThru(); 2 | var Synth = proxyquire('../js/Synth', { 3 | './synthMixins/ADSR': function() {}, 4 | './synthMixins/PitchShifter': function() {}, 5 | './synthMixins/WaveForm': function() {} 6 | }); 7 | 8 | describe('Synth', function() { 9 | var synth, gainNode, oscillator, audioContext, aNote = {pitch: 81, frequency: 440}, aNote2 = {pitch: 93, frequency: 880}; 10 | 11 | beforeEach(function() { 12 | oscillator = jasmine.createSpyObj('oscillator', ['setPeriodicWave', 'connect', 'disconnect', 'start', 'stop']); 13 | oscillator.frequency = {}; 14 | gainNode = jasmine.createSpyObj('gainNode', ['connect']); 15 | gainNode.gain = {}; 16 | stereoPanner = jasmine.createSpyObj('stereoPanner', ['connect']); 17 | stereoPanner.pan = {}; 18 | 19 | audioContext = { 20 | createOscillator: function() {}, 21 | createPeriodicWave: function() {}, 22 | createStereoPanner: function() {}, 23 | createGain: function() {}, 24 | destination: {} 25 | }; 26 | 27 | spyOn(audioContext, 'createOscillator').and.returnValue(oscillator); 28 | spyOn(audioContext, 'createGain').and.returnValue(gainNode); 29 | spyOn(audioContext, 'createStereoPanner').and.returnValue(stereoPanner); 30 | 31 | synth = new Synth(audioContext); 32 | }); 33 | 34 | afterEach(function() { 35 | delete global.AudioContext; 36 | }); 37 | 38 | it('constructs', function() { 39 | expect(synth).toEqual(jasmine.any(Object)); 40 | }); 41 | 42 | it('starts playing', function() { 43 | synth.play(aNote); 44 | expect(oscillator.start).toHaveBeenCalledWith(0); 45 | expect(oscillator.frequency.value).toEqual(aNote.frequency) 46 | }); 47 | 48 | it('stops playing', function() { 49 | synth.play(aNote); 50 | synth.stop(aNote); 51 | expect(oscillator.stop).toHaveBeenCalledWith(0); 52 | }); 53 | 54 | it('plays note several times', function() { 55 | synth.play(aNote); 56 | synth.stop(aNote); 57 | synth.play(aNote); 58 | expect(oscillator.start.calls.count()).toBe(2); 59 | }); 60 | 61 | it('connects all oscillators when connect is called', function() { 62 | synth.play(aNote); 63 | synth.play(aNote2); 64 | expect(oscillator.connect).toHaveBeenCalledWith(gainNode); 65 | expect(oscillator.connect.calls.count()).toBe(2); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /spec/WaveFormSpec.js: -------------------------------------------------------------------------------- 1 | var proxyquire = require('proxyquire').noCallThru(); 2 | 3 | var WaveForm = proxyquire('../js/synthMixins/WaveForm', { 4 | '../waveforms/sine': {periodicWave: true, sine: true}, 5 | '../waveforms/sawtooth': {periodicWave: true, sawtooth: true}, 6 | '../waveforms/square': {periodicWave: true, square: true} 7 | }); 8 | var PitchShifter = require('../js/synthMixins/PitchShifter'); 9 | 10 | var oscillator, audioContext, note; 11 | 12 | describe("Mixins", function() { 13 | beforeEach(function() { 14 | oscillator = jasmine.createSpyObj('oscillator', ['setPeriodicWave', 'connect', 'start', 'stop']); 15 | audioContext = { 16 | createOscillator: function() {}, 17 | }; 18 | spyOn(audioContext, 'createOscillator').and.returnValue(oscillator); 19 | 20 | synth = { 21 | play: function() { return oscillator; }, 22 | stop: function() {} 23 | }; 24 | 25 | note = {pitch:44, frequency:100}; 26 | }); 27 | 28 | describe('WaveForm', function() { 29 | beforeEach(function() { 30 | WaveForm.call(synth); 31 | }); 32 | 33 | it('sets wave form', function() { 34 | synth.waveForm = 'square'; 35 | synth.play(note); 36 | expect(oscillator.setPeriodicWave).toHaveBeenCalledWith( 37 | jasmine.objectContaining({periodicWave: true, square: true}) 38 | ); 39 | }); 40 | 41 | it('uses sine if no wave form is set', function() { 42 | synth.play(note); 43 | expect(oscillator.setPeriodicWave).toHaveBeenCalledWith( 44 | jasmine.objectContaining({periodicWave: true, sine: true}) 45 | ); 46 | }); 47 | }); 48 | 49 | describe('', function() { 50 | beforeEach(function() { 51 | PitchShifter.call(synth); 52 | }); 53 | }); 54 | }); -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.js" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.js" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /tpl/controls.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

Tremolo

5 | 8 | 11 | 15 | 19 |
20 | 21 |
22 |

Vibrato

23 | 26 | 29 | 33 | 37 |
38 | 39 |
40 |

Delay

41 | 44 | 47 | 51 | 55 | 59 |
60 | 61 |
62 |

ADSR envelope

63 |
64 |
68 |
72 |
76 |
80 |
81 | ADSR 82 |
83 | 84 |
85 | 89 | 93 |
94 | 95 |
96 | 100 | 104 | 108 |
109 | --------------------------------------------------------------------------------