metronome/rust/pm-synth/tests/player.rs
Me Here 802e46f5bb pm-daisy: wire Pod controls (encoder/buttons/knobs/RGB LEDs)
The Daisy Pod hardware arrived (Seed 1.2 / PCM3060). Build the controls
phase on top of the audio spike: encoder turn = tempo, push = play/pause,
buttons step the program list, knob 1 = volume, RGB LED 1 = beat colored
by dynamic, RGB LED 2 = transport. Boot still plays the spike groove so
the spike's decision criteria stay observable.

- pm-synth: add Player::{position,seek,set_volume,last_level} + Synth::
  set_master, with host tests (tests/player.rs). Live tempo/program
  changes rebuild the Player in thread context and swap it in under a
  short critical section, preserving loop phase (seek) on tempo changes.
- pm-daisy: SysTick 1 kHz millis tick, non-blocking beat flash, and
  PLAYING/VOLUME/LAST_LEVEL atomics the audio IRQ honors/publishes.
- New modules controls.rs (buttons/encoder/pots, libDaisy debounce +
  quadrature decode, 1 kHz poll), leds.rs (active-low RGB + boot
  self-test), programs.rs (spike groove at index 0 + pm-grid grooves).
- Pin map verified against libDaisy daisy_pod.cpp + the daisy crate's
  pins.rs. Builds clean for all three Seed revisions; 91 KB/128 KB flash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:04:59 -05:00

86 lines
3 KiB
Rust

//! Host tests for the `Player` controls added for live tempo/volume on the Daisy Pod
//! (`position` / `seek` / `set_volume` / `last_level`). These run on the host (the no_std lib links
//! against the test harness's std allocator, same as `synthrender`), so the device behavior is
//! exercised without hardware.
use pm_synth::{Player, SPIKE_BARS, SPIKE_PROGRAM};
fn spike_player() -> Player {
let track = track_format::parse(SPIKE_PROGRAM);
Player::new(&track, SPIKE_BARS)
}
#[test]
fn fresh_player_starts_at_zero() {
let p = spike_player();
assert_eq!(p.position(), 0.0);
assert_eq!(p.last_level(), 0, "no click has fired yet");
}
#[test]
fn position_advances_with_playback() {
let mut p = spike_player();
for _ in 0..48_000 {
p.next_sample();
}
let pos = p.position();
assert!(pos > 0.0 && pos < 1.0, "position should be mid-loop, got {pos}");
}
#[test]
fn seek_sets_position_and_is_idempotent_with_position() {
let mut p = spike_player();
p.seek(0.5);
let pos = p.position();
assert!((pos - 0.5).abs() < 0.01, "seek(0.5) → position {pos}, want ~0.5");
}
#[test]
fn seek_clamps_to_loop() {
let mut p = spike_player();
p.seek(2.0); // out of range
assert!(p.position() < 1.0, "seek past the end must clamp inside the loop");
p.seek(-1.0);
assert_eq!(p.position(), 0.0, "negative seek clamps to start");
}
#[test]
fn seek_cursor_lands_on_next_event() {
// The spike program has a kick on every beat (level 1/X). After seeking just before the loop
// end, advancing should still fire the wrap-around downbeat — i.e. the cursor was repositioned,
// not left dangling past the end.
let mut p = spike_player();
let before = p.fired();
p.seek(0.999);
// Run through the loop seam (a chunk longer than the remaining tail).
for _ in 0..48_000 {
p.next_sample();
}
assert!(p.fired() > before, "clicks should fire after seeking near the end and wrapping");
}
#[test]
fn last_level_reports_accent_priority() {
// kick909 is level 1 (X with no explicit accent map → normal), clap on beats 2&4 is X (accent).
// Run a full loop; by the end last_level must have seen at least a normal-level hit.
let mut p = spike_player();
let track = track_format::parse(SPIKE_PROGRAM);
let mbar = track_format::schedule::master_bar_ns(&track) * SPIKE_BARS;
let samples = (mbar as f64 / 1e9 * pm_synth::SR as f64) as usize;
for _ in 0..samples {
p.next_sample();
}
assert!(p.last_level() >= 1 && p.last_level() <= 3, "last_level must be a valid click level, got {}", p.last_level());
}
#[test]
fn set_volume_does_not_panic_and_silences_at_zero() {
let mut p = spike_player();
p.set_volume(0.0);
// Drive past the first downbeat; at zero master the output must be silent.
let mut peak = 0.0f32;
for _ in 0..24_000 {
peak = peak.max(p.next_sample().abs());
}
assert_eq!(peak, 0.0, "volume 0 should produce silence, peaked at {peak}");
}