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

@@ -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<const B: usize> AudioProcessor<B> 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
}
}

View File

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

View File

@@ -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: &[] },
],
};

View File

@@ -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: &[] },
],
};

View File

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

View File

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

View File

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

View File

@@ -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: &[] },
],
};
}

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,
);
}
}
}

436
www/bootstrap.js vendored
View File

@@ -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 ZM → C3B3, with S D G H J for sharps
// Upper row QU → C4B4, 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) {