diff --git a/.claude/settings.json b/.claude/settings.json index d30b738..f139e48 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,7 +7,8 @@ "Bash(find /Users/mattsp/.cargo/registry/src -name memory.x -path */rp*)", "Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)", "Bash(chmod +x /Users/mattsp/projects/sound/build-web.sh)", - "Bash(/Users/mattsp/projects/sound/build-web.sh)" + "Bash(/Users/mattsp/projects/sound/build-web.sh)", + "Bash(wasm-pack build:*)" ] } } diff --git a/crates/synth-visualiser/src/engine.rs b/crates/synth-visualiser/src/engine.rs index 1333465..dde706e 100644 --- a/crates/synth-visualiser/src/engine.rs +++ b/crates/synth-visualiser/src/engine.rs @@ -46,6 +46,17 @@ impl AudioEngine { self.analyser.clone() } + /// Returns the underlying AudioContext so JS can create and schedule nodes. + pub fn audio_context(&self) -> AudioContext { + self.ctx.clone() + } + + /// Returns the main input GainNode. Connect synthesiser sources here so + /// they pass through the analyser and reach the speakers. + pub fn input_node(&self) -> GainNode { + self.gain.clone() + } + pub fn set_params(&self, _json: &str) -> Result<(), JsValue> { // TODO: parse JSON and post to AudioWorkletNode MessagePort Ok(()) diff --git a/crates/synth-visualiser/src/keyboard.rs b/crates/synth-visualiser/src/keyboard.rs new file mode 100644 index 0000000..5ea0623 --- /dev/null +++ b/crates/synth-visualiser/src/keyboard.rs @@ -0,0 +1,252 @@ +//! Virtual piano keyboard rendered on a Canvas 2D context. +//! +//! Renders two octaves (C3–B4, MIDI 48–71). Pointer events return the MIDI +//! note number that was hit, or -1 for none / note-off. The JS caller is +//! responsible for actually synthesising sound via the Web Audio API. + +use wasm_bindgen::prelude::*; +use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d}; + +// ── Layout constants ────────────────────────────────────────────────────────── + +/// First MIDI note shown (C3 = 48). +const START_NOTE: u8 = 48; +/// Number of octaves shown. +const NUM_OCTAVES: usize = 2; +/// White keys per octave. +const WHITE_PER_OCT: usize = 7; +/// Total white keys on the keyboard. +const TOTAL_WHITE: usize = NUM_OCTAVES * WHITE_PER_OCT; + +/// Semitone offset within an octave for each white key (C D E F G A B). +const WHITE_SEMITONE: [u8; 7] = [0, 2, 4, 5, 7, 9, 11]; + +/// Black key info: (center x in white-key units, semitone within octave). +/// The center x is the fraction of a white key's width from the left edge of +/// the octave. These values give standard piano proportions. +const BLACK_KEYS: [(f64, u8); 5] = [ + (0.67, 1), // C# + (1.67, 3), // D# + (3.67, 6), // F# + (4.67, 8), // G# + (5.67, 10), // A# +]; + +/// Black key width as a fraction of white key width. +const BK_W_RATIO: f64 = 0.60; +/// Black key height as a fraction of the total canvas height. +const BK_H_RATIO: f64 = 0.60; + +// ── VirtualKeyboard ─────────────────────────────────────────────────────────── + +#[wasm_bindgen] +pub struct VirtualKeyboard { + canvas: HtmlCanvasElement, + ctx2d: CanvasRenderingContext2d, + /// Currently pressed note (-1 = none). Set by pointer or external call. + active_note: i32, + /// Note under the pointer when not pressing (for hover highlight). + hover_note: i32, + /// True while a pointer button is held down over this canvas. + pressing: bool, +} + +#[wasm_bindgen] +impl VirtualKeyboard { + #[wasm_bindgen(constructor)] + pub fn new(canvas_id: &str) -> Result { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let canvas: HtmlCanvasElement = document + .get_element_by_id(canvas_id) + .ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))? + .dyn_into()?; + let ctx2d: CanvasRenderingContext2d = canvas + .get_context("2d")? + .ok_or("no 2d context")? + .dyn_into()?; + Ok(VirtualKeyboard { + canvas, ctx2d, + active_note: -1, + hover_note: -1, + pressing: false, + }) + } + + // ── Pointer events ──────────────────────────────────────────────────────── + + /// Returns the MIDI note hit, or -1. + pub fn on_pointer_down(&mut self, x: f32, y: f32) -> i32 { + self.pressing = true; + self.hover_note = -1; + let note = self.hit_note(x as f64, y as f64); + self.active_note = note; + note + } + + /// While pressing: returns current note (or -1 for off-key), so the caller + /// can glide the oscillator. While hovering: updates highlight, returns -2 + /// (caller should ignore for synthesis). + pub fn on_pointer_move(&mut self, x: f32, y: f32) -> i32 { + let note = self.hit_note(x as f64, y as f64); + if self.pressing { + self.active_note = note; + note + } else { + self.hover_note = note; + -2 // sentinel: hover-only, no synthesis change + } + } + + /// Always returns -1 (note off). + pub fn on_pointer_up(&mut self, _x: f32, _y: f32) -> i32 { + self.pressing = false; + self.active_note = -1; + -1 + } + + /// Called when the pointer leaves the canvas element. Returns -1 if a + /// note was active (caller should stop synthesis). + pub fn on_pointer_leave(&mut self) -> i32 { + self.hover_note = -1; + if self.pressing { + self.pressing = false; + self.active_note = -1; + return -1; + } + -2 // was just hovering, no synthesis change + } + + // ── Computer-keyboard / external control ────────────────────────────────── + + /// Set the active note from an external source (computer keyboard). + /// Pass -1 to clear. + pub fn set_active_note(&mut self, note: i32) { + self.active_note = note; + } + + /// Returns the currently active MIDI note, or -1. + pub fn active_note(&self) -> i32 { + self.active_note + } + + // ── Rendering ───────────────────────────────────────────────────────────── + + pub fn draw(&self) { + let cw = self.canvas.width() as f64; + let ch = self.canvas.height() as f64; + if cw < 1.0 || ch < 1.0 { return; } + + let ctx = &self.ctx2d; + let key_w = cw / TOTAL_WHITE as f64; + let bk_w = key_w * BK_W_RATIO; + let bk_h = ch * BK_H_RATIO; + + // Background strip + ctx.set_fill_style_str("#0d0d0d"); + ctx.fill_rect(0.0, 0.0, cw, ch); + + // ── White keys ──────────────────────────────────────────────────────── + for i in 0..TOTAL_WHITE { + let oct = i / WHITE_PER_OCT; + let sem = WHITE_SEMITONE[i % WHITE_PER_OCT]; + let note = (START_NOTE + oct as u8 * 12 + sem) as i32; + let x = i as f64 * key_w; + + let color = if note == self.active_note { + "#6ab4e8" + } else if note == self.hover_note { + "#e4e4e4" + } else { + "#d0d0d0" + }; + + ctx.set_fill_style_str(color); + ctx.fill_rect(x + 1.0, 0.0, key_w - 2.0, ch - 1.0); + + // Subtle 3-D edge: bright top, darker side + ctx.set_fill_style_str("rgba(255,255,255,0.4)"); + ctx.fill_rect(x + 1.0, 0.0, key_w - 2.0, 3.0); + ctx.set_fill_style_str("rgba(0,0,0,0.15)"); + ctx.fill_rect(x + key_w - 3.0, 0.0, 2.0, ch - 1.0); + + // C-note octave label at the bottom + if sem == 0 { + let oct_label = format!("C{}", START_NOTE / 12 + oct as u8); + ctx.set_font("bold 9px sans-serif"); + ctx.set_text_align("center"); + ctx.set_fill_style_str(if note == self.active_note { "#fff" } else { "#888" }); + let _ = ctx.fill_text(&oct_label, x + key_w * 0.5, ch - 4.0); + } + } + + // ── Black keys (drawn on top) ───────────────────────────────────────── + for oct in 0..NUM_OCTAVES { + let oct_x = oct as f64 * WHITE_PER_OCT as f64 * key_w; + for &(offset, sem) in &BLACK_KEYS { + let note = (START_NOTE + oct as u8 * 12 + sem) as i32; + let bx = oct_x + offset * key_w - bk_w * 0.5; + + let color = if note == self.active_note { + "#3a8fc0" + } else if note == self.hover_note { + "#2a2a2a" + } else { + "#101010" + }; + + ctx.set_fill_style_str(color); + ctx.fill_rect(bx, 0.0, bk_w, bk_h); + + // Subtle highlight on the top edge + ctx.set_fill_style_str("rgba(255,255,255,0.10)"); + ctx.fill_rect(bx + 1.0, 0.0, bk_w - 2.0, 4.0); + + // Bottom rounded look (darker strip) + ctx.set_fill_style_str("rgba(0,0,0,0.5)"); + ctx.fill_rect(bx, bk_h - 6.0, bk_w, 6.0); + } + } + + // Thin border around the whole keyboard + ctx.set_stroke_style_str("#333"); + ctx.set_line_width(1.0); + ctx.stroke_rect(0.0, 0.0, cw, ch); + } +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +impl VirtualKeyboard { + fn key_w(&self) -> f64 { self.canvas.width() as f64 / TOTAL_WHITE as f64 } + fn bk_w(&self) -> f64 { self.key_w() * BK_W_RATIO } + fn bk_h(&self) -> f64 { self.canvas.height() as f64 * BK_H_RATIO } + + /// Returns the MIDI note number at canvas position (x, y), or -1. + fn hit_note(&self, x: f64, y: f64) -> i32 { + let cw = self.canvas.width() as f64; + let ch = self.canvas.height() as f64; + if x < 0.0 || x >= cw || y < 0.0 || y >= ch { return -1; } + + let key_w = self.key_w(); + let bk_w = self.bk_w(); + let bk_h = self.bk_h(); + + // Black keys first (they render on top of white keys) + for oct in 0..NUM_OCTAVES { + let oct_x = oct as f64 * WHITE_PER_OCT as f64 * key_w; + for &(offset, sem) in &BLACK_KEYS { + let bx = oct_x + offset * key_w - bk_w * 0.5; + if x >= bx && x <= bx + bk_w && y <= bk_h { + return (START_NOTE + oct as u8 * 12 + sem) as i32; + } + } + } + + // White key + let i = (x / key_w) as usize; + let oct = i / WHITE_PER_OCT; + let sem = WHITE_SEMITONE[i % WHITE_PER_OCT]; + (START_NOTE + oct as u8 * 12 + sem) as i32 + } +} diff --git a/crates/synth-visualiser/src/lib.rs b/crates/synth-visualiser/src/lib.rs index f7a25da..9ac2733 100644 --- a/crates/synth-visualiser/src/lib.rs +++ b/crates/synth-visualiser/src/lib.rs @@ -17,12 +17,14 @@ pub mod oscilloscope; pub mod spectrum; pub mod patchbay; pub mod params; +pub mod keyboard; pub use engine::AudioEngine; pub use oscilloscope::OscilloscopeView; pub use spectrum::SpectrumView; pub use patchbay::PatchBay; pub use params::SynthParams; +pub use keyboard::VirtualKeyboard; /// Called once by bootstrap.js after the WASM module loads. #[wasm_bindgen(start)] diff --git a/www/bootstrap.js b/www/bootstrap.js index f6c723f..fc9f641 100644 --- a/www/bootstrap.js +++ b/www/bootstrap.js @@ -2,8 +2,9 @@ * 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, and provides a - * draggable resize handle for the patch bay panel. + * 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, { @@ -11,16 +12,14 @@ import init, { OscilloscopeView, SpectrumView, PatchBay, + VirtualKeyboard, SynthParams, } from "./pkg/synth_visualiser.js"; // ── Canvas buffer sizing ────────────────────────────────────────────────────── -// A has two independent sizes: -// - CSS display size (width/height CSS properties) — how large it appears -// - Drawing buffer (width/height HTML attributes) — actual pixel resolution -// -// We must keep them in sync; if the buffer is smaller than the display size the -// browser stretches it and everything looks blurry / oversized. +// 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); @@ -34,11 +33,9 @@ function fitCanvas(canvas) { // ── Resize handle ───────────────────────────────────────────────────────────── function initResizeHandle() { - const handle = document.getElementById("resize-handle"); - const panel = document.getElementById("patchbay-panel"); - let dragging = false; - let startY = 0; - let startH = 0; + 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; @@ -47,19 +44,81 @@ function initResizeHandle() { handle.setPointerCapture(e.pointerId); handle.classList.add("active"); }); - handle.addEventListener("pointermove", e => { if (!dragging) return; - // Dragging up (negative dy) increases the panel height - const dy = e.clientY - startY; - const h = Math.max(80, startH - dy); - panel.style.height = h + "px"; + panel.style.height = Math.max(80, startH - (e.clientY - startY)) + "px"; }); - - handle.addEventListener("pointerup", () => { dragging = false; handle.classList.remove("active"); }); - handle.addEventListener("pointercancel",() => { dragging = false; handle.classList.remove("active"); }); + 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"); @@ -73,40 +132,111 @@ async function bootstrap() { try { await init(); - const engine = new AudioEngine(); + // ── WASM objects ────────────────────────────────────────────────────── + 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"); + const keyboard = new VirtualKeyboard("keyboard-canvas"); - // Fit all canvas buffers to their current CSS layout size before we - // ask Rust for canvas.width() to position the default modules. - const pbCanvas = document.getElementById("patchbay-canvas"); - const oscCanvas = document.getElementById("oscilloscope-canvas"); - const specCanvas = document.getElementById("spectrum-canvas"); - const allCanvases = [oscCanvas, specCanvas, pbCanvas]; + // ── 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); - // Seed a default patch using the now-correct canvas width. + // 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); - // Keep canvas buffers in sync whenever the panel is resized. - const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas)); - allCanvases.forEach(c => ro.observe(c)); - - // Patch bay pointer events + // ── 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()); @@ -114,11 +244,13 @@ async function bootstrap() { 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); @@ -127,6 +259,14 @@ async function bootstrap() { 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}`; diff --git a/www/index.html b/www/index.html index 1de36c0..89b7e67 100644 --- a/www/index.html +++ b/www/index.html @@ -88,7 +88,18 @@ flex-shrink: 0; height: var(--patchbay-h); min-height: 80px; - max-height: calc(100vh - 120px); + max-height: calc(100vh - 220px); + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--panel); + } + + /* Virtual keyboard — fixed height */ + .panel--keyboard { + flex-shrink: 0; + height: 100px; border-top: 1px solid var(--border); display: flex; flex-direction: column; @@ -165,6 +176,11 @@
Patch Bay
+ +
+
Keyboard  Z–M · Q–U · S D G H J · 2 3 5 6 7
+ +