/** * 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 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, 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();