From 3b4782565b836adfa43e305fb6a1dd11525f844a Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sun, 13 Sep 2026 16:56:03 +0200 Subject: [PATCH] Tests: 22 covering grid/compile/eval/engine (was 0; orca-rs has 11) grid: ruler_snap, resize clamps, bounds, fixed-stride preservation. dsp: op mapping, input/param/CC wiring, env detection, sine bounds, exact saw/mix samples, output summing, note gating, envelope rise+release, cutoff curve. engine: A4->440, oldest-voice steal, note reaping, CC registers, limiter. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx --- src/dsp.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/engine.rs | 72 +++++++++++++++++++++++++++ src/grid.rs | 42 ++++++++++++++++ 3 files changed, 247 insertions(+) diff --git a/src/dsp.rs b/src/dsp.rs index 0d5593d..f35e21d 100644 --- a/src/dsp.rs +++ b/src/dsp.rs @@ -302,3 +302,136 @@ pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 { v.env_level = env_max; out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::grid::Grid; + + fn grid_from(src: &str) -> Grid { + let mut g = Grid::new(); + for (r, line) in src.lines().enumerate() { + for (c, ch) in line.chars().enumerate() { + g.set(c, r, ch); + } + } + g + } + + fn voice(freq: f32, gate: f32, vel: f32) -> Voice { + let mut v = Voice::new(); + v.active = true; + v.freq = freq; + v.gate = gate; + v.vel = vel; + v + } + + fn render(v: &mut Voice, p: &Program, n: usize) -> Vec { + let regs = [0.0f32; 128]; + (0..n).map(|_| eval_voice(v, p, ®s, 48000.0)).collect() + } + + #[test] + fn compile_ignores_non_operators() { + // digits and dots are params/empty, not nodes + let p = compile(&grid_from("O5.\n!..")); + assert_eq!(p.nodes.len(), 2); // O and ! + } + + #[test] + fn compile_wires_input_from_above() { + // O over ! -> the ! node's input is the O's slot + let p = compile(&grid_from("O\n!")); + let out_node = p.nodes.iter().find(|n| matches!(n.op, Op::Out)).unwrap(); + assert!(out_node.input.is_some()); + } + + #[test] + fn compile_reads_left_param() { + // '5' left of L becomes a constant param + let p = compile(&grid_from("5L")); + let lp = p.nodes.iter().find(|n| matches!(n.op, Op::Low)).unwrap(); + assert!(matches!(lp.left, Param::Const(x) if (x - 5.0).abs() < 1e-6)); + } + + #[test] + fn compile_lowercase_left_is_cc_reg() { + let p = compile(&grid_from("aG")); + let g = p.nodes.iter().find(|n| matches!(n.op, Op::Gain)).unwrap(); + assert!(matches!(g.left, Param::Reg(0))); + } + + #[test] + fn compile_detects_envelope() { + assert!(compile(&grid_from("O\nE\n!")).has_env); + assert!(!compile(&grid_from("O\n!")).has_env); + } + + #[test] + fn empty_grid_is_silent() { + let p = compile(&grid_from("...\n...")); + let mut v = voice(440.0, 1.0, 1.0); + assert!(render(&mut v, &p, 64).iter().all(|&y| y == 0.0)); + } + + #[test] + fn sine_is_bounded_and_audible() { + let p = compile(&grid_from("O\n!")); + let mut v = voice(440.0, 1.0, 1.0); + let out = render(&mut v, &p, 2048); + assert!(out.iter().all(|&y| y.abs() <= 1.0 + 1e-6)); + assert!(out.iter().cloned().fold(0.0, f32::max) > 0.5); // reaches amplitude + } + + #[test] + fn saw_first_sample_is_minus_one() { + // phase starts at 0 -> saw = 2*0-1 = -1 + let p = compile(&grid_from("A\n!")); + let mut v = voice(440.0, 1.0, 1.0); + assert!((render(&mut v, &p, 1)[0] - (-1.0)).abs() < 1e-6); + } + + #[test] + fn outputs_sum_across_columns() { + // two saw->out columns: first sample -1 + -1 = -2 + let p = compile(&grid_from("A.A\n!.!")); + let mut v = voice(440.0, 1.0, 1.0); + assert!((render(&mut v, &p, 1)[0] - (-2.0)).abs() < 1e-6); + } + + #[test] + fn no_envelope_is_gated_by_note() { + let p = compile(&grid_from("A\n!")); + let mut open = voice(440.0, 1.0, 1.0); + let mut shut = voice(440.0, 0.0, 1.0); + assert!(render(&mut open, &p, 1)[0].abs() > 0.5); + assert_eq!(render(&mut shut, &p, 1)[0], 0.0); // gate 0 -> silent + } + + #[test] + fn envelope_rises_then_releases() { + let p = compile(&grid_from("O\nE\n!")); + let mut v = voice(440.0, 1.0, 1.0); + render(&mut v, &p, 8000); // ~sustain reached + let peak = v.env_level; + assert!(peak > 0.9, "env should rise to sustain, got {peak}"); + v.gate = 0.0; + render(&mut v, &p, 24000); // release (~0.5s >> 0.15s release) + assert!(v.env_level < 0.1, "env should decay after note off, got {}", v.env_level); + } + + #[test] + fn mix_adds_left_cell() { + // (1,0)=A saw(-1); (1,1)=M reads above(empty=0) + left cell A(-1) = -1 + let p = compile(&grid_from("A.\nAM\n.!")); + let mut v = voice(440.0, 1.0, 1.0); + assert!((render(&mut v, &p, 1)[0] - (-1.0)).abs() < 1e-6); + } + + #[test] + fn cutoff_hz_is_monotonic() { + assert!(cutoff_hz(0.0) < cutoff_hz(5.0)); + assert!(cutoff_hz(5.0) < cutoff_hz(9.0)); + } +} diff --git a/src/engine.rs b/src/engine.rs index 04923e8..cecd611 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -91,3 +91,75 @@ impl Engine { (mix * 0.3).tanh() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dsp::compile; + use crate::grid::Grid; + + #[cfg(test)] + impl Engine { + fn first_freq(&self) -> Option { + self.voices.iter().find(|v| v.active).map(|v| v.freq) + } + fn reg(&self, i: usize) -> f32 { + self.regs[i] + } + } + + fn grid_from(src: &str) -> Grid { + let mut g = Grid::new(); + for (r, line) in src.lines().enumerate() { + for (c, ch) in line.chars().enumerate() { + g.set(c, r, ch); + } + } + g + } + + #[test] + fn noteon_maps_a4_to_440() { + let mut e = Engine::new(4, 48000.0); + e.apply(Event::NoteOn { note: 69, vel: 100 }); + assert!((e.first_freq().unwrap() - 440.0).abs() < 0.01); + assert_eq!(e.active_voices(), 1); + } + + #[test] + fn noteoff_reaps_gated_voice() { + let p = compile(&grid_from("A\n!")); // no envelope -> gate reaps immediately + let mut e = Engine::new(4, 48000.0); + e.apply(Event::NoteOn { note: 60, vel: 100 }); + e.render(&p); + assert_eq!(e.active_voices(), 1); + e.apply(Event::NoteOff { note: 60 }); + e.render(&p); + assert_eq!(e.active_voices(), 0); + } + + #[test] + fn monophonic_steals_the_oldest_voice() { + let mut e = Engine::new(1, 48000.0); + e.apply(Event::NoteOn { note: 60, vel: 100 }); + e.apply(Event::NoteOn { note: 72, vel: 100 }); + assert_eq!(e.active_voices(), 1); + assert!((e.first_freq().unwrap() - 440.0 * 2f32.powf((72.0 - 69.0) / 12.0)).abs() < 0.01); + } + + #[test] + fn cc_writes_register() { + let mut e = Engine::new(2, 48000.0); + e.apply(Event::Cc { idx: 0, val: 127 }); + assert!((e.reg(0) - 1.0).abs() < 1e-6); + } + + #[test] + fn master_output_is_limited() { + // many stacked saws would exceed 1.0 without the limiter + let p = compile(&grid_from("A.A.A.A\n!.!.!.!")); + let mut e = Engine::new(4, 48000.0); + e.apply(Event::NoteOn { note: 60, vel: 127 }); + assert!(e.render(&p).abs() <= 1.0); + } +} diff --git a/src/grid.rs b/src/grid.rs index a76415a..b26938e 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -53,3 +53,45 @@ pub fn ruler_snap(size: usize, delta_rulers: isize) -> usize { let n = ((size as isize - 1) / RULER as isize + delta_rulers).max(0); (RULER as isize * n + 1) as usize } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ruler_snap_grows_and_shrinks() { + // ruler-aligned sizes are 1, 9, 17, ... + assert_eq!(ruler_snap(24, 1), 25); + assert_eq!(ruler_snap(24, -1), 17); + assert_eq!(ruler_snap(1, -1), 1); // cannot go below 1 + } + + #[test] + fn resize_clamps_to_bounds() { + let mut g = Grid::new(); + g.resize(9999, 9999); + assert_eq!((g.h, g.w), (MAX_H, MAX_W)); + g.resize(0, 0); + assert_eq!((g.h, g.w), (1, 1)); + } + + #[test] + fn get_out_of_bounds_is_empty() { + let g = Grid::new(); + assert_eq!(g.get(MAX_W + 5, MAX_H + 5), '.'); + assert_eq!(g.get(g.w, 0), '.'); // just past the right edge + } + + #[test] + fn set_get_roundtrip_and_resize_preserves() { + let mut g = Grid::new(); + g.set(3, 2, 'O'); + assert_eq!(g.get(3, 2), 'O'); + g.resize(4, 4); // shrink below the cell? (3,2) still inside 4x4 + assert_eq!(g.get(3, 2), 'O'); + g.resize(2, 2); // now (3,2) is out of bounds -> hidden + assert_eq!(g.get(3, 2), '.'); + g.resize(MAX_H, MAX_W); // grow back -> content returns (fixed stride) + assert_eq!(g.get(3, 2), 'O'); + } +}