From f26ecda58c09a90ca455bb23dcea46e6af49c191 Mon Sep 17 00:00:00 2001 From: Matt Spencer Date: Thu, 26 Mar 2026 10:44:18 +0000 Subject: [PATCH] Add different wave and filter types --- crates/synth-core/src/audio_out.rs | 41 +++ crates/synth-core/src/descriptor.rs | 4 + crates/synth-core/src/envelope.rs | 8 +- crates/synth-core/src/filter.rs | 5 +- crates/synth-core/src/lfo.rs | 6 +- crates/synth-core/src/lib.rs | 1 + crates/synth-core/src/oscillator.rs | 4 +- crates/synth-core/src/vca.rs | 2 +- crates/synth-visualiser/src/patchbay.rs | 247 ++++++++++++-- www/bootstrap.js | 436 ++++++++++++++++++------ 10 files changed, 614 insertions(+), 140 deletions(-) create mode 100644 crates/synth-core/src/audio_out.rs diff --git a/crates/synth-core/src/audio_out.rs b/crates/synth-core/src/audio_out.rs new file mode 100644 index 0000000..8197900 --- /dev/null +++ b/crates/synth-core/src/audio_out.rs @@ -0,0 +1,41 @@ +//! Audio output sink — final stage in the signal chain. +//! +//! In the patch bay this is the node you connect the last module to; it +//! represents the physical speakers / DAC. + +use crate::{AudioProcessor, config::SampleRate}; +use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind}; + +pub struct AudioOut { + pub level: f32, + sample_rate: SampleRate, +} + +impl AudioOut { + pub fn new(sample_rate: SampleRate) -> Self { + Self { level: 0.8, sample_rate } + } + + pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor { + kind: "out", + label: "Output", + jacks: &[ + JackDescriptor { id: "audio_in", label: "In", direction: Direction::Input, signal: SignalKind::Audio }, + ], + params: &[ + ParamDescriptor { id: "level", label: "Level", min: 0.0, max: 1.0, default: 0.8, unit: "", labels: &[] }, + ], + }; +} + +impl AudioProcessor for AudioOut { + fn process(&mut self, out: &mut [f32; B]) { + for s in out.iter_mut() { + *s *= self.level; + } + } + + fn reset(&mut self) { + let _ = self.sample_rate; // kept for API symmetry + } +} diff --git a/crates/synth-core/src/descriptor.rs b/crates/synth-core/src/descriptor.rs index af95037..5eef161 100644 --- a/crates/synth-core/src/descriptor.rs +++ b/crates/synth-core/src/descriptor.rs @@ -50,6 +50,10 @@ pub struct ParamDescriptor { pub default: f32, /// Display unit string (e.g. `"Hz"`, `"s"`, `""`). pub unit: &'static str, + /// When non-empty, this param is a discrete enum selector. + /// Each entry is the display label for that step (step 0 = index 0, etc.). + /// `min` should be 0.0 and `max` should be `(labels.len() - 1) as f32`. + pub labels: &'static [&'static str], } /// Full compile-time descriptor for a DSP component type. diff --git a/crates/synth-core/src/envelope.rs b/crates/synth-core/src/envelope.rs index acf88fb..0019d66 100644 --- a/crates/synth-core/src/envelope.rs +++ b/crates/synth-core/src/envelope.rs @@ -34,10 +34,10 @@ impl Adsr { JackDescriptor { id: "env_out", label: "Env", direction: Direction::Output, signal: SignalKind::Cv }, ], params: &[ - ParamDescriptor { id: "attack_s", label: "Attack", min: 0.001, max: 4.0, default: 0.01, unit: "s" }, - ParamDescriptor { id: "decay_s", label: "Decay", min: 0.001, max: 4.0, default: 0.1, unit: "s" }, - ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "" }, - ParamDescriptor { id: "release_s", label: "Release", min: 0.001, max: 8.0, default: 0.3, unit: "s" }, + ParamDescriptor { id: "attack_s", label: "Attack", min: 0.001, max: 4.0, default: 0.01, unit: "s", labels: &[] }, + ParamDescriptor { id: "decay_s", label: "Decay", min: 0.001, max: 4.0, default: 0.1, unit: "s", labels: &[] }, + ParamDescriptor { id: "sustain", label: "Sustain", min: 0.0, max: 1.0, default: 0.7, unit: "", labels: &[] }, + ParamDescriptor { id: "release_s", label: "Release", min: 0.001, max: 8.0, default: 0.3, unit: "s", labels: &[] }, ], }; diff --git a/crates/synth-core/src/filter.rs b/crates/synth-core/src/filter.rs index 40055eb..73ab03e 100644 --- a/crates/synth-core/src/filter.rs +++ b/crates/synth-core/src/filter.rs @@ -40,9 +40,8 @@ impl Svf { JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio }, ], params: &[ - ParamDescriptor { id: "cutoff_hz", label: "Cutoff", min: 20.0, max: 20_000.0, default: 2_000.0, unit: "Hz" }, - ParamDescriptor { id: "resonance", label: "Res", min: 0.0, max: 1.0, default: 0.5, unit: "" }, - ParamDescriptor { id: "mode", label: "Mode", min: 0.0, max: 3.0, default: 0.0, unit: "" }, + ParamDescriptor { id: "cutoff_hz", label: "Cutoff", min: 20.0, max: 20_000.0, default: 2_000.0, unit: "Hz", labels: &[] }, + ParamDescriptor { id: "resonance", label: "Res", min: 0.0, max: 1.0, default: 0.5, unit: "", labels: &[] }, ], }; diff --git a/crates/synth-core/src/lfo.rs b/crates/synth-core/src/lfo.rs index 3f615f6..5e0e366 100644 --- a/crates/synth-core/src/lfo.rs +++ b/crates/synth-core/src/lfo.rs @@ -27,9 +27,9 @@ impl Lfo { JackDescriptor { id: "cv_out", label: "Out", direction: Direction::Output, signal: SignalKind::Cv }, ], params: &[ - ParamDescriptor { id: "rate_hz", label: "Rate", min: 0.01, max: 20.0, default: 2.0, unit: "Hz" }, - ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "" }, - ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 0.0, unit: "" }, + ParamDescriptor { id: "rate_hz", label: "Rate", min: 0.01, max: 20.0, default: 2.0, unit: "Hz", labels: &[] }, + ParamDescriptor { id: "depth", label: "Depth", min: 0.0, max: 1.0, default: 0.5, unit: "", labels: &[] }, + ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 0.0, unit: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] }, ], }; diff --git a/crates/synth-core/src/lib.rs b/crates/synth-core/src/lib.rs index 27ba19a..7b6fac4 100644 --- a/crates/synth-core/src/lib.rs +++ b/crates/synth-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod vca; pub mod lfo; pub mod midi; pub mod patch; +pub mod audio_out; pub use config::SampleRate; pub use math::{db_to_linear, linear_to_db, lerp, midi_note_to_hz}; diff --git a/crates/synth-core/src/oscillator.rs b/crates/synth-core/src/oscillator.rs index 26b7fc5..6572c49 100644 --- a/crates/synth-core/src/oscillator.rs +++ b/crates/synth-core/src/oscillator.rs @@ -35,8 +35,8 @@ impl Vco { JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio }, ], params: &[ - ParamDescriptor { id: "freq_hz", label: "Freq", min: 20.0, max: 20_000.0, default: 440.0, unit: "Hz" }, - ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 1.0, unit: "" }, + ParamDescriptor { id: "freq_hz", label: "Freq", min: 20.0, max: 20_000.0, default: 440.0, unit: "Hz", labels: &[] }, + ParamDescriptor { id: "waveform", label: "Wave", min: 0.0, max: 4.0, default: 1.0, unit: "", labels: &["Sine", "Saw", "Sqr", "Tri", "Pls"] }, ], }; diff --git a/crates/synth-core/src/vca.rs b/crates/synth-core/src/vca.rs index 15b637d..7fef937 100644 --- a/crates/synth-core/src/vca.rs +++ b/crates/synth-core/src/vca.rs @@ -21,7 +21,7 @@ impl Vca { JackDescriptor { id: "audio_out", label: "Out", direction: Direction::Output, signal: SignalKind::Audio }, ], params: &[ - ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "" }, + ParamDescriptor { id: "gain", label: "Gain", min: 0.0, max: 1.0, default: 1.0, unit: "", labels: &[] }, ], }; } diff --git a/crates/synth-visualiser/src/patchbay.rs b/crates/synth-visualiser/src/patchbay.rs index 5f2b5e0..50455ea 100644 --- a/crates/synth-visualiser/src/patchbay.rs +++ b/crates/synth-visualiser/src/patchbay.rs @@ -11,18 +11,20 @@ use synth_core::filter::Svf; use synth_core::envelope::Adsr; use synth_core::vca::Vca; use synth_core::lfo::Lfo; +use synth_core::audio_out::AudioOut; // ── Component registry — single source of truth ────────────────────────────── /// All available component types, sourced directly from synth-core. /// Adding a new component to synth-core and this array is all that's needed /// for it to appear in the patch bay palette. -const REGISTRY: [ComponentDescriptor; 5] = [ +const REGISTRY: [ComponentDescriptor; 6] = [ Vco::DESCRIPTOR, Svf::DESCRIPTOR, Adsr::DESCRIPTOR, Vca::DESCRIPTOR, Lfo::DESCRIPTOR, + AudioOut::DESCRIPTOR, ]; // ── Visual constants ────────────────────────────────────────────────────────── @@ -50,6 +52,7 @@ fn module_color(kind: &str) -> &'static str { "adsr" => "#2a9d3c", "vca" => "#c47a2e", "lfo" => "#2a8f9d", + "out" => "#9d1515", _ => "#555555", } } @@ -116,6 +119,27 @@ impl Module { && y >= self.y && y <= self.y + self.height() } + /// Hit test for parameter rows. Returns `(param_idx, current_val, min, max)`. + fn hit_param(&self, x: f32, y: f32) -> Option<(usize, f32, f32, f32)> { + let desc = self.descriptor(); + if desc.params.is_empty() { return None; } + let tx = self.x + 8.0; + let tw = MOD_W - 16.0; + if x < tx || x > tx + tw { return None; } + let n_in = desc.jacks.iter().filter(|j| j.direction == Direction::Input).count(); + let n_out = desc.jacks.iter().filter(|j| j.direction == Direction::Output).count(); + let jack_rows = n_in.max(n_out) as f32; + let params_top = self.y + MOD_HEADER + jack_rows * JACK_ROW + 3.0; + for (pi, p) in desc.params.iter().enumerate() { + let py = params_top + 4.0 + pi as f32 * PARAM_ROW; + if y >= py && y <= py + PARAM_ROW { + let val = self.param_values.get(pi).copied().unwrap_or(p.default); + return Some((pi, val, p.min, p.max)); + } + } + None + } + fn hit_jack(&self, x: f32, y: f32) -> Option<(String, Direction, SignalKind)> { let desc = self.descriptor(); let mut in_idx = 0usize; @@ -159,6 +183,16 @@ enum DragState { cur_x: f32, cur_y: f32, }, + /// Dragging a parameter slider. `start_y` / `start_val` are anchored at + /// the pointer-down position so the value is relative to where you clicked. + Param { + module_id: u32, + param_idx: usize, + start_y: f32, + start_val: f32, + min: f32, + max: f32, + }, } // ── PatchBay ────────────────────────────────────────────────────────────────── @@ -171,6 +205,12 @@ pub struct PatchBay { cables: Vec, drag: DragState, next_id: u32, + /// Incremented whenever the patch topology changes (modules added/removed, + /// cables added/removed). JS polls this to know when to rebuild the audio graph. + patch_version: u32, + /// Incremented whenever a parameter value changes. JS polls this to update + /// audio params without rebuilding the full graph. + params_version: u32, } #[wasm_bindgen] @@ -193,6 +233,8 @@ impl PatchBay { cables: Vec::new(), drag: DragState::Idle, next_id: 1, + patch_version: 0, + params_version: 0, }) } @@ -203,6 +245,7 @@ impl PatchBay { self.next_id += 1; let param_values = desc.params.iter().map(|p| p.default).collect(); self.modules.push(Module { id, kind: desc.kind, label: desc.label, x, y, param_values }); + self.patch_version += 1; id } else { 0 @@ -213,6 +256,21 @@ impl PatchBay { pub fn remove_module(&mut self, id: u32) { self.modules.retain(|m| m.id != id); self.cables.retain(|c| c.src_module != id && c.dst_module != id); + self.patch_version += 1; + } + + /// Returns a counter that increments on every structural patch change + /// (module added/removed, cable added/removed). JS polls this each frame + /// to know when to rebuild the Web Audio graph. + pub fn patch_version(&self) -> u32 { + self.patch_version + } + + /// Returns a counter that increments whenever a parameter value changes. + /// JS polls this each frame to apply live audio param updates without a + /// full graph rebuild. + pub fn params_version(&self) -> u32 { + self.params_version } /// Update a parameter value (param_idx is the position in the descriptor's params slice). @@ -220,6 +278,7 @@ impl PatchBay { if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) { if param_idx < m.param_values.len() { m.param_values[param_idx] = value; + self.params_version += 1; } } } @@ -255,6 +314,43 @@ impl PatchBay { } } + // Parameter interaction (between jacks and header). + // Enum params select a segment immediately on click; continuous params start a drag. + #[derive(Clone)] + enum ParamAction { Enum(u32, usize, usize), Continuous(u32, usize, f32, f32, f32) } + let action = (0..n).rev().find_map(|i| { + let m = &self.modules[i]; + m.hit_param(x, y).map(|(pidx, start_val, min, max)| { + let desc = m.descriptor(); + let p = &desc.params[pidx]; + if !p.labels.is_empty() { + let tx = m.x + 8.0; + let tw = MOD_W - 16.0; + let n_lbls = p.labels.len() as f32; + let seg = ((x - tx) / tw * n_lbls).floor().clamp(0.0, n_lbls - 1.0) as usize; + ParamAction::Enum(m.id, pidx, seg) + } else { + ParamAction::Continuous(m.id, pidx, start_val, min, max) + } + }) + }); + match action { + Some(ParamAction::Enum(module_id, param_idx, seg)) => { + if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) { + if param_idx < m.param_values.len() { + m.param_values[param_idx] = seg as f32; + self.params_version += 1; + } + } + return; + } + Some(ParamAction::Continuous(module_id, param_idx, start_val, min, max)) => { + self.drag = DragState::Param { module_id, param_idx, start_y: y, start_val, min, max }; + return; + } + None => {} + } + // Header drag for i in (0..n).rev() { if self.modules[i].hit_header(x, y) { @@ -284,6 +380,18 @@ impl PatchBay { cur_x: x, cur_y: y, }; } + DragState::Param { module_id, param_idx, start_y, start_val, min, max } => { + // 120 px of travel = full range; drag up increases value + let range = max - min; + let delta = (start_y - y) / 120.0 * range; + let new_val = (start_val + delta).clamp(min, max); + if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) { + if param_idx < m.param_values.len() { + m.param_values[param_idx] = new_val; + self.params_version += 1; + } + } + } DragState::Idle => {} } } @@ -313,6 +421,7 @@ impl PatchBay { dst_module: dst_m, dst_jack: dst_j, }); + self.patch_version += 1; break; } } @@ -460,15 +569,38 @@ impl PatchBay { } fn draw_modules(&self, ctx: &CanvasRenderingContext2d) { + // If a param drag is active, extract which module/param is highlighted + let active_param: Option<(u32, usize)> = match &self.drag { + DragState::Param { module_id, param_idx, .. } => Some((*module_id, *param_idx)), + _ => None, + }; for m in &self.modules { - self.draw_module(ctx, m); + let has_signal = self.cables.iter().any(|c| c.dst_module == m.id); + let active_pidx = active_param.and_then(|(mid, pi)| if mid == m.id { Some(pi) } else { None }); + self.draw_module(ctx, m, has_signal, active_pidx); } } - fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module) { + fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module, has_signal: bool, active_param: Option) { let desc = m.descriptor(); let mh = m.height(); let color = module_color(m.kind); + let is_out = m.kind == "out"; + + // For the output node: draw a coloured outer glow ring first so it + // sits behind the module body. + if is_out { + let glow = if has_signal { "#22c55e" } else { "#4a1010" }; + ctx.set_stroke_style_str(glow); + ctx.set_line_width(if has_signal { 2.5 } else { 1.0 }); + rounded_rect( + ctx, + (m.x - 4.0) as f64, (m.y - 4.0) as f64, + (MOD_W + 8.0) as f64, (mh + 8.0) as f64, + 10.0, + ); + ctx.stroke(); + } // Body ctx.set_fill_style_str("#20242e"); @@ -484,12 +616,36 @@ impl PatchBay { ctx.set_fill_style_str("rgba(255,255,255,0.95)"); ctx.set_font("bold 12px monospace"); ctx.set_text_align("center"); + let label_cx = if is_out { m.x + MOD_W * 0.38 } else { m.x + MOD_W * 0.5 }; let _ = ctx.fill_text( m.label, - (m.x + MOD_W * 0.5) as f64, + label_cx as f64, (m.y + MOD_HEADER * 0.70) as f64, ); + // Output node: LIVE / UNPATCHED badge on the right of the header + if is_out { + if has_signal { + ctx.set_fill_style_str("#22c55e"); + ctx.set_font("bold 9px monospace"); + ctx.set_text_align("right"); + let _ = ctx.fill_text( + "● LIVE", + (m.x + MOD_W - 6.0) as f64, + (m.y + MOD_HEADER * 0.70) as f64, + ); + } else { + ctx.set_fill_style_str("rgba(255,255,255,0.28)"); + ctx.set_font("9px monospace"); + ctx.set_text_align("right"); + let _ = ctx.fill_text( + "unpatched", + (m.x + MOD_W - 6.0) as f64, + (m.y + MOD_HEADER * 0.70) as f64, + ); + } + } + // Outer border ctx.set_stroke_style_str("rgba(255,255,255,0.07)"); ctx.set_line_width(1.0); @@ -536,31 +692,72 @@ impl PatchBay { // Parameters for (pi, p) in desc.params.iter().enumerate() { + let is_active = active_param == Some(pi); let py = params_top + 4.0 + pi as f32 * PARAM_ROW; let val = m.param_values.get(pi).copied().unwrap_or(p.default); - let ratio = ((val - p.min) / (p.max - p.min)).clamp(0.0, 1.0); - let tx = m.x + 8.0; - let tw = MOD_W - 16.0; - let track_y = py + 13.0; + let tx = m.x + 8.0; + let tw = MOD_W - 16.0; - // Track bg - ctx.set_fill_style_str("#181b22"); - ctx.fill_rect(tx as f64, track_y as f64, tw as f64, 3.0); - // Track fill - ctx.set_fill_style_str(color); - ctx.fill_rect(tx as f64, track_y as f64, (tw * ratio) as f64, 3.0); + if !p.labels.is_empty() { + // ── Enum selector: a row of clickable labelled segments ─────── + let n_segs = p.labels.len(); + let selected = val.round() as usize; + let seg_w = tw / n_segs as f32; + let btn_y = py + 2.0; + let btn_h = PARAM_ROW - 4.0; - // Labels - ctx.set_fill_style_str("rgba(160,165,180,0.8)"); - ctx.set_font("9px sans-serif"); - ctx.set_text_align("left"); - let _ = ctx.fill_text(p.label, tx as f64, (py + 10.0) as f64); - ctx.set_text_align("right"); - let _ = ctx.fill_text( - &format_param(val, p.unit), - (tx + tw) as f64, - (py + 10.0) as f64, - ); + ctx.set_font("bold 8px monospace"); + for (li, lbl) in p.labels.iter().enumerate() { + let bx = tx + li as f32 * seg_w; + let bw = seg_w - 1.0; + if li == selected { + ctx.set_fill_style_str(color); + } else { + ctx.set_fill_style_str("#181b22"); + } + ctx.fill_rect(bx as f64, btn_y as f64, bw as f64, btn_h as f64); + ctx.set_fill_style_str(if li == selected { + "rgba(255,255,255,0.95)" + } else { + "rgba(140,145,160,0.7)" + }); + ctx.set_text_align("center"); + let _ = ctx.fill_text( + lbl, + (bx + bw * 0.5) as f64, + (btn_y + btn_h * 0.72) as f64, + ); + } + } else { + // ── Continuous slider ───────────────────────────────────────── + let ratio = ((val - p.min) / (p.max - p.min)).clamp(0.0, 1.0); + let track_y = py + 13.0; + + // Active row highlight + if is_active { + ctx.set_fill_style_str("rgba(255,255,255,0.05)"); + ctx.fill_rect(m.x as f64, py as f64, MOD_W as f64, PARAM_ROW as f64); + } + + // Track bg + ctx.set_fill_style_str("#181b22"); + ctx.fill_rect(tx as f64, track_y as f64, tw as f64, if is_active { 4.0 } else { 3.0 }); + // Track fill + ctx.set_fill_style_str(if is_active { "rgba(255,255,255,0.85)" } else { color }); + ctx.fill_rect(tx as f64, track_y as f64, (tw * ratio) as f64, if is_active { 4.0 } else { 3.0 }); + + // Labels + ctx.set_fill_style_str(if is_active { "rgba(230,235,255,1.0)" } else { "rgba(160,165,180,0.8)" }); + ctx.set_font(if is_active { "bold 9px sans-serif" } else { "9px sans-serif" }); + ctx.set_text_align("left"); + let _ = ctx.fill_text(p.label, tx as f64, (py + 10.0) as f64); + ctx.set_text_align("right"); + let _ = ctx.fill_text( + &format_param(val, p.unit), + (tx + tw) as f64, + (py + 10.0) as f64, + ); + } } } diff --git a/www/bootstrap.js b/www/bootstrap.js index fc9f641..27dd200 100644 --- a/www/bootstrap.js +++ b/www/bootstrap.js @@ -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) {