Files
analogue_synth/www/bootstrap.js

277 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
*/
import init, {
AudioEngine,
OscilloscopeView,
SpectrumView,
PatchBay,
VirtualKeyboard,
SynthParams,
} 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);
const h = Math.round(canvas.clientHeight);
if (w > 0 && h > 0 && (canvas.width !== w || canvas.height !== h)) {
canvas.width = w;
canvas.height = h;
}
}
// ── Resize handle ─────────────────────────────────────────────────────────────
function initResizeHandle() {
const handle = document.getElementById("resize-handle");
const panel = document.getElementById("patchbay-panel");
let dragging = false, startY = 0, startH = 0;
handle.addEventListener("pointerdown", e => {
dragging = true;
startY = e.clientY;
startH = panel.offsetHeight;
handle.setPointerCapture(e.pointerId);
handle.classList.add("active");
});
handle.addEventListener("pointermove", e => {
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);
}
// ── 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.
function createSynth(audioCtx, inputNode) {
let osc = null;
let envGain = null;
let noteCache = -1;
function midiToHz(n) {
return 440 * Math.pow(2, (n - 69) / 12);
}
async function ensureRunning() {
if (audioCtx.state !== "running") await audioCtx.resume();
}
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);
}
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;
},
activeNote() { return noteCache; },
};
}
// ── Computer keyboard map (standard DAW layout) ───────────────────────────────
// Lower row ZM → C3B3, with S D G H J for sharps
// Upper row QU → C4B4, 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,
q:60, 2:61, w:62, 3:63, e:64, r:65, 5:66, t:67, 6:68, y:69, 7:70, u:71,
};
// ── Bootstrap ─────────────────────────────────────────────────────────────────
const loader = document.getElementById("loader");
const status = document.getElementById("status");
const srLabel = document.getElementById("sample-rate");
const frameTime = document.getElementById("frame-time");
async function bootstrap() {
initResizeHandle();
try {
await init();
// ── WASM objects ──────────────────────────────────────────────────────
const engine = new AudioEngine();
await engine.attach();
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");
const keyboard = new VirtualKeyboard("keyboard-canvas");
// ── Canvas sizing ─────────────────────────────────────────────────────
const pbCanvas = document.getElementById("patchbay-canvas");
const oscCanvas = document.getElementById("oscilloscope-canvas");
const spCanvas = document.getElementById("spectrum-canvas");
const kbCanvas = document.getElementById("keyboard-canvas");
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);
// ── Patch bay pointer events ──────────────────────────────────────────
pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY));
pbCanvas.addEventListener("pointermove", e => patchbay.on_pointer_move(e.offsetX, e.offsetY));
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 ───────────────────────────────────────────────────────
const audioCtx = engine.audio_context();
const inputNode = engine.input_node();
const synth = createSynth(audioCtx, inputNode);
// ── Virtual keyboard pointer events ───────────────────────────────────
let pointerDown = false;
kbCanvas.addEventListener("pointerdown", e => {
pointerDown = true;
kbCanvas.setPointerCapture(e.pointerId);
const note = keyboard.on_pointer_down(e.offsetX, e.offsetY);
if (note >= 0) synth.noteOn(note);
else synth.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();
}
});
kbCanvas.addEventListener("pointerup", e => {
pointerDown = false;
keyboard.on_pointer_up(e.offsetX, e.offsetY);
synth.noteOff();
});
kbCanvas.addEventListener("pointerleave", () => {
const result = keyboard.on_pointer_leave();
if (result === -1) synth.noteOff(); // was pressing
});
// ── Computer keyboard events ──────────────────────────────────────────
const heldKeys = new Set();
document.addEventListener("keydown", e => {
if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return;
const note = KEY_NOTE[e.key.toLowerCase()];
if (note === undefined) return;
heldKeys.add(e.key.toLowerCase());
keyboard.set_active_note(note);
synth.noteOn(note);
});
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();
} 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);
}
});
// ── Params + engine start ─────────────────────────────────────────────
const params = new SynthParams();
engine.set_params(params.to_json());
srLabel.textContent = `SR: ${engine.sample_rate()} Hz`;
status.textContent = "Running";
engine.start();
// ── Render loop ───────────────────────────────────────────────────────
let last = performance.now();
function frame(now) {
oscilloscope.draw();
spectrum.draw();
patchbay.draw();
keyboard.draw();
frameTime.textContent = `frame: ${(now - last).toFixed(1)} ms`;
last = now;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
loader.classList.add("hidden");
// Show keyboard hint briefly then fade it
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);
}
} catch (err) {
console.error("[bootstrap] Fatal:", err);
loader.textContent = `Error: ${err.message ?? err}`;
}
}
bootstrap();