diff options
| author | s-ol <s+removethis@s-ol.nu> | 2023-09-02 11:02:33 +0000 |
|---|---|---|
| committer | s-ol <s+removethis@s-ol.nu> | 2023-09-02 11:05:09 +0000 |
| commit | 9d4923c01b565e8cdfc01214ef0b762924cd9714 (patch) | |
| tree | ef00b651c46ddb471bf63fcf044c553e3541e4a6 | |
| parent | show index-in-pattern (diff) | |
| download | isomorphic-kb-explorer-9d4923c01b565e8cdfc01214ef0b762924cd9714.tar.gz isomorphic-kb-explorer-9d4923c01b565e8cdfc01214ef0b762924cd9714.zip | |
add synthesizer
| -rw-r--r-- | main.js | 40 | ||||
| -rw-r--r-- | simple-js-synth.js | 164 | ||||
| -rw-r--r-- | synth.html | 402 | ||||
| -rw-r--r-- | synth.js | 52 |
4 files changed, 649 insertions, 9 deletions
@@ -1,5 +1,6 @@ import * as layout from './layout.js'; import * as pattern from './pattern.js'; +import * as synth from './synth.js'; const sqrt3 = Math.sqrt(3); const sqrt32 = sqrt3 / 2; @@ -43,11 +44,6 @@ const main = document.querySelector('main'); const bg = document.getElementById('canvas-bg').getContext('2d'); const fg = document.getElementById('canvas-fg').getContext('2d'); -let mousePos = [0, 0]; -main.onmousemove = (e) => { - mousePos = [e.clientX - main.clientWidth/2, e.clientY - main.clientHeight/2]; -}; - document.body.onkeydown = (e) => { switch (e.key) { case 'q': layout.turn(-1); break; @@ -55,6 +51,29 @@ document.body.onkeydown = (e) => { } }; +main.onpointerdown = +main.onpointermove = (e) => { + if (e.type.endsWith('move')) { + if (!e.target.hasPointerCapture(e.pointerId)) return; + } else { + e.target.setPointerCapture(e.pointerId); + } + + e.preventDefault(); + + const [qq, rr] = layout.getSteps(); + const pos = [e.clientX - main.clientWidth/2, e.clientY - main.clientHeight/2]; + const [q, r] = px2hex(rotate(pos, layout.getRot())); + const note = q*qq + r*rr; + + synth.on(`ptr-${e.pointerId}`, note); +}; +main.onpointerup = (e) => { + e.preventDefault(); + + synth.off(`ptr-${e.pointerId}`); +}; + let lastCanvasSize = 0; const updateForeground = (canvasSize) => { @@ -125,7 +144,13 @@ const draw = () => { const stepIndex = steps.indexOf(note); hexagon(bg); - if (stepIndex > -1) { + if (synth.isOn(note)) { + bg.fillStyle = '#e3baf5'; + bg.fill(); + + bg.beginPath(); + bg.arc(0, 0, size*0.65, 0, Math.PI*2); + } else if (stepIndex > -1) { bg.fillStyle = '#bae3f5'; bg.fill(); @@ -152,9 +177,6 @@ const draw = () => { } } - // const mouse = px2hex(rotate(mousePos, rot)); - // bg.arc(...hex2px(mouse), 10, 0, 2*Math.PI); - document.body.style.setProperty('--global-rot', `${rot}rad`); requestAnimationFrame(draw); }; diff --git a/simple-js-synth.js b/simple-js-synth.js new file mode 100644 index 0000000..77a206d --- /dev/null +++ b/simple-js-synth.js @@ -0,0 +1,164 @@ +// (c) Copyright 2017, Sean Connelly (@voidqk), http://syntheti.cc +// MIT License +// Project Home: https://github.com/voidqk/simple-js-synth + +function SimpleJSSynth(dest, opts){ + // `dest` is the AudioNode destination + // `opts` is an object; see notes further down for meaning and range of values. + // { + // osc1type : 'sine'|'square'|'sawtooth'|'triangle', // type of wave + // osc1vol : 0 to 1, // oscillator volume (linear) + // osc1tune : 0, // relative tuning (semitones) + // osc2type, osc2vol, osc2tune, // settings for osc2 + // osc3type, osc3vol, osc3tune, // settings for osc3 + // attack : 0 to inf, // attack time (seconds) + // decay : 0 to inf, // decay time (seconds) + // sustain : 0 to 1, // sustain (fraction of max vol) + // susdecay : 0 to inf, // decay during sustain (seconds) + // cutoff : -inf to inf // filter cutoff (relative semitones) + // } + + var ctx = dest.context; // get the WebAudio context + + // + // Osc1 ---> Osc1 Gain ---+ + // | + // Osc2 ---> Osc2 Gain ---+---> Envelope Gain ---> Filter --> Destination + // | + // Osc3 ---> Osc3 Gain ---+ + // + + var filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(22050, ctx.currentTime); + filter.Q.setValueAtTime(0.5, ctx.currentTime); + + var my = filter; // the returned object is the filter + + var gain = ctx.createGain(); + gain.gain.setValueAtTime(0, ctx.currentTime); + gain.connect(filter); + + function oscgain(v, def){ + var g = ctx.createGain(); + v = typeof v === 'number' ? v : def; + g.gain.setValueAtTime(0, ctx.currentTime); + g.connect(gain); + return { node: g, base: v }; + } + var osc1gain = oscgain(opts.osc1vol, 0.8); + var osc2gain = oscgain(opts.osc2vol, 0.6); + var osc3gain = oscgain(opts.osc3vol, 0.4); + + function osctype(type, g){ + var osc = ctx.createOscillator(); + osc.type = typeof type === 'string' ? type : 'sine'; + osc.connect(g); + return osc; + } + var osc1 = osctype(opts.osc1type, osc1gain.node); + var osc2 = osctype(opts.osc2type, osc2gain.node); + var osc3 = osctype(opts.osc3type, osc3gain.node); + + function calctune(t){ + if (typeof t !== 'number') + return 1; + return Math.pow(2, t / 12); + } + var tune1 = calctune(opts.osc1tune); + var tune2 = calctune(opts.osc2tune); + var tune3 = calctune(opts.osc3tune); + var cutoff = calctune(opts.cutoff); + + var attack = typeof opts.attack == 'number' ? opts.attack : 0.1; + var decay = typeof opts.decay == 'number' ? opts.decay : 0.2; + var sustain = typeof opts.sustain == 'number' ? opts.sustain : 0.5; + var susdecay = typeof opts.susdecay == 'number' ? opts.susdecay : 10; + + // clamp the values a bit + var eps = 0.001; + if (attack < eps) + attack = eps; + if (decay < eps) + decay = eps; + if (sustain < eps) + sustain = eps; + if (susdecay < eps) + susdecay = eps; + + var basefreq = 0; + var silent = 0; + var ndown = false; + + osc1.start(); + osc2.start(); + osc3.start(); + + my.connect(dest); + + my.noteOn = function(freq, vol){ + ndown = true; + basefreq = freq; + var now = ctx.currentTime; + osc1.frequency.setValueAtTime(freq * tune1, now); + osc2.frequency.setValueAtTime(freq * tune2, now); + osc3.frequency.setValueAtTime(freq * tune3, now); + filter.frequency.setValueAtTime(Math.min(freq * cutoff, 22050), now); + osc1gain.node.gain.setValueAtTime(vol * osc1gain.base, now); + osc2gain.node.gain.setValueAtTime(vol * osc2gain.base, now); + osc3gain.node.gain.setValueAtTime(vol * osc3gain.base, now); + var v = gain.gain.value; + gain.gain.cancelScheduledValues(now); + gain.gain.setValueAtTime(v, now); + var hitpeak = now + attack; + var hitsus = hitpeak + decay * (1 - sustain); + silent = hitsus + susdecay; + gain.gain.linearRampToValueAtTime(1, hitpeak); + gain.gain.linearRampToValueAtTime(sustain, hitsus); + gain.gain.linearRampToValueAtTime(0.000001, silent); + }; + + my.bend = function(semitones){ + var b = basefreq * Math.pow(2, semitones / 12); + var now = ctx.currentTime; + osc1.frequency.setTargetAtTime(b * tune1, now, 0.1); + osc2.frequency.setTargetAtTime(b * tune2, now, 0.1); + osc3.frequency.setTargetAtTime(b * tune3, now, 0.1); + }; + + my.noteOff = function(){ + ndown = false; + var now = ctx.currentTime; + var v = gain.gain.value; + gain.gain.cancelScheduledValues(now); + gain.gain.setValueAtTime(v, now); + silent = now + decay * v; + gain.gain.linearRampToValueAtTime(0.000001, silent); + }; + + my.isReady = function(){ + return ctx.currentTime >= silent && !ndown; + }; + + my.stop = function(){ + ndown = false; + var now = ctx.currentTime; + osc1gain.node.gain.setValueAtTime(0.000001, now); + osc2gain.node.gain.setValueAtTime(0.000001, now); + osc3gain.node.gain.setValueAtTime(0.000001, now); + silent = 0; + }; + + my.destroy = function(){ + ndown = false; + silent = 0; + osc1.stop(); + osc2.stop(); + osc3.stop(); + my.disconnect(); + }; + + return my; +} + +export default SimpleJSSynth; diff --git a/synth.html b/synth.html new file mode 100644 index 0000000..4d486a5 --- /dev/null +++ b/synth.html @@ -0,0 +1,402 @@ +<!doctype html> +<html lang="en"> +<!-- +(c) Copyright 2016, Sean Connelly (@voidqk), http://syntheti.cc +MIT License +Project Home: https://github.com/voidqk/simple-js-synth +--> +<head> + <title>Simple JS Synth [demo]</title> + <style> +html, body { font-family: sans-serif; } +table, tr { + border-collapse: collapse; + padding: 0; +} +th, td { + padding: 5px; + border: 1px solid #777; + text-align: center; +} +pre { + text-align: left; + margin: 0 10px; +} +#osc1vol , #osc2vol , #osc3vol { width: 120px; } +#osc1tune, #osc2tune, #osc3tune { width: 280px; } +#attack , #decay , #sustain , #susdecay { width: 500px; } +#cutoff { width: 400px; } + </style> +</head> +<body> + <pre> +(c) Copyright 2016, Sean Connelly (@voidqk), http://syntheti.cc +MIT License +Project Home: https://github.com/velipso/simple-js-synth + </pre> + <table> + <tbody> + <tr> + <th onclick="MM();">Oscillator</th> + <th>Type</th> + <th>Volume</th> + <th>Tune</th> + <td rowspan="11"><pre id="raw"></pre></td> + </tr> + <tr> + <td>1</td> + <td><select id="osc1type"> + <option value="sine">sine</option> + <option value="square">square</option> + <option value="triangle">triangle</option> + <option value="sawtooth">sawtooth</option> + </select></td> + <td>0 <input type="range" min="0" max="50" value="20" id="osc1vol" /> 0.5</td> + <td>-12 <input type="range" min="-120" max="120" value="0" id="osc1tune" /> +12</td> + </tr> + <tr> + <td>2</td> + <td><select id="osc2type"> + <option value="sine">sine</option> + <option value="square" selected="selected">square</option> + <option value="triangle">triangle</option> + <option value="sawtooth">sawtooth</option> + </select></td> + <td>0 <input type="range" min="0" max="50" value="10" id="osc2vol" /> 0.5</td> + <td>-12 <input type="range" min="-120" max="120" value="120" id="osc2tune" /> +12</td> + </tr> + <tr> + <td>3</td> + <td><select id="osc3type"> + <option value="sine">sine</option> + <option value="square">square</option> + <option value="triangle">triangle</option> + <option value="sawtooth">sawtooth</option> + </select></td> + <td>0 <input type="range" min="0" max="50" value="5" id="osc3vol" /> 0.5</td> + <td>-12 <input type="range" min="-120" max="120" value="-120" id="osc3tune" /> +12</td> + </tr> + <tr> + <th colspan="4" style="border-top: 2px solid #777;">Envelope</th> + </tr> + <tr> + <th>Attack</th> + <td colspan="3">0 <input type="range" min="0" max="200" value="0" id="attack" /> 2</td> + </tr> + <tr> + <th>Decay</th> + <td colspan="3">0 <input type="range" min="0" max="500" value="30" id="decay" /> 5</td> + </tr> + <tr> + <th>Sustain</th> + <td colspan="3">0 <input type="range" min="0" max="100" value="50" id="sustain" /> 1</td> + </tr> + <tr> + <th>Sus. Decay</th> + <td colspan="3">0 <input type="range" min="0" max="100" value="10" id="susdecay" /> 10</td> + </tr> + <tr> + <td colspan="4" style="padding: 0; line-height: 0;"><canvas id="env" width="1440" height="200" style="width: 720px; height: 100px;"></canvas></td> + </tr> + <tr style="border-top: 2px solid #777;"> + <th id="cutoff_txt">Cutoff</th> + <td><select id="filter"> + <option value="1">Enable</option> + <option value="0">Disable</option> + </select></td> + <td colspan="2">-12 <input type="range" min="-120" max="480" value="360" id="cutoff" /> +48</td> + </tr> + </tbody> + </table> + <br /> + <script type="module"> +import SimpleJSSynth from './simple-js-synth.js'; + +// create our WebAudio context +var AudioContext = window.AudioContext || window.webkitAudioContext; +var actx = new AudioContext(); + +// store the global bend amount for future notes to play at +var globalbend = 0; + +// create a pool of monophonic synths to turn it into polyphonic +var pool = []; + +// store our options to detect changes +var lastopts = null; + +function reload(){ + // create our options object based on the form + function osctype(id){ + var sel = document.getElementById('osc' + id + 'type'); + return sel.options[sel.selectedIndex].value; + } + function getnum(id, base){ + return parseFloat(document.getElementById(id).value) / base; + } + var docutoff = document.getElementById('filter').selectedIndex == 0; + var opts = { + osc1type: osctype(1), + osc1vol : getnum('osc1vol', 100), + osc1tune: getnum('osc1tune', 10), + osc2type: osctype(2), + osc2vol : getnum('osc2vol', 100), + osc2tune: getnum('osc2tune', 10), + osc3type: osctype(3), + osc3vol : getnum('osc3vol', 100), + osc3tune: getnum('osc3tune', 10), + attack : getnum('attack', 100), + decay : getnum('decay', 100), + sustain : getnum('sustain', 100), + susdecay: getnum('susdecay', 10), + cutoff : docutoff ? getnum('cutoff', 10) : 1000 + }; + + // if our options haven't changed from before, then return immediately + if (lastopts !== null && + lastopts.osc1type === opts.osc1type && + lastopts.osc1vol === opts.osc1vol && + lastopts.osc1tune === opts.osc1tune && + lastopts.osc2type === opts.osc2type && + lastopts.osc2vol === opts.osc2vol && + lastopts.osc2tune === opts.osc2tune && + lastopts.osc3type === opts.osc3type && + lastopts.osc3vol === opts.osc3vol && + lastopts.osc3tune === opts.osc3tune && + lastopts.attack === opts.attack && + lastopts.decay === opts.decay && + lastopts.sustain === opts.sustain && + lastopts.susdecay === opts.susdecay && + lastopts.cutoff === opts.cutoff) + return; + + // output JSON object to UI + document.getElementById('raw').innerHTML = JSON.stringify(opts, null, 3); + + // change UI to reflect whether cutoff is enabled + document.getElementById('cutoff_txt').style.color = docutoff ? '#000' : '#999'; + document.getElementById('cutoff').disabled = !docutoff; + + // draw the envelope + (function(){ + var cnv = document.getElementById('env'); + var ctx = cnv.getContext('2d'); + var pps = 170; // pixels per second + var attack_x = opts.attack * pps; + var sustain_x = attack_x + (1 - opts.sustain) * opts.decay * pps; + var sustain_y = (1 - opts.sustain) * cnv.height; + var endsus_x = sustain_x + 2 * pps; + var endsus_y = (1 - opts.sustain * (1 - 2 / Math.max(opts.susdecay, 0.001))) * cnv.height; + var decay_x = endsus_x + opts.sustain * 0.5 * opts.decay * pps; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, cnv.width, cnv.height); + ctx.fillStyle = '#eee'; + ctx.fillRect(endsus_x, 0, cnv.width, cnv.height); + ctx.beginPath(); + ctx.moveTo(0, cnv.height); + ctx.lineTo(attack_x, 0); + ctx.lineTo(sustain_x, sustain_y); + ctx.lineTo(endsus_x, endsus_y); + ctx.lineTo(decay_x, cnv.height); + ctx.strokeStyle = '#000'; + ctx.setLineDash([]); + ctx.lineWidth = 4; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(attack_x, 0); + ctx.lineTo(attack_x, cnv.height); + ctx.moveTo(sustain_x, 0); + ctx.lineTo(sustain_x, cnv.height); + ctx.moveTo(endsus_x, 0); + ctx.lineTo(endsus_x, cnv.height); + ctx.setLineDash([5, 5]); + ctx.lineWidth = 2; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(sustain_x, sustain_y, 10, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(endsus_x, endsus_y, 10, 0, Math.PI * 2); + ctx.stroke(); + })(); + + // destroy all synths currently in the pool + while (pool.length > 0) + pool.pop().destroy(); // pop a synth and destroy it + + // recreate the pool based on the new options + for (var i = 0; i < 20; i++) + pool.push(SimpleJSSynth(actx.destination, opts)); + + // save the options for later comparison + lastopts = opts; +} + +// attach update events to reload +(['osc1type', 'osc1vol', 'osc1tune', 'osc2type', 'osc2vol', 'osc2tune', 'osc3type', 'osc3vol', + 'osc3tune', 'attack', 'decay', 'sustain', 'susdecay', 'filter', 'cutoff']).forEach(function(id){ + document.getElementById(id).addEventListener('change', reload); +}); + +// check our options every 200ms +setInterval(reload, 200); + +// fired when a note is hit, either via the UI, or via MIDI +function noteHit(freq, vol){ + actx.resume(); + + for (var i = 0; i < pool.length; i++){ + // search the pool of synths that are ready for a new note + if (pool[i].isReady()){ + // trigger and return the synth + pool[i].noteOn(freq, vol); + return pool[i]; + } + } + // no synths available, so don't do anything, and return a junk object + return { + noteOff: function(){}, + bend: function(){} + }; +} + +// track which notes are currently playing +var mididown = []; +for (var i = 0; i < 128; i++) + mididown.push(false); + +// called when a note is hit or released via UI or MIDI +function midiHit(note, vel, down){ + // silence the note + if (mididown[note]){ + mididown[note].noteOff(); + mididown[note] = false; + } + // if we're pressing down, play the note + if (down){ + var freq = 440 * Math.pow(2, (note - 69) / 12); // convert note to frequency + var vol = vel / 127; // convert velocity to volume + mididown[note] = noteHit(freq, vol); + // if we have a global bend, apply it immediately + if (globalbend != 0) + mididown[note].bend(globalbend); + } + // if the note is in the range of the UI, then color the appropriate div + if (note >= 36 && note < 84) + divs[note - 36].style.background = down ? '#aaf' : '#eef'; +} + +// called when MIDI sends a bend message +function midiBend(amt){ + // set the global bend of +-2 semitones + globalbend = amt * 2; + + // scan all playing notes and bend them accordingly + for (var i = 0; i < 128; i++){ + if (mididown[i]) + mididown[i].bend(globalbend); + } +} + +// called when MIDI has initialized +function midiInit(midi){ + function midiHook(){ + var inputs = midi.inputs.values(); + for (var input = inputs.next(); input && !input.done; input = inputs.next()) + input.value.onmidimessage = midiEvent; + } + midiHook(); + midi.onstatechange = midiHook; +} + +// called when MIDI fails to initialize... don't do anything +function midiReject(){ + console.warn('MIDI failed to initialize... oh well'); +} + +// called when a MIDI message is received +function midiEvent(ev){ + if (ev.data.length < 2) + return; + // look for note on, note off, and pitch bend messages + if ((ev.data[0] & 0xF0) == 0x90) + midiHit(ev.data[1], ev.data[2], true); + else if ((ev.data[0] & 0xF0) == 0x80) + midiHit(ev.data[1], 0, false); + else if ((ev.data[0] & 0xF0) == 0xE0){ + if (ev.data[1] == 0 && ev.data[2] == 0x40) + midiBend(0); + else + midiBend((ev.data[1] | (ev.data[2] << 7)) * 2 / 0x3FFF - 1); + } +} + +// request MIDI access +if (navigator.requestMIDIAccess) + navigator.requestMIDIAccess().then(midiInit, midiReject); + +// create our 48 divs for the user to click +var divs = []; +for (var i = 0; i < 48; i++){ + if (i == 12 || i == 24 || i == 36) + document.body.appendChild(document.createElement('br')); + (function(div, i){ + // create the DIV + divs.push(div); + div.style.display = 'inline-block'; + div.style.margin = '1px'; + div.style.padding = '15px'; + div.style.border = '1px solid #000'; + div.style.cursor = 'default'; + div.style.backgroundColor = '#eef'; + div.appendChild(document.createTextNode( + (['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'])[i % 12] + + (Math.floor(i / 12) + 2))); + document.body.appendChild(div); + + // hook mouse events to the DIV + function cancel(e){ + e.preventDefault(); + e.stopPropagation(); + return false; + } + function mousedown(e){ + // if the mouse is pressed, simulate a MIDI note on + midiHit(36 + i, 100, true); + return cancel(e); + } + function mouseup(e){ + // if the mouse is released, simulate a MIDI note off + midiHit(36 + i, 0, false); + return cancel(e); + } + div.addEventListener('mousemove', cancel); + div.addEventListener('mousedown', mousedown); + div.addEventListener('mouseup', mouseup); + div.addEventListener('mouseout', mouseup); + div.addEventListener('touchmove', cancel); + div.addEventListener('touchstart', mousedown); + div.addEventListener('touchend', mouseup); + div.addEventListener('touchleave', mouseup); + div.addEventListener('selectstart', cancel); + div.unselectable = 'on'; + div.style.mozUserSelect = 'none'; + div.style.webkitUserSelect = 'none'; + div.style.userSelect = 'none'; + })(document.createElement('div'), i); +} + +function MM(){ + var i = 0, p = 0, s = 'MMMOM]QMM]\\MLMMMOM]QMM]\\ORlT]ORlT]\\]QOLOLMLMOLpA'; + function n(){ + var c = s.charCodeAt(i++) - 65, m = 70 + c % 11, z = Math.floor(c / 11); + if (p > 70) midiHit(p, 0, false); + if (m > 70) midiHit(m, 100, true ); + if (z > 0) setTimeout(n, z * 187); + p = m; + } + n(); +} + </script> +</body> +</html> diff --git a/synth.js b/synth.js new file mode 100644 index 0000000..74dc051 --- /dev/null +++ b/synth.js @@ -0,0 +1,52 @@ +import SimpleJSSynth from './simple-js-synth.js'; +import * as pattern from './pattern.js'; + +const ctx = new window.AudioContext(); + +const opts = {}; +const voices = []; +const tones = []; + +export const getOptions = () => opts; + +export const setOptions = (o) => { + Object.assign(opts, o); + + while (voices.length) + voices.pop().destroy(); + + for (let i = 0; i < opts.polyphony; i++) + voices.push(SimpleJSSynth(ctx.destination, opts)); +}; + +setOptions({ + osc1type: "sine", osc1vol: 0.2, osc1tune: 0, + osc2type: "square", osc2vol: 0.14, osc2tune: 12, + osc3type: "sine", osc3vol: 0.05, osc3tune: -6.7, + attack: 0, decay: 0.3, sustain: 0.5, susdecay: 5, + cutoff: 36, + + polyphony: 8, +}); + +export const on = (key, note, vol=1) => { + ctx.resume() + + if (tones[key]?.note === note) return; + off(key); + + const freq = 110.0 * Math.pow(2, note / pattern.getLength()); + + const voice = voices.find(s => s.isReady()); + if (!voice) return; + + voice.noteOn(freq, vol); + tones[key] = off && { note, voice }; +}; + +export const off = (key) => { + tones[key]?.voice.noteOff(); + delete tones[key]; +}; + +export const isOn = (note) => Object.values(tones).some(t => t.note === note); |
