├── img ├── copy.png ├── cut.png ├── loop.png ├── mute.png ├── open.png ├── play.png ├── save.png ├── stop.png ├── swap.png ├── zoom.png ├── mixer.png ├── paste.png ├── pause.png ├── radio.png ├── record.png ├── zoom-1.png ├── normalize.png ├── ping-pong.png ├── quantize.png ├── resample.png ├── reverse.png ├── zoom-in.png ├── zoom-out.png ├── sound-wave.jpg ├── black-button.xcf ├── circuit-board.png ├── polarity-flip.png ├── remove-offset.png ├── zoom-selection.png ├── remove-offset-small.png ├── sources.txt └── boombox.svg ├── samples ├── violin.wav ├── metronome.wav ├── guitar-strum.wav └── acoustic-grand-piano.wav ├── static-server.py ├── css ├── sample-editor.css └── site.css ├── js ├── machines │ ├── blank-template.js │ ├── periodic-wave.js │ ├── signal-follower.js │ └── chebyshev.js ├── sampler.js ├── audioworkletprocessors.js ├── input.js └── generative.js ├── design-notes.txt ├── sample-editor.html ├── frontend └── app.js └── index.html /img/copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/copy.png -------------------------------------------------------------------------------- /img/cut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/cut.png -------------------------------------------------------------------------------- /img/loop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/loop.png -------------------------------------------------------------------------------- /img/mute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/mute.png -------------------------------------------------------------------------------- /img/open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/open.png -------------------------------------------------------------------------------- /img/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/play.png -------------------------------------------------------------------------------- /img/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/save.png -------------------------------------------------------------------------------- /img/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/stop.png -------------------------------------------------------------------------------- /img/swap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/swap.png -------------------------------------------------------------------------------- /img/zoom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/zoom.png -------------------------------------------------------------------------------- /img/mixer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/mixer.png -------------------------------------------------------------------------------- /img/paste.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/paste.png -------------------------------------------------------------------------------- /img/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/pause.png -------------------------------------------------------------------------------- /img/radio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/radio.png -------------------------------------------------------------------------------- /img/record.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/record.png -------------------------------------------------------------------------------- /img/zoom-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/zoom-1.png -------------------------------------------------------------------------------- /img/normalize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/normalize.png -------------------------------------------------------------------------------- /img/ping-pong.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/ping-pong.png -------------------------------------------------------------------------------- /img/quantize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/quantize.png -------------------------------------------------------------------------------- /img/resample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/resample.png -------------------------------------------------------------------------------- /img/reverse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/reverse.png -------------------------------------------------------------------------------- /img/zoom-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/zoom-in.png -------------------------------------------------------------------------------- /img/zoom-out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/zoom-out.png -------------------------------------------------------------------------------- /img/sound-wave.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/sound-wave.jpg -------------------------------------------------------------------------------- /samples/violin.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/samples/violin.wav -------------------------------------------------------------------------------- /img/black-button.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/black-button.xcf -------------------------------------------------------------------------------- /img/circuit-board.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/circuit-board.png -------------------------------------------------------------------------------- /img/polarity-flip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/polarity-flip.png -------------------------------------------------------------------------------- /img/remove-offset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/remove-offset.png -------------------------------------------------------------------------------- /img/zoom-selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/zoom-selection.png -------------------------------------------------------------------------------- /samples/metronome.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/samples/metronome.wav -------------------------------------------------------------------------------- /samples/guitar-strum.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/samples/guitar-strum.wav -------------------------------------------------------------------------------- /img/remove-offset-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/img/remove-offset-small.png -------------------------------------------------------------------------------- /samples/acoustic-grand-piano.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ElizabethHudnott/sound-synth/HEAD/samples/acoustic-grand-piano.wav -------------------------------------------------------------------------------- /static-server.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, send_from_directory 2 | 3 | app = Flask('DevServer', static_url_path='/') 4 | 5 | @app.route('/') 6 | def send(path): 7 | return send_from_directory('.', path) 8 | 9 | app.run() 10 | -------------------------------------------------------------------------------- /css/sample-editor.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: black; 3 | color: white; 4 | } 5 | 6 | #waveform-outer-container { 7 | overflow-x: auto; 8 | } 9 | 10 | #waveform-container { 11 | height: 285px; 12 | } 13 | 14 | #waveform-inner-container { 15 | position: fixed; 16 | } 17 | 18 | #waveform { 19 | background: MidnightBlue; 20 | top: 24px; 21 | position: absolute; 22 | } 23 | 24 | #waveform-overlay { 25 | position: absolute; 26 | touch-action: none; 27 | } 28 | 29 | .no-focus-outline:focus { 30 | outline: none; 31 | } 32 | 33 | .dropdown-menu.fade:not(.show) { 34 | pointer-events: none; 35 | } 36 | 37 | .dropdown-menu.fade { 38 | display: block; 39 | pointer-events: auto; 40 | } 41 | 42 | .dropdown-menu button { 43 | cursor: default; 44 | } 45 | -------------------------------------------------------------------------------- /img/sources.txt: -------------------------------------------------------------------------------- 1 | https://www.maxpixel.net/Play-Sound-Audio-Glossy-Button-Blue-Start-Video-158489 2 | http://www.iconarchive.com/show/oxygen-icons-by-oxygen-icons.org/Actions-zoom-in-icon.html 3 | http://www.iconarchive.com/show/oxygen-icons-by-oxygen-icons.org/Actions-zoom-out-icon.html 4 | http://www.iconarchive.com/show/oxygen-icons-by-oxygen-icons.org/Actions-zoom-original-icon.html 5 | http://www.iconarchive.com/show/small-n-flat-icons-by-paomedia/mixer-icon.htmlhttps://commons.wikimedia.org/wiki/File:Mute_Icon.svg 6 | http://www.iconarchive.com/show/windows-8-icons-by-icons8/Science-Plus-Minus-icon.html 7 | http://www.iconarchive.com/show/blogger-icons-by-rafiqul-hassan/Arrow-icon.html 8 | http://www.iconarchive.com/show/noto-emoji-activities-icons-by-google/52744-ping-pong-icon.html 9 | https://svgsilh.com/image/25661.html 10 | https://commons.wikimedia.org/wiki/File:Radio-icon.svg 11 | https://commons.wikimedia.org/wiki/File:10a_stairs_inv.svg 12 | https://en.wikipedia.org/wiki/File:Fisher_iris_versicolor_sepalwidth.svg 13 | 14 | FatCow Icons 15 | Creative Commons Attribution 3.0 License 16 | http://www.fatcow.com/free-icons 17 | http://www.iconarchive.com/show/farm-fresh-icons-by-fatcow/paste-plain-icon.html 18 | 19 | dryicons 20 | https://dryicons.com 21 | http://www.iconarchive.com/show/aesthetica-2-icons-by-dryicons/page-swap-icon.html 22 | -------------------------------------------------------------------------------- /css/site.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | font-family: sans-serif; 7 | font-size: 11pt; 8 | margin: 8px; 9 | } 10 | 11 | fieldset { 12 | break-inside: avoid; 13 | line-height: 1.6; 14 | margin: 8px auto; 15 | width: 407px; 16 | } 17 | 18 | fieldset fieldset { 19 | border-style: solid; 20 | border-width: 1px; 21 | width: 100%; 22 | } 23 | 24 | input[type=range] { 25 | vertical-align: middle; 26 | } 27 | 28 | legend { 29 | font-family: fantasy; 30 | } 31 | 32 | td { 33 | vertical-align: top; 34 | } 35 | 36 | #navbar { 37 | align-items: center; 38 | display: flex; 39 | margin-bottom: 3px; 40 | } 41 | 42 | #navbar>* { 43 | margin-right: 10px; 44 | } 45 | 46 | #controls { 47 | display: none; 48 | margin-left: 2vw; 49 | margin-right: 2vw; 50 | min-height: calc(100vh - 16px); 51 | } 52 | 53 | #main-controls { 54 | column-fill: auto; 55 | column-width: 407px; 56 | column-gap: 2vw; 57 | } 58 | 59 | #recording-device select { 60 | width: 324px; 61 | } 62 | 63 | #graph-canvas { 64 | width: 100%; 65 | } 66 | 67 | .control-value { 68 | font-size: smaller; 69 | } 70 | 71 | .flip-x { 72 | transform: scaleX(-1); 73 | } 74 | 75 | .icon { 76 | width: 16px; 77 | height: 16px; 78 | } 79 | 80 | .hidden { 81 | display: none; 82 | } 83 | 84 | .readout { 85 | background-color: #eee; 86 | color: #00A; 87 | display: inline-block; 88 | font-family: monospace; 89 | font-size: 90%; 90 | font-weight: 900; 91 | margin-top: 2px; 92 | margin-bottom: 2px; 93 | text-align: center; 94 | min-width: 2em; 95 | } 96 | -------------------------------------------------------------------------------- /js/machines/blank-template.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | 'use strict'; 3 | 4 | class MyMachine extends Machine { 5 | static Param = Synth.enumFromArray([ 6 | 'PARAMETER_NAME', // Write the parameter's description here. 7 | ]); 8 | 9 | constructor(audioContext) { 10 | // Call the superclass constructor, passing it initial values for each of the 11 | // machine's parameters. 12 | super([ 13 | // Insert the default parameter values here. 14 | ]); 15 | 16 | // Connecting a node to this machine will connect that node to each of these 17 | // internal destinations. 18 | this.inputs = []; 19 | 20 | // Connecting this machine to an external destination will connect each of these 21 | // internal nodes to the external destination. 22 | this.outputs = []; 23 | } 24 | 25 | setParameters(changes, time, callbacks) { 26 | const Parameter = MyMachine.Param; // Parameter names 27 | const parameters = this.parameters; // Parameter values 28 | const me = this; // For referring to inside callbacks. 29 | 30 | for (let change of changes) { 31 | if (change.machine !== this) { 32 | continue; 33 | } 34 | 35 | const parameterNumber = change.parameterNumber; 36 | let value = parameters[parameterNumber]; 37 | 38 | switch (parameterNumber) { 39 | case Parameter.PARAMETER_NAME: 40 | // Implement the parameter change here. 41 | break; 42 | 43 | case undefined: 44 | console.error(this.constructor.name + ': An unknown parameter name was used.'); 45 | break; 46 | } 47 | } 48 | } 49 | 50 | } 51 | 52 | global.Machines.My = MyMachine; 53 | 54 | })(window); 55 | -------------------------------------------------------------------------------- /js/machines/periodic-wave.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | 'use strict'; 3 | 4 | class PeriodicWaveMachine extends Machine { 5 | static Param = Synth.enumFromArray([ 6 | 'SIN', // Sine coefficients 7 | 'COS', // Cosine coefficients 8 | ]); 9 | 10 | constructor(audioContext, targetOscillator) { 11 | super([ 12 | [1], 13 | [] 14 | ]); 15 | 16 | this.audioContext = audioContext; 17 | this.target = targetOscillator; 18 | this.inputs = []; 19 | this.outputs = []; 20 | } 21 | 22 | setParameters(changes, time, callbacks) { 23 | const Parameter = PeriodicWaveMachine.Param; // Parameter names 24 | const parameters = this.parameters; // Parameter values 25 | let dirtyCoefficients = false; 26 | 27 | for (let change of changes) { 28 | if (change.machine !== this) { 29 | continue; 30 | } 31 | 32 | switch (change.parameterNumber) { 33 | case Parameter.SIN: 34 | case Parameter.COS: 35 | dirtyCoefficients = true; 36 | break; 37 | 38 | case undefined: 39 | console.error(this.constructor.name + ': An unknown parameter name was used.'); 40 | break; 41 | } 42 | } 43 | 44 | if (dirtyCoefficients) { 45 | const sin = parameters[Parameter.SIN].slice(); 46 | sin.unshift(0); 47 | const cos = parameters[Parameter.COS].slice(); 48 | cos.unshift(0); 49 | const sinLength = sin.length; 50 | const cosLength = cos.length; 51 | if (sinLength < cosLength) { 52 | for (let i = sinLength; i < cosLength; i++) { 53 | sin[i] = 0; 54 | } 55 | } else if (cosLength < sinLength) { 56 | for (let i = cosLength; i < sinLength; i++) { 57 | cos[i] = 0; 58 | } 59 | } 60 | const wave = this.audioContext.createPeriodicWave(cos, sin); 61 | const target = this.target; 62 | callbacks.push(function () { 63 | target.setPeriodicWave(wave); 64 | }); 65 | } 66 | } 67 | 68 | } 69 | 70 | global.Machines.PeriodicWave = PeriodicWaveMachine; 71 | 72 | })(window); 73 | -------------------------------------------------------------------------------- /js/sampler.js: -------------------------------------------------------------------------------- 1 | (function (global) { 2 | 'use strict'; 3 | 4 | const select = document.createElement('select'); 5 | select.id = 'recording-device'; 6 | let mediaStream, recorder; 7 | 8 | const Sampler = { 9 | devices: select, 10 | ondatarecorded: undefined, 11 | recording: false, 12 | requestAccess: requestAccess, 13 | startRecording: startRecording, 14 | stopRecording: stopRecording, 15 | cancelRecording: cancelRecording, 16 | }; 17 | 18 | function filterDevices(devices) { 19 | select.innerHTML = ''; 20 | let option = document.createElement('option'); 21 | option.innerText = 'Default Recording Device'; 22 | option.value = ''; 23 | select.appendChild(option); 24 | 25 | for (let i = 0; i < devices.length; i++) { 26 | const device = devices[i]; 27 | if (device.kind === 'audioinput') { 28 | option = document.createElement('option'); 29 | let label = device.label; 30 | if (label === '') { 31 | label = 'Device ' + String(i + 1); 32 | } 33 | option.innerText = label; 34 | option.value = device.deviceId; 35 | select.appendChild(option); 36 | } 37 | } 38 | } 39 | 40 | if (navigator.mediaDevices) { 41 | navigator.mediaDevices.enumerateDevices().then(filterDevices); 42 | navigator.mediaDevices.addEventListener('devicechange', function (event) { 43 | navigator.mediaDevices.enumerateDevices().then(filterDevices); 44 | }); 45 | } 46 | 47 | function stopStream() { 48 | for (let track of mediaStream.getTracks()) { 49 | track.stop(); 50 | } 51 | mediaStream = undefined; 52 | } 53 | 54 | function dataAvailable(event) { 55 | recorder = undefined; 56 | stopStream(); 57 | const reader = new FileReader(); 58 | reader.onloadend = function (event) { 59 | audioContext.decodeAudioData(this.result) 60 | .then(Sampler.ondatarecorded) 61 | }; 62 | reader.readAsArrayBuffer(event.data); 63 | } 64 | 65 | function requestAccess(constraints) { 66 | if (constraints === undefined) { 67 | constraints = {}; 68 | } 69 | const deviceID = select.value; 70 | if (deviceID === '') { 71 | constraints.deviceId = undefined; 72 | } else { 73 | constraints.deviceId = {exact: deviceID}; 74 | } 75 | return navigator.mediaDevices.getUserMedia({audio : constraints}) 76 | .then(function (stream) { 77 | mediaStream = stream; 78 | recorder = new MediaRecorder(stream); 79 | recorder.ondataavailable = dataAvailable; 80 | navigator.mediaDevices.enumerateDevices().then(filterDevices); 81 | }); 82 | } 83 | 84 | function startRecording() { 85 | recorder.start(); 86 | Sampler.recording = true; 87 | } 88 | 89 | function stopRecording() { 90 | recorder.stop(); 91 | Sampler.recording = false; 92 | } 93 | 94 | function cancelRecording() { 95 | recorder.ondataavailable = undefined; 96 | recorder.stop(); 97 | stopStream(); 98 | Sampler.recording = false; 99 | recorder = undefined; 100 | } 101 | 102 | global.Sampler = Sampler; 103 | 104 | })(window); 105 | -------------------------------------------------------------------------------- /js/audioworkletprocessors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class WavetableProcessor extends AudioWorkletProcessor { 4 | static get parameterDescriptors() { 5 | return [{ 6 | name: 'position', 7 | minValue: 0 8 | }]; 9 | } 10 | 11 | process(inputs, outputs, parameters) { 12 | const numInputs = inputs.length; 13 | const output = outputs[0][0]; 14 | const positions = parameters.position; 15 | 16 | if (positions.length === 1) { 17 | const position = positions[0] % numInputs; 18 | const lowerPosition = Math.trunc(position); 19 | const upperPosition = (lowerPosition + 1) % numInputs; 20 | const lowerWave = inputs[lowerPosition][0]; 21 | const upperWave = inputs[upperPosition][0]; 22 | const upperPortion = position - lowerPosition; 23 | const lowerPortion = 1 - upperPortion; 24 | for (let i = 0; i < 128; i++) { 25 | output[i] = lowerPortion * lowerWave[i] + upperPortion * upperWave[i]; 26 | } 27 | } else { 28 | for (let i = 0; i < 128; i++) { 29 | const position = positions[i] % numInputs; 30 | const lowerPosition = Math.trunc(position); 31 | const upperPosition = (lowerPosition + 1) % numInputs; 32 | const lowerWave = inputs[lowerPosition][0]; 33 | const upperWave = inputs[upperPosition][0]; 34 | const upperPortion = position - lowerPosition; 35 | const lowerPortion = 1 - upperPortion; 36 | output[i] = lowerPortion * lowerWave[i] + upperPortion * upperWave[i]; 37 | } 38 | } 39 | return true; 40 | } 41 | } 42 | 43 | registerProcessor('wavetable', WavetableProcessor); 44 | 45 | class ReciprocalProcessor extends AudioWorkletProcessor { 46 | static get parameterDescriptors() { 47 | return []; 48 | } 49 | 50 | process(inputs, outputs) { 51 | const input = inputs[0][0]; 52 | const output = outputs[0][0]; 53 | 54 | for (let i = 0; i < 128; i++) { 55 | output[i] = 1 / input[i]; 56 | } 57 | return true; 58 | } 59 | } 60 | 61 | registerProcessor('reciprocal', ReciprocalProcessor); 62 | 63 | class SampleAndHoldProcessor extends AudioWorkletProcessor { 64 | static get parameterDescriptors() { 65 | return [{ 66 | name: 'sampleRate', 67 | automationRate: 'k-rate', 68 | defaultValue: sampleRate, 69 | minValue: 0, 70 | maxValue: sampleRate, 71 | }]; 72 | } 73 | 74 | constructor() { 75 | super(); 76 | this.sample = 0; 77 | this.timeHeld = 0; 78 | } 79 | 80 | process(inputs, outputs, parameters) { 81 | const input = inputs[0][0]; 82 | const output = outputs[0][0]; 83 | const frequency = parameters.sampleRate[0]; 84 | 85 | if (frequency === sampleRate) { 86 | output.set(input); 87 | this.sample = input[127]; 88 | this.timeHeld = 0; 89 | return true; 90 | } 91 | 92 | const holdTime = sampleRate / frequency; 93 | let value = this.sample; 94 | let timeHeld = this.timeHeld; 95 | 96 | for (let i = 0; i < 128; i++) { 97 | timeHeld++; 98 | if (timeHeld >= holdTime) { 99 | value = input[i]; 100 | timeHeld -= holdTime; 101 | } 102 | output[i] = value; 103 | } 104 | this.sample = value; 105 | this.timeHeld = timeHeld; 106 | return true; 107 | } 108 | 109 | } 110 | 111 | registerProcessor('sample-and-hold', SampleAndHoldProcessor); 112 | -------------------------------------------------------------------------------- /js/machines/signal-follower.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | 'use strict'; 3 | 4 | class SignalFollowerMachine extends Machine { 5 | static Param = Synth.enumFromArray([ 6 | 'ATTACK', // Attack in milliseconds 7 | 'RELEASE', // Release in milliseconds 8 | 'SENSITIVITY', // Lowest frequency to respond to in Hertz 9 | ]); 10 | 11 | constructor(audioContext) { 12 | // Call the superclass constructor, passing it initial values for each of the 13 | // machine's parameters. 14 | super([ 15 | 0, 16 | 0, 17 | 100, 18 | ]); 19 | this.audioContext = audioContext; 20 | 21 | const rectifier = audioContext.createWaveShaper(); 22 | const arr = new Float32Array(257); 23 | for (let i = 0; i < 257; i++) { 24 | const x = (i - 128) / 128; 25 | arr[i] = x * x; 26 | } 27 | rectifier.curve = arr; 28 | 29 | const convolver = audioContext.createConvolver(); 30 | this.convolver = convolver; 31 | this.calcImpulse(); 32 | rectifier.connect(convolver); 33 | 34 | const gain = audioContext.createGain(); 35 | gain.gain.value = 2; 36 | convolver.connect(gain); 37 | 38 | const offset = audioContext.createConstantSource(); 39 | offset.offset.value = -1; 40 | offset.start(); 41 | 42 | // Connecting a node to this machine will connect that node to each of these 43 | // internal destinations. 44 | this.inputs = [rectifier]; 45 | 46 | // Connecting this machine to an external destination will connect each of these 47 | // internal nodes to the external destination. 48 | this.outputs = [gain, offset]; 49 | } 50 | 51 | calcImpulse() { 52 | const Parameter = SignalFollowerMachine.Param; 53 | const parameters = this.parameters; 54 | const sampleRate = this.audioContext.sampleRate; 55 | const attackLength = Math.round(sampleRate * parameters[Parameter.ATTACK] / 1000); 56 | const mainLength = Math.ceil(sampleRate / parameters[Parameter.SENSITIVITY]); 57 | const releaseLength = Math.round(sampleRate * parameters[Parameter.RELEASE] / 1000); 58 | const endMain = attackLength + mainLength; 59 | const totalLength = endMain + releaseLength; 60 | const attackGradient = 2 / attackLength; 61 | const releaseGradient = 2 / releaseLength; 62 | const buffer = this.audioContext.createBuffer(1, totalLength, sampleRate); 63 | const data = buffer.getChannelData(0); 64 | for (let i = 0; i < attackLength; i++) { 65 | data[i] = i * attackGradient - 1; 66 | } 67 | data.fill(1, attackLength, endMain); 68 | for (let i = 0; i < releaseLength; i++) { 69 | data[endMain + i] = 1 - i * releaseGradient; 70 | } 71 | this.convolver.buffer = buffer; 72 | } 73 | 74 | setParameters(changes, time, callbacks) { 75 | const Parameter = SignalFollowerMachine.Param; // Parameter names 76 | const me = this; // For referring to inside callbacks. 77 | let dirtyImpulse = false; 78 | 79 | for (let change of changes) { 80 | if (change.machine !== this) { 81 | continue; 82 | } 83 | 84 | const parameterNumber = change.parameterNumber; 85 | 86 | if (parameterNumber >= 0 && parameterNumber <= Parameter.SENSITIVITY) { 87 | dirtyImpulse = true; 88 | } else { 89 | console.error(this.constructor.name + ': An unknown parameter name was used.'); 90 | break; 91 | } 92 | } 93 | if (dirtyImpulse) { 94 | callbacks.push(function () { 95 | me.calcImpulse(); 96 | }); 97 | } 98 | } 99 | 100 | } 101 | 102 | global.Machines.SignalFollower = SignalFollowerMachine; 103 | 104 | })(window); 105 | -------------------------------------------------------------------------------- /js/machines/chebyshev.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | 'use strict'; 3 | 4 | const arrayLength = 2520; 5 | 6 | const factors = [ 7 | 105, 90, 84, 72, 70, 63, 60, 56, 45, 42, 40, 36, 35, 30, 28, 24, 21, 20, 18, 15, 14, 8 | 12, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 9 | ]; 10 | 11 | class ChebyshevMachine extends Machine { 12 | static get numberOfPolynomials() { 13 | return 10; 14 | } 15 | 16 | static Param = Synth.enumFromArray([ 17 | 'HARMONIC_1', // Weighting of T1(x) 18 | 'HARMONIC_2', // Weighting of T2(x) 19 | 'HARMONIC_3', // Weighting of T3(x) 20 | 'HARMONIC_4', // Weighting of T4(x) 21 | 'HARMONIC_5', // Weighting of T5(x) 22 | 'HARMONIC_6', // Weighting of T6(x) 23 | 'HARMONIC_7', // Weighting of T7(x) 24 | 'HARMONIC_8', // Weighting of T8(x) 25 | 'HARMONIC_9', // Weighting of T9(x) 26 | 'HARMONIC_10', // Weighting of T10(x) 27 | 'ODD', // Odd harmonics are multiplied by this amount 28 | 'EVEN', // Even harmonics are multiplied by this amount 29 | 'SLOPE', // Higher harmonics are reduced when this parameter is less than one. 30 | 'DRIVE', // Non-negative number. Zero is no distortion. 31 | 'OFFSET', // Amount of offset to add as a proportion of the amount of drive. 32 | 'ACCURACY', // An integer from 0 (least accurate) to 31 (most accurate) 33 | ]); 34 | 35 | constructor(audioContext) { 36 | // Call the superclass constructor, passing it initial values for each of the 37 | // machine's parameters. 38 | super([ 39 | 1, // Amount of 1st harmonic. Default to no distortion. 40 | 0, // Amount of 2nd harmonic. Default to no distortion. 41 | 0, // Amount of 3rd harmonic. Default to no distortion. 42 | 0, // Amount of 4th harmonic. Default to no distortion. 43 | 0, // Amount of 5th harmonic. Default to no distortion. 44 | 0, // Amount of 6th harmonic. Default to no distortion. 45 | 0, // Amount of 7th harmonic. Default to no distortion. 46 | 0, // Amount of 8th harmonic. Default to no distortion. 47 | 0, // Amount of 9th harmonic. Default to no distortion. 48 | 0, // Amount of 10th harmonic. Default to no distortion. 49 | 1, // Don't modify odd harmonic weightings. 50 | 1, // Don't modify even harmonic weightings. 51 | 1, // No slope. Don't modify harmonic weightings. 52 | 0, // No drive 53 | 0, // No offset 54 | 23, // Default accuracy (280 points) 55 | ]); 56 | 57 | // Here we create the machine's internal components using the Web Audio API. 58 | // In this case we just need a single WaveShaperNode. 59 | const shaper = audioContext.createWaveShaper(); 60 | this.shaper = shaper; 61 | 62 | // Connecting a node to this machine will connect that node to each of these 63 | // internal destinations. 64 | this.inputs = [shaper]; 65 | 66 | // Connecting this machine to an external destination will connect each of these 67 | // internal nodes to the external destination. 68 | this.outputs = [shaper]; 69 | } 70 | 71 | setParameters(changes, time, callbacks) { 72 | const Parameter = ChebyshevMachine.Param; // Parameter names 73 | const parameters = this.parameters; // Parameter values 74 | const me = this; // For referring to inside callbacks. 75 | let dirtyCurve = false; 76 | 77 | for (let change of changes) { 78 | if (change.machine !== this) { 79 | continue; 80 | } 81 | 82 | const parameterNumber = change.parameterNumber; 83 | 84 | if (parameterNumber === Parameter.ACCURACY) { 85 | // Ensure this parameter has an integer value between in the right range 86 | let value = Math.round(parameters[parameterNumber]); 87 | if (value < 0) { 88 | value = 0; 89 | } else if (value >= factors.length) { 90 | value = factors.length - 1; 91 | } 92 | parameters[Parameter.ACCURACY] = value; 93 | } 94 | 95 | if (parameterNumber >= 0 && parameterNumber <= Parameter.ACCURACY) { 96 | dirtyCurve = true; 97 | } else { 98 | console.error(this.constructor.name + ': An unknown parameter name was used.'); 99 | } 100 | } 101 | 102 | if (dirtyCurve) { 103 | // Compute the weightings 104 | const numCoefficients = ChebyshevMachine.numberOfPolynomials; 105 | const coefficients = new Array(numCoefficients); 106 | const odd = parameters[Parameter.ODD]; 107 | for (let i = 0; i < numCoefficients; i += 2) { 108 | coefficients[i] = parameters[i] * odd; 109 | } 110 | 111 | const even = parameters[Parameter.EVEN]; 112 | for (let i = 1; i < numCoefficients; i += 2) { 113 | coefficients[i] = parameters[i] * even; 114 | } 115 | 116 | const slope = parameters[Parameter.SLOPE]; 117 | if (slope !== 0) { 118 | const m = (slope - 1) / (numCoefficients - 1); 119 | for (let i = 0; i < numCoefficients; i++) { 120 | coefficients[i] = (m * i + 1) * coefficients[i]; 121 | } 122 | } 123 | 124 | // Compute the weighted sum of the polynomials 125 | const step = factors[parameters[Parameter.ACCURACY]]; 126 | const length = arrayLength / step; 127 | const curve = new Float32Array(length); 128 | for (let i = 0; i < length; i++) { 129 | const index = i * step; 130 | let value = 0; 131 | for (let j = 0; j < numCoefficients; j++) { 132 | value += coefficients[j] * chebyshevPolynomials[j][index]; 133 | } 134 | curve[i] = value; 135 | } 136 | 137 | // Normalize the curve and apply drive 138 | let min = curve[0]; 139 | let max = min; 140 | for (let i = 1; i < length; i++) { 141 | const value = curve[i]; 142 | if (value < min) { 143 | min = value; 144 | } else if (value > max) { 145 | max = value; 146 | } 147 | } 148 | const originalOffset = (min + max) / 2; 149 | const amplitude = (max - min) / 2; 150 | const drive = 1 + parameters[Parameter.DRIVE]; 151 | const offset = parameters[Parameter.OFFSET] * (drive - 1); 152 | for (let i = 0; i < length; i++) { 153 | let value = ((curve[i] - originalOffset) / amplitude) * drive + offset; 154 | if (value > 1) { 155 | value = 1; 156 | } else if (value < -1) { 157 | value = -1; 158 | } 159 | curve[i] = value; 160 | } 161 | 162 | callbacks.push(function () { 163 | me.shaper.curve = curve; 164 | }); 165 | } 166 | } 167 | 168 | } 169 | 170 | const numPolynomials = ChebyshevMachine.numberOfPolynomials; 171 | const chebyshevPolynomials = new Array(numPolynomials); 172 | for (let i = 0; i < numPolynomials; i++) { 173 | chebyshevPolynomials[i] = new Float32Array(arrayLength); 174 | } 175 | for (let i = 0; i < arrayLength; i++) { 176 | const x = 2 * i / arrayLength - 1; 177 | chebyshevPolynomials[0][i] = x; 178 | chebyshevPolynomials[1][i] = 2 * x * x -1; 179 | } 180 | for (let j = 2; j < numPolynomials; j++) { 181 | for (let i = 0; i < arrayLength; i++) { 182 | const x = 2 * i / arrayLength - 1; 183 | chebyshevPolynomials[j][i] = 2 * x * chebyshevPolynomials[j - 1][i] - chebyshevPolynomials[j - 2][i]; 184 | } 185 | } 186 | 187 | 188 | global.Machines.Chebyshev = ChebyshevMachine; 189 | 190 | })(window); 191 | -------------------------------------------------------------------------------- /design-notes.txt: -------------------------------------------------------------------------------- 1 | General 2 | ------- 3 | * Firefox compatibility 4 | 5 | Sequencer 6 | --------- 7 | URGENT 8 | * copyParameter and swapParameters should work between two columns (or the same column) of a pattern rather than (limited to) within a phrase (+turn into generators) 9 | option to alter LINE_TIME changes inside expand(), compact() and copyAndCompact 10 | * Phrase.transpose needs to transpose phrases not currently transposed 11 | * Handle when a pattern with nested phrases is expanded or compacted: Add a PHRASE_SPEED parameter? 12 | 13 | * Let PHRASE_TRANSPOSE work when rows are not aligned 14 | * Old chord should continue playing for a bit when using DELAY_TICKS 15 | * Bug when glissando and glide are used together 16 | * Review direct access to Change.value in playback routine 17 | * Support nested phrases in the master column 18 | * Allow columns to move at different tempos 19 | (Second kind of loop: column loop?) 20 | * Columns that exhaust their rows before another column completes start looping 21 | - Can designate one column to loop x times before moving onto the next pattern (otherwise the pattern ends when the longest column completes once (including normal loop parameters)) 22 | - If the same phrase is on the same channel in two consecutive patterns then have the option to continue playing without restarting ("free running", offset ignored) as a phrase property 23 | Phrase: 24 | * loop on/off (Reuse LOOP parameter, any non-zero value) 25 | * LOOP & BREAK parameters inside a phrase should be independent 26 | * loop start point (Reuse LOOP parameter) 27 | - "free running" on/off (implement using an undefined offset?) 28 | * Song playback shouldn't all be scheduled at once 29 | Actually a whole pattern should be scheduled at once so ChangeType.EXPONENTIAL works 30 | 31 | NEXT FEATURES 32 | Method to split/join patterns 33 | Method to quantize to every x lines (also reduce delays for x<1, set to 0 otherwise) 34 | If notes are triggered more than once or twice per quantum then quantize them to the 1/3, 1/2 & 2/3 waypoints or not at all (noteParameters) 35 | Quantize the durations also 36 | How to handle pattern/column delay? 37 | Method to quantize relative to another track 38 | Method to randomize/humanize 39 | Add PHRASE_VELOCITY parameter (dynamics) 40 | Song Loop From 41 | Freeze/Unfreeze phrase (render to buffer for better performance) 42 | 43 | UNDESIGNED 44 | Area for scratch, unused patterns and/or phrases 45 | Edit function to reverse a sequence of rows 46 | Initial song parameters for Channel 1,... Channel N 47 | 48 | MAYBES 49 | Phrase/pattern forwards/backwards (step 1, -1, maybe more) (how to counter messing up fades?) 50 | Changes that alternate/randomize between a fixed range of options on each pass 51 | Probability / Aleatoric / Chance / Indeterminate music 52 | Earle Brown, Twenty-Five Pages 53 | Terry Riley, In C 54 | Witold Lutosławski 55 | Graphic notation: https://en.wikipedia.org/wiki/Graphic_notation_(music) 56 | 57 | Sequencer UI 58 | ------------ 59 | ability to drag entire graph up or down, etc. 60 | ability to select multiple graph nodes 61 | 62 | Documentation 63 | ------------- 64 | Make Wiki complete and up to date 65 | 66 | MIDI/Input 67 | ---------- 68 | New modes: 69 | unison 70 | combinations with phrase trigger in lower octaves 71 | Metronome UI 72 | 73 | UI 74 | -- 75 | Bug: when MAX_WAVEFORM is changed while noise is active (fix during the rewrite) 76 | 77 | Sampler 78 | ------- 79 | UI: 80 | allow inserting exact number of samples of silence 81 | show clipping 82 | pencil tool (with cubic interpolation) 83 | Context menu: undo, redo | cut*, copy*, paste, paste mixed, trim* (to selection) | select all | set as loop (or set as loop start + set as loop end) 84 | * = not visible when there's no selection 85 | Pan without shift key, select with shift key 86 | Page down 87 | Shift+Ctrl+left/right should snap to zero crossing, loop points, start and end + Snap using mouse mode 88 | Trim to selection 89 | Scroll bar not visible on mobile -> Draw a custom one 90 | playback separately OR on a track 91 | UI for Parameter.OFFSET? ruler and snap 92 | samples need names 93 | method to create a sample by crossfading one sample into another 94 | Check Number.MAX_VALUE is being applied consistently 95 | editing stereo sample channels separately 96 | Time stretching 97 | Add custom offset/beat slice positions 98 | (e.g. see https://www.renoise.com/blog/Slice%20Markers%20Explained) 99 | MOD2Sample (advanced) 100 | 101 | Synth 102 | ----- 103 | * Glissando + glide simultaneously 104 | Choice of sample release action (fade or end loop) 105 | Reverb 106 | UI Rewrite 107 | Choice of New Note Action 108 | (release, cut, continue until gate closed or cut, duplicate notes of the same instrument always cut) 109 | Instruments 110 | Velocity & Pitch Automations 111 | Add "Noise Type 2" to UI 112 | Changes for 1 line vs changes over a pattern vs ongoing results? 113 | Allow choosing sampled notes by time as well as by pitch (for percussion)? 114 | 115 | Glide / Arpeggio 116 | ----- 117 | Glide amount (a time or a rate) 118 | Glide mode (time or rate) 119 | Handle gate triggered previously but not yet closed 120 | 121 | Noise 122 | ----- 123 | Noise through filter as a modulation source 124 | 125 | Low Priority 126 | ------------ 127 | Parse smpl and inst chunks of a WAV file. 128 | Parse more than one sample per IFF file + envelope chunks, etc. 129 | Make modals local to the component (Sampler, Synth...) (https://webkul.com/blog/how-to-display-a-bootstrap-modal-inside-a-div/) 130 | Insert silence modal: String should be fixed length, overtype entry, spinners increment current column 131 | 132 | ------ 133 | LFO modulating decay, etc. 134 | LFO rate modulated by an envelope 135 | LFO rate modulated by an LFO 136 | LFO amount modulated by an LFO 137 | **Pulse width modulated by an envelope** 138 | -------- 139 | 140 | -------- 141 | Method Phrase/Column Pattern Song 142 | clone X X 143 | fromArray/fromObj X X 144 | clear X X 145 | copy X X x 146 | equals X X 147 | expand X X 148 | compact X x 149 | copyAndCompact X 150 | fill X 151 | transpose X 152 | play X X x 153 | --------- 154 | merge (all, commands, notes)X x 155 | overwrite (all, commands, notes)X x 156 | insert (all, commands, notes)X x x 157 | insertEmpty X x 158 | remove X x x 159 | pause x 160 | goto x 161 | 162 | Method Sample Instrument 163 | clone x 164 | reverse f,t x 165 | pingPong x x 166 | amplitude f,t x 167 | removeOffset x x 168 | normalize f,t x 169 | amplify f,t x 170 | chord x x 171 | findZero x 172 | copy f,t 173 | remove f,t 174 | insert x 175 | insertSilence x 176 | mix x 177 | separateStereo x x 178 | mixToMono x x 179 | 180 | ------------------------------ 181 | 182 | * BPM 183 | * Groove 184 | Broken Chord 185 | *Number of Notes 186 | Chord Pattern 187 | * Glissando 188 | * Number of Triggers 189 | Legato 190 | Retrigger Volume 191 | 192 | events = lcm(numberOfNotes, numberOfTriggers) 193 | (multiply by an integer if needed to be at least as many as the glissando) 194 | 195 | ticks = events / noteValue (in sixteenths) 196 | (multiply by an integer if needed to be at least 1) 197 | 198 | totalTicks = noteValue * ticks 199 | 200 | retrigger = totalTicks / numberOfTriggers if numberOfTriggers > 0 201 | 0 otherwise 202 | chordSpeed = totalTicks / numberOfNotes 203 | 204 | glideTime = glidePercentage if there are chords 205 | glidePercentage * noteValue otherwise 206 | 207 | 208 | ------------------------------ 209 | 210 | Sample 211 | ------ 212 | slices 213 | 214 | Slice 215 | ----- 216 | startOffset 217 | endOffset 218 | duration 219 | triggerNote 220 | gain 221 | 222 | 223 | SamplePlayer / SlicePlayer 224 | -------------------------- 225 | bufferNode 226 | -duration (remove, move into start() method) 227 | samplePeriod 228 | gain 229 | *start() 230 | -------------------------------------------------------------------------------- /sample-editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ORSSUM: Online Retro Synthesizer & Sequencer for Unleashing Music 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 33 |
34 | 35 | 122 | 123 |
124 |
125 |
126 | 127 | 128 | 129 | 130 | 148 | 149 |
150 |
151 |
152 |
153 |
154 | 155 | 187 | 188 | 234 | 235 | 278 | 279 | 311 | 312 | 359 | 360 | 361 | -------------------------------------------------------------------------------- /js/input.js: -------------------------------------------------------------------------------- 1 | (function (global) { 2 | 'use strict'; 3 | 4 | class SynthInputEvent extends Event { 5 | constructor(channels, changes, timestamp) { 6 | super('synthinput'); 7 | this.channels = channels; 8 | this.changes = changes; 9 | this.timestamp = timestamp; 10 | } 11 | } 12 | 13 | class Input extends EventTarget { 14 | 15 | constructor(name, midiPortOrChCount) { 16 | super(); 17 | this.name = name; 18 | let numberOfChannels; 19 | if (midiPortOrChCount > 0) { 20 | numberOfChannels = Math.trunc(midiPortOrChCount); 21 | this.midiPort = undefined; 22 | } else { 23 | numberOfChannels = 16; 24 | this.midiPort = midiPortOrChCount; 25 | } 26 | 27 | /* Map each input channel to one or more synth channels. 28 | * If fromChannel[i] === undefined then the channel doesn't raise events. 29 | */ 30 | const fromChannel = new Array(numberOfChannels); 31 | const toChannel = new Array(numberOfChannels); 32 | this.fromChannel = fromChannel; 33 | this.toChannel = toChannel; 34 | fromChannel[0] = 0; 35 | 36 | /* The notes being recorded on each input channel in ascending pitch order if an 37 | * arpeggio is being played, or from first down to last down if not... 38 | */ 39 | this.notes = new Array(numberOfChannels); 40 | for (let i = 0; i < numberOfChannels; i++) { 41 | this.notes[i] = []; 42 | } 43 | 44 | /* ... and the synth channels that each of those notes is being played on, 45 | * or undefined if the note's not currently being played because of lack of an 46 | * available channel. 47 | */ 48 | this.notesToChannels = new Array(numberOfChannels); 49 | for (let i = 0; i < numberOfChannels; i++) { 50 | this.notesToChannels[i] = []; 51 | } 52 | 53 | /* For each input channel, whether holding down multiple notes creates an arpeggio 54 | * or if a last down priority is used. 55 | */ 56 | this.arpeggio = new Array(numberOfChannels); 57 | this.arpeggio.fill(false); 58 | 59 | /* For each input channel the intervals (zero based) of the broken chord to play. 60 | * If the input channel is given more than one synthesizer channel then multiple 61 | * broken chords (transposed versions of each other) can cycle simultaneously. 62 | */ 63 | this.chord = new Array(numberOfChannels); 64 | for (let i = 0; i < numberOfChannels; i++) { 65 | this.chord[i] = [0]; 66 | } 67 | 68 | // The gating option used to trigger new notes sent by each input channel. 69 | this.gate = new Array(numberOfChannels); 70 | this.gate.fill(Synth.Gate.OPEN); 71 | 72 | /* If a channel is locked down then releasing all keys pressed doesn't stop 73 | * the last released note from playing. It continues playing until a new key is pressed. 74 | */ 75 | this.lockDown = new Array(numberOfChannels); 76 | this.lockDown.fill(false); 77 | this.isLockedDown = new Array(numberOfChannels); 78 | this.isLockedDown.fill(false); 79 | 80 | // For each input channel, whether or not to retrigger revived notes. 81 | this.retrigger = new Array(numberOfChannels); 82 | this.retrigger.fill(false); 83 | 84 | /* For each input channel, whether there is glide only when overlapped notes are 85 | * revived (true), or glide both when receiving Note On messages and when reviving 86 | * notes (false, i.e. mono mode). 87 | */ 88 | this.legato = new Array(numberOfChannels); 89 | this.legato.fill(true); 90 | 91 | /* The change type used to implement glide. SET disables glide. EXPONENTIAL is 92 | * a normal glide. LINEAR is an alternative glide style. 93 | */ 94 | this.glide = new Array(numberOfChannels); 95 | this.glide.fill(Synth.ChangeType.EXPONENTIAL); 96 | 97 | /* A list of the order in which *synth* channels last had their notes released. 98 | * The most recently released channel number is at the end of the list. 99 | */ 100 | this.channelQueue = []; 101 | } 102 | 103 | get numberOfChannels() { 104 | return this.fromChannel.length; 105 | } 106 | 107 | enableArpeggio(channel, enabled) { 108 | if (enabled) { 109 | const oldNotes = this.notes[channel]; 110 | const newNotes = oldNotes.slice().sort(); 111 | const oldChannels = this.notesToChannels[channel]; 112 | const newChannels = []; 113 | for (let i = 0; i < newNotes.length; i++) { 114 | const index = oldNotes.indexOf(newNotes[i]); 115 | newChannels.push(oldChannels[index]); 116 | } 117 | this.notes[channel] = newNotes; 118 | this.notesToChannels[channel] = newChannels; 119 | } 120 | this.arpeggio[channel] = enabled; 121 | } 122 | 123 | setLockDown(channel, lockDown) { 124 | this.lockDown[channel] = lockDown; 125 | if (lockDown) { 126 | if (this.notes[channel].length === 0) { 127 | this.noteOn(channel, 60); 128 | this.isLockedDown[channel] = true; 129 | } 130 | } else if (this.isLockedDown[channel]) { 131 | this.noteOff(channel, this.notes[channel][0]); 132 | this.isLockedDown[channel] = false; 133 | } 134 | } 135 | 136 | parseMIDI(bytes) { 137 | const parameterMap = new Map(); 138 | const command = bytes[0] & 0xf0; 139 | const inputChannel = bytes[0] & 0x0f; 140 | const fromChannel = this.fromChannel[inputChannel]; 141 | let toChannel = this.toChannel[inputChannel]; 142 | 143 | if (fromChannel === undefined) { 144 | return [[], parameterMap]; 145 | } 146 | if (toChannel === undefined || toChannel < fromChannel) { 147 | toChannel = fromChannel; 148 | } 149 | 150 | let synthChannel; 151 | 152 | switch (command) { 153 | 154 | case 0x90: { // Note on 155 | const velocity = bytes[2]; 156 | 157 | if (velocity > 0) { 158 | const note = bytes[1]; 159 | const notes = this.notes[inputChannel]; 160 | const numNotes = notes.length; 161 | 162 | if (this.isLockedDown[inputChannel] && notes[0] !== note) { 163 | synthChannel = this.notesToChannels[inputChannel][0]; 164 | notes.splice(0, 1); 165 | this.notesToChannels[inputChannel] = []; 166 | } 167 | 168 | let changeType; 169 | if (this.legato[inputChannel]) { 170 | changeType = Synth.ChangeType.SET; 171 | } else { 172 | changeType = this.glide[inputChannel]; 173 | } 174 | 175 | if (this.arpeggio[inputChannel]) { 176 | if (synthChannel === undefined) { 177 | synthChannel = fromChannel; 178 | } 179 | let noteIndex = numNotes; 180 | while (noteIndex > 0 && notes[noteIndex - 1] > note) { 181 | noteIndex--; 182 | } 183 | if (noteIndex === 0 || note !== notes[noteIndex - 1]) { 184 | notes.splice(noteIndex, 0, note); 185 | this.notesToChannels[inputChannel].splice(noteIndex, 0, synthChannel); 186 | parameterMap.set(Synth.Param.NOTES, new Synth.Change(changeType, notes.slice())); 187 | } 188 | } else { 189 | const numChannels = toChannel - fromChannel + 1; 190 | const synthChannels = this.notesToChannels[inputChannel]; 191 | let noteIndex = notes.indexOf(note); 192 | let existingChannel; 193 | if (noteIndex !== -1) { 194 | // Check if note is already on. 195 | existingChannel = synthChannels[noteIndex]; 196 | if (existingChannel !== undefined) { 197 | synthChannel = existingChannel; 198 | } 199 | notes.splice(noteIndex, 1); 200 | synthChannels.splice(noteIndex, 1); 201 | } 202 | if (synthChannel === undefined) { 203 | if (numNotes < numChannels) { 204 | // Find a free channel. 205 | let minQueuePosition; 206 | for (let i = fromChannel; i <= toChannel; i++) { 207 | if (!synthChannels.includes(i)) { 208 | const queuePosition = this.channelQueue.indexOf(i); 209 | if (queuePosition === -1) { 210 | synthChannel = i; 211 | break; 212 | } else if (minQueuePosition === undefined || queuePosition < minQueuePosition) { 213 | minQueuePosition = queuePosition; 214 | } 215 | } 216 | } 217 | if (synthChannel === undefined) { 218 | synthChannel = this.channelQueue[minQueuePosition]; 219 | } 220 | } else { 221 | // Mute an existing note. 222 | for (let i = 0; i < numNotes; i++) { 223 | const channel = synthChannels[i]; 224 | if (channel !== undefined) { 225 | synthChannel = channel; 226 | synthChannels[i] = undefined; 227 | break; 228 | } 229 | } 230 | } 231 | } 232 | if (existingChannel === undefined) { 233 | const chord = calculateChord(this.chord[inputChannel], note); 234 | parameterMap.set(Synth.Param.NOTES, new Synth.Change(changeType, chord)); 235 | } 236 | notes.push(note); 237 | synthChannels.push(synthChannel); 238 | } 239 | 240 | parameterMap.set(Synth.Param.VELOCITY, new Synth.Change(Synth.ChangeType.SET, velocity)); 241 | let gate = this.gate[inputChannel]; 242 | if (this.lockDown[inputChannel]) { 243 | gate = gate & Synth.Gate.REOPEN; 244 | this.isLockedDown[inputChannel] = false; 245 | } 246 | parameterMap.set(Synth.Param.GATE, new Synth.Change(Synth.ChangeType.SET, gate)); 247 | break; 248 | } // else fall through if velocity == 0 249 | } 250 | 251 | case 0x80: { // Note off 252 | const note = bytes[1]; 253 | const notes = this.notes[inputChannel]; 254 | const numNotes = notes.length; 255 | if (numNotes === 1 && this.lockDown[inputChannel]) { 256 | this.isLockedDown[inputChannel] = true; 257 | return [[], parameterMap]; 258 | } 259 | const noteIndex = notes.indexOf(note); 260 | if (noteIndex !== -1) { 261 | const synthChannels = this.notesToChannels[inputChannel]; 262 | synthChannel = synthChannels[noteIndex]; 263 | notes.splice(noteIndex, 1); 264 | synthChannels.splice(noteIndex, 1); 265 | const changeType = this.glide[inputChannel]; 266 | let closeGate = false; 267 | 268 | if (this.arpeggio[inputChannel]) { 269 | if (numNotes === 1) { 270 | closeGate = true 271 | if (fromChannel !== synthChannel) { 272 | return [[fromChannel, synthChannel], parameterMap]; 273 | } 274 | } else if (synthChannel !== fromChannel) { 275 | closeGate = true; 276 | } else { 277 | parameterMap.set(Synth.Param.NOTES, new Synth.Change(changeType, notes.slice())); 278 | } 279 | } else { 280 | const numChannels = toChannel - fromChannel + 1; 281 | if (numNotes > numChannels && synthChannel >= fromChannel && synthChannel <= toChannel) { 282 | let revivedIndex = numNotes - 2; 283 | while (synthChannels[revivedIndex] !== undefined) { 284 | revivedIndex--; 285 | } 286 | const revivedNote = notes[revivedIndex]; 287 | const chord = calculateChord(this.chord[inputChannel], revivedNote); 288 | parameterMap.set(Synth.Param.NOTES, new Synth.Change(changeType, chord)); 289 | synthChannels[revivedIndex] = synthChannel; 290 | if (this.retrigger[inputChannel]) { 291 | const gate = this.gate[inputChannel]; 292 | parameterMap.set(Synth.Param.GATE, new Synth.Change(Synth.ChangeType.SET, gate)); 293 | } 294 | } else { 295 | const channelQueue = this.channelQueue; 296 | const queuePosition = channelQueue.indexOf(synthChannel); 297 | if (queuePosition === -1) { 298 | channelQueue.push(synthChannel); 299 | } else { 300 | channelQueue.copyWithin(queuePosition, queuePosition + 1); 301 | channelQueue[channelQueue.length - 1] = synthChannel; 302 | } 303 | closeGate = true; 304 | } 305 | } 306 | if (closeGate && 307 | (this.isLockedDown[inputChannel] || (this.gate[inputChannel] & Synth.Gate.TRIGGER) !== Synth.Gate.TRIGGER) 308 | ) { 309 | parameterMap.set(Synth.Param.GATE, new Synth.Change(Synth.ChangeType.SET, Synth.Gate.CLOSED)); 310 | } 311 | } 312 | break; 313 | } 314 | 315 | case 0xb0: { 316 | if (bytes[1] === 120) { // All sound off 317 | parameterMap.set(Synth.Param.GATE, new Synth.Change(Synth.ChangeType.SET, Synth.Gate.CUT)); 318 | const synthChannels = new Array(toChannel - fromChannel + 1); 319 | for (let i = fromChannel; i <= toChannel; i++) { 320 | synthChannels[i - fromChannel] = i; 321 | } 322 | this.notes[inputChannel] = []; 323 | this.notesToChannels[inputChannel] = []; 324 | this.isLockedDown[inputChannel] = false; 325 | return [synthChannels, parameterMap]; 326 | } 327 | break; 328 | } 329 | } 330 | if (synthChannel === undefined) { 331 | synthChannel = 0; 332 | } 333 | return [[synthChannel], parameterMap]; 334 | } 335 | 336 | parseAndDispatch(bytes, timestamp) { 337 | const [channels, parameterMap] = this.parseMIDI(bytes); 338 | if (channels.length > 0) { 339 | const event = new SynthInputEvent(channels, parameterMap, timestamp); 340 | this.dispatchEvent(event); 341 | } 342 | } 343 | 344 | noteOn(channel, note, velocity) { 345 | const timestamp = performance.now(); 346 | const bytes = [ 347 | 0x90 | channel, 348 | note, 349 | velocity === undefined ? 127 : velocity 350 | ]; 351 | this.parseAndDispatch(bytes, timestamp); 352 | } 353 | 354 | noteOff(channel, note) { 355 | const timestamp = performance.now(); 356 | const bytes = [ 357 | 0x80 | channel, 358 | note, 359 | 127 360 | ]; 361 | this.parseAndDispatch(bytes, timestamp); 362 | } 363 | 364 | allSoundOff() { 365 | const timestamp = performance.now(); 366 | const bytes = [0, 120]; 367 | const numberOfChannels = this.fromChannel.length; 368 | for (let i = 0; i < numberOfChannels; i++) { 369 | bytes[0] = 0xb0 | i; 370 | this.parseAndDispatch(bytes, timestamp); // All notes off 371 | } 372 | } 373 | 374 | open() { 375 | const port = this.midiPort; 376 | if (port !== undefined) { 377 | const me = this; 378 | return port.open().then(function (port) { 379 | port.onmidimessage = function (event) { 380 | me.parseAndDispatch(event.data, event.timestamp); 381 | }; 382 | return me; 383 | }); 384 | } 385 | } 386 | 387 | close() { 388 | const port = this.midiPort; 389 | let promise; 390 | if (port !== undefined) { 391 | port.onmidimessage = null; 392 | const me = this; 393 | promise = port.close().then(function (port) { 394 | return me; 395 | }); 396 | } else { 397 | promise = Promise.resolve(this); 398 | } 399 | this.allSoundOff(); 400 | return promise; 401 | } 402 | 403 | get manufacturer() { 404 | if (this.midiPort === undefined) { 405 | return null; 406 | } else { 407 | return this.midiPort.manufacturer; 408 | } 409 | } 410 | 411 | get version() { 412 | if (this.midiPort === undefined) { 413 | return null; 414 | } else { 415 | return this.midiPort.version; 416 | } 417 | } 418 | 419 | addEventListener(type, listener, options) { 420 | this.open(); 421 | super.addEventListener(type, listener, options); 422 | } 423 | 424 | } 425 | 426 | // Maps IDs to lazily created 'input objects'. 427 | const inputs = new Map(); 428 | const select = document.createElement('select'); 429 | select.id = 'input-port'; 430 | 431 | function addPort(id, name) { 432 | const option = document.createElement('option'); 433 | option.value = id; 434 | option.innerText = name; 435 | select.appendChild(option); 436 | } 437 | 438 | function removePort(id) { 439 | const element = select.querySelector(`option[value="${id}"]`); 440 | if (element !== null) { 441 | element.remove(); 442 | const input = inputs.get(id); 443 | if (input !== undefined) { 444 | input.allSoundOff(); 445 | inputs.delete(id); 446 | } 447 | } 448 | } 449 | 450 | function addCustomPort(id, input) { 451 | removePort(id); 452 | addPort(id, input.name); 453 | inputs.set(id, input); 454 | } 455 | 456 | function removeCustomPort(inputToRemove) { 457 | for (let [id, input] of inputs) { 458 | if (input === inputToRemove) { 459 | removePort(id); 460 | break; 461 | } 462 | } 463 | } 464 | 465 | if (window.parent !== window || window.opener !== null) { 466 | const webLink = new Input('WebMidiLink'); 467 | inputs.set('WebMidiLink', webLink); 468 | addPort('WebMidiLink', 'WebMidiLink'); 469 | 470 | function webMIDILinkReceive(event) { 471 | const message = event.data.split(','); 472 | if (message[0].toLowerCase() !== 'midi') { 473 | return; 474 | } 475 | const bytes = []; 476 | const numFields = message.length; 477 | for (let i = 1; i < numFields; i++) { 478 | const value = parseInt(message[i], 16); 479 | if (Number.isNaN(value)) { 480 | console.warn('Invalid WebMidiLink message received: ' + event.data); 481 | return; 482 | } 483 | bytes.push(value); 484 | } 485 | webLink.parseAndDispatch(bytes, performance.now()); 486 | } 487 | 488 | webLink.open = function () { 489 | window.addEventListener("message", webMIDILinkReceive); 490 | return Promise.resolve(webLink); 491 | } 492 | 493 | webLink.close = function () { 494 | window.removeEventListener("message", webMIDILinkReceive); 495 | webLink.allSoundOff(); 496 | return Promise.resolve(webLink); 497 | } 498 | } 499 | 500 | const keyboard = new Input('Computer Keyboard', 2); 501 | inputs.set('ComputerKeyboard', keyboard); 502 | keyboard.octave = 4; 503 | keyboard.split = 128; 504 | keyboard.velocity = 127; 505 | 506 | let access; 507 | 508 | function open() { 509 | if (navigator.requestMIDIAccess) { 510 | return navigator.requestMIDIAccess().then(function (midiAccess) { 511 | access = midiAccess; 512 | for (let [id, port] of midiAccess.inputs) { 513 | addPort(id, port.name || id); 514 | } 515 | access.onstatechange = function (event) { 516 | const port = event.port; 517 | if (port.type === 'input') { 518 | const id = port.id; 519 | if (port.state === 'connected') { 520 | addPort(id, port.name || id); 521 | } else { 522 | removePort(id); 523 | } 524 | } 525 | }; 526 | }); 527 | } else { 528 | return Promise.reject(new Error("Browser doesn't support Web MIDI.")); 529 | } 530 | } 531 | 532 | function close() { 533 | for (let input of inputs.values()) { 534 | input.close(); 535 | } 536 | if (access !== undefined) { 537 | access.onstatechange = null; 538 | for (let id of access.inputs.keys()) { 539 | removePort(id); 540 | } 541 | access = undefined; 542 | } 543 | } 544 | 545 | function port(id) { 546 | let input = inputs.get(id); 547 | 548 | if (input !== undefined) { 549 | return input; 550 | } 551 | 552 | if (access === undefined) { 553 | return undefined; 554 | } 555 | 556 | const midiPort = access.inputs.get(id); 557 | if (midiPort === undefined) { 558 | return undefined; 559 | } 560 | 561 | input = new Input(midiPort.name || id, midiPort); 562 | inputs.set(id, input); 563 | return input; 564 | } 565 | 566 | function calculateChord(pattern, rootNote) { 567 | const length = pattern.length; 568 | const result = new Array(length); 569 | for (let i = 0; i < length; i++) { 570 | result[i] = rootNote + pattern[i]; 571 | } 572 | return result; 573 | } 574 | 575 | function trapKeyboardEvent(event) { 576 | if (event.repeat || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { 577 | return false; 578 | } 579 | 580 | const element = document.activeElement; 581 | if (element === null) return true; 582 | const tagName = element.tagName; 583 | 584 | if (tagName === 'INPUT') { 585 | const type = element.type; 586 | return type === 'button' || type === 'checkbox' || type === 'color' || 587 | type === 'file' || type === 'radio' || type === 'range' || type === 'reset'; 588 | } else { 589 | return tagName !== 'TEXTAREA'; 590 | } 591 | } 592 | 593 | function keyboardChannel(note) { 594 | if (note >= keyboard.split) { 595 | return 1; 596 | } else { 597 | return 0; 598 | } 599 | } 600 | 601 | window.addEventListener('keydown', function (event) { 602 | if (!trapKeyboardEvent(event)) { 603 | return; 604 | } 605 | 606 | const code = event.code; 607 | 608 | if (code === 'Quote') { 609 | keyboard.allSoundOff(); 610 | event.preventDefault(); 611 | } else if (code === 'NumpadDivide') { 612 | keyboard.octave = Math.max(keyboard.octave - 1, 0); 613 | event.preventDefault(); 614 | } else if (code === 'NumpadMultiply') { 615 | keyboard.octave = Math.min(keyboard.octave + 1, 7); 616 | event.preventDefault(); 617 | } else { 618 | let note = keymap.get(code); 619 | if (note === undefined) { 620 | return; 621 | } 622 | event.preventDefault(); 623 | const channel = keyboardChannel(note); 624 | note = note + (keyboard.octave - 4) * 12; 625 | if (note < 0) { 626 | return; 627 | } 628 | keyboard.noteOn(channel, note, keyboard.velocity); 629 | } 630 | }); 631 | 632 | window.addEventListener('keyup', function (event) { 633 | if (!trapKeyboardEvent(event)) { 634 | return; 635 | } 636 | let note = keymap.get(event.code); 637 | if (note === undefined) { 638 | return; 639 | } 640 | event.preventDefault(); 641 | const channel = keyboardChannel(note); 642 | note = note + (keyboard.octave - 4) * 12; 643 | if (note < 0) { 644 | return; 645 | } 646 | keyboard.noteOff(channel, note); 647 | }); 648 | 649 | 650 | window.addEventListener('blur', function (event) { 651 | if (typeof(debug) !== 'object' || !debug.input) { 652 | keyboard.allSoundOff(); 653 | } 654 | }); 655 | 656 | const keymap = new Map(); 657 | keymap.set('IntlBackslash', 47); 658 | keymap.set('KeyZ', 48); 659 | keymap.set('KeyS', 49); 660 | keymap.set('KeyX', 50); 661 | keymap.set('KeyD', 51); 662 | keymap.set('KeyC', 52); 663 | keymap.set('KeyV', 53); 664 | keymap.set('KeyG', 54); 665 | keymap.set('KeyB', 55); 666 | keymap.set('KeyH', 56); 667 | keymap.set('KeyN', 57); 668 | keymap.set('KeyJ', 58); 669 | keymap.set('KeyM', 59); 670 | keymap.set('Comma', 60); 671 | keymap.set('KeyL', 61); 672 | keymap.set('Period', 62); 673 | keymap.set('Semicolon', 63); 674 | keymap.set('Slash', 64); 675 | keymap.set('KeyQ', 60); 676 | keymap.set('Digit2', 61); 677 | keymap.set('KeyW', 62); 678 | keymap.set('Digit3', 63); 679 | keymap.set('KeyE', 64); 680 | keymap.set('KeyR', 65); 681 | keymap.set('Digit5', 66); 682 | keymap.set('KeyT', 67); 683 | keymap.set('Digit6', 68); 684 | keymap.set('KeyY', 69); 685 | keymap.set('Digit7', 70); 686 | keymap.set('KeyU', 71); 687 | keymap.set('KeyI', 72); 688 | keymap.set('Digit9', 73); 689 | keymap.set('KeyO', 74); 690 | keymap.set('Digit0', 75); 691 | keymap.set('KeyP', 76); 692 | keymap.set('BracketLeft', 77); 693 | keymap.set('Equal', 78); 694 | keymap.set('BracketRight', 79); 695 | 696 | global.MusicInput = { 697 | Port: Input, 698 | SynthInputEvent: SynthInputEvent, 699 | open: open, 700 | close: close, 701 | keyboard: keyboard, 702 | port: port, 703 | ports: select, 704 | addPort: addCustomPort, 705 | removePort: removeCustomPort, 706 | chord: calculateChord, 707 | trapKeyboardEvent: trapKeyboardEvent, 708 | }; 709 | 710 | })(window); 711 | -------------------------------------------------------------------------------- /frontend/app.js: -------------------------------------------------------------------------------- 1 | const BUFFER_LENGTH = 5; 2 | const audioContext = new AudioContext({latencyHint: 0.06}); 3 | let debug = {input: false}; 4 | const system = new Synth.System(audioContext, initialize); 5 | const channels = system.channels; 6 | const keyboard = MusicInput.keyboard; 7 | const generator = new SongGenerator(); 8 | let gateTemporarilyOpen = false; 9 | let inputPort, inputChannel = 0, chord = [0]; 10 | let numRecordings = 0; 11 | 12 | document.getElementById('recording-device').prepend(Sampler.devices); 13 | 14 | { 15 | const select = MusicInput.ports; 16 | const div = document.getElementById('input-config'); 17 | div.insertBefore(select, div.children[0]); 18 | 19 | select.addEventListener('input', function (event) { 20 | if (inputPort !== undefined) { 21 | inputPort.close(); 22 | } 23 | inputPort = MusicInput.port(this.value); 24 | inputPort.open(); 25 | inputPort.addEventListener('synthinput', processInput); 26 | applyInputMode(); 27 | applyGateSetting(); 28 | }); 29 | } 30 | 31 | document.getElementById('btn-fill-in').addEventListener('click', function (event) { 32 | const phrase = generator.generatePhrase(); 33 | system.stop(); 34 | phrase.play(system); 35 | }); 36 | 37 | document.getElementById('chord').addEventListener('input', function (event) { 38 | chord = [0]; 39 | const chordText = this.value; 40 | const strLen = chordText.length; 41 | let charIndex = 0; 42 | while (charIndex < strLen) { 43 | let char = chordText[charIndex]; 44 | let interval; 45 | if (char === '-') { 46 | charIndex++; 47 | if (charIndex < strLen) { 48 | interval = -parseInt(chordText[charIndex], 36); 49 | } else { 50 | break; 51 | } 52 | } else { 53 | interval = parseInt(chordText[charIndex], 36) - 1; 54 | } 55 | charIndex++; 56 | if (!Number.isNaN(interval)) { 57 | chord.push(interval); 58 | } 59 | } 60 | if (document.getElementById('input-mode-transpose-chord').checked) { 61 | keyboard.chord[0] = chord; 62 | if (inputPort !== undefined) { 63 | inputPort.chord[inputChannel] = chord; 64 | } 65 | } 66 | playNote(); 67 | }); 68 | 69 | function applyInputMode() { 70 | if (inputPort === undefined) { 71 | return; 72 | } 73 | 74 | inputPort.legato[inputChannel] = false; 75 | 76 | if (document.getElementById('input-mode-mono').checked) { 77 | inputPort.toChannel[inputChannel] = 0; 78 | inputPort.enableArpeggio(inputChannel, false); 79 | inputPort.chord[inputChannel] = [0]; 80 | inputPort.setLockDown(inputChannel, false); 81 | } else if (document.getElementById('input-mode-poly').checked) { 82 | inputPort.toChannel[inputChannel] = channels.length - 1; 83 | inputPort.enableArpeggio(inputChannel, false); 84 | inputPort.chord[inputChannel] = [0]; 85 | inputPort.setLockDown(inputChannel, false); 86 | } else if (document.getElementById('input-mode-arp').checked) { 87 | inputPort.enableArpeggio(inputChannel, true); 88 | inputPort.setLockDown(inputChannel, false); 89 | } else { // Transposed chord mode 90 | inputPort.toChannel[inputChannel] = channels.length - 1; 91 | inputPort.enableArpeggio(inputChannel, false); 92 | inputPort.chord[inputChannel] = chord; 93 | inputPort.lockDown[inputChannel] = true; 94 | } 95 | } 96 | 97 | function applyGateSetting() { 98 | let gate; 99 | if (document.getElementById('one-shot').checked) { 100 | gate = Synth.Gate.TRIGGER; 101 | } else { 102 | gate = Synth.Gate.OPEN; 103 | } 104 | if (document.getElementById('legato').checked) { 105 | gate = gate + Synth.Gate.LEGATO; 106 | } 107 | 108 | if (keyboard.split === 128) { 109 | keyboard.gate[0] = gate; 110 | } else { 111 | keyboard.gate[0] = gate & Synth.Gate.REOPEN; 112 | } 113 | keyboard.gate[1] = gate; 114 | 115 | if (inputPort !== undefined) { 116 | inputPort.gate[inputChannel] = gate; 117 | } 118 | } 119 | 120 | document.getElementById('input-channel').addEventListener('input', function (event) { 121 | if (inputPort === undefined) { 122 | return; 123 | } 124 | 125 | const newChannel = parseInt(this.value); 126 | inputPort.fromChannel[inputChannel] = undefined; 127 | inputPort.fromChannel[newChannel] = 0; 128 | inputChannel = newChannel; 129 | applyInputMode(); 130 | applyGateSetting(); 131 | }); 132 | 133 | document.getElementById('input-mode-mono').addEventListener('input', function (event) { 134 | keyboard.split = 128; 135 | keyboard.toChannel[0] = 0; 136 | keyboard.enableArpeggio(0, false); 137 | keyboard.chord[0] = [0]; 138 | keyboard.setLockDown(0, false); 139 | applyInputMode(); 140 | }); 141 | 142 | document.getElementById('input-mode-poly').addEventListener('input', function (event) { 143 | keyboard.split = 128; 144 | keyboard.toChannel[0] = channels.length - 1; 145 | keyboard.enableArpeggio(0, false); 146 | keyboard.chord[0] = [0]; 147 | keyboard.setLockDown(0, false); 148 | applyInputMode(); 149 | }); 150 | 151 | document.getElementById('input-mode-arp').addEventListener('input', function (event) { 152 | keyboard.split = 128; 153 | keyboard.enableArpeggio(0, true); 154 | keyboard.setLockDown(0, false); 155 | applyInputMode(); 156 | }); 157 | 158 | document.getElementById('input-mode-transpose-chord').addEventListener('input', function (event) { 159 | keyboard.split = 60; 160 | keyboard.toChannel[0] = 0; 161 | keyboard.enableArpeggio(0, false); 162 | keyboard.chord[0] = chord; 163 | keyboard.lockDown[0] = true; 164 | applyInputMode(); 165 | }); 166 | 167 | function toggleSound() { 168 | const transposeMode = document.getElementById('input-mode-transpose-chord').checked; 169 | if (keyboard.isLockedDown[0]) { 170 | if (transposeMode) { 171 | keyboard.allSoundOff(); 172 | } else { 173 | keyboard.setLockDown(0, false); 174 | } 175 | } else { 176 | keyboard.setLockDown(0, true); 177 | } 178 | if (inputPort !== undefined) { 179 | if (inputPort.isLockedDown[inputChannel]) { 180 | if (transposeMode) { 181 | inputPort.allSoundOff(); 182 | } else { 183 | inputPort.setLockDown(inputChannel, false); 184 | } 185 | } else { 186 | inputPort.setLockDown(inputChannel, true); 187 | } 188 | } 189 | } 190 | 191 | window.addEventListener('blur', function (event) { 192 | const transposeMode = document.getElementById('input-mode-transpose-chord').checked; 193 | if (!transposeMode) { 194 | keyboard.lockDown[0] = false; 195 | } 196 | }); 197 | 198 | system.ondatarecorded = function (blob) { 199 | const mediaElement = document.getElementById('recording'); 200 | if (mediaElement.src.startsWith('blob:')) { 201 | URL.revokeObjectURL(mediaElement.src); 202 | } 203 | mediaElement.src = URL.createObjectURL(blob); 204 | } 205 | 206 | function set(parameterNumber, value, delay, changeType, channelNumber) { 207 | if (channelNumber === undefined) { 208 | channelNumber = -1; 209 | } 210 | system.set(parameterNumber, value, delay, changeType, channelNumber); 211 | } 212 | 213 | function setMacro(macro, value, delay, changeType, channelNumber) { 214 | system.setMacro(macro, value, delay, changeType, channelNumber); 215 | } 216 | 217 | function setMachine(machine, parameterNumber, value, delay, changeType, channelNumber) { 218 | system.setMachine(machine, parameterNumber, value, delay, changeType, channelNumber); 219 | } 220 | 221 | function setTempoAutomation(parameterNumber, power, channelNumber) { 222 | system.setTempoAutomation(parameterNumber, power, channelNumber); 223 | } 224 | 225 | function removeTempoAutomation(parameterNumber, channelNumber) { 226 | system.removeTempoAutomation(parameterNumber, channelNumber); 227 | } 228 | 229 | function processInput(event) { 230 | if (debug.input) { 231 | console.log('Input received.'); 232 | console.log('Synth channels: ' + event.channels); 233 | console.log('Parameters:'); 234 | for (let [key, change] of event.changes) { 235 | console.log('\t' + key + ' -> ' + change.value); 236 | } 237 | } 238 | if (this !== inputPort && this !== keyboard) { 239 | return; 240 | } 241 | 242 | const changes = event.changes; 243 | const noteChange = changes.get(Synth.Param.NOTES); 244 | if (noteChange !== undefined) { 245 | const note = noteChange.value[0]; 246 | document.getElementById('note').value = note; 247 | document.getElementById('frequency').value = channels[0].noteFrequencies[note]; 248 | } 249 | const numChannels = channels.length; 250 | for (let channelNumber of event.channels) { 251 | if (channelNumber < numChannels) { 252 | channels[channelNumber].setParameters(changes, undefined, true); 253 | } 254 | } 255 | } 256 | 257 | // Sends a simulated MIDI message. 258 | function testInput(channel, command, ...data) { 259 | const bytes = [command | channel].concat(data); 260 | keyboard.parseAndDispatch(bytes); 261 | } 262 | 263 | function initialize() { 264 | const channel1 = new Synth.Channel(system); 265 | const channel2 = new Synth.Channel(system); 266 | channel2.connect(channel1); 267 | 268 | function initializeInput() { 269 | const inputName = MusicInput.ports.value; 270 | if (inputName !== '') { 271 | inputPort = MusicInput.port(inputName); 272 | inputPort.addEventListener('synthinput', processInput); 273 | inputPort.toChannel[0] = channels.length - 1; 274 | inputPort.legato[0] = false; 275 | } 276 | } 277 | 278 | MusicInput.open().then(initializeInput); 279 | keyboard.addEventListener('synthinput', processInput); 280 | keyboard.toChannel[0] = channels.length - 1; 281 | keyboard.fromChannel[1] = 1; 282 | keyboard.legato[0] = false; 283 | keyboard.legato[1] = false; 284 | 285 | const parameterMap = new Map(); 286 | parameterMap.set(Synth.Param.GLIDE, new Synth.Change(Synth.ChangeType.SET, 0)); 287 | parameterMap.set(Synth.Param.FILTER_MIX, new Synth.Change(Synth.ChangeType.SET, 0)); 288 | parameterMap.set(Synth.Param.UNFILTERED_MIX, new Synth.Change(Synth.ChangeType.SET, 100)); 289 | parameterMap.set(Synth.Param.ATTACK_CURVE, new Synth.Change(Synth.ChangeType.SET, 3)); 290 | parameterMap.set(Synth.Param.DELAY, new Synth.Change(Synth.ChangeType.SET, 1)); 291 | channels[0].setParameters(parameterMap); 292 | channels[1].setParameters(parameterMap); 293 | 294 | sendNewLine(); 295 | setInterval(sendNewLine, BUFFER_LENGTH * 20); 296 | 297 | const piano = new Synth.SampledInstrument('Acoustic Grand Piano'); 298 | system.instruments[0] = piano; 299 | piano.loadSampleFromURL(audioContext, 0, 'samples/acoustic-grand-piano.wav').catch(resourceError); 300 | const guitar = new Synth.SampledInstrument('Guitar Strum'); 301 | system.instruments[1] = guitar; 302 | guitar.loadSampleFromURL(audioContext, 0, 'samples/guitar-strum.wav').catch(resourceError); 303 | 304 | const violin = new Synth.SampledInstrument('Violin'); 305 | system.instruments[2] = violin; 306 | violin.loadSampleFromURL(audioContext, 0, 'samples/violin.wav').catch(resourceError); 307 | } 308 | 309 | function begin() { 310 | audioContext.resume(); 311 | system.start(); 312 | document.getElementById('intro').style.display = 'none'; 313 | document.getElementById('controls').style.display = 'block'; 314 | const patreonScript = document.createElement('SCRIPT'); 315 | patreonScript.async = true; 316 | document.getElementById('patreon').appendChild(patreonScript); 317 | patreonScript.src = 'https://c6.patreon.com/becomePatronButton.bundle.js'; 318 | resizeGraph(); 319 | window.addEventListener('resize', resizeGraph); 320 | } 321 | 322 | const emptyMap = new Map(); 323 | function sendNewLine() { 324 | const notesOn = (keyboard.notes[0].length > 0 || 325 | (inputPort !== undefined && inputPort.notes[inputChannel].length > 0) 326 | ); 327 | if ( 328 | notesOn || 329 | Math.abs(channels[0].glissandoStepsDone) < Math.abs(channels[0].parameters[Synth.Param.GLISSANDO]) || 330 | Math.abs(channels[1].glissandoStepsDone) < Math.abs(channels[1].parameters[Synth.Param.GLISSANDO]) 331 | ) { 332 | const now = system.nextStep(); 333 | let nextLine = Math.max(now, system.nextLine); 334 | const bufferUntil = now + BUFFER_LENGTH; 335 | while (nextLine <= bufferUntil) { 336 | channels[0].setParameters(emptyMap, nextLine, true); 337 | channels[1].setParameters(emptyMap, nextLine, true); 338 | const newNextLine = system.nextLine; 339 | if (newNextLine > nextLine) { 340 | nextLine = newNextLine; 341 | } else{ 342 | break; 343 | } 344 | } 345 | } 346 | } 347 | 348 | function playNote() { 349 | const note = parseInt(document.getElementById('note').value); 350 | const gate = keyboard.gate[0]; 351 | keyboard.gate[0] = Synth.Gate.LEGATO_TRIGGER; 352 | keyboard.noteOn(0, note); 353 | sendNewLine(); 354 | keyboard.noteOff(0, note); 355 | keyboard.gate[0] = gate; 356 | } 357 | 358 | function openGateTemporarily() { 359 | if ((channels[0].parameters[Synth.Param.GATE] & Synth.Gate.TRIGGER) !== Synth.Gate.OPEN) { 360 | set(Synth.Param.GATE, Synth.Gate.REOPEN); 361 | gateTemporarilyOpen = true; 362 | } 363 | } 364 | 365 | function closeGateOpenedTemporarily() { 366 | if (gateTemporarilyOpen) { 367 | set(Synth.Param.GATE, Synth.Gate.CLOSED); 368 | gateTemporarilyOpen = false; 369 | } 370 | } 371 | 372 | function calcGroove(str) { 373 | const strings = str.split(','); 374 | const groove = []; 375 | for (s of strings) { 376 | const number = parseFloat(s); 377 | if (number >= 1) { 378 | groove.push(number); 379 | } 380 | } 381 | if (groove.length === 0) { 382 | groove[0] = parseFloat(document.getElementById('line-time').value); 383 | } else if (groove.length === 1) { 384 | document.getElementById('line-time').value = groove[0]; 385 | } 386 | set(Synth.Param.GROOVE, groove); 387 | } 388 | 389 | function resourceError(error) { 390 | console.error(error.source + ': ' + error.message); 391 | } 392 | 393 | function addInstrumentToList(instrument) { 394 | return function (resource) { 395 | const instrumentNumber = system.instruments.length; 396 | system.instruments[instrumentNumber] = instrument; 397 | const dropDown = document.getElementById('sample-list'); 398 | const option = document.createElement('option'); 399 | option.value = instrumentNumber + 1; 400 | option.innerText = instrument.name; 401 | dropDown.appendChild(option); 402 | }; 403 | } 404 | 405 | function uploadSamples() { 406 | const files = document.getElementById('sample-upload').files; 407 | for (let i = 0; i < files.length; i++) { 408 | const name = files[i].name.replace(/\.\w*$/, '') 409 | const instrument = new Synth.SampledInstrument(name); 410 | instrument.loadSampleFromFile(audioContext, 0, files[i]) 411 | .then(addInstrumentToList(instrument)); 412 | } 413 | } 414 | 415 | function pauseRecording() { 416 | if (system.recordingState === 'paused') { 417 | system.resumeRecording(); 418 | } else { 419 | system.requestRecording(); 420 | system.pauseRecording(); 421 | } 422 | } 423 | 424 | Sampler.ondatarecorded = function (buffer) { 425 | numRecordings++; 426 | const name = 'Recording ' + numRecordings; 427 | const instrument = new Synth.SampledInstrument(name); 428 | const sample = new Synth.Sample(buffer); 429 | instrument.addSample(0, sample); 430 | const instrumentNumber = system.instruments.length; 431 | system.instruments[instrumentNumber] = instrument; 432 | const dropDown = document.getElementById('sample-list'); 433 | const option = document.createElement('option'); 434 | option.value = instrumentNumber + 1; 435 | option.innerText = name; 436 | dropDown.appendChild(option); 437 | } 438 | 439 | document.getElementById('sampler-btn').addEventListener('click', function (event) { 440 | if (Sampler.recording) { 441 | Sampler.stopRecording(); 442 | event.currentTarget.children[0].src = 'img/record.png'; 443 | } else { 444 | Sampler.requestAccess().then(function () { 445 | Sampler.startRecording(); 446 | document.getElementById('sampler-btn').children[0].src = 'img/stop.png'; 447 | }); 448 | } 449 | }); 450 | 451 | let graphPointsX = [0, 15, 17, 31]; 452 | let graphPointsY = [-1, 0.125, -0.125, 1]; 453 | 454 | function updateGraphedSound() { 455 | if (channels !== undefined) { 456 | const parameterMap = new Map(); 457 | parameterMap.set(Synth.Param.WAVE_X, new Synth.Change(Synth.ChangeType.SET, graphPointsX)); 458 | parameterMap.set(Synth.Param.WAVE_Y, new Synth.Change(Synth.ChangeType.SET, graphPointsY)); 459 | channels[0].setParameters(parameterMap); 460 | channels[1].setParameters(parameterMap); 461 | } 462 | } 463 | 464 | const canvas = document.getElementById('graph-canvas'); 465 | const context2d = canvas.getContext('2d'); 466 | const graphMarkSize = 7; 467 | const graphRowColors = ['#e8e8e8', '#d0d0d0']; 468 | let graphWidth, graphHeight, graphMidY; 469 | let graphUnitX, graphGridHeight, graphSnapY = true; 470 | let graphMouseX, graphMouseY, graphChangeX, graphChangeY; 471 | 472 | function drawGraph() { 473 | context2d.clearRect(-1 - graphMarkSize / 2, -1 - graphMarkSize / 2, canvas.width + 2, canvas.height + 2); 474 | const rowHeight = graphHeight / graphGridHeight; 475 | let colorIndex = 0; 476 | for (let y = -rowHeight / 2; y < graphHeight; y = y + rowHeight) { 477 | context2d.fillStyle = graphRowColors[colorIndex]; 478 | context2d.fillRect(0, y, graphWidth, rowHeight); 479 | colorIndex = (colorIndex + 1) % 2; 480 | } 481 | 482 | context2d.beginPath(); 483 | context2d.moveTo(0, graphMidY); 484 | context2d.lineTo(graphWidth, graphMidY); 485 | context2d.strokeStyle = 'grey'; 486 | context2d.stroke(); 487 | 488 | const numValues = graphPointsX.length; 489 | context2d.beginPath(); 490 | context2d.moveTo(0, Math.round(graphMidY - graphPointsY[0] * graphMidY)); 491 | for (let i = 1; i < numValues; i++) { 492 | const x = Math.round(graphPointsX[i] * graphUnitX); 493 | const y = Math.round(graphMidY - graphPointsY[i] * graphMidY); 494 | context2d.lineTo(x, y); 495 | } 496 | context2d.strokeStyle = 'black'; 497 | context2d.stroke(); 498 | 499 | context2d.fillStyle = 'black'; 500 | for (let i = 0; i < numValues; i++) { 501 | const x = Math.round(graphPointsX[i] * graphUnitX); 502 | const y = Math.round(graphMidY - graphPointsY[i] * graphMidY); 503 | context2d.fillRect(x - graphMarkSize / 2, y - graphMarkSize / 2, graphMarkSize, graphMarkSize); 504 | } 505 | if (graphMouseX !== undefined) { 506 | context2d.fillStyle = 'red'; 507 | context2d.fillRect(graphMouseX * graphUnitX - graphMarkSize / 2, graphMidY - graphMouseY * graphMidY - graphMarkSize / 2, graphMarkSize, graphMarkSize); 508 | } 509 | } 510 | 511 | function resizeGraph() { 512 | const canvasWidth = canvas.clientWidth; 513 | canvas.width = canvasWidth; 514 | const numValues = graphPointsX.length; 515 | const maxX = graphPointsX[numValues - 1]; 516 | graphWidth = Math.min( 517 | Math.max( 518 | Math.trunc((canvasWidth - graphMarkSize) * maxX) / maxX, 519 | graphMarkSize * maxX 520 | ), 521 | (graphMarkSize + 4) * maxX 522 | ); 523 | graphUnitX = graphWidth / maxX; 524 | 525 | graphHeight = canvas.height - graphMarkSize; 526 | graphGridHeight = document.getElementById('graph-grid-y').value; 527 | if (graphGridHeight <= 16) { 528 | graphHeight = Math.ceil(150 / graphGridHeight) * graphGridHeight; 529 | } else { 530 | graphHeight = (graphMarkSize + 2) * graphGridHeight; 531 | } 532 | const canvasHeight = graphHeight + graphMarkSize; 533 | canvas.height = canvasHeight; 534 | canvas.style.height = canvasHeight + 'px'; 535 | graphMidY = graphHeight / 2; 536 | 537 | context2d.setTransform(1, 0, 0, 1, graphMarkSize / 2, graphMarkSize / 2); 538 | requestAnimationFrame(drawGraph); 539 | } 540 | 541 | function resampleGraphPoints() { 542 | const numValues = graphPointsX.length; 543 | const currentSize = graphPointsX[numValues - 1] + 1; 544 | const textbox = document.getElementById('graph-width'); 545 | let newSize = parseInt(textbox.value); 546 | const maxSize = Math.trunc((canvas.width - graphMarkSize) / graphMarkSize); 547 | if (newSize > maxSize) { 548 | newSize = maxSize; 549 | textbox.value = newSize; 550 | } 551 | if (newSize === currentSize) { 552 | return; 553 | } 554 | 555 | const multiplier = (newSize - 1) / (currentSize- 1); 556 | const newX = [0]; 557 | const newY = [graphPointsY[0]]; 558 | let prevX = 0; 559 | for (let i = 1; i < numValues - 1; i++) { 560 | let x = Math.round(graphPointsX[i] * multiplier); 561 | let y = graphPointsY[i]; 562 | if (x === prevX) { 563 | x++; 564 | } 565 | if (x >= newSize - 1) { 566 | if (x === prevX + 1) { 567 | let midValue = (newY[i - 1] + y) / 2; 568 | if (graphSnapY) { 569 | const halfGridHeight = graphGridHeight / 2; 570 | midValue = Math.round(midValue * halfGridHeight) / halfGridHeight; 571 | } 572 | newY[i - 1] = midValue; 573 | break; 574 | } else { 575 | x--; 576 | } 577 | } 578 | newX[i] = x; 579 | newY[i] = y; 580 | prevX = x; 581 | } 582 | newX.push(newSize - 1); 583 | newY.push(graphPointsY[numValues - 1]); 584 | graphPointsX = newX; 585 | graphPointsY = newY; 586 | resizeGraph(); 587 | } 588 | 589 | function setGraphSize() { 590 | const numValues = graphPointsX.length; 591 | const currentSize = graphPointsX[numValues - 1] + 1; 592 | const textbox = document.getElementById('graph-width'); 593 | let newSize = parseInt(textbox.value); 594 | const maxSize = Math.trunc((canvas.width - graphMarkSize) / graphMarkSize); 595 | if (newSize < 2) { 596 | newSize = 2; 597 | textbox.value = '2'; 598 | } else if (newSize > maxSize) { 599 | newSize = maxSize; 600 | textbox.value = newSize; 601 | } 602 | if (newSize === currentSize) { 603 | return; 604 | } else if (newSize > currentSize) { 605 | graphPointsX.push(newSize - 1); 606 | graphPointsY.push(graphPointsY[numValues - 1]); 607 | } else { 608 | let before = graphPointsX[numValues - 2]; 609 | let i = numValues - 2; 610 | while (before >= newSize) { 611 | i--; 612 | before = graphPointsX[i]; 613 | } 614 | const after = graphPointsX[i + 1]; 615 | const beforeValue = graphPointsY[i]; 616 | const afterValue = graphPointsY[i + 1]; 617 | const newX = graphPointsX.slice(0, i + 1); 618 | const newY = graphPointsY.slice(0, i + 1); 619 | let finalY = beforeValue + (newSize - 1 - before) * (afterValue - beforeValue) / (after - before); 620 | if (graphSnapY) { 621 | const halfGridHeight = graphGridHeight / 2; 622 | finalY = Math.round(finalY * halfGridHeight) / halfGridHeight; 623 | } 624 | newX.push(newSize - 1); 625 | newY.push(finalY); 626 | graphPointsX = newX; 627 | graphPointsY = newY; 628 | } 629 | updateGraphedSound(); 630 | resizeGraph(); 631 | } 632 | 633 | function snapGraph(snap, force) { 634 | graphSnapY = snap; 635 | if (snap && (graphGridHeight >= 8 || force)) { 636 | const numValues = graphPointsX.length; 637 | const halfGridHeight = graphGridHeight / 2; 638 | for (let i = 0; i < numValues; i++) { 639 | let newValue = Math.round(graphPointsY[i] * halfGridHeight) / halfGridHeight; 640 | if (newValue > 1) { 641 | newValue = 1; 642 | } else if (newValue < -1) { 643 | newValue = -1; 644 | } 645 | graphPointsY[i] = newValue; 646 | } 647 | updateGraphedSound(); 648 | requestAnimationFrame(drawGraph); 649 | } 650 | } 651 | 652 | function resetGraphData() { 653 | graphPointsX = [0, graphPointsX[graphPointsX.length - 1]]; 654 | graphPointsY = [-1, 1]; 655 | updateGraphedSound(); 656 | requestAnimationFrame(drawGraph); 657 | } 658 | 659 | canvas.addEventListener('mousemove', function (event) { 660 | const numValues = graphPointsX.length; 661 | const maxX = graphPointsX[numValues - 1]; 662 | let x = Math.round((event.offsetX - graphMarkSize / 2) / graphUnitX); 663 | let outOfRange = false; 664 | if (x < 0) { 665 | x = 0; 666 | outOfRange = true; 667 | } else if (x > maxX) { 668 | x = maxX; 669 | outOfRange = true; 670 | } 671 | 672 | const halfGridHeight = graphGridHeight / 2; 673 | let y = 1 - Math.round(event.offsetY - graphMarkSize / 2) / graphMidY; 674 | let roundedY, displayY; 675 | if (y < -1) { 676 | y = -1; 677 | roundedY = -halfGridHeight; 678 | displayY = roundedY; 679 | outOfRange = true; 680 | } else if (y > 1) { 681 | y = 1; 682 | roundedY = halfGridHeight; 683 | displayY = roundedY; 684 | outOfRange = true; 685 | } else { 686 | roundedY = Math.round(y * halfGridHeight); 687 | if (graphSnapY) { 688 | y = roundedY / halfGridHeight; 689 | displayY = roundedY; 690 | } else { 691 | displayY = Math.round(y * halfGridHeight * 10) / 10; 692 | } 693 | } 694 | 695 | if (x != graphMouseX || y !== graphMouseY) { 696 | if (graphChangeX !== undefined) { 697 | let index = graphPointsX.indexOf(graphChangeX); 698 | if (graphChangeX === 0 && x > 0) { 699 | if (graphPointsX[1] > 1 && graphChangeY === roundedY) { 700 | graphPointsX.splice(1, 0, 1); 701 | graphPointsY.splice(1, 0, y); 702 | graphChangeX = 1; 703 | x = 1; 704 | index = 1; 705 | } else { 706 | x = 0; 707 | } 708 | } else if (graphChangeX === maxX && x < maxX) { 709 | if (graphPointsX[numValues - 2] < maxX - 1 && graphChangeY === roundedY) { 710 | graphPointsX.splice(numValues - 1, 0, maxX - 1); 711 | graphPointsY.splice(numValues - 1, 0, y); 712 | graphChangeX = maxX - 1; 713 | x = graphChangeX; 714 | index = numValues - 1; 715 | } else { 716 | x = maxX; 717 | } 718 | } else { 719 | if (x > graphPointsX[index - 1] && x < graphPointsX[index + 1]) { 720 | graphPointsX[index] = x; 721 | graphChangeX = x; 722 | } else { 723 | x = graphChangeX; 724 | } 725 | } 726 | graphPointsY[index] = y; 727 | updateGraphedSound(); 728 | } 729 | 730 | if (x != graphMouseX || y !== graphMouseY) { 731 | if (outOfRange && graphChangeX === undefined) { 732 | graphMouseX = undefined; 733 | document.getElementById('mouse-coords').innerHTML = ' '; 734 | } else { 735 | graphMouseX = x; 736 | graphMouseY = y; 737 | document.getElementById('mouse-coords').innerHTML = 'x: ' + x + ', y: ' + displayY; 738 | } 739 | requestAnimationFrame(drawGraph); 740 | if (graphChangeY !== roundedY) { 741 | graphChangeY = undefined; 742 | } 743 | } 744 | } 745 | }); 746 | 747 | canvas.addEventListener('mouseleave', function (event) { 748 | graphMouseX = undefined; 749 | graphChangeX = undefined; 750 | requestAnimationFrame(drawGraph); 751 | document.getElementById('mouse-coords').innerHTML = ' '; 752 | }); 753 | 754 | canvas.addEventListener('mousedown', function (event) { 755 | graphChangeX = graphMouseX; 756 | const numValues = graphPointsX.length; 757 | for (let i = 0; i < numValues; i++) { 758 | const x = graphPointsX[i]; 759 | if (graphChangeX === x) { 760 | graphPointsY[i] = graphMouseY; 761 | break; 762 | } else if (graphChangeX < x) { 763 | graphPointsX.splice(i, 0, graphChangeX); 764 | graphPointsY.splice(i, 0, graphMouseY); 765 | break; 766 | } 767 | } 768 | updateGraphedSound(); 769 | requestAnimationFrame(drawGraph); 770 | const halfGridHeight = graphGridHeight / 2; 771 | graphChangeY = Math.round((1 - Math.round(event.offsetY - graphMarkSize / 2) / graphMidY) * halfGridHeight); 772 | }); 773 | 774 | canvas.addEventListener('mouseup', function (event) { 775 | graphChangeX = undefined; 776 | }); 777 | 778 | canvas.addEventListener('dblclick', function (event) { 779 | const numValues = graphPointsX.length; 780 | if (graphMouseX === 0 || graphMouseX === graphPointsX[numValues - 1]) { 781 | return; 782 | } 783 | for (let i = 0; i < numValues; i++) { 784 | const x = graphPointsX[i]; 785 | if (graphMouseX === x) { 786 | graphPointsX.splice(i, 1); 787 | graphPointsY.splice(i, 1); 788 | updateGraphedSound(); 789 | requestAnimationFrame(drawGraph); 790 | break; 791 | } else if (graphMouseX < x) { 792 | break; 793 | } 794 | } 795 | graphMouseX = undefined; 796 | }); 797 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ORSSUM: Online Retro Synthesizer & Sequencer for Unleashing Music 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 24 |
25 | 26 |
27 |
28 |
29 |
30 | Activation 31 | 32 | 33 | 34 |
35 | You can also play notes using the computer keyboard or MIDI. 36 |
37 | MIDI: 38 | 56 |
57 | Mono 58 | Two Note Polyphony 59 | Arpeggio 60 |
61 | Transposed Chord 62 |
63 |
64 |
65 | Articulation 66 | Fixed Length Note 67 | Legato 68 |
69 | 70 | 4/16 71 | Note Value 72 | 77 |
78 |
79 | Generation 80 |
81 | Waveform 82 | 83 | 84 | 85 | 86 | 87 | 88 | 99 | 110 | 111 |
MinMax
89 | 90 |
91 | Sine + Triangle
92 | Triangle + Sawtooth
93 | Sawtooth + Custom
94 | Custom + Pulse
95 | Pulse + Sine
96 | Noise 97 | Key Tracking 98 |
100 | 101 |
102 | Sine + Triangle
103 | Triangle + Sawtooth
104 | Sawtooth + Custom
105 | Custom + Pulse
106 | Pulse + Sine
107 | 108 | 109 |
112 |
113 | LFO 1 114 | 115 | LFO 2 116 |
117 |
118 | Oscillator Detuning 119 | Coarse: 120 | 121 | 0 122 |
123 | Fine: 124 | 125 | 0 126 |
127 |
128 | Sample 129 | 135 | 136 |
137 | 138 |
139 | Hold and Decay 140 |
141 |
142 |
143 | Modulation 144 | Ring Modulation 145 |
146 |
147 | LFO 1 148 | Sine 149 | Square 150 | Sawtooth 151 | Triangle 152 | Inverted 153 |
154 | Rate
155 | Fade In 156 | Fade Out 157 | Retrigger 158 |
159 | Delay / Hold
160 | Attack / Decay
161 |
162 | Rate Modulation 163 |
164 | 167 |
168 |
169 | LFO 2 170 | Sine 171 | Square 172 | Sawtooth 173 | Triangle 174 | Inverted 175 |
176 | Rate
177 | Fade In 178 | Fade Out 179 | Retrigger 180 |
181 | Delay / Hold
182 | Attack / Decay
183 |
184 | Rate Modulation 185 |
186 | 189 |
190 |
191 | Frequency 192 | Frequency
193 | Note
194 | Voice Detuning
195 | 196 | 0.00 197 | Glide Time 198 |
199 |
200 | Vibrato 201 | Extent 202 |
203 | LFO 1 204 | 205 | LFO 2 206 |
207 |
208 | Siren 209 | Extent 210 |
211 |
212 | Pulse Width Modulation 213 | Min Pulse Width
214 | Max Pulse Width
215 | LFO 1 216 | 217 | LFO 2 218 |
219 |
220 | Filter 221 | Low Pass 222 | High Pass 223 | Band Pass 224 | Notch 225 |
226 | All Pass 227 | Low Shelf 228 | High Shelf 229 | Peaking 230 |
231 | Wet Mix
232 | Dry Mix
233 | Min Frequency 234 | Max = Min
235 | 238 | Min Resonance / Width 239 | Max = Min
240 | 243 | Gain
244 | 245 | 246 | 247 | 252 | 253 | 254 | 255 | 260 | 261 |
Frequency: 248 | LFO 1 249 | 250 | LFO 2 251 |
Resonance: 256 | LFO 1 257 | 258 | LFO 2 259 |
262 |
263 |
264 | Amplitude Envelope 265 | Attack 266 | Curve 267 |
268 | Hold
269 | Decay 270 | Linear 271 | Exponential 272 |
273 | Sustain
274 | Release 275 | Linear 276 | Exponential 277 |
278 |
279 | Tremolo 280 | Depth 281 |
282 | LFO 1 283 | 284 | LFO 2 285 |
286 |
287 | Delay 288 | Min Delay 289 | Max = Min
290 | 293 | Delay Mix
294 | 295 | Feedback 296 | 297 |
298 | LFO 1 299 | 300 | LFO 2 301 |
302 |
303 | Sequencing 304 | 305 | Line Time (in ¹⁄₅₀ths of a second) 306 |
307 | Groove: 308 | 309 | ♩ = 4 lines 310 |
311 | 312 | 6 313 | Ticks /line 314 |
315 | 1 316 | Broken Chord (chromatic scale, e.g. 158) 317 |
318 | 319 | 2 320 | Chord Rotation Time 321 |
322 | Cycle 323 | To and Fro 324 | To and Fro 2 325 | Random 326 |
327 | 328 | 0 329 | Glissando Amount 330 |
331 | 332 | 6 333 | Glissando Time 334 |
335 | 336 | 0 337 | Retrigger Time 338 | Legato 339 |
340 | 341 | Retrigger Volume 342 |
343 |
344 | Pan 345 | Leftmost Point
346 | Rightmost Point
347 | LFO 1 348 | 349 | LFO 2 350 |
351 |
352 | Output 353 | Volume 354 | Mute 355 |
356 |
357 | Recording 358 | 359 | 360 | 361 | Append 362 |
363 | 364 |
365 |
366 |
367 | Custom Wave Shape 368 | Width: 369 | 370 | 371 | 372 | Height: 373 | 374 | Snap Y 375 | 376 |
377 | 378 |
379 |
380 |
381 | 382 | 383 | -------------------------------------------------------------------------------- /js/generative.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const WHOLE_TONE_SCALE = [2, 2, 2, 2, 2, 2]; 4 | const OCTATONIC_SCALE = [2, 1, 2, 1, 2, 1, 2, 1]; 5 | 6 | function makeCDF(map) { 7 | const keys = []; 8 | const cumulativeProbabilities = []; 9 | let total = 0; 10 | for (let frequency of map.values()) { 11 | total += frequency; 12 | } 13 | let sum = 0; 14 | for (let [key, frequency] of map.entries()) { 15 | keys.push(key); 16 | sum += frequency / total; 17 | cumulativeProbabilities.push(sum); 18 | } 19 | return [cumulativeProbabilities, keys]; 20 | } 21 | 22 | function cdfLookup(distribution) { 23 | const probability = Math.random(); 24 | const probabilities = distribution[0]; 25 | const values = distribution[1]; 26 | const length = probabilities.length; 27 | let i = 0; 28 | while (i < length - 1 && probabilities[i] < probability) { 29 | i++ 30 | } 31 | return values[i]; 32 | } 33 | 34 | function uniformCDF(from, to) { 35 | const n = to - from + 1; 36 | const values = new Array(n); 37 | const cumulativeProbabilities = new Array(n); 38 | for (let i = 0; i < n; i++) { 39 | values[i] = from + i; 40 | cumulativeProbabilities[i] = (i + 1) / n; 41 | } 42 | return [cumulativeProbabilities, values]; 43 | } 44 | 45 | function expectedValue(distribution) { 46 | const probabilities = distribution[0]; 47 | const values = distribution[1]; 48 | const numValues = values.length; 49 | let previousProbability = 0; 50 | let sum = 0; 51 | for (let i = 0; i < numValues; i++) { 52 | const probability = probabilities[i]; 53 | sum += values[i] * (probability - previousProbability); 54 | previousProbability = probability; 55 | } 56 | return sum; 57 | } 58 | 59 | function generatePitchSpace(scale, baseNote, minNote, maxNote) { 60 | const scaleLength = scale.length; 61 | const highPitches = []; 62 | const lowPitches = []; 63 | let index = 0; 64 | let note = baseNote; 65 | while (note <= maxNote) { 66 | if (note >= minNote) { 67 | highPitches.push(note); 68 | } 69 | note = note + scale[index]; 70 | index = (index + 1) % scaleLength; 71 | } 72 | index = scaleLength - 1; 73 | note = baseNote - scale[index]; 74 | while (note >= minNote) { 75 | if (note <= maxNote) { 76 | lowPitches.push(note); 77 | } 78 | index--; 79 | if (index === -1) { 80 | index = scaleLength - 1; 81 | } 82 | note = note - scale[index]; 83 | } 84 | return lowPitches.reverse().concat(highPitches); 85 | } 86 | 87 | const TimeSignatureType = Object.freeze({ 88 | SIMPLE: 1, 89 | COMPLEX: 2, 90 | COMPOUND: 3, 91 | }); 92 | 93 | class TimeSigature { 94 | constructor(type, length, beatLength, groupings) { 95 | this.type = type; 96 | this.length = length; 97 | this.beatLength = beatLength; 98 | this.groupings = groupings; 99 | } 100 | } 101 | 102 | const RhythmType = Object.freeze({ 103 | UNCONSTRAINED: 0, 104 | FIRST_BAR: 1, 105 | REPEATED: 2, 106 | }); 107 | 108 | class SongGenerator { 109 | constructor() { 110 | // Approximate length in quavers. 111 | this.lengthDist = uniformCDF(3, 10); 112 | 113 | /* Frequency distribution of duplets and triplets for complex time signatures 114 | * (and maybe quintuplets or septuplets). 115 | */ 116 | this.subdivisionDist = makeCDF(new Map([[2, 2], [3, 1]])); 117 | 118 | /* Distribution of eighth notes (1), half notes (4), etc. */ 119 | this.beatDist = makeCDF(new Map([ 120 | [1, 12], [2, 6], [3, 4], [4, 3], [6, 2] 121 | ])); 122 | 123 | // Distribution of rests, assuming a 16 beat length 124 | this.restTimeDist = uniformCDF(0, 6); 125 | 126 | this.minNote = 47; // B2 127 | this.maxNote = 70; // A#4 128 | this.modeDist = uniformCDF(1, 7); // The scale degree, 1 = major, 2 = dorian, 6 = minor, etc. 129 | 130 | this.contourDist = makeCDF(new Map([ 131 | ['=', 10], ['+', 30], ['-', 21], ['--', 9], ['+-', 15], ['-+', 15], 132 | ])); 133 | 134 | this.conjunctLength = uniformCDF(2, 5); 135 | this.conjunctIntervals = makeCDF(new Map([ 136 | [1, 67], [2, 33], 137 | ])); 138 | 139 | this.disjunctLength = uniformCDF(2, 3); 140 | this.disjunctIntervals = makeCDF(new Map([ 141 | [4, 1], [5, 1], [6, 1], 142 | ])); 143 | 144 | this.numConjunctContours = 3; 145 | this.numDisjunctContours = 3; 146 | this.conjunctDistance = 2; 147 | 148 | this.patternDist = makeCDF(new Map([ 149 | ['conjunct', 1], ['disjunct', 1], ['mixed', 1], 150 | ])); 151 | this.conjunctProbability = 0.8; // within mixed phrases 152 | 153 | /* 0 = staccato 154 | * 1 = legato 155 | * 0.5 < x < 1 represent intermediate qualities 156 | * 0 < x <= 0.5 probably aren't musically valid? 157 | */ 158 | this.articulation = 0.75; 159 | 160 | this.offbeatVelocity = 80; 161 | 162 | this.structureDist = makeCDF(new Map([ 163 | [[0] , 6], 164 | [[0, 1] /* AB binary form */ , 2], 165 | [[0, 1, 0] /* ABA ternary form */ , 2], 166 | [[0, 1, 0, 1] /* ABAB */ , 1], 167 | [[0, 1, 0, 2] /* ABAC */ , 1], 168 | ])); 169 | 170 | // Probability of repetition in the song structure 171 | this.repeatProbability = 0.5; 172 | 173 | // Probability of using strophic form at the super structure level 174 | this.variationsProbability = 1/7; 175 | 176 | // Probability of generating a rondo 177 | this.rondoProbability = 0.2; 178 | 179 | this.variationProbability = 0.5; 180 | } 181 | 182 | generateTimeSignature(type) { 183 | let length = cdfLookup(this.lengthDist); 184 | let beatLength = 1, lengths; 185 | if (type === undefined) { 186 | type = Math.trunc(Math.random() * 3) + 1; 187 | } 188 | switch (type) { 189 | case TimeSignatureType.SIMPLE: 190 | // 1 = quavers as the basic note (x/8 time), 2 = crotchet (x/4 time), etc. 191 | beatLength = 2 ** Math.trunc(Math.random() * 3); 192 | while (length % beatLength !== 0) { 193 | beatLength /= 2; 194 | } 195 | lengths = new Array(length); 196 | lengths.fill(1); 197 | break; 198 | 199 | case TimeSignatureType.COMPOUND: 200 | length = Math.min(Math.round(length / 3) * 3, 6); 201 | lengths = new Array(length / 3); 202 | lengths.fill(3); 203 | break; 204 | 205 | case TimeSignatureType.COMPLEX: 206 | lengths = []; 207 | let lengthSoFar = 0; 208 | while (lengthSoFar < length - 1 || lengths.length < 2) { 209 | const subdivision = cdfLookup(this.subdivisionDist); 210 | lengths.push(subdivision); 211 | lengthSoFar += subdivision; 212 | } 213 | let allTheSame = true; 214 | for (let i = 1; i < lengths.length; i++) { 215 | if (lengths[i] !== lengths[0]) { 216 | allTheSame = false; 217 | break; 218 | } 219 | } 220 | if (allTheSame) { 221 | const subdivisions = this.subdivisionDist[1]; 222 | lengthSoFar -= lengths[1]; 223 | lengths[1] = subdivisions[0]; 224 | if (lengths[0] === lengths[1]) { 225 | lengths[1] = subdivisions[1]; 226 | } 227 | lengthSoFar += lengths[1]; 228 | } 229 | length = lengthSoFar; 230 | break; 231 | } 232 | return new TimeSigature(type, length, beatLength, lengths); 233 | } 234 | 235 | generateRhythm(timeSignature, rhythmType) { 236 | const timeSignatureType = timeSignature.type; 237 | const length = timeSignature.length; 238 | const mainBeatLength = timeSignature.beatLength; 239 | let lengths = timeSignature.groupings.slice(); 240 | let numBlocks = lengths.length; 241 | let noteValues = []; 242 | 243 | if (timeSignatureType === TimeSignatureType.SIMPLE) { 244 | let i = 0, offset = 0, owed = 0; 245 | let beatLength; 246 | while (i < numBlocks) { 247 | if (owed > numBlocks - i) { 248 | owed = 0; 249 | } 250 | if (owed === 0) { 251 | beatLength = cdfLookup(this.beatDist); 252 | } else { 253 | beatLength = owed; 254 | } 255 | if ((offset + beatLength > mainBeatLength && offset !== 0) || 256 | beatLength > numBlocks - i || beatLength > numBlocks - 1 257 | ) { 258 | if (owed === 0) { 259 | owed = beatLength; 260 | } 261 | do { 262 | beatLength = cdfLookup(this.beatDist); 263 | } while ((offset + beatLength > mainBeatLength && offset !== 0) || beatLength > numBlocks - i || beatLength > numBlocks - 1) 264 | } 265 | if (beatLength === owed) { 266 | owed = 0; 267 | } 268 | 269 | if (beatLength > 1) { 270 | lengths.splice(i, beatLength, beatLength); 271 | numBlocks -= beatLength - 1; 272 | } 273 | noteValues[i] = [beatLength]; 274 | offset = (offset + beatLength) % mainBeatLength; 275 | i++; 276 | } 277 | lengths = []; 278 | const newValues = []; 279 | let currentBlock = noteValues[0]; 280 | let numEighths = currentBlock[0]; 281 | for (let i = 1; i < numBlocks; i++) { 282 | if (numEighths % mainBeatLength === 0) { 283 | newValues.push(currentBlock); 284 | lengths.push(numEighths); 285 | currentBlock = []; 286 | numEighths = 0; 287 | } 288 | const value = noteValues[i][0]; 289 | currentBlock.push(value); 290 | numEighths += value; 291 | } 292 | newValues.push(currentBlock); 293 | lengths.push(numEighths); 294 | noteValues = newValues; 295 | numBlocks = lengths.length; 296 | 297 | } else { 298 | 299 | // Compound and complex signatures 300 | let i = 0, owed = 0, beatLength; 301 | while (i < numBlocks) { 302 | const subdivision = lengths[i]; 303 | if (owed === 0) { 304 | beatLength = cdfLookup(this.beatDist); 305 | } else { 306 | beatLength = owed; 307 | } 308 | while ((beatLength > 2 && beatLength !== 4) || 309 | (beatLength === 4 && (numBlocks === 2 || subdivision !== lengths[i + 1])) 310 | ) { 311 | if (beatLength === 4) { 312 | owed = 4; 313 | } 314 | beatLength = cdfLookup(this.beatDist); 315 | } 316 | if (beatLength === owed) { 317 | owed = 0; 318 | } 319 | 320 | let beats; 321 | switch (beatLength) { 322 | case 1: 323 | beats = new Array(subdivision); 324 | beats.fill(1); 325 | break; 326 | 327 | case 2: 328 | beats = [subdivision]; 329 | break; 330 | 331 | case 4: 332 | beats = [2 * subdivision]; 333 | lengths.splice(i, 2, 2 * subdivision); 334 | numBlocks--; 335 | break; 336 | } 337 | noteValues[i] = beats; 338 | i++; 339 | } 340 | } 341 | 342 | // Use negative "beat lengths" to indicate rests. 343 | let restsToAllocate = Math.round(cdfLookup(this.restTimeDist) * length / 16); 344 | let attempts = 0; 345 | while (restsToAllocate !== 0 && attempts < 100) { 346 | const index = Math.trunc(Math.random() * length); 347 | let currentIndex = 0; 348 | for (let i = 0; i < numBlocks; i++) { 349 | const blockLength = lengths[i]; 350 | if (index < currentIndex + blockLength) { 351 | const beats = noteValues[i]; 352 | const blockLength = beats.length; 353 | if (restsToAllocate > 0) { 354 | for (let indexWithinBlock = blockLength - 1; indexWithinBlock >= 0; indexWithinBlock--) { 355 | const beatLength = beats[indexWithinBlock]; 356 | if (beatLength > 0) { 357 | beats[indexWithinBlock] = -beatLength; 358 | restsToAllocate -= beatLength; 359 | break; 360 | } 361 | } 362 | } else { 363 | for (let indexWithinBlock = 0; indexWithinBlock < blockLength; indexWithinBlock++) { 364 | const beatLength = beats[indexWithinBlock]; 365 | if (beatLength < 0) { 366 | beats[indexWithinBlock] = -beatLength; 367 | restsToAllocate -= beatLength; 368 | break; 369 | } 370 | } 371 | } 372 | break; 373 | } 374 | currentIndex += blockLength; 375 | } 376 | attempts++; 377 | } 378 | 379 | // Make the longest note be on the beat (no syncopation for now) 380 | for (let i = 0; i < numBlocks; i++) { 381 | const block = noteValues[i]; 382 | const first = block[0]; 383 | const max = Math.max(...block); 384 | if (first !== max) { 385 | const index = block.indexOf(max); 386 | block[index] = first; 387 | block[0] = max; 388 | } 389 | } 390 | 391 | let startBlock = 0; 392 | 393 | switch (rhythmType) { 394 | case RhythmType.FIRST_BAR: 395 | { 396 | // First bar cannot begin with a rest. 397 | while (noteValues[startBlock][0] < 0 && startBlock < numBlocks - 1) { 398 | startBlock++; 399 | } 400 | if (noteValues[startBlock][0] < 0) { 401 | startBlock = 0; 402 | noteValues[0][0] = -noteValues[0][0]; 403 | } 404 | break; 405 | } 406 | 407 | case RhythmType.REPEATED: 408 | { 409 | /* For a repeating rhythm the longest pause or the longest note is 410 | * perceived as the final element. 411 | */ 412 | let maxRestLength = 0; 413 | let maxLastNoteLength = 0; 414 | let maxRestBlock; 415 | let maxNoteLength = 0; 416 | let maxNoteBlock = 0; 417 | for (let i = 0; i < numBlocks; i++) { 418 | let block = noteValues[i]; 419 | let blockLength = block.length; 420 | let restLength = 0; 421 | let lastNoteLength = 0; 422 | for (let j = 0; j < blockLength; j++) { 423 | if (block[j] < 0) { 424 | restLength += -block[j]; 425 | } else { 426 | lastNoteLength = block[j]; 427 | } 428 | } 429 | if (lastNoteLength > maxNoteLength) { 430 | maxNoteLength = lastNoteLength; 431 | maxNoteBlock = i; 432 | } 433 | 434 | let blockNum = (i + 1) % numBlocks; 435 | while (blockNum !== i) { 436 | block = noteValues[blockNum]; 437 | blockLength = block.length; 438 | if (block[0] > 0) { 439 | break; 440 | } 441 | for (let j = 0; j < blockLength; j++) { 442 | restLength += -block[j]; 443 | } 444 | blockNum = (blockNum + 1) % numBlocks; 445 | } 446 | if (restLength > maxRestLength || (restLength === maxRestLength && lastNoteLength > maxLastNoteLength)) { 447 | maxRestLength = restLength; 448 | maxRestBlock = blockNum; 449 | maxLastNoteLength = lastNoteLength; 450 | } 451 | } 452 | 453 | if (maxRestBlock !== undefined) { 454 | startBlock = maxRestBlock; 455 | } else { 456 | startBlock = maxNoteBlock; 457 | } 458 | break; 459 | } 460 | } 461 | 462 | if (startBlock > 0) { 463 | const newValues = []; 464 | let i = startBlock; 465 | do { 466 | newValues.push(noteValues[i]); 467 | i = (i + 1) % numBlocks; 468 | } while (i !== startBlock); 469 | noteValues = newValues; 470 | } 471 | 472 | console.log('Blocks: ' + lengths); 473 | console.log('Rhythm: ' + noteValues.flat()); 474 | 475 | return noteValues; 476 | } 477 | 478 | generateScale(intervals) { 479 | const numNotes = intervals.length; 480 | const mode = (cdfLookup(this.modeDist) - 1) % numNotes; 481 | const scale = new Array(numNotes); 482 | for (let i = 0; i < numNotes; i++) { 483 | scale[i] = intervals[(i + mode) % numNotes]; 484 | } 485 | return scale; 486 | } 487 | 488 | generateContour(conjunctive) { 489 | const type = cdfLookup(this.contourDist); 490 | const lengthDist = conjunctive ? this.conjunctLength : this.disjunctLength; 491 | const intervalDist = conjunctive ? this.conjunctIntervals : this.disjunctIntervals; 492 | let length = cdfLookup(lengthDist); 493 | const intervals = [0]; 494 | let position = 0; 495 | switch (type) { 496 | case '=': 497 | if (conjunctive && length === 2) { 498 | intervals.push(0); 499 | } else { 500 | let sign = Math.random() < 0.5 ? -1 : 1; 501 | for (let i = 1; i < length; i++) { 502 | const interval = cdfLookup(intervalDist) - 1; 503 | position += sign * interval; 504 | intervals.push(position); 505 | if (interval !== 0) { 506 | sign = -sign; 507 | } 508 | } 509 | } 510 | break; 511 | 512 | case '+': 513 | for (let i = 1; i < length; i++) { 514 | const interval = cdfLookup(intervalDist) - 1; 515 | position += interval; 516 | intervals.push(position); 517 | } 518 | break; 519 | 520 | case '-': 521 | for (let i = 1; i < length; i++) { 522 | const interval = cdfLookup(intervalDist) - 1; 523 | position -= interval; 524 | intervals.push(position); 525 | } 526 | break; 527 | 528 | case '--': 529 | for (let i = 1; i < length; i++) { 530 | const interval = cdfLookup(intervalDist) - 1; 531 | position -= interval; 532 | intervals.push(position); 533 | } 534 | position = Math.trunc(Math.random() * (position + 1)) - 1 535 | intervals.push(position); 536 | length = cdfLookup(lengthDist); 537 | for (let i = 1; i < length; i++) { 538 | const interval = cdfLookup(intervalDist) - 1; 539 | position -= interval; 540 | intervals.push(position); 541 | } 542 | break; 543 | 544 | case '+-': 545 | for (let i = 1; i < length; i++) { 546 | const interval = cdfLookup(intervalDist) - 1; 547 | position += interval; 548 | intervals.push(position); 549 | } 550 | length = cdfLookup(lengthDist); 551 | for (let i = 1; i < length; i++) { 552 | const interval = cdfLookup(intervalDist) - 1; 553 | position -= interval; 554 | intervals.push(position); 555 | } 556 | break; 557 | 558 | case '-+': 559 | for (let i = 1; i < length; i++) { 560 | const interval = cdfLookup(intervalDist) - 1; 561 | position -= interval; 562 | intervals.push(position); 563 | } 564 | length = cdfLookup(lengthDist); 565 | for (let i = 1; i < length; i++) { 566 | const interval = cdfLookup(intervalDist) - 1; 567 | position += interval; 568 | intervals.push(position); 569 | } 570 | break; 571 | } 572 | return intervals; 573 | } 574 | 575 | static putContourInPitchSpace(contour, pitches) { 576 | const length = contour.length; 577 | const numPitches = pitches.length; 578 | let min = contour[0]; 579 | let max = contour[0]; 580 | for (let i = 1; i < length; i++) { 581 | const offset = contour[i]; 582 | if (offset < min) { 583 | min = offset; 584 | } else if (offset > max) { 585 | max = offset; 586 | } 587 | } 588 | 589 | const output = []; 590 | for (let i = -min; i < numPitches - max; i++) { 591 | const run = []; 592 | for (let j = 0; j < length; j++) { 593 | run.push(pitches[i + contour[j]]); 594 | } 595 | output.push(run); 596 | } 597 | return output; 598 | } 599 | 600 | generateMelody(pitchSpace, conjunctPatterns, disjunctPatterns, type, lastPitch, length) { 601 | const conjunctDistance = this.conjunctDistance; 602 | const minPitch = pitchSpace[0], maxPitch = pitchSpace[pitchSpace.length - 1]; 603 | const midPitch = (minPitch + maxPitch) / 2; 604 | const notes = []; 605 | let currentLength = 0; 606 | let conjunctive = type === 'conjunct'; 607 | let patterns, numCandidates, oversizeCandidates, candidate; 608 | let logStr = 'Melody: '; 609 | while (currentLength < length - 1) { 610 | if (type === 'mixed') { 611 | conjunctive = Math.random() < this.conjunctProbability; 612 | } 613 | 614 | patterns = conjunctive ? conjunctPatterns : disjunctPatterns; 615 | oversizeCandidates = []; 616 | candidate = undefined; 617 | 618 | if (conjunctive) { 619 | const dupCandidates = [], noDupCandidates = []; 620 | for (let pattern of patterns) { 621 | if (currentLength + pattern.length > length) { 622 | oversizeCandidates.push(pattern); 623 | } else { 624 | const distance = Math.abs(pattern[0] - lastPitch); 625 | if (distance === 0) { 626 | dupCandidates.push(pattern); 627 | } else if (distance <= conjunctDistance) { 628 | noDupCandidates.push(pattern); 629 | } 630 | } 631 | } 632 | numCandidates = noDupCandidates.length; 633 | if (numCandidates > 0) { 634 | candidate = noDupCandidates[Math.trunc(Math.random() * numCandidates)]; 635 | } 636 | if (candidate === undefined) { 637 | numCandidates = dupCandidates.length; 638 | if (numCandidates > 0) { 639 | candidate = dupCandidates[Math.trunc(Math.random() * numCandidates)]; 640 | } 641 | } 642 | } else { 643 | const distance = cdfLookup(this.disjunctIntervals); 644 | let startPitch; 645 | if (lastPitch > midPitch) { 646 | startPitch = lastPitch - distance; 647 | } else { 648 | startPitch = lastPitch + distance; 649 | } 650 | if (startPitch < minPitch) { 651 | startPitch = minPitch; 652 | } else if (startPitch > maxPitch) { 653 | startPitch = maxPitch; 654 | } 655 | const candidates = []; 656 | for (let pattern of patterns) { 657 | if (pattern[0] === startPitch) { 658 | if (currentLength + pattern.length > length) { 659 | oversizeCandidates.push(pattern); 660 | } else { 661 | candidates.push(pattern); 662 | } 663 | } 664 | } 665 | numCandidates = candidates.length; 666 | if (numCandidates > 0) { 667 | candidate = candidates[Math.trunc(Math.random() * numCandidates)]; 668 | } 669 | } 670 | 671 | if (candidate === undefined) { 672 | numCandidates = oversizeCandidates.length; 673 | if (numCandidates > 0) { 674 | candidate = oversizeCandidates[Math.trunc(Math.random() * numCandidates)]; 675 | } else { 676 | numCandidates = patterns.length; 677 | candidate = patterns[Math.trunc(Math.random() * numCandidates)]; 678 | } 679 | } 680 | notes.splice(currentLength, 0, ...candidate); 681 | currentLength += candidate.length; 682 | lastPitch = candidate[candidate.length - 1]; 683 | logStr += candidate + '|'; 684 | } 685 | if (currentLength < length) { 686 | let finalPitch = 2 * notes[currentLength - 1] - notes[currentLength - 2]; 687 | if (finalPitch < minPitch) { 688 | finalPitch = minPitch; 689 | } else if (finalPitch > maxPitch) { 690 | finalPitch = maxPitch; 691 | } 692 | if (finalPitch - notes[currentLength - 1] === 0 && finalPitch > minPitch) { 693 | finalPitch--; 694 | } 695 | notes.push(finalPitch); 696 | logStr += finalPitch; 697 | } else { 698 | notes.splice(length, currentLength - length); 699 | } 700 | console.log(logStr); 701 | return notes; 702 | } 703 | 704 | generateSongStructure() { 705 | let superStructure, superLength 706 | const isVariations = Math.random() < this.variationsProbability; 707 | if (isVariations) { 708 | superLength = Math.trunc(Math.random() * 3 + 2); 709 | superStructure = new Array(superLength); 710 | superStructure.fill(0); 711 | } else { 712 | do { 713 | superStructure = cdfLookup(this.structureDist); 714 | superLength = superStructure.length; 715 | } while (superStructure.length === 1); 716 | } 717 | 718 | const rondo = Math.random() < this.rondoProbability; 719 | const inner = cdfLookup(this.structureDist); 720 | const structure = []; 721 | const variants = []; 722 | const structures = []; 723 | let offset = rondo ? 1 : 0; 724 | const currentVariants = rondo ? [-1] : []; 725 | console.log('Super structure: ' + superStructure); 726 | 727 | for (let i = 0; i < superLength; i++) { 728 | if (rondo) { 729 | structure.push(0); 730 | currentVariants[0]++; 731 | variants.push(currentVariants[0]); 732 | } 733 | const superValue = superStructure[i]; 734 | let form = structures[superValue]; 735 | if (form === undefined) { 736 | form = inner.slice(); 737 | for (let j = 0; j < form.length; j++) { 738 | form[j] += offset; 739 | } 740 | offset = Math.max(...form) + 1; 741 | if (Math.random() < this.repeatProbability) { 742 | const withRepeats = []; 743 | for (let value of form) { 744 | withRepeats.push(value); 745 | withRepeats.push(value); 746 | } 747 | form = withRepeats; 748 | } 749 | structures[superValue] = form; 750 | console.log(superValue + ' -> ' + form); 751 | } 752 | structure.splice(structure.length, 0, ...form); 753 | // TODO implement variation at the super structure level 754 | for (let value of form) { 755 | if (currentVariants[value] === undefined) { 756 | currentVariants[value] = 0; 757 | } else if (Math.random() < this.variationProbability) { 758 | currentVariants[value]++; 759 | } 760 | variants.push(currentVariants[value]); 761 | } 762 | } 763 | if (rondo) { 764 | structure.push(0); 765 | currentVariants[0]++; 766 | variants.push(currentVariants[0]); 767 | } else { 768 | const length = structure.length; 769 | const lastValue = structure[length - 1]; 770 | if (inner.length > 1 || 771 | (lastValue === structure[length - 2] && variants[length - 1] === variants[length - 2]) || 772 | structure.length < 3 773 | ) { 774 | // Add a more final ending 775 | structure.push(lastValue); 776 | currentVariants[lastValue]++; 777 | variants.push(currentVariants[lastValue]); 778 | } 779 | } 780 | 781 | console.log('Structure: ' + structure); 782 | console.log('Variations: ' + variants); 783 | } 784 | 785 | generateOutput(noteValues, melody, length) { 786 | const phrase = new Sequencer.Phrase('Generated', length * 2); 787 | const numBlocks = noteValues.length; 788 | let rowNum = 0; 789 | let melodyIndex = 0; 790 | while (rowNum < length) { 791 | for (let i = 0; i < numBlocks; i++) { 792 | const beatGroup = noteValues[i]; 793 | for (let j = 0; j < beatGroup.length; j++) { 794 | const beatLength = beatGroup[j]; 795 | if (beatLength > 0) { 796 | const parameterMap = new Map(); 797 | const note = melody[melodyIndex]; 798 | const duration = beatLength * 2 * this.articulation; 799 | const velocity = j === 0 ? 127 : this.offbeatVelocity; 800 | parameterMap.set(Synth.Param.NOTES, new Synth.Change(Synth.ChangeType.SET, [note])); 801 | parameterMap.set(Synth.Param.GATE, new Synth.Change(Synth.ChangeType.SET, Synth.Gate.TRIGGER)); 802 | parameterMap.set(Synth.Param.DURATION, new Synth.Change(Synth.ChangeType.SET, duration)); 803 | parameterMap.set(Synth.Param.VELOCITY, new Synth.Change(Synth.ChangeType.SET, velocity)); 804 | phrase.rows[rowNum] = parameterMap; 805 | melodyIndex++; 806 | } 807 | rowNum += Math.abs(beatLength) * 2; 808 | } 809 | } 810 | } 811 | return phrase; 812 | } 813 | 814 | generatePhrase(timeSignatureType) { 815 | const timeSignature = this.generateTimeSignature(timeSignatureType); 816 | console.log('Time signature: ' + timeSignature.length + '/' + String(8 / timeSignature.beatLength)); 817 | if (timeSignature.type !== TimeSignatureType.SIMPLE) { 818 | console.log('Beat: ' + timeSignature.groupings); 819 | } 820 | 821 | const numBars = 4; 822 | let noteValues = this.generateRhythm(timeSignature, RhythmType.FIRST_BAR); 823 | for (let i = 1; i < numBars; i++) { 824 | noteValues = noteValues.concat(this.generateRhythm(timeSignature, RhythmType.UNCONSTRAINED)); 825 | } 826 | 827 | const numBlocks = noteValues.length; 828 | let numNotes = 0; 829 | for (let i = 0; i < numBlocks; i++) { 830 | const beatGroup = noteValues[i]; 831 | for (let j = 0; j < beatGroup.length; j++) { 832 | const beatLength = beatGroup[j]; 833 | if (beatLength > 0) { 834 | numNotes++; 835 | } 836 | } 837 | } 838 | 839 | const scale = this.generateScale(Sequencer.DIATONIC_SCALE); 840 | console.log('Scale: ' + scale); 841 | const rootNote = this.minNote + Math.trunc((this.maxNote - this.minNote + 1) / 2 - 6 + Math.random() * 12); 842 | console.log('Root Note: ' + rootNote); 843 | const pitchSpace = generatePitchSpace(scale, rootNote, this.minNote, this.maxNote); 844 | 845 | const conjunctPatterns = []; 846 | let numContours = 0; 847 | console.log('Contours:'); 848 | while (numContours < this.numConjunctContours) { 849 | const contour = this.generateContour(true); 850 | console.log(contour); 851 | const newPatterns = SongGenerator.putContourInPitchSpace(contour, pitchSpace); 852 | if (newPatterns.length > 0) { 853 | conjunctPatterns.splice(conjunctPatterns.length, 0, ...newPatterns); 854 | numContours++; 855 | } 856 | } 857 | 858 | const disjunctPatterns = []; 859 | numContours = 0; 860 | while (numContours < this.numDisjunctContours) { 861 | const contour = this.generateContour(false); 862 | console.log(contour); 863 | const newPatterns = SongGenerator.putContourInPitchSpace(contour, pitchSpace); 864 | if (newPatterns.length > 0) { 865 | disjunctPatterns.splice(disjunctPatterns.length, 0, ...newPatterns); 866 | numContours++; 867 | } 868 | } 869 | 870 | const melody = this.generateMelody(pitchSpace, conjunctPatterns, disjunctPatterns, 'mixed', rootNote, numNotes); 871 | 872 | const phrase = this.generateOutput(noteValues, melody, timeSignature.length * numBars * 2); 873 | return phrase; 874 | } 875 | 876 | } 877 | -------------------------------------------------------------------------------- /img/boombox.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 41 | 43 | Created by potrace 1.15, written by Peter Selinger 2001-2017 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 57 | 62 | 67 | 72 | 77 | 82 | 87 | 92 | 97 | 102 | 107 | 112 | 117 | 122 | 123 | 124 | --------------------------------------------------------------------------------