diff --git a/crates/synth-visualiser/src/patchbay.rs b/crates/synth-visualiser/src/patchbay.rs index a835441..d54a2ca 100644 --- a/crates/synth-visualiser/src/patchbay.rs +++ b/crates/synth-visualiser/src/patchbay.rs @@ -86,6 +86,10 @@ struct Module { x: f32, y: f32, param_values: Vec, + /// Per-param CV modulation offset for display only (JS pushes live values + /// each frame via `set_cv_mod`). Added to `param_values` then clamped to + /// [min, max] before rendering. Reset to 0 each frame via `clear_cv_mods`. + cv_mod: Vec, } impl Module { @@ -279,7 +283,8 @@ impl PatchBay { 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 }); + let cv_mod = vec![0.0_f32; desc.params.len()]; + self.modules.push(Module { id, kind: desc.kind, label: desc.label, x, y, param_values, cv_mod }); self.patch_version += 1; id } else { @@ -308,6 +313,28 @@ impl PatchBay { self.params_version } + /// Reset all CV display offsets to zero. Call once per frame before + /// pushing fresh values via `set_cv_mod`. + pub fn clear_cv_mods(&mut self) { + for m in &mut self.modules { + for v in &mut m.cv_mod { *v = 0.0; } + } + } + + /// Push a live CV modulation offset (in the param's native unit) for + /// display purposes. The offset is added to the knob value and clamped + /// to [min, max] before rendering. + pub fn set_cv_mod(&mut self, module_id: u32, param_id: &str, offset: f32) { + if let Some(m) = self.modules.iter_mut().find(|m| m.id == module_id) { + let desc = REGISTRY.iter().find(|d| d.kind == m.kind).expect("unknown kind"); + if let Some(pi) = desc.params.iter().position(|p| p.id == param_id) { + if pi < m.cv_mod.len() { + m.cv_mod[pi] = offset; + } + } + } + } + /// 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) { @@ -729,7 +756,9 @@ impl PatchBay { 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 base = m.param_values.get(pi).copied().unwrap_or(p.default); + let mod_v = m.cv_mod.get(pi).copied().unwrap_or(0.0); + let val = (base + mod_v).clamp(p.min, p.max); let tx = m.x + 8.0; let tw = MOD_W - 16.0; @@ -802,10 +831,15 @@ impl PatchBay { // ── Mini oscilloscope preview (VCO / LFO) ──────────────────────────── if has_waveform_preview(desc) { + // Effective value = knob + CV modulation offset, clamped to param range. let get_param = |id: &str| -> f32 { desc.params.iter().position(|p| p.id == id) - .and_then(|pi| m.param_values.get(pi)) - .copied() + .map(|pi| { + let base = m.param_values.get(pi).copied().unwrap_or(0.0); + let mod_v = m.cv_mod.get(pi).copied().unwrap_or(0.0); + let p = &desc.params[pi]; + (base + mod_v).clamp(p.min, p.max) + }) .unwrap_or(0.0) }; let wave_idx = get_param("waveform").round() as usize; diff --git a/www/bootstrap.js b/www/bootstrap.js index ab5259e..64037c5 100644 --- a/www/bootstrap.js +++ b/www/bootstrap.js @@ -360,6 +360,20 @@ class PatchRouter { } } +// ── Waveform sample (mirrors Rust waveform_sample) ─────────────────────────── + +function waveformSample(waveIdx, phase) { + const TAU = 2 * Math.PI; + switch (waveIdx) { + case 0: return Math.sin(phase * TAU); // Sine + case 1: return 2 * phase - 1; // Saw + case 2: return phase < 0.5 ? 1 : -1; // Square + case 3: return 4 * Math.abs(phase - Math.floor(phase + 0.5)) - 1; // Triangle + case 4: return phase < 0.25 ? 1 : -1; // Pulse + default: return 0; + } +} + // ── Computer keyboard map (standard DAW layout) ─────────────────────────────── const KEY_NOTE = { @@ -401,6 +415,15 @@ async function bootstrap() { const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas)); allCanvases.forEach(c => ro.observe(c)); + // ── Component registry: param min/max for CV display scaling ───────── + // { kind → { paramId → { min, max } } } + const paramRanges = new Map( + JSON.parse(patchbay.available_components()).map(c => [ + c.kind, + new Map(c.params.map(p => [p.id, { min: p.min, max: p.max }])) + ]) + ); + // ── Default patch bay layout ────────────────────────────────────────── const cw = pbCanvas.width || pbCanvas.clientWidth || 800; patchbay.add_module("vco", cw * 0.08, 80); @@ -500,6 +523,37 @@ async function bootstrap() { const outLabel = router.isOutputPatched() ? "patched" : "unpatched"; status.textContent = router.isOutputPatched() ? "Running · output patched" : "Running · output unpatched"; + // ── Push live CV modulation offsets to the patchbay for display ── + // This makes param bars and waveform previews reflect CV-modulated + // values even when the knob hasn't moved. + { + const patch = JSON.parse(patchbay.get_patch_json()); + patchbay.clear_cv_mods(); + for (const cable of patch.cables) { + if (!cable.dst_jack.startsWith("cv_")) continue; + const srcMod = patch.modules.find(m => m.id === cable.src); + if (!srcMod || srcMod.kind !== "lfo") continue; + + const paramId = cable.dst_jack.slice(3); // "cv_rate_hz" → "rate_hz" + const dstMod = patch.modules.find(m => m.id === cable.dst); + if (!dstMod) continue; + + const range = paramRanges.get(dstMod.kind)?.get(paramId); + if (!range) continue; + + // LFO normalised output: -depth..+depth + const rate = srcMod.params.rate_hz ?? 2; + const depth = srcMod.params.depth ?? 0.5; + const waveIdx = Math.round(srcMod.params.waveform ?? 0); + const phase = ((now / 1000) * rate) % 1.0; + const lfoNorm = waveformSample(waveIdx, phase) * depth; + + // Scale to ±½ of the param's full range for display + const offset = lfoNorm * (range.max - range.min) * 0.5; + patchbay.set_cv_mod(dstMod.id, paramId, offset); + } + } + oscilloscope.draw(); spectrum.draw(); patchbay.draw(now);