Add waveform animations
This commit is contained in:
@@ -8,7 +8,9 @@
|
|||||||
"Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)",
|
"Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)",
|
||||||
"Bash(chmod +x /Users/mattsp/projects/sound/build-web.sh)",
|
"Bash(chmod +x /Users/mattsp/projects/sound/build-web.sh)",
|
||||||
"Bash(/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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const PARAM_ROW: f32 = 20.0;
|
|||||||
const JACK_R: f32 = 7.0;
|
const JACK_R: f32 = 7.0;
|
||||||
/// Smaller jack used for per-parameter CV inputs, rendered in the param area.
|
/// 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_H: f32 = 48.0;
|
||||||
const PALETTE_BTN_W: f32 = 76.0;
|
const PALETTE_BTN_W: f32 = 76.0;
|
||||||
const PALETTE_PAD: f32 = 8.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_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 n_out = desc.jacks.iter().filter(|j| j.direction == Direction::Output).count();
|
||||||
let jack_rows = n_in.max(n_out) as f32;
|
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 {
|
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 w = self.canvas.width() as f64;
|
||||||
let h = self.canvas.height() as f64;
|
let h = self.canvas.height() as f64;
|
||||||
let ctx = &self.ctx2d;
|
let ctx = &self.ctx2d;
|
||||||
@@ -489,7 +498,7 @@ impl PatchBay {
|
|||||||
|
|
||||||
self.draw_palette(ctx);
|
self.draw_palette(ctx);
|
||||||
self.draw_cables(ctx);
|
self.draw_cables(ctx);
|
||||||
self.draw_modules(ctx);
|
self.draw_modules(ctx, time_ms);
|
||||||
self.draw_drag_cable(ctx);
|
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);
|
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
|
// If a param drag is active, extract which module/param is highlighted
|
||||||
let active_param: Option<(u32, usize)> = match &self.drag {
|
let active_param: Option<(u32, usize)> = match &self.drag {
|
||||||
DragState::Param { module_id, param_idx, .. } => Some((*module_id, *param_idx)),
|
DragState::Param { module_id, param_idx, .. } => Some((*module_id, *param_idx)),
|
||||||
@@ -603,11 +612,11 @@ impl PatchBay {
|
|||||||
for m in &self.modules {
|
for m in &self.modules {
|
||||||
let has_signal = self.cables.iter().any(|c| c.dst_module == m.id);
|
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 });
|
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 desc = m.descriptor();
|
||||||
let mh = m.height();
|
let mh = m.height();
|
||||||
let color = module_color(m.kind);
|
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) {
|
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<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]
|
#[inline]
|
||||||
fn hypot(dx: f32, dy: f32) -> f32 {
|
fn hypot(dx: f32, dy: f32) -> f32 {
|
||||||
(dx * dx + dy * dy).sqrt()
|
(dx * dx + dy * dy).sqrt()
|
||||||
|
|||||||
2
www/bootstrap.js
vendored
2
www/bootstrap.js
vendored
@@ -502,7 +502,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
oscilloscope.draw();
|
oscilloscope.draw();
|
||||||
spectrum.draw();
|
spectrum.draw();
|
||||||
patchbay.draw();
|
patchbay.draw(now);
|
||||||
keyboard.draw();
|
keyboard.draw();
|
||||||
frameTime.textContent = `frame: ${(now - last).toFixed(1)} ms`;
|
frameTime.textContent = `frame: ${(now - last).toFixed(1)} ms`;
|
||||||
last = now;
|
last = now;
|
||||||
|
|||||||
Reference in New Issue
Block a user