277 lines
12 KiB
JavaScript
277 lines
12 KiB
JavaScript
/**
|
||
* 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();
|