diff --git a/crates/synth-core/src/filter.rs b/crates/synth-core/src/filter.rs index 73ab03e..67362ed 100644 --- a/crates/synth-core/src/filter.rs +++ b/crates/synth-core/src/filter.rs @@ -36,7 +36,6 @@ impl 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: &[ diff --git a/crates/synth-core/src/oscillator.rs b/crates/synth-core/src/oscillator.rs index 6572c49..1e1c4a1 100644 --- a/crates/synth-core/src/oscillator.rs +++ b/crates/synth-core/src/oscillator.rs @@ -31,7 +31,6 @@ impl Vco { 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: &[ diff --git a/crates/synth-core/src/vca.rs b/crates/synth-core/src/vca.rs index 7fef937..f9c6a1a 100644 --- a/crates/synth-core/src/vca.rs +++ b/crates/synth-core/src/vca.rs @@ -17,7 +17,6 @@ impl 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: &[ diff --git a/crates/synth-visualiser/src/patchbay.rs b/crates/synth-visualiser/src/patchbay.rs index 50455ea..5d549c8 100644 --- a/crates/synth-visualiser/src/patchbay.rs +++ b/crates/synth-visualiser/src/patchbay.rs @@ -34,6 +34,8 @@ const MOD_HEADER: f32 = 26.0; const JACK_ROW: f32 = 24.0; const PARAM_ROW: f32 = 20.0; const JACK_R: f32 = 7.0; +/// Smaller jack used for per-parameter CV inputs, rendered in the param area. +const CV_JACK_R: f32 = 4.5; const PALETTE_H: f32 = 48.0; const PALETTE_BTN_W: f32 = 76.0; const PALETTE_PAD: f32 = 8.0; @@ -106,6 +108,18 @@ impl Module { out_idx += 1; } } + // Virtual per-parameter CV input jacks: id = "cv_{param_id}" + if let Some(param_id) = jack_id.strip_prefix("cv_") { + 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 params_top = self.y + MOD_HEADER + n_in.max(n_out) as f32 * JACK_ROW + 3.0; + for (pi, p) in desc.params.iter().enumerate() { + if p.id == param_id && p.labels.is_empty() { + let jy = params_top + 4.0 + pi as f32 * PARAM_ROW + PARAM_ROW * 0.5; + return Some((self.x, jy)); + } + } + } None } @@ -158,6 +172,18 @@ impl Module { return Some((j.id.to_string(), j.direction, j.signal)); } } + // Virtual per-parameter CV input jacks + 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 params_top = self.y + MOD_HEADER + n_in.max(n_out) as f32 * JACK_ROW + 3.0; + for (pi, p) in desc.params.iter().enumerate() { + if p.labels.is_empty() { + let jy = params_top + 4.0 + pi as f32 * PARAM_ROW + PARAM_ROW * 0.5; + if hypot(x - self.x, y - jy) <= CV_JACK_R * 2.5 { + return Some((format!("cv_{}", p.id), Direction::Input, SignalKind::Cv)); + } + } + } None } } @@ -730,6 +756,11 @@ impl PatchBay { } } else { // ── Continuous slider ───────────────────────────────────────── + // Small CV input jack on the left edge of the param row + let jy = py + PARAM_ROW * 0.5; + draw_jack(ctx, m.x as f64, jy as f64, CV_JACK_R as f64, + signal_color(SignalKind::Cv), false); + let ratio = ((val - p.min) / (p.max - p.min)).clamp(0.0, 1.0); let track_y = py + 13.0; diff --git a/www/bootstrap.js b/www/bootstrap.js index 27dd200..24e434f 100644 --- a/www/bootstrap.js +++ b/www/bootstrap.js @@ -231,9 +231,9 @@ class PatchRouter { return dst?.kind === "out" && c.dst_jack === "audio_in"; }); - // Mark VCAs that have an ADSR patched into their cv_in + // Mark VCAs that have an ADSR patched into any of their CV param inputs for (const c of patch.cables) { - if (c.dst_jack !== "cv_in") continue; + if (!c.dst_jack.startsWith("cv_")) 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") { @@ -312,28 +312,49 @@ class PatchRouter { const dst = this.nodes.get(cable.dst); if (!src?.node || !dst?.node) return; // ADSR has node:null — skip - // ── Audio signal cables ──────────────────────────────────────────── + // ── Audio signal ─────────────────────────────────────────────────── 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 + // ── Per-parameter CV modulation: cv_{param_id} ──────────────────── + if (cable.dst_jack.startsWith("cv_")) { + // ADSR → VCA: handled by parameter automation in noteOn/Off (not a node connection) + if (src.kind === "adsr") return; + const ap = this._getAudioParam(dst, cable.dst_jack.slice(3)); + if (ap) try { src.node.connect(ap); } catch(_) {} } } + // Returns the Web Audio AudioParam that corresponds to a param id on a node, + // or null if no mapping exists. + _getAudioParam(nodeInfo, paramId) { + switch (nodeInfo.kind) { + case "vco": + if (paramId === "freq_hz") return nodeInfo.node.frequency; + break; + case "svf": + if (paramId === "cutoff_hz") return nodeInfo.node.frequency; + if (paramId === "resonance") return nodeInfo.node.Q; + break; + case "vca": + if (paramId === "gain") return nodeInfo.node.gain; + break; + case "lfo": + if (paramId === "rate_hz") return nodeInfo.oscNode.frequency; + if (paramId === "depth") return nodeInfo.node.gain; + break; + case "out": + if (paramId === "level") return nodeInfo.node.gain; + break; + case "adsr": + // ADSR has no Web Audio node; envelope is driven via parameter automation + break; + } + return null; + } + _midiHz(note) { return 440 * Math.pow(2, (note - 69) / 12); }