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>
86 lines
3 KiB
Rust
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}");
|
|
}
|