Add different wave and filter types
This commit is contained in:
436
www/bootstrap.js
vendored
436
www/bootstrap.js
vendored
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* bootstrap.js — ES module entry point.
|
||||
*
|
||||
* Initialises the WASM module, wires canvas elements to Rust-exported types,
|
||||
* keeps canvas drawing buffers in sync with their CSS size, provides a
|
||||
* draggable resize handle for the patch bay, and drives a monophonic
|
||||
* synthesiser from the virtual keyboard and computer keyboard.
|
||||
* The PatchRouter class mirrors the visual patch bay into a Web Audio graph.
|
||||
* Sound only flows when the Output node has a cable connected; the signal
|
||||
* travels through whatever chain the user has assembled. The engine's
|
||||
* AnalyserNode sits after the Output node, so the oscilloscope and spectrum
|
||||
* always reflect exactly what feeds the Output.
|
||||
*/
|
||||
|
||||
import init, {
|
||||
@@ -17,9 +18,6 @@ import init, {
|
||||
} from "./pkg/synth_visualiser.js";
|
||||
|
||||
// ── Canvas buffer sizing ──────────────────────────────────────────────────────
|
||||
// The canvas *attribute* (width/height) sets the drawing-buffer resolution.
|
||||
// The CSS size only controls how it's displayed. We keep them in sync so the
|
||||
// Rust code always draws at 1:1 pixels.
|
||||
|
||||
function fitCanvas(canvas) {
|
||||
const w = Math.round(canvas.clientWidth);
|
||||
@@ -38,9 +36,7 @@ function initResizeHandle() {
|
||||
let dragging = false, startY = 0, startH = 0;
|
||||
|
||||
handle.addEventListener("pointerdown", e => {
|
||||
dragging = true;
|
||||
startY = e.clientY;
|
||||
startH = panel.offsetHeight;
|
||||
dragging = true; startY = e.clientY; startH = panel.offsetHeight;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
handle.classList.add("active");
|
||||
});
|
||||
@@ -48,71 +44,302 @@ function initResizeHandle() {
|
||||
if (!dragging) return;
|
||||
panel.style.height = Math.max(80, startH - (e.clientY - startY)) + "px";
|
||||
});
|
||||
const stopDrag = () => { dragging = false; handle.classList.remove("active"); };
|
||||
handle.addEventListener("pointerup", stopDrag);
|
||||
handle.addEventListener("pointercancel", stopDrag);
|
||||
const stop = () => { dragging = false; handle.classList.remove("active"); };
|
||||
handle.addEventListener("pointerup", stop);
|
||||
handle.addEventListener("pointercancel", stop);
|
||||
}
|
||||
|
||||
// ── Monophonic synthesiser ────────────────────────────────────────────────────
|
||||
// Uses the Web Audio API directly. All notes are routed through the engine's
|
||||
// input GainNode → AnalyserNode → speakers so the oscilloscope and spectrum
|
||||
// analyser reflect the keyboard output.
|
||||
// ── PatchRouter ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Reads get_patch_json() whenever patch_version() changes, then rebuilds a
|
||||
// Web Audio graph that mirrors the visual patch bay topology.
|
||||
//
|
||||
// Module → Web Audio mapping:
|
||||
// vco → OscillatorNode (sawtooth default; frequency driven by keyboard)
|
||||
// svf → BiquadFilterNode (cutoff + Q from params; LFO can modulate frequency)
|
||||
// vca → GainNode (gain from param; driven by ADSR envelope if patched)
|
||||
// adsr → (no node) drives the VCA GainNode gain via parameter automation
|
||||
// lfo → OscillatorNode + GainNode (low-freq oscillator modulating AudioParams)
|
||||
// out → GainNode (level param) → masterEnv → engine analyser → speakers
|
||||
//
|
||||
// Signal only reaches the speakers when an "out" module has an audio_in cable.
|
||||
|
||||
function createSynth(audioCtx, inputNode) {
|
||||
let osc = null;
|
||||
let envGain = null;
|
||||
let noteCache = -1;
|
||||
class PatchRouter {
|
||||
constructor(audioCtx, engineInputNode) {
|
||||
this.ctx = audioCtx;
|
||||
|
||||
function midiToHz(n) {
|
||||
return 440 * Math.pow(2, (n - 69) / 12);
|
||||
// masterEnv: a final GainNode between the Out module and the engine
|
||||
// input (analyser). When no ADSR envelope is in the chain, noteOn/Off
|
||||
// drives this gain directly. When an ADSR drives a VCA, masterEnv
|
||||
// stays at 1.0 and the VCA gain carries the envelope shape.
|
||||
this.masterEnv = audioCtx.createGain();
|
||||
this.masterEnv.gain.value = 0;
|
||||
this.masterEnv.connect(engineInputNode);
|
||||
|
||||
this.nodes = new Map(); // moduleId → nodeInfo object
|
||||
this.lastVersion = -1;
|
||||
this.outHasCable = false; // true when Out.audio_in has a cable
|
||||
this.hasAdsrVca = false; // true when a VCA is driven by ADSR
|
||||
this.activeNote = -1;
|
||||
}
|
||||
|
||||
async function ensureRunning() {
|
||||
if (audioCtx.state !== "running") await audioCtx.resume();
|
||||
// Call each frame. Rebuilds the audio graph only when the patch changes.
|
||||
sync(version, patchJson) {
|
||||
if (version === this.lastVersion) return;
|
||||
this.lastVersion = version;
|
||||
this._rebuild(JSON.parse(patchJson));
|
||||
}
|
||||
|
||||
return {
|
||||
async noteOn(midiNote) {
|
||||
await ensureRunning();
|
||||
if (midiNote === noteCache) return;
|
||||
|
||||
const now = audioCtx.currentTime;
|
||||
if (!osc) {
|
||||
// Fresh attack
|
||||
osc = audioCtx.createOscillator();
|
||||
envGain = audioCtx.createGain();
|
||||
osc.type = "sawtooth";
|
||||
osc.frequency.value = midiToHz(midiNote);
|
||||
envGain.gain.setValueAtTime(0.0001, now);
|
||||
envGain.gain.exponentialRampToValueAtTime(0.3, now + 0.015);
|
||||
osc.connect(envGain);
|
||||
envGain.connect(inputNode);
|
||||
osc.start();
|
||||
} else {
|
||||
// Glide to new pitch without retriggering the envelope
|
||||
osc.frequency.setTargetAtTime(midiToHz(midiNote), now, 0.015);
|
||||
// Call each frame when params_version changes. Updates audio params in-place
|
||||
// without tearing down and rebuilding the Web Audio graph.
|
||||
updateParams(patchJson) {
|
||||
const patch = JSON.parse(patchJson);
|
||||
for (const m of patch.modules) {
|
||||
const info = this.nodes.get(m.id);
|
||||
if (!info) continue;
|
||||
const now = this.ctx.currentTime;
|
||||
switch (m.kind) {
|
||||
case "vco": {
|
||||
if (this.activeNote < 0) {
|
||||
info.node.frequency.setTargetAtTime(m.params.freq_hz ?? 440, now, 0.01);
|
||||
}
|
||||
const wt = (["sine","sawtooth","square","triangle","sawtooth"])[Math.round(m.params.waveform ?? 1)] ?? "sawtooth";
|
||||
if (info.node.type !== wt) info.node.type = wt;
|
||||
break;
|
||||
}
|
||||
case "svf":
|
||||
info.node.frequency.setTargetAtTime(m.params.cutoff_hz ?? 2000, now, 0.01);
|
||||
info.node.Q.setTargetAtTime(0.5 + (m.params.resonance ?? 0.5) * 19.5, now, 0.01);
|
||||
break;
|
||||
case "vca":
|
||||
if (!info.adsrControlled) {
|
||||
info.node.gain.setTargetAtTime(m.params.gain ?? 1.0, now, 0.01);
|
||||
}
|
||||
info.staticGain = m.params.gain ?? 1.0;
|
||||
break;
|
||||
case "adsr":
|
||||
info.attack = m.params.attack_s ?? 0.01;
|
||||
info.decay = m.params.decay_s ?? 0.1;
|
||||
info.sustain = m.params.sustain ?? 0.7;
|
||||
info.release = m.params.release_s ?? 0.3;
|
||||
break;
|
||||
case "lfo":
|
||||
info.oscNode.frequency.setTargetAtTime(m.params.rate_hz ?? 2, now, 0.01);
|
||||
info.node.gain.setTargetAtTime((m.params.depth ?? 0.5) * 600, now, 0.01);
|
||||
break;
|
||||
case "out":
|
||||
info.node.gain.setTargetAtTime(m.params.level ?? 0.8, now, 0.01);
|
||||
break;
|
||||
}
|
||||
noteCache = midiNote;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
noteOff() {
|
||||
if (!osc) return;
|
||||
const now = audioCtx.currentTime;
|
||||
envGain.gain.cancelScheduledValues(now);
|
||||
envGain.gain.setValueAtTime(envGain.gain.value, now);
|
||||
envGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.28);
|
||||
const o = osc, g = envGain;
|
||||
setTimeout(() => { try { o.stop(); o.disconnect(); g.disconnect(); } catch (_) {} }, 400);
|
||||
osc = null; envGain = null; noteCache = -1;
|
||||
},
|
||||
// ── noteOn / noteOff ──────────────────────────────────────────────────────
|
||||
|
||||
activeNote() { return noteCache; },
|
||||
};
|
||||
noteOn(midiNote, retrigger = true) {
|
||||
// Unblock AudioContext on first user gesture
|
||||
if (this.ctx.state !== "running") this.ctx.resume();
|
||||
|
||||
const wasActive = this.activeNote >= 0;
|
||||
this.activeNote = midiNote;
|
||||
if (!this.outHasCable) return;
|
||||
|
||||
const hz = this._midiHz(midiNote);
|
||||
const now = this.ctx.currentTime;
|
||||
|
||||
// Update all VCO frequencies
|
||||
for (const [, info] of this.nodes) {
|
||||
if (info.kind !== "vco") continue;
|
||||
if (wasActive && !retrigger) {
|
||||
// Glide: smooth frequency transition, no envelope retrigger
|
||||
info.node.frequency.setTargetAtTime(hz, now, 0.02);
|
||||
} else {
|
||||
info.node.frequency.setValueAtTime(hz, now);
|
||||
}
|
||||
}
|
||||
|
||||
if (!retrigger && wasActive) return; // glide: done
|
||||
|
||||
// Trigger envelope
|
||||
if (this.hasAdsrVca) {
|
||||
// ADSR-driven VCA: masterEnv is a pass-through at 1.0
|
||||
this.masterEnv.gain.cancelScheduledValues(now);
|
||||
this.masterEnv.gain.setValueAtTime(1.0, now);
|
||||
|
||||
for (const [, info] of this.nodes) {
|
||||
if (info.kind !== "vca" || !info.adsrControlled) continue;
|
||||
const adsr = this.nodes.get(info.adsrId);
|
||||
if (!adsr) continue;
|
||||
const g = info.node.gain;
|
||||
g.cancelScheduledValues(now);
|
||||
g.setValueAtTime(0.0001, now);
|
||||
g.linearRampToValueAtTime(info.staticGain, now + adsr.attack);
|
||||
g.linearRampToValueAtTime(info.staticGain * adsr.sustain, now + adsr.attack + adsr.decay);
|
||||
}
|
||||
} else {
|
||||
// No ADSR: simple attack gate on masterEnv
|
||||
const g = this.masterEnv.gain;
|
||||
g.cancelScheduledValues(now);
|
||||
g.setValueAtTime(0.0001, now);
|
||||
g.exponentialRampToValueAtTime(0.3, now + 0.015);
|
||||
}
|
||||
}
|
||||
|
||||
noteOff() {
|
||||
this.activeNote = -1;
|
||||
const now = this.ctx.currentTime;
|
||||
|
||||
if (this.hasAdsrVca) {
|
||||
for (const [, info] of this.nodes) {
|
||||
if (info.kind !== "vca" || !info.adsrControlled) continue;
|
||||
const adsr = this.nodes.get(info.adsrId);
|
||||
if (!adsr) continue;
|
||||
const g = info.node.gain;
|
||||
g.cancelScheduledValues(now);
|
||||
g.setValueAtTime(Math.max(g.value, 0.0001), now);
|
||||
g.exponentialRampToValueAtTime(0.0001, now + adsr.release);
|
||||
}
|
||||
} else {
|
||||
const g = this.masterEnv.gain;
|
||||
g.cancelScheduledValues(now);
|
||||
g.setValueAtTime(Math.max(g.value, 0.0001), now);
|
||||
g.exponentialRampToValueAtTime(0.0001, now + 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
isOutputPatched() { return this.outHasCable; }
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
_rebuild(patch) {
|
||||
// Tear down: stop oscillators, disconnect all nodes
|
||||
for (const [, info] of this.nodes) {
|
||||
if (info.oscNode) { try { info.oscNode.stop(); info.oscNode.disconnect(); } catch(_){} }
|
||||
if (info.node) { try { info.node.disconnect(); } catch(_){} }
|
||||
}
|
||||
this.nodes.clear();
|
||||
|
||||
// Create Web Audio nodes for each visual module
|
||||
for (const m of patch.modules) {
|
||||
const info = this._makeNode(m);
|
||||
if (info) this.nodes.set(m.id, info);
|
||||
}
|
||||
|
||||
// Determine topology flags before wiring
|
||||
this.outHasCable = patch.cables.some(c => {
|
||||
const dst = patch.modules.find(m => m.id === c.dst);
|
||||
return dst?.kind === "out" && c.dst_jack === "audio_in";
|
||||
});
|
||||
|
||||
// Mark VCAs that have an ADSR patched into their cv_in
|
||||
for (const c of patch.cables) {
|
||||
if (c.dst_jack !== "cv_in") continue;
|
||||
const srcMod = patch.modules.find(m => m.id === c.src);
|
||||
const dstInfo = this.nodes.get(c.dst);
|
||||
if (srcMod?.kind === "adsr" && dstInfo?.kind === "vca") {
|
||||
dstInfo.adsrControlled = true;
|
||||
dstInfo.adsrId = c.src;
|
||||
}
|
||||
}
|
||||
this.hasAdsrVca = [...this.nodes.values()].some(n => n.kind === "vca" && n.adsrControlled);
|
||||
|
||||
// Wire audio and CV cables
|
||||
for (const c of patch.cables) this._wire(c, patch);
|
||||
|
||||
// If a note is held across the rebuild, reapply it
|
||||
if (this.activeNote >= 0 && this.outHasCable) {
|
||||
this.noteOn(this.activeNote, true);
|
||||
} else if (!this.outHasCable) {
|
||||
// Ensure silence immediately when output becomes unpatched
|
||||
this.masterEnv.gain.cancelScheduledValues(0);
|
||||
this.masterEnv.gain.setValueAtTime(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
_makeNode(m) {
|
||||
const ctx = this.ctx;
|
||||
switch (m.kind) {
|
||||
case "vco": {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = (["sine","sawtooth","square","triangle","sawtooth"])[Math.round(m.params.waveform ?? 1)] ?? "sawtooth";
|
||||
osc.frequency.value = m.params.freq_hz ?? 440;
|
||||
osc.start();
|
||||
return { kind: "vco", node: osc, oscNode: osc };
|
||||
}
|
||||
case "svf": {
|
||||
const f = ctx.createBiquadFilter();
|
||||
f.type = "lowpass";
|
||||
f.frequency.value = m.params.cutoff_hz ?? 2000;
|
||||
f.Q.value = 0.5 + (m.params.resonance ?? 0.5) * 19.5;
|
||||
return { kind: "svf", node: f };
|
||||
}
|
||||
case "vca": {
|
||||
const g = ctx.createGain();
|
||||
g.gain.value = m.params.gain ?? 1.0;
|
||||
return { kind: "vca", node: g, adsrControlled: false, adsrId: null,
|
||||
staticGain: m.params.gain ?? 1.0 };
|
||||
}
|
||||
case "adsr": {
|
||||
// Pure data — drives VCA gain automation in noteOn/Off, no Web Audio node
|
||||
return { kind: "adsr", node: null,
|
||||
attack: m.params.attack_s ?? 0.01,
|
||||
decay: m.params.decay_s ?? 0.1,
|
||||
sustain: m.params.sustain ?? 0.7,
|
||||
release: m.params.release_s ?? 0.3 };
|
||||
}
|
||||
case "lfo": {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sine";
|
||||
osc.frequency.value = m.params.rate_hz ?? 2;
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = (m.params.depth ?? 0.5) * 600; // modulation depth in Hz
|
||||
osc.connect(gain);
|
||||
osc.start();
|
||||
return { kind: "lfo", node: gain, oscNode: osc };
|
||||
}
|
||||
case "out": {
|
||||
const g = ctx.createGain();
|
||||
g.gain.value = m.params.level ?? 0.8;
|
||||
g.connect(this.masterEnv); // always routes to the analyser chain
|
||||
return { kind: "out", node: g };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_wire(cable, patch) {
|
||||
const src = this.nodes.get(cable.src);
|
||||
const dst = this.nodes.get(cable.dst);
|
||||
if (!src?.node || !dst?.node) return; // ADSR has node:null — skip
|
||||
|
||||
// ── Audio signal cables ────────────────────────────────────────────
|
||||
if (cable.dst_jack === "audio_in") {
|
||||
try { src.node.connect(dst.node); } catch(_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── CV modulation cables ───────────────────────────────────────────
|
||||
if (cable.dst_jack === "cv_in") {
|
||||
if (src.kind === "lfo") {
|
||||
if (dst.kind === "svf") {
|
||||
// LFO modulates filter cutoff frequency
|
||||
try { src.node.connect(dst.node.frequency); } catch(_) {}
|
||||
} else if (dst.kind === "vco") {
|
||||
// LFO modulates VCO pitch (vibrato)
|
||||
try { src.node.connect(dst.node.frequency); } catch(_) {}
|
||||
}
|
||||
// LFO → VCA: not wired (VCA cv_in is for ADSR envelope only)
|
||||
}
|
||||
// ADSR → VCA: handled by parameter automation in noteOn/Off, not a node connection
|
||||
}
|
||||
}
|
||||
|
||||
_midiHz(note) {
|
||||
return 440 * Math.pow(2, (note - 69) / 12);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Computer keyboard map (standard DAW layout) ───────────────────────────────
|
||||
// Lower row Z–M → C3–B3, with S D G H J for sharps
|
||||
// Upper row Q–U → C4–B4, with 2 3 5 6 7 for sharps
|
||||
|
||||
const KEY_NOTE = {
|
||||
z:48, s:49, x:50, d:51, c:52, v:53, g:54, b:55, h:56, n:57, j:58, m:59,
|
||||
@@ -136,7 +363,7 @@ async function bootstrap() {
|
||||
const engine = new AudioEngine();
|
||||
await engine.attach();
|
||||
|
||||
const analyser = engine.analyser_node();
|
||||
const analyser = engine.analyser_node();
|
||||
const oscilloscope = new OscilloscopeView("oscilloscope-canvas", analyser);
|
||||
const spectrum = new SpectrumView("spectrum-canvas", analyser);
|
||||
const patchbay = new PatchBay("patchbay-canvas");
|
||||
@@ -150,17 +377,16 @@ async function bootstrap() {
|
||||
const allCanvases = [oscCanvas, spCanvas, pbCanvas, kbCanvas];
|
||||
|
||||
allCanvases.forEach(fitCanvas);
|
||||
|
||||
// Keep buffers in sync when panels are resized
|
||||
const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas));
|
||||
allCanvases.forEach(c => ro.observe(c));
|
||||
|
||||
// ── Default patch bay layout ──────────────────────────────────────────
|
||||
const cw = pbCanvas.width || pbCanvas.clientWidth || 800;
|
||||
patchbay.add_module("vco", cw * 0.12, 80);
|
||||
patchbay.add_module("adsr", cw * 0.32, 80);
|
||||
patchbay.add_module("svf", cw * 0.55, 80);
|
||||
patchbay.add_module("vca", cw * 0.76, 80);
|
||||
patchbay.add_module("vco", cw * 0.08, 80);
|
||||
patchbay.add_module("adsr", cw * 0.26, 80);
|
||||
patchbay.add_module("svf", cw * 0.46, 80);
|
||||
patchbay.add_module("vca", cw * 0.66, 80);
|
||||
patchbay.add_module("out", cw * 0.84, 80);
|
||||
|
||||
// ── Patch bay pointer events ──────────────────────────────────────────
|
||||
pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY));
|
||||
@@ -168,40 +394,37 @@ async function bootstrap() {
|
||||
pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY));
|
||||
pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY));
|
||||
|
||||
// ── Synthesiser ───────────────────────────────────────────────────────
|
||||
// ── Patch router: Web Audio graph driven by patch bay topology ────────
|
||||
const audioCtx = engine.audio_context();
|
||||
const inputNode = engine.input_node();
|
||||
const synth = createSynth(audioCtx, inputNode);
|
||||
const router = new PatchRouter(audioCtx, inputNode);
|
||||
|
||||
// ── Virtual keyboard pointer events ───────────────────────────────────
|
||||
let pointerDown = false;
|
||||
let kbDown = false;
|
||||
|
||||
kbCanvas.addEventListener("pointerdown", e => {
|
||||
pointerDown = true;
|
||||
kbDown = true;
|
||||
kbCanvas.setPointerCapture(e.pointerId);
|
||||
const note = keyboard.on_pointer_down(e.offsetX, e.offsetY);
|
||||
if (note >= 0) synth.noteOn(note);
|
||||
else synth.noteOff();
|
||||
if (note >= 0) router.noteOn(note, true);
|
||||
else router.noteOff();
|
||||
});
|
||||
|
||||
kbCanvas.addEventListener("pointermove", e => {
|
||||
const result = keyboard.on_pointer_move(e.offsetX, e.offsetY);
|
||||
if (result === -2) return; // hover only, no synthesis change
|
||||
if (pointerDown) {
|
||||
if (result >= 0) synth.noteOn(result); // noteOn handles glide
|
||||
else synth.noteOff();
|
||||
if (result === -2) return; // hover only
|
||||
if (kbDown) {
|
||||
if (result >= 0) router.noteOn(result, false); // glide
|
||||
else router.noteOff();
|
||||
}
|
||||
});
|
||||
|
||||
kbCanvas.addEventListener("pointerup", e => {
|
||||
pointerDown = false;
|
||||
kbDown = false;
|
||||
keyboard.on_pointer_up(e.offsetX, e.offsetY);
|
||||
synth.noteOff();
|
||||
router.noteOff();
|
||||
});
|
||||
|
||||
kbCanvas.addEventListener("pointerleave", () => {
|
||||
const result = keyboard.on_pointer_leave();
|
||||
if (result === -1) synth.noteOff(); // was pressing
|
||||
if (kbDown) { kbDown = false; keyboard.on_pointer_leave(); router.noteOff(); }
|
||||
else { keyboard.on_pointer_leave(); }
|
||||
});
|
||||
|
||||
// ── Computer keyboard events ──────────────────────────────────────────
|
||||
@@ -213,26 +436,20 @@ async function bootstrap() {
|
||||
if (note === undefined) return;
|
||||
heldKeys.add(e.key.toLowerCase());
|
||||
keyboard.set_active_note(note);
|
||||
synth.noteOn(note);
|
||||
router.noteOn(note, !heldKeys.size > 1); // retrigger only if first key
|
||||
});
|
||||
|
||||
document.addEventListener("keyup", e => {
|
||||
const key = e.key.toLowerCase();
|
||||
heldKeys.delete(key);
|
||||
const note = KEY_NOTE[key];
|
||||
if (note === undefined) return;
|
||||
|
||||
// Only stop if no other mapped key is still held
|
||||
const anyHeld = [...heldKeys].some(k => KEY_NOTE[k] !== undefined);
|
||||
if (!anyHeld) {
|
||||
keyboard.set_active_note(-1);
|
||||
synth.noteOff();
|
||||
if (KEY_NOTE[key] === undefined) return;
|
||||
const remaining = [...heldKeys].filter(k => KEY_NOTE[k] !== undefined);
|
||||
if (remaining.length > 0) {
|
||||
const last = KEY_NOTE[remaining.at(-1)];
|
||||
keyboard.set_active_note(last);
|
||||
router.noteOn(last, false); // glide to last held key
|
||||
} else {
|
||||
// Switch to the last remaining held key
|
||||
const lastKey = [...heldKeys].filter(k => KEY_NOTE[k] !== undefined).at(-1);
|
||||
const lastNote = KEY_NOTE[lastKey];
|
||||
keyboard.set_active_note(lastNote);
|
||||
synth.noteOn(lastNote);
|
||||
keyboard.set_active_note(-1);
|
||||
router.noteOff();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -246,7 +463,22 @@ async function bootstrap() {
|
||||
|
||||
// ── Render loop ───────────────────────────────────────────────────────
|
||||
let last = performance.now();
|
||||
let lastParamsVersion = -1;
|
||||
function frame(now) {
|
||||
// Sync Web Audio graph to patch bay topology when patch changes
|
||||
router.sync(patchbay.patch_version(), patchbay.get_patch_json());
|
||||
|
||||
// Apply live param changes without full graph rebuild
|
||||
const pv = patchbay.params_version();
|
||||
if (pv !== lastParamsVersion) {
|
||||
lastParamsVersion = pv;
|
||||
router.updateParams(patchbay.get_patch_json());
|
||||
}
|
||||
|
||||
// Update output-node status label
|
||||
const outLabel = router.isOutputPatched() ? "patched" : "unpatched";
|
||||
status.textContent = router.isOutputPatched() ? "Running · output patched" : "Running · output unpatched";
|
||||
|
||||
oscilloscope.draw();
|
||||
spectrum.draw();
|
||||
patchbay.draw();
|
||||
@@ -259,12 +491,12 @@ async function bootstrap() {
|
||||
|
||||
loader.classList.add("hidden");
|
||||
|
||||
// Show keyboard hint briefly then fade it
|
||||
// Brief keyboard hint
|
||||
const hint = document.getElementById("kb-hint");
|
||||
if (hint) {
|
||||
hint.style.transition = "opacity 1s";
|
||||
setTimeout(() => { hint.style.opacity = "1"; }, 2000);
|
||||
setTimeout(() => { hint.style.opacity = "0"; }, 6000);
|
||||
setTimeout(() => { hint.style.opacity = "0"; }, 7000);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user