Add audio nodes to the patch bay

Also make the patch bay responsive.
This commit is contained in:
2026-03-26 08:47:47 +00:00
parent c3cb7aa84b
commit c8ef3df460
13 changed files with 1055 additions and 152 deletions

View File

@@ -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],
}

View File

@@ -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 }

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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.01.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<const B: usize> AudioProcessor<B> for Vca {

View File

@@ -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"]

View File

@@ -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<f32>,
}
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<Jack>,
cables: Vec<Cable>,
dragging_from: Option<usize>, // jack index being dragged from
drag_x: f32,
drag_y: f32,
canvas: HtmlCanvasElement,
ctx2d: CanvasRenderingContext2d,
modules: Vec<Module>,
cables: Vec<VisCable>,
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<usize> {
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()
}