Add different wave and filter types

This commit is contained in:
2026-03-26 10:44:18 +00:00
parent d00ac70e1c
commit f26ecda58c
10 changed files with 614 additions and 140 deletions

View File

@@ -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<VisCable>,
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<usize>) {
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,
);
}
}
}