Add waveform animations

This commit is contained in:
2026-03-26 15:32:03 +00:00
parent d47cae5a95
commit ecb61dad41
3 changed files with 146 additions and 14 deletions

View File

@@ -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<usize>) {
fn draw_module(&self, ctx: &CanvasRenderingContext2d, m: &Module, has_signal: bool, active_param: Option<usize>, 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.01.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<f32>,
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()