diff --git a/.claude/settings.json b/.claude/settings.json index f139e48..161080f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -8,7 +8,9 @@ "Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)", "Bash(chmod +x /Users/mattsp/projects/sound/build-web.sh)", "Bash(/Users/mattsp/projects/sound/build-web.sh)", - "Bash(wasm-pack build:*)" + "Bash(wasm-pack build:*)", + "Read(//Users/mattsp/projects/sound/**)", + "Bash(./build-web.sh)" ] } } diff --git a/crates/synth-visualiser/src/patchbay.rs b/crates/synth-visualiser/src/patchbay.rs index 5d549c8..a835441 100644 --- a/crates/synth-visualiser/src/patchbay.rs +++ b/crates/synth-visualiser/src/patchbay.rs @@ -29,13 +29,15 @@ const REGISTRY: [ComponentDescriptor; 6] = [ // ── Visual constants ────────────────────────────────────────────────────────── -const MOD_W: f32 = 168.0; -const MOD_HEADER: f32 = 26.0; -const JACK_ROW: f32 = 24.0; -const PARAM_ROW: f32 = 20.0; -const JACK_R: f32 = 7.0; +const MOD_W: f32 = 168.0; +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 CV_JACK_R: f32 = 4.5; +/// Height of the mini waveform oscilloscope drawn inside VCO / LFO modules. +const WAVEFORM_PREVIEW_H: f32 = 44.0; const PALETTE_H: f32 = 48.0; const PALETTE_BTN_W: f32 = 76.0; const PALETTE_PAD: f32 = 8.0; @@ -44,7 +46,14 @@ fn module_height(desc: &ComponentDescriptor) -> f32 { 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; - MOD_HEADER + jack_rows * JACK_ROW + 6.0 + desc.params.len() as f32 * PARAM_ROW + 8.0 + let preview = if has_waveform_preview(desc) { WAVEFORM_PREVIEW_H + 8.0 } else { 0.0 }; + MOD_HEADER + jack_rows * JACK_ROW + 6.0 + desc.params.len() as f32 * PARAM_ROW + 8.0 + preview +} + +/// True for modules that have a `waveform` parameter and therefore get a +/// mini oscilloscope drawn at the bottom of their panel. +fn has_waveform_preview(desc: &ComponentDescriptor) -> bool { + desc.params.iter().any(|p| p.id == "waveform") } fn module_color(kind: &str) -> &'static str { @@ -465,7 +474,7 @@ impl PatchBay { } } - pub fn draw(&self) { + pub fn draw(&self, time_ms: f64) { let w = self.canvas.width() as f64; let h = self.canvas.height() as f64; let ctx = &self.ctx2d; @@ -489,7 +498,7 @@ impl PatchBay { self.draw_palette(ctx); self.draw_cables(ctx); - self.draw_modules(ctx); + self.draw_modules(ctx, time_ms); self.draw_drag_cable(ctx); } @@ -594,7 +603,7 @@ impl PatchBay { let _ = ctx.fill_text("click to add · drag header to move · dbl-click to remove", w - 8.0, ph - 6.0); } - fn draw_modules(&self, ctx: &CanvasRenderingContext2d) { + fn draw_modules(&self, ctx: &CanvasRenderingContext2d, time_ms: f64) { // 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)), @@ -603,11 +612,11 @@ impl PatchBay { for m in &self.modules { 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); + self.draw_module(ctx, m, has_signal, active_pidx, time_ms); } } - fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module, has_signal: bool, active_param: Option) { + fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module, has_signal: bool, active_param: Option, time_ms: f64) { let desc = m.descriptor(); let mh = m.height(); let color = module_color(m.kind); @@ -790,6 +799,38 @@ impl PatchBay { ); } } + + // ── Mini oscilloscope preview (VCO / LFO) ──────────────────────────── + if has_waveform_preview(desc) { + let get_param = |id: &str| -> f32 { + desc.params.iter().position(|p| p.id == id) + .and_then(|pi| m.param_values.get(pi)) + .copied() + .unwrap_or(0.0) + }; + let wave_idx = get_param("waveform").round() as usize; + + // n_cycles: how many complete cycles fill the preview width. + // amplitude: peak height as a fraction of the available vertical space. + let (n_cycles, amplitude, phase_anchor) = match m.kind { + "lfo" => { + let rate = get_param("rate_hz").max(0.001); + let depth = get_param("depth").clamp(0.0, 1.0); + let n = rate.clamp(0.25, 10.0); + // Current phase: free-running from time_ms + let anchor = ((time_ms / 1000.0) as f32 * rate).rem_euclid(1.0); + (n, depth, Some(anchor)) + } + _ => { + let freq = get_param("freq_hz").max(20.0); + ((freq / 220.0).clamp(0.5, 8.0), 1.0_f32, None) + } + }; + + let preview_y = params_top + 4.0 + desc.params.len() as f32 * PARAM_ROW + 4.0; + draw_waveform_preview(ctx, wave_idx, n_cycles, amplitude, phase_anchor, color, + m.x + 8.0, preview_y, MOD_W - 16.0, WAVEFORM_PREVIEW_H); + } } fn draw_cables(&self, ctx: &CanvasRenderingContext2d) { @@ -948,6 +989,95 @@ fn format_param(val: f32, unit: &str) -> String { } } +/// Draw a mini waveform preview inside a bounding rect. +/// +/// - `wave_idx`: 0=Sine 1=Saw 2=Square 3=Triangle 4=Pulse +/// - `n_cycles`: how many complete cycles fill the width +/// - `amplitude`: peak-height scale 0.0–1.0 +/// - `phase_anchor`: `None` = static display (VCO); `Some(p)` = animated, right +/// edge shows phase `p` and time scrolls left (LFO free-running) +fn draw_waveform_preview( + ctx: &CanvasRenderingContext2d, + wave_idx: usize, + n_cycles: f32, + amplitude: f32, + phase_anchor: Option, + accent: &str, + x: f32, y: f32, w: f32, h: f32, +) { + // Background + ctx.set_fill_style_str("#0a0c12"); + ctx.fill_rect(x as f64, y as f64, w as f64, h as f64); + + // Subtle border + ctx.set_stroke_style_str("rgba(255,255,255,0.07)"); + ctx.set_line_width(1.0); + ctx.stroke_rect(x as f64, y as f64, w as f64, h as f64); + + // Centre line + let cy = (y + h * 0.5) as f64; + ctx.set_stroke_style_str("rgba(255,255,255,0.06)"); + ctx.set_line_width(0.5); + ctx.begin_path(); + ctx.move_to(x as f64, cy); + ctx.line_to((x + w) as f64, cy); + ctx.stroke(); + + // Waveform path + let n = 160_usize; + let pad = 4.0_f32; + let draw_w = w - pad * 2.0; + let amp = (h * 0.38 * amplitude.clamp(0.0, 1.0)) as f64; + + ctx.set_stroke_style_str(accent); + ctx.set_line_width(1.5); + ctx.set_global_alpha(0.88); + ctx.begin_path(); + + for i in 0..=n { + let t = i as f32 / n as f32; + let phase = match phase_anchor { + // Static: left=0, right=n_cycles + None => (t * n_cycles) % 1.0, + // Animated: right edge = current phase, scrolls left over time + Some(cp) => (cp - n_cycles * (1.0 - t)).rem_euclid(1.0), + }; + let s = waveform_sample(wave_idx, phase) as f64; + let px = (x + pad + t * draw_w) as f64; + let py = cy - s * amp; + if i == 0 { ctx.move_to(px, py); } else { ctx.line_to(px, py); } + } + ctx.stroke(); + + // For animated LFO: draw a bright dot at the right edge marking "now" + if let Some(cp) = phase_anchor { + let s = waveform_sample(wave_idx, cp) as f64; + let dot_x = (x + w - pad) as f64; + let dot_y = cy - s * amp; + ctx.set_fill_style_str(accent); + ctx.set_global_alpha(1.0); + ctx.begin_path(); + let _ = ctx.arc(dot_x, dot_y, 2.5, 0.0, core::f64::consts::TAU); + ctx.fill(); + } + + ctx.set_global_alpha(1.0); +} + +/// Compute one sample of a parameterised waveform at normalised `phase` (0..1). +#[inline] +fn waveform_sample(wave_idx: usize, phase: f32) -> f32 { + use core::f32::consts::TAU; + match wave_idx { + 0 => (phase * TAU).sin(), // Sine + 1 => 2.0 * phase - 1.0, // Saw + 2 => if phase < 0.5 { 1.0 } else { -1.0 }, // Square + 3 => 4.0 * (phase - (phase + 0.5).floor()).abs() - 1.0, // Triangle + 4 => if phase < 0.25 { 1.0 } else { -1.0 }, // Pulse + _ => 0.0, + } +} + #[inline] fn hypot(dx: f32, dy: f32) -> f32 { (dx * dx + dy * dy).sqrt() diff --git a/www/bootstrap.js b/www/bootstrap.js index 24e434f..ab5259e 100644 --- a/www/bootstrap.js +++ b/www/bootstrap.js @@ -502,7 +502,7 @@ async function bootstrap() { oscilloscope.draw(); spectrum.draw(); - patchbay.draw(); + patchbay.draw(now); keyboard.draw(); frameTime.textContent = `frame: ${(now - last).toFixed(1)} ms`; last = now;