Dynamic changes to LFO

This commit is contained in:
2026-03-26 15:47:04 +00:00
parent ecb61dad41
commit b8d3dc48ab
2 changed files with 92 additions and 4 deletions

View File

@@ -86,6 +86,10 @@ struct Module {
x: f32, x: f32,
y: f32, y: f32,
param_values: Vec<f32>, param_values: Vec<f32>,
/// 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<f32>,
} }
impl Module { impl Module {
@@ -279,7 +283,8 @@ impl PatchBay {
let id = self.next_id; let id = self.next_id;
self.next_id += 1; self.next_id += 1;
let param_values = desc.params.iter().map(|p| p.default).collect(); 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; self.patch_version += 1;
id id
} else { } else {
@@ -308,6 +313,28 @@ impl PatchBay {
self.params_version 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). /// 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) { 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 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() { for (pi, p) in desc.params.iter().enumerate() {
let is_active = active_param == Some(pi); let is_active = active_param == Some(pi);
let py = params_top + 4.0 + pi as f32 * PARAM_ROW; 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 tx = m.x + 8.0;
let tw = MOD_W - 16.0; let tw = MOD_W - 16.0;
@@ -802,10 +831,15 @@ impl PatchBay {
// ── Mini oscilloscope preview (VCO / LFO) ──────────────────────────── // ── Mini oscilloscope preview (VCO / LFO) ────────────────────────────
if has_waveform_preview(desc) { if has_waveform_preview(desc) {
// Effective value = knob + CV modulation offset, clamped to param range.
let get_param = |id: &str| -> f32 { let get_param = |id: &str| -> f32 {
desc.params.iter().position(|p| p.id == id) desc.params.iter().position(|p| p.id == id)
.and_then(|pi| m.param_values.get(pi)) .map(|pi| {
.copied() 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) .unwrap_or(0.0)
}; };
let wave_idx = get_param("waveform").round() as usize; let wave_idx = get_param("waveform").round() as usize;

54
www/bootstrap.js vendored
View File

@@ -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) ─────────────────────────────── // ── Computer keyboard map (standard DAW layout) ───────────────────────────────
const KEY_NOTE = { const KEY_NOTE = {
@@ -401,6 +415,15 @@ async function bootstrap() {
const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas)); const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas));
allCanvases.forEach(c => ro.observe(c)); 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 ────────────────────────────────────────── // ── Default patch bay layout ──────────────────────────────────────────
const cw = pbCanvas.width || pbCanvas.clientWidth || 800; const cw = pbCanvas.width || pbCanvas.clientWidth || 800;
patchbay.add_module("vco", cw * 0.08, 80); patchbay.add_module("vco", cw * 0.08, 80);
@@ -500,6 +523,37 @@ async function bootstrap() {
const outLabel = router.isOutputPatched() ? "patched" : "unpatched"; const outLabel = router.isOutputPatched() ? "patched" : "unpatched";
status.textContent = router.isOutputPatched() ? "Running · output patched" : "Running · output 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(); oscilloscope.draw();
spectrum.draw(); spectrum.draw();
patchbay.draw(now); patchbay.draw(now);