diff --git a/.claude/settings.json b/.claude/settings.json index 4ac62e2..d30b738 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,9 @@ "Bash(grep -v \"^$\")", "WebFetch(domain:github.com)", "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(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)" ] } } diff --git a/build-web.sh b/build-web.sh new file mode 100755 index 0000000..150a493 --- /dev/null +++ b/build-web.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Build the synth-visualiser WASM package and place it in www/pkg/. +# +# Usage: +# ./build-web.sh # release build (optimised) +# ./build-web.sh --dev # dev build (faster, no wasm-opt) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ── Argument parsing ────────────────────────────────────────────────────────── +MODE="release" +WASM_PACK_PROFILE="--release" +for arg in "$@"; do + case "$arg" in + --dev) MODE="dev"; WASM_PACK_PROFILE="--dev" ;; + --help) echo "Usage: $0 [--dev]"; exit 0 ;; + *) echo "Unknown argument: $arg"; exit 1 ;; + esac +done + +# ── Preflight checks ────────────────────────────────────────────────────────── +if ! command -v wasm-pack &>/dev/null; then + echo "error: wasm-pack not found. Install it with:" + echo " cargo install wasm-pack" + exit 1 +fi + +if ! rustup target list --installed | grep -q "wasm32-unknown-unknown"; then + echo "Adding wasm32-unknown-unknown target..." + rustup target add wasm32-unknown-unknown +fi + +# ── Build ───────────────────────────────────────────────────────────────────── +echo "Building synth-visualiser ($MODE)..." +wasm-pack build crates/synth-visualiser \ + $WASM_PACK_PROFILE \ + --target web \ + --out-dir ../../www/pkg + +echo "" +echo "Done. Serve with:" +echo " npx serve www" +echo " or" +echo " python3 -m http.server --directory www 8080" diff --git a/crates/synth-core/src/descriptor.rs b/crates/synth-core/src/descriptor.rs new file mode 100644 index 0000000..af95037 --- /dev/null +++ b/crates/synth-core/src/descriptor.rs @@ -0,0 +1,70 @@ +//! Compile-time metadata descriptors for synth-core DSP components. +//! +//! Each component exposes a `DESCRIPTOR` constant that describes its display +//! name, jack ports, and adjustable parameters. The synth-visualiser reads +//! these descriptors to build the patch-bay UI, ensuring the visual +//! representation stays in sync with the actual DSP API without any +//! duplication. +//! +//! All fields use `'static` references so the data lives in read-only memory +//! with zero run-time cost on both embedded and WASM targets. + +/// Which direction a signal flows through a jack. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + Input, + Output, +} + +/// The kind of signal a jack carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignalKind { + /// Audio-rate signal (e.g. VCO output, filter output). + Audio, + /// Control-voltage signal (e.g. LFO output, envelope output, pitch CV). + Cv, + /// Boolean gate signal (e.g. ADSR gate input). + Gate, +} + +/// Metadata for a single jack (input or output port) on a component. +#[derive(Clone, Copy, Debug)] +pub struct JackDescriptor { + /// Short machine-readable routing key (e.g. `"cv_in"`). + pub id: &'static str, + /// Human-readable label shown next to the jack in the patch bay. + pub label: &'static str, + pub direction: Direction, + pub signal: SignalKind, +} + +/// Metadata for a single adjustable parameter on a component. +#[derive(Clone, Copy, Debug)] +pub struct ParamDescriptor { + /// Short machine-readable identifier (e.g. `"freq_hz"`). + pub id: &'static str, + /// Human-readable label shown in the patch bay. + pub label: &'static str, + pub min: f32, + pub max: f32, + pub default: f32, + /// Display unit string (e.g. `"Hz"`, `"s"`, `""`). + pub unit: &'static str, +} + +/// Full compile-time descriptor for a DSP component type. +/// +/// Stored as a `const` associated item on each component struct so that the +/// patch-bay can query `Vco::DESCRIPTOR`, `Svf::DESCRIPTOR`, etc. without any +/// runtime overhead. +#[derive(Clone, Copy, Debug)] +pub struct ComponentDescriptor { + /// Short type identifier used as a routing and serialisation key (e.g. `"vco"`). + pub kind: &'static str, + /// Human-readable component name shown in the palette (e.g. `"VCO"`). + pub label: &'static str, + /// Ordered list of jacks. + pub jacks: &'static [JackDescriptor], + /// Ordered list of tunable parameters. + pub params: &'static [ParamDescriptor], +} diff --git a/crates/synth-core/src/envelope.rs b/crates/synth-core/src/envelope.rs index d6bee5c..acf88fb 100644 --- a/crates/synth-core/src/envelope.rs +++ b/crates/synth-core/src/envelope.rs @@ -1,6 +1,7 @@ //! ADSR envelope generator. use crate::{AudioProcessor, config::SampleRate}; +use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind}; #[derive(Clone, Copy, Debug, PartialEq)] enum Stage { Idle, Attack, Decay, Sustain, Release } @@ -25,6 +26,21 @@ impl Adsr { } } + pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor { + kind: "adsr", + label: "ADSR", + jacks: &[ + JackDescriptor { id: "gate_in", label: "Gate", direction: Direction::Input, signal: SignalKind::Gate }, + 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" }, + ], + }; + pub fn gate_on(&mut self) { self.stage = Stage::Attack; } pub fn gate_off(&mut self) { self.stage = Stage::Release; } pub fn is_idle(&self) -> bool { self.stage == Stage::Idle } diff --git a/crates/synth-core/src/filter.rs b/crates/synth-core/src/filter.rs index a4aaf2e..40055eb 100644 --- a/crates/synth-core/src/filter.rs +++ b/crates/synth-core/src/filter.rs @@ -4,6 +4,7 @@ //! - `Svf` — State-variable filter (LP / HP / BP / Notch) use crate::{AudioProcessor, CVProcessor, config::SampleRate}; +use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind}; #[derive(Clone, Copy, Debug, PartialEq)] pub enum FilterMode { @@ -30,6 +31,21 @@ impl Svf { Self { cutoff_hz, resonance, mode, sample_rate, low: 0.0, band: 0.0 } } + pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor { + kind: "svf", + label: "Filter", + jacks: &[ + JackDescriptor { id: "audio_in", label: "In", direction: Direction::Input, signal: SignalKind::Audio }, + JackDescriptor { id: "cv_in", label: "CV", direction: Direction::Input, signal: SignalKind::Cv }, + 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: "" }, + ], + }; + #[inline] fn process_sample(&mut self, input: f32) -> f32 { let f = 2.0 * libm::sinf( diff --git a/crates/synth-core/src/lfo.rs b/crates/synth-core/src/lfo.rs index d076a79..3f615f6 100644 --- a/crates/synth-core/src/lfo.rs +++ b/crates/synth-core/src/lfo.rs @@ -5,6 +5,7 @@ //! in the range –1.0 to +1.0. use crate::{AudioProcessor, config::SampleRate, oscillator::Waveform}; +use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind}; pub struct Lfo { pub waveform: Waveform, @@ -19,6 +20,19 @@ impl Lfo { Self { waveform, rate_hz, depth, phase: 0.0, sample_rate } } + pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor { + kind: "lfo", + label: "LFO", + jacks: &[ + 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: "" }, + ], + }; + #[inline] fn next_sample(&mut self) -> f32 { let p = self.phase; diff --git a/crates/synth-core/src/lib.rs b/crates/synth-core/src/lib.rs index 673096d..27ba19a 100644 --- a/crates/synth-core/src/lib.rs +++ b/crates/synth-core/src/lib.rs @@ -18,6 +18,7 @@ extern crate libm; extern crate alloc; pub mod config; +pub mod descriptor; pub mod math; pub mod oscillator; pub mod filter; diff --git a/crates/synth-core/src/oscillator.rs b/crates/synth-core/src/oscillator.rs index 2a6a14a..26b7fc5 100644 --- a/crates/synth-core/src/oscillator.rs +++ b/crates/synth-core/src/oscillator.rs @@ -4,6 +4,7 @@ //! Uses phase accumulation; bandlimited variants (BLEP/BLAMP) to follow. use crate::{AudioProcessor, CVProcessor, config::SampleRate}; +use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind}; #[derive(Clone, Copy, Debug, PartialEq)] pub enum Waveform { @@ -26,6 +27,19 @@ impl Vco { Self { waveform, freq_hz, phase: 0.0, sample_rate } } + pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor { + kind: "vco", + label: "VCO", + jacks: &[ + JackDescriptor { id: "cv_in", label: "CV", direction: Direction::Input, signal: SignalKind::Cv }, + 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: "" }, + ], + }; + #[inline] fn next_sample(&mut self) -> f32 { let p = self.phase; diff --git a/crates/synth-core/src/vca.rs b/crates/synth-core/src/vca.rs index 3c93b81..15b637d 100644 --- a/crates/synth-core/src/vca.rs +++ b/crates/synth-core/src/vca.rs @@ -1,6 +1,7 @@ //! Voltage-controlled amplifier (VCA). use crate::{AudioProcessor, CVProcessor}; +use crate::descriptor::{ComponentDescriptor, Direction, JackDescriptor, ParamDescriptor, SignalKind}; pub struct Vca { pub gain: f32, // 0.0–1.0 @@ -10,6 +11,19 @@ impl Vca { pub fn new(gain: f32) -> Self { Self { gain } } + + pub const DESCRIPTOR: ComponentDescriptor = ComponentDescriptor { + kind: "vca", + label: "VCA", + jacks: &[ + JackDescriptor { id: "audio_in", label: "In", direction: Direction::Input, signal: SignalKind::Audio }, + JackDescriptor { id: "cv_in", label: "CV", direction: Direction::Input, signal: SignalKind::Cv }, + 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: "" }, + ], + }; } impl AudioProcessor for Vca { diff --git a/crates/synth-visualiser/Cargo.toml b/crates/synth-visualiser/Cargo.toml index bfdf112..8a77985 100644 --- a/crates/synth-visualiser/Cargo.toml +++ b/crates/synth-visualiser/Cargo.toml @@ -53,3 +53,8 @@ features = [ [dev-dependencies] wasm-bindgen-test = "0.3" + +# Tell wasm-pack's bundled wasm-opt to accept the bulk-memory and SIMD +# instructions that rustc emits for wasm32-unknown-unknown. +[package.metadata.wasm-pack.profile.release] +wasm-opt = ["-O4", "--enable-bulk-memory", "--enable-simd", "--enable-nontrapping-float-to-int"] diff --git a/crates/synth-visualiser/src/patchbay.rs b/crates/synth-visualiser/src/patchbay.rs index 6b721ac..5f2b5e0 100644 --- a/crates/synth-visualiser/src/patchbay.rs +++ b/crates/synth-visualiser/src/patchbay.rs @@ -1,36 +1,178 @@ -//! Patch bay — drag-and-drop cable routing between module jacks. +//! Patch bay — drag-and-drop module-based synth configurator. +//! +//! Visual metadata is derived directly from synth-core component descriptors, +//! so the patch bay stays in sync with the DSP implementation automatically. use wasm_bindgen::prelude::*; use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d}; +use synth_core::descriptor::{ComponentDescriptor, Direction, SignalKind}; +use synth_core::oscillator::Vco; +use synth_core::filter::Svf; +use synth_core::envelope::Adsr; +use synth_core::vca::Vca; +use synth_core::lfo::Lfo; + +// ── 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] = [ + Vco::DESCRIPTOR, + Svf::DESCRIPTOR, + Adsr::DESCRIPTOR, + Vca::DESCRIPTOR, + Lfo::DESCRIPTOR, +]; + +// ── Visual constants ────────────────────────────────────────────────────────── + +const MOD_W: f32 = 168.0; +const MOD_HEADER: f32 = 26.0; +const JACK_ROW: f32 = 24.0; +const PARAM_ROW: f32 = 20.0; +const JACK_R: f32 = 7.0; +const PALETTE_H: f32 = 48.0; +const PALETTE_BTN_W: f32 = 76.0; +const PALETTE_PAD: f32 = 8.0; + +fn module_height(desc: &ComponentDescriptor) -> f32 { + 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; + MOD_HEADER + jack_rows * JACK_ROW + 6.0 + desc.params.len() as f32 * PARAM_ROW + 8.0 +} + +fn module_color(kind: &str) -> &'static str { + match kind { + "vco" => "#3b6fd4", + "svf" => "#8b3bcc", + "adsr" => "#2a9d3c", + "vca" => "#c47a2e", + "lfo" => "#2a8f9d", + _ => "#555555", + } +} + +fn signal_color(kind: SignalKind) -> &'static str { + match kind { + SignalKind::Audio => "#f59e0b", + SignalKind::Cv => "#06b6d4", + SignalKind::Gate => "#22c55e", + } +} + +// ── Data structures ─────────────────────────────────────────────────────────── #[derive(Clone, Debug)] -struct Jack { - module_id: String, - jack_id: String, +struct Module { + id: u32, + kind: &'static str, + label: &'static str, x: f32, y: f32, - is_output: bool, + param_values: Vec, +} + +impl Module { + fn descriptor(&self) -> &'static ComponentDescriptor { + REGISTRY.iter().find(|d| d.kind == self.kind).expect("unknown kind") + } + + fn height(&self) -> f32 { + module_height(self.descriptor()) + } + + /// Returns the canvas position of a jack by id. + fn jack_pos(&self, jack_id: &str) -> Option<(f32, f32)> { + let desc = self.descriptor(); + let mut in_idx = 0usize; + let mut out_idx = 0usize; + for j in desc.jacks { + if j.direction == Direction::Input { + if j.id == jack_id { + let y = self.y + MOD_HEADER + in_idx as f32 * JACK_ROW + JACK_ROW / 2.0; + return Some((self.x, y)); + } + in_idx += 1; + } else { + if j.id == jack_id { + let y = self.y + MOD_HEADER + out_idx as f32 * JACK_ROW + JACK_ROW / 2.0; + return Some((self.x + MOD_W, y)); + } + out_idx += 1; + } + } + None + } + + fn hit_header(&self, x: f32, y: f32) -> bool { + x >= self.x && x <= self.x + MOD_W + && y >= self.y && y <= self.y + MOD_HEADER + } + + fn hit_body(&self, x: f32, y: f32) -> bool { + x >= self.x && x <= self.x + MOD_W + && y >= self.y && y <= self.y + self.height() + } + + fn hit_jack(&self, x: f32, y: f32) -> Option<(String, Direction, SignalKind)> { + let desc = self.descriptor(); + let mut in_idx = 0usize; + let mut out_idx = 0usize; + for j in desc.jacks { + let (jx, jy) = if j.direction == Direction::Input { + let jy = self.y + MOD_HEADER + in_idx as f32 * JACK_ROW + JACK_ROW / 2.0; + in_idx += 1; + (self.x, jy) + } else { + let jy = self.y + MOD_HEADER + out_idx as f32 * JACK_ROW + JACK_ROW / 2.0; + out_idx += 1; + (self.x + MOD_W, jy) + }; + if hypot(x - jx, y - jy) <= JACK_R * 2.0 { + return Some((j.id.to_string(), j.direction, j.signal)); + } + } + None + } } #[derive(Clone, Debug)] -struct Cable { - src: usize, // index into jacks - dst: usize, +struct VisCable { + src_module: u32, + src_jack: String, + src_signal: SignalKind, + dst_module: u32, + dst_jack: String, } +#[derive(Clone, Debug)] +enum DragState { + Idle, + Module { id: u32, off_x: f32, off_y: f32 }, + Cable { + from_module: u32, + from_jack: String, + from_dir: Direction, + from_signal: SignalKind, + cur_x: f32, + cur_y: f32, + }, +} + +// ── PatchBay ────────────────────────────────────────────────────────────────── + #[wasm_bindgen] pub struct PatchBay { - canvas: HtmlCanvasElement, - ctx2d: CanvasRenderingContext2d, - jacks: Vec, - cables: Vec, - dragging_from: Option, // jack index being dragged from - drag_x: f32, - drag_y: f32, + canvas: HtmlCanvasElement, + ctx2d: CanvasRenderingContext2d, + modules: Vec, + cables: Vec, + drag: DragState, + next_id: u32, } -const JACK_RADIUS: f32 = 8.0; - #[wasm_bindgen] impl PatchBay { #[wasm_bindgen(constructor)] @@ -41,28 +183,151 @@ impl PatchBay { .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(PatchBay { canvas, ctx2d, - jacks: Vec::new(), - cables: Vec::new(), - dragging_from: None, - drag_x: 0.0, - drag_y: 0.0, + modules: Vec::new(), + cables: Vec::new(), + drag: DragState::Idle, + next_id: 1, }) } - pub fn register_jack(&mut self, module_id: &str, jack_id: &str, x: f32, y: f32, is_output: bool) { - self.jacks.push(Jack { - module_id: module_id.to_string(), - jack_id: jack_id.to_string(), - x, y, is_output, - }); + /// Instantiate a component by kind at (x, y). Returns the module id, or 0 on error. + pub fn add_module(&mut self, kind: &str, x: f32, y: f32) -> u32 { + if let Some(desc) = REGISTRY.iter().find(|d| d.kind == kind) { + let id = self.next_id; + 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 }); + id + } else { + 0 + } + } + + /// Remove a module and all cables connected to it. + 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); + } + + /// Update a parameter value (param_idx is the position in the descriptor's params slice). + pub fn set_param(&mut self, module_id: u32, param_idx: usize, value: f32) { + 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; + } + } + } + + pub fn on_pointer_down(&mut self, x: f32, y: f32) { + // Palette click → add module + if y < PALETTE_H { + let idx = ((x - PALETTE_PAD) / (PALETTE_BTN_W + PALETTE_PAD)) as usize; + if idx < REGISTRY.len() { + let kind = REGISTRY[idx].kind; + let cx = self.canvas.width() as f32 / 2.0 - MOD_W / 2.0; + let cy = PALETTE_H + 20.0 + (self.modules.len() as f32 % 5.0) * 24.0; + self.add_module(kind, cx, cy); + } + return; + } + + // Jacks have priority over headers (check top-most module first) + let n = self.modules.len(); + for i in (0..n).rev() { + let m = &self.modules[i]; + if let Some((jack_id, dir, signal)) = m.hit_jack(x, y) { + let mid = m.id; + self.drag = DragState::Cable { + from_module: mid, + from_jack: jack_id, + from_dir: dir, + from_signal: signal, + cur_x: x, + cur_y: y, + }; + return; + } + } + + // Header drag + for i in (0..n).rev() { + if self.modules[i].hit_header(x, y) { + let id = self.modules[i].id; + let off_x = x - self.modules[i].x; + let off_y = y - self.modules[i].y; + // Bring to front + let m = self.modules.remove(i); + self.modules.push(m); + self.drag = DragState::Module { id, off_x, off_y }; + return; + } + } + } + + pub fn on_pointer_move(&mut self, x: f32, y: f32) { + match self.drag.clone() { + DragState::Module { id, off_x, off_y } => { + if let Some(m) = self.modules.iter_mut().find(|m| m.id == id) { + m.x = (x - off_x).max(0.0); + m.y = (y - off_y).max(PALETTE_H + 2.0); + } + } + DragState::Cable { from_module, from_jack, from_dir, from_signal, .. } => { + self.drag = DragState::Cable { + from_module, from_jack, from_dir, from_signal, + cur_x: x, cur_y: y, + }; + } + DragState::Idle => {} + } + } + + pub fn on_pointer_up(&mut self, x: f32, y: f32) { + let drag = self.drag.clone(); + self.drag = DragState::Idle; + + if let DragState::Cable { from_module, from_jack, from_dir, from_signal, .. } = drag { + for mi in 0..self.modules.len() { + if self.modules[mi].id == from_module { continue; } + if let Some((jack_id, dir, _)) = self.modules[mi].hit_jack(x, y) { + let (src_m, src_j, dst_m, dst_j) = + if from_dir == Direction::Output && dir == Direction::Input { + (from_module, from_jack, self.modules[mi].id, jack_id) + } else if from_dir == Direction::Input && dir == Direction::Output { + (self.modules[mi].id, jack_id, from_module, from_jack) + } else { + break; + }; + // One cable per input jack + self.cables.retain(|c| !(c.dst_module == dst_m && c.dst_jack == dst_j)); + self.cables.push(VisCable { + src_module: src_m, + src_jack: src_j, + src_signal: from_signal, + dst_module: dst_m, + dst_jack: dst_j, + }); + break; + } + } + } + } + + /// Double-click removes the topmost module under the cursor. + pub fn on_double_click(&mut self, x: f32, y: f32) { + if y < PALETTE_H { return; } + if let Some(id) = self.modules.iter().rev() + .find(|m| m.hit_body(x, y)) + .map(|m| m.id) + { + self.remove_module(id); + } } pub fn draw(&self) { @@ -70,86 +335,392 @@ impl PatchBay { let h = self.canvas.height() as f64; let ctx = &self.ctx2d; - ctx.set_fill_style_str("#1a1a1a"); + // Background + ctx.set_fill_style_str("#111418"); ctx.fill_rect(0.0, 0.0, w, h); - // Draw cables - ctx.set_stroke_style_str("#ffcc00"); - ctx.set_line_width(2.0); - for cable in &self.cables { - if cable.src < self.jacks.len() && cable.dst < self.jacks.len() { - let src = &self.jacks[cable.src]; - let dst = &self.jacks[cable.dst]; - ctx.begin_path(); - ctx.move_to(src.x as f64, src.y as f64); - // Catmull-Rom-ish curve - let mx = (src.x as f64 + dst.x as f64) / 2.0; - let my = ((src.y as f64 + dst.y as f64) / 2.0) + 40.0; - ctx.quadratic_curve_to(mx, my, dst.x as f64, dst.y as f64); - ctx.stroke(); + // Subtle grid + ctx.set_fill_style_str("#191d24"); + let step = 24.0_f64; + let mut gx = 0.0_f64; + while gx < w { + let mut gy = PALETTE_H as f64; + while gy < h { + ctx.fill_rect(gx, gy, 1.5, 1.5); + gy += step; } + gx += step; } - // Draw in-progress drag cable - if let Some(src_idx) = self.dragging_from { - if src_idx < self.jacks.len() { - let src = &self.jacks[src_idx]; - ctx.set_stroke_style_str("rgba(255,204,0,0.5)"); - ctx.begin_path(); - ctx.move_to(src.x as f64, src.y as f64); - ctx.line_to(self.drag_x as f64, self.drag_y as f64); - ctx.stroke(); - } + self.draw_palette(ctx); + self.draw_cables(ctx); + self.draw_modules(ctx); + self.draw_drag_cable(ctx); + } + + /// Returns a JSON array describing every registered component type, + /// with jack and parameter metadata sourced from synth-core descriptors. + pub fn available_components(&self) -> String { + let mut out = String::from("["); + for (i, desc) in REGISTRY.iter().enumerate() { + if i > 0 { out.push(','); } + out.push_str(&format!( + r#"{{"kind":"{kind}","label":"{label}","jacks":{jacks},"params":{params}}}"#, + kind = desc.kind, + label = desc.label, + jacks = jacks_to_json(desc), + params = params_to_json(desc), + )); } + out.push(']'); + out + } - // Draw jacks - for jack in &self.jacks { - ctx.begin_path(); - ctx.arc(jack.x as f64, jack.y as f64, JACK_RADIUS as f64, 0.0, std::f64::consts::TAU) - .unwrap_or(()); - if jack.is_output { - ctx.set_fill_style_str("#00e5ff"); - } else { - ctx.set_fill_style_str("#ff4081"); + /// Returns a JSON snapshot of the current patch: module positions, + /// parameter values, and all cable connections. + pub fn get_patch_json(&self) -> String { + let mut out = String::from(r#"{"modules":["#); + for (i, m) in self.modules.iter().enumerate() { + if i > 0 { out.push(','); } + let desc = m.descriptor(); + out.push_str(&format!( + r#"{{"id":{id},"kind":"{kind}","x":{x},"y":{y},"params":{{"#, + id = m.id, + kind = m.kind, + x = m.x as i32, + y = m.y as i32, + )); + for (pi, p) in desc.params.iter().enumerate() { + if pi > 0 { out.push(','); } + let v = m.param_values.get(pi).copied().unwrap_or(p.default); + out.push_str(&format!(r#""{}":{:.4}"#, p.id, v)); } - ctx.fill(); - ctx.set_stroke_style_str("#444"); - ctx.set_line_width(1.0); - ctx.stroke(); + out.push_str("}}"); } - } - - pub fn on_pointer_down(&mut self, x: f32, y: f32) { - self.dragging_from = self.hit_test(x, y); - self.drag_x = x; - self.drag_y = y; - } - - pub fn on_pointer_move(&mut self, x: f32, y: f32) { - self.drag_x = x; - self.drag_y = y; - } - - pub fn on_pointer_up(&mut self, x: f32, y: f32) { - if let Some(src_idx) = self.dragging_from.take() { - if let Some(dst_idx) = self.hit_test(x, y) { - let src_is_out = self.jacks[src_idx].is_output; - let dst_is_out = self.jacks[dst_idx].is_output; - // Only allow output → input connections - if src_is_out && !dst_is_out { - self.cables.push(Cable { src: src_idx, dst: dst_idx }); - } else if !src_is_out && dst_is_out { - self.cables.push(Cable { src: dst_idx, dst: src_idx }); - } - } + out.push_str(r#"],"cables":["#); + for (i, c) in self.cables.iter().enumerate() { + if i > 0 { out.push(','); } + out.push_str(&format!( + r#"{{"src":{sm},"src_jack":"{sj}","dst":{dm},"dst_jack":"{dj}"}}"#, + sm = c.src_module, + sj = c.src_jack, + dm = c.dst_module, + dj = c.dst_jack, + )); } - } - - fn hit_test(&self, x: f32, y: f32) -> Option { - self.jacks.iter().position(|j| { - let dx = j.x - x; - let dy = j.y - y; - (dx * dx + dy * dy).sqrt() <= JACK_RADIUS * 1.5 - }) + out.push_str("]}"); + out } } + +// ── Private drawing ─────────────────────────────────────────────────────────── + +impl PatchBay { + fn draw_palette(&self, ctx: &CanvasRenderingContext2d) { + let w = self.canvas.width() as f64; + let ph = PALETTE_H as f64; + + // Panel background + ctx.set_fill_style_str("#1a1e26"); + ctx.fill_rect(0.0, 0.0, w, ph); + + // Bottom border + ctx.set_stroke_style_str("#2c3040"); + ctx.set_line_width(1.0); + ctx.begin_path(); + ctx.move_to(0.0, ph); + ctx.line_to(w, ph); + ctx.stroke(); + + for (i, desc) in REGISTRY.iter().enumerate() { + let bx = PALETTE_PAD + i as f32 * (PALETTE_BTN_W + PALETTE_PAD); + let by = 6.0_f32; + let bw = PALETTE_BTN_W; + let bh = PALETTE_H - 12.0; + + ctx.set_fill_style_str(module_color(desc.kind)); + rounded_rect(ctx, bx as f64, by as f64, bw as f64, bh as f64, 5.0); + ctx.fill(); + + ctx.set_fill_style_str("rgba(255,255,255,0.92)"); + ctx.set_font("bold 11px monospace"); + ctx.set_text_align("center"); + let _ = ctx.fill_text( + desc.label, + (bx + bw * 0.5) as f64, + (by + bh * 0.62) as f64, + ); + } + + // Hint + ctx.set_fill_style_str("rgba(120,130,150,0.6)"); + ctx.set_font("10px sans-serif"); + ctx.set_text_align("right"); + let _ = ctx.fill_text("click to add · drag header to move · dbl-click to remove", w - 8.0, ph - 6.0); + } + + fn draw_modules(&self, ctx: &CanvasRenderingContext2d) { + for m in &self.modules { + self.draw_module(ctx, m); + } + } + + fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module) { + let desc = m.descriptor(); + let mh = m.height(); + let color = module_color(m.kind); + + // Body + ctx.set_fill_style_str("#20242e"); + rounded_rect(ctx, m.x as f64, m.y as f64, MOD_W as f64, mh as f64, 7.0); + ctx.fill(); + + // Header + ctx.set_fill_style_str(color); + rounded_rect_top(ctx, m.x as f64, m.y as f64, MOD_W as f64, MOD_HEADER as f64, 7.0); + ctx.fill(); + + // Header label + ctx.set_fill_style_str("rgba(255,255,255,0.95)"); + ctx.set_font("bold 12px monospace"); + ctx.set_text_align("center"); + let _ = ctx.fill_text( + m.label, + (m.x + MOD_W * 0.5) 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); + rounded_rect(ctx, m.x as f64, m.y as f64, MOD_W as f64, mh as f64, 7.0); + ctx.stroke(); + + // Jacks + let mut in_idx = 0usize; + let mut out_idx = 0usize; + for j in desc.jacks { + let sig_color = signal_color(j.signal); + if j.direction == Direction::Input { + let jx = m.x; + let jy = m.y + MOD_HEADER + in_idx as f32 * JACK_ROW + JACK_ROW / 2.0; + draw_jack(ctx, jx as f64, jy as f64, JACK_R as f64, sig_color, false); + ctx.set_fill_style_str("rgba(190,195,210,0.75)"); + ctx.set_font("10px sans-serif"); + ctx.set_text_align("left"); + let _ = ctx.fill_text(j.label, (jx + JACK_R + 6.0) as f64, (jy + 4.0) as f64); + in_idx += 1; + } else { + let jx = m.x + MOD_W; + let jy = m.y + MOD_HEADER + out_idx as f32 * JACK_ROW + JACK_ROW / 2.0; + draw_jack(ctx, jx as f64, jy as f64, JACK_R as f64, sig_color, true); + ctx.set_fill_style_str("rgba(190,195,210,0.75)"); + ctx.set_font("10px sans-serif"); + ctx.set_text_align("right"); + let _ = ctx.fill_text(j.label, (jx - JACK_R - 6.0) as f64, (jy + 4.0) as f64); + out_idx += 1; + } + } + + // Divider above params + let jack_rows = in_idx.max(out_idx) as f32; + let params_top = m.y + MOD_HEADER + jack_rows * JACK_ROW + 3.0; + if !desc.params.is_empty() { + ctx.set_stroke_style_str("rgba(255,255,255,0.05)"); + ctx.set_line_width(1.0); + ctx.begin_path(); + ctx.move_to((m.x + 8.0) as f64, params_top as f64); + ctx.line_to((m.x + MOD_W - 8.0) as f64, params_top as f64); + ctx.stroke(); + } + + // Parameters + for (pi, p) in desc.params.iter().enumerate() { + 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; + + // 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); + + // 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, + ); + } + } + + fn draw_cables(&self, ctx: &CanvasRenderingContext2d) { + for cable in &self.cables { + let sp = self.modules.iter().find(|m| m.id == cable.src_module) + .and_then(|m| m.jack_pos(&cable.src_jack)); + let dp = self.modules.iter().find(|m| m.id == cable.dst_module) + .and_then(|m| m.jack_pos(&cable.dst_jack)); + if let (Some((x1, y1)), Some((x2, y2))) = (sp, dp) { + draw_cable(ctx, x1 as f64, y1 as f64, x2 as f64, y2 as f64, + signal_color(cable.src_signal), 1.0); + } + } + } + + fn draw_drag_cable(&self, ctx: &CanvasRenderingContext2d) { + if let DragState::Cable { from_module, ref from_jack, from_signal, cur_x, cur_y, .. } = self.drag { + if let Some((x1, y1)) = self.modules.iter() + .find(|m| m.id == from_module) + .and_then(|m| m.jack_pos(from_jack)) + { + draw_cable(ctx, x1 as f64, y1 as f64, cur_x as f64, cur_y as f64, + signal_color(from_signal), 0.55); + } + } + } +} + +// ── Free drawing helpers ────────────────────────────────────────────────────── + +fn draw_jack(ctx: &CanvasRenderingContext2d, x: f64, y: f64, r: f64, color: &str, is_output: bool) { + // Outer ring + ctx.begin_path(); + let _ = ctx.arc(x, y, r + 2.5, 0.0, core::f64::consts::TAU); + ctx.set_fill_style_str("#161920"); + ctx.fill(); + ctx.set_stroke_style_str(color); + ctx.set_line_width(1.5); + ctx.stroke(); + + // Inner fill + ctx.begin_path(); + let _ = ctx.arc(x, y, r * 0.55, 0.0, core::f64::consts::TAU); + ctx.set_fill_style_str(if is_output { color } else { "#0d0f14" }); + ctx.fill(); +} + +fn draw_cable( + ctx: &CanvasRenderingContext2d, + x1: f64, y1: f64, x2: f64, y2: f64, + color: &str, alpha: f64, +) { + let dx = (x2 - x1).abs(); + let sag = (dx * 0.35 + 40.0).min(100.0); + let cpx1 = x1 + (x2 - x1) * 0.35; + let cpy1 = y1 + sag; + let cpx2 = x2 - (x2 - x1) * 0.35; + let cpy2 = y2 + sag; + + ctx.set_global_alpha(alpha * 0.25); + ctx.begin_path(); + ctx.move_to(x1, y1); + ctx.bezier_curve_to(cpx1, cpy1, cpx2, cpy2, x2, y2); + ctx.set_stroke_style_str(color); + ctx.set_line_width(5.0); + ctx.stroke(); + + ctx.set_global_alpha(alpha); + ctx.begin_path(); + ctx.move_to(x1, y1); + ctx.bezier_curve_to(cpx1, cpy1, cpx2, cpy2, x2, y2); + ctx.set_stroke_style_str(color); + ctx.set_line_width(2.0); + ctx.stroke(); + + ctx.set_global_alpha(1.0); +} + +fn rounded_rect(ctx: &CanvasRenderingContext2d, x: f64, y: f64, w: f64, h: f64, r: f64) { + ctx.begin_path(); + ctx.move_to(x + r, y); + ctx.line_to(x + w - r, y); + let _ = ctx.arc_to(x + w, y, x + w, y + r, r); + ctx.line_to(x + w, y + h - r); + let _ = ctx.arc_to(x + w, y + h, x + w - r, y + h, r); + ctx.line_to(x + r, y + h); + let _ = ctx.arc_to(x, y + h, x, y + h - r, r); + ctx.line_to(x, y + r); + let _ = ctx.arc_to(x, y, x + r, y, r); + ctx.close_path(); +} + +fn rounded_rect_top(ctx: &CanvasRenderingContext2d, x: f64, y: f64, w: f64, h: f64, r: f64) { + ctx.begin_path(); + ctx.move_to(x + r, y); + ctx.line_to(x + w - r, y); + let _ = ctx.arc_to(x + w, y, x + w, y + r, r); + ctx.line_to(x + w, y + h); + ctx.line_to(x, y + h); + ctx.line_to(x, y + r); + let _ = ctx.arc_to(x, y, x + r, y, r); + ctx.close_path(); +} + +// ── JSON helpers ────────────────────────────────────────────────────────────── + +fn jacks_to_json(desc: &ComponentDescriptor) -> String { + let mut out = String::from("["); + for (i, j) in desc.jacks.iter().enumerate() { + if i > 0 { out.push(','); } + out.push_str(&format!( + r#"{{"id":"{id}","label":"{label}","direction":"{dir}","signal":"{sig}"}}"#, + id = j.id, + label = j.label, + dir = if j.direction == Direction::Output { "output" } else { "input" }, + sig = match j.signal { + SignalKind::Audio => "audio", + SignalKind::Cv => "cv", + SignalKind::Gate => "gate", + }, + )); + } + out.push(']'); + out +} + +fn params_to_json(desc: &ComponentDescriptor) -> String { + let mut out = String::from("["); + for (i, p) in desc.params.iter().enumerate() { + if i > 0 { out.push(','); } + out.push_str(&format!( + r#"{{"id":"{id}","label":"{label}","min":{min:.4},"max":{max:.4},"default":{def:.4},"unit":"{unit}"}}"#, + id = p.id, + label = p.label, + min = p.min, + max = p.max, + def = p.default, + unit = p.unit, + )); + } + out.push(']'); + out +} + +fn format_param(val: f32, unit: &str) -> String { + match unit { + "Hz" => { + if val >= 1000.0 { + format!("{:.1}k", val / 1000.0) + } else { + format!("{:.0}Hz", val) + } + } + "s" => format!("{:.2}s", val), + _ => format!("{:.2}", val), + } +} + +#[inline] +fn hypot(dx: f32, dy: f32) -> f32 { + (dx * dx + dy * dy).sqrt() +} diff --git a/www/bootstrap.js b/www/bootstrap.js index 6f2e811..f6c723f 100644 --- a/www/bootstrap.js +++ b/www/bootstrap.js @@ -1,8 +1,9 @@ /** * bootstrap.js — ES module entry point. * - * Imports the wasm-pack-generated glue, initialises the WASM binary, then - * wires up Rust-exported types to the canvas elements in index.html. + * 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. */ import init, { @@ -13,35 +14,98 @@ import init, { 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. + +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; + let startY = 0; + let 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; + // 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"; + }); + + handle.addEventListener("pointerup", () => { dragging = false; handle.classList.remove("active"); }); + handle.addEventListener("pointercancel",() => { dragging = false; handle.classList.remove("active"); }); +} + +// ── 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(); 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"); - // Register module jacks (x, y coordinates relative to the canvas) - patchbay.register_jack("vco", "out", 50, 60, true); - patchbay.register_jack("filter", "in", 150, 60, false); - patchbay.register_jack("filter", "out", 250, 60, true); - patchbay.register_jack("vca", "in", 350, 60, false); - patchbay.register_jack("lfo", "cv-out", 450, 60, true); - patchbay.register_jack("filter", "cv-in", 550, 60, false); + // 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]; - const pbCanvas = document.getElementById("patchbay-canvas"); - 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)); + allCanvases.forEach(fitCanvas); + + // Seed a default patch using the now-correct canvas width. + 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 + 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)); const params = new SynthParams(); engine.set_params(params.to_json()); diff --git a/www/index.html b/www/index.html index 38cae55..1de36c0 100644 --- a/www/index.html +++ b/www/index.html @@ -6,62 +6,132 @@ Synth Visualiser