- schedule.rs: ports the firmware's durs/timeline math (app.py tick/_prepare_next). render(track, bars) yields the deterministic click timeline; tests/schedule.rs asserts quarter-note spacing, subdivisions, swing 2/3:1/3, polymeter 5:4, accents/ghosts, mute, and multi-bar looping. All green on the host. - The crate is now #![no_std] + alloc and builds for thumbv8m.main-none-eabihf, so the codec + scheduler are firmware-ready (verified: cargo build --lib --target thumbv8m.main-none-eabihf). ./rust/run.sh -> 9 tests pass (2 conformance + 7 schedule). docs/rust-port.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
70 lines
2.6 KiB
Rust
70 lines
2.6 KiB
Rust
//! Stage 2 scheduler timing tests — deterministic, no hardware. Asserts the click
|
||
//! timeline math (subdivision spacing, swing ratio, polymeter, accents, mute).
|
||
|
||
use track_format::parse;
|
||
use track_format::schedule::{lane_durs, master_bar_ns, render};
|
||
|
||
const S: i64 = 1_000_000_000; // one second in ns
|
||
|
||
#[test]
|
||
fn quarter_notes_at_120() {
|
||
let t = parse("t120;kick:4=Xxxx");
|
||
let cl = render(&t, 1);
|
||
assert_eq!(cl.iter().map(|c| c.time_ns).collect::<Vec<_>>(), vec![0, S / 2, S, 3 * S / 2]);
|
||
assert_eq!(cl.iter().map(|c| c.level).collect::<Vec<_>>(), vec![2, 1, 1, 1]); // accent on beat 1
|
||
}
|
||
|
||
#[test]
|
||
fn eighth_subdivision_spacing() {
|
||
// hats on the quarters only (X.x.x.x.) over an 8th grid → 0, 0.5, 1.0, 1.5 s
|
||
let t = parse("t120;hatClosed:4/2=X.x.x.x.");
|
||
let cl = render(&t, 1);
|
||
assert_eq!(cl.iter().map(|c| c.time_ns).collect::<Vec<_>>(), vec![0, S / 2, S, 3 * S / 2]);
|
||
assert_eq!(cl[0].level, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn swing_splits_two_thirds_one_third() {
|
||
let t = parse("t120;ride:4/2s=Xxxxxxxx"); // all 8ths sounding, swung
|
||
let durs = lane_durs(&t.lanes[0], 120, master_bar_ns(&t));
|
||
let beat = 500_000_000;
|
||
assert_eq!(durs[0], beat * 2 / 3); // on-beat = long
|
||
assert_eq!(durs[1], beat / 3); // off-beat = short
|
||
assert!(durs[0] > durs[1]);
|
||
// a long+short pair is one beat (within integer-division rounding)
|
||
assert!((durs[0] + durs[1] - beat).abs() <= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn polymeter_five_over_four() {
|
||
let t = parse("t100;kick:4=Xxxx;claves:5=Xxxxx~");
|
||
let mbar = master_bar_ns(&t);
|
||
assert_eq!(mbar, 2_400_000_000); // 4 beats at 100 bpm
|
||
|
||
// claves (lane 1) is poly: 5 steps spread evenly across the master bar
|
||
assert_eq!(lane_durs(&t.lanes[1], 100, mbar), vec![480_000_000; 5]);
|
||
|
||
let claves: Vec<i64> = render(&t, 1).into_iter().filter(|c| c.lane == 1).map(|c| c.time_ns).collect();
|
||
assert_eq!(claves, vec![0, 480_000_000, 960_000_000, 1_440_000_000, 1_920_000_000]);
|
||
}
|
||
|
||
#[test]
|
||
fn accents_and_ghosts_carry_through() {
|
||
let t = parse("t120;snare:4/3=..gg.gX.gg.g"); // ghosts (3) + an accent (2)
|
||
let cl = render(&t, 1);
|
||
assert!(cl.iter().any(|c| c.level == 3)); // ghost present
|
||
assert!(cl.iter().any(|c| c.level == 2)); // accent present
|
||
}
|
||
|
||
#[test]
|
||
fn muted_lane_is_silent() {
|
||
let t = parse("t120;kick:4=Xxxx;snare:4=Xxxx!");
|
||
let cl = render(&t, 1);
|
||
assert!(cl.iter().all(|c| c.lane == 0)); // the muted snare produces no clicks
|
||
}
|
||
|
||
#[test]
|
||
fn loops_over_multiple_bars() {
|
||
let t = parse("t120;kick:4=Xxxx");
|
||
assert_eq!(render(&t, 2).len(), 8); // 4 quarter-note clicks per bar × 2 bars
|
||
}
|