diff --git a/docs/rust-port.md b/docs/rust-port.md index e3fb3c1..0953e54 100644 --- a/docs/rust-port.md +++ b/docs/rust-port.md @@ -36,10 +36,15 @@ polymeter, and the playback-flow tokens. No hardware, fully testable in the cont This is the highest-value slice: small, gated by work already done, and it proves the toolchain. -### Stage 2 — scheduler/engine -Port the look-ahead step scheduler. `engine.js` already marks these primitives -`PORTS TO FIRMWARE`, and `app.py`'s `tick()` is the same model. Host-test it by asserting click -*timings* for known patches (e.g. swing ratios, polymeter bar lengths) — still no hardware. +### Stage 2 — scheduler/engine ✅ DONE (`rust/track-format/src/schedule.rs`) +Ported the look-ahead step scheduler (the `durs` math from `app.py` `tick`/`_prepare_next`). +`render(track, bars)` produces the deterministic click timeline; `tests/schedule.rs` asserts the +timings — quarter-note spacing, subdivisions, swing 2/3:1/3, polymeter 5:4, accents/ghosts, mute, +multi-bar looping. All green on the host, no hardware. The real-time firmware loop will just play +this timeline against the wall clock. + +**Also done:** the crate is now `#![no_std]` + `alloc` and **builds for the RP2350 target** +(`cargo build --lib --target thumbv8m.main-none-eabihf`) — the codec + scheduler are firmware-ready. ### Stage 3 — drivers (hardware) On `embassy` / `rp-hal`: diff --git a/rust/track-format/src/lib.rs b/rust/track-format/src/lib.rs index 7ba3165..7f3adc3 100644 --- a/rust/track-format/src/lib.rs +++ b/rust/track-format/src/lib.rs @@ -4,10 +4,18 @@ //! `tests/fixtures/track-format.json` — the same golden vectors `engine.js` and //! `app.py` pass. This is the third implementation; it must agree with them. //! -//! Uses `std` (String/Vec/BTreeSet) for now. A `no_std` + `alloc` version is a later -//! refinement (swap the collections for `alloc` equivalents); the logic is unchanged. +//! `no_std` + `alloc`: builds for the RP2350 firmware target +//! (`thumbv8m.main-none-eabihf`) as well as on the host for tests. +#![no_std] -use std::collections::BTreeSet; +#[macro_use] +extern crate alloc; + +use alloc::collections::BTreeSet; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +pub mod schedule; #[derive(Debug, Clone)] pub struct Lane { @@ -350,7 +358,7 @@ fn lane_to_str(l: &Lane) -> String { pub fn serialize(t: &Track) -> String { let mut parts = vec![format!("t{}", t.bpm)]; if let Some(v) = t.volume { - parts.push(format!("vol{}", (v * 100.0).round() as i64)); + parts.push(format!("vol{}", (v * 100.0 + 0.5) as i64)); // no_std-safe round (volume is 0..1) } if t.count_ms > 0 { parts.push(format!("cd{}", t.count_ms / 1000)); diff --git a/rust/track-format/src/schedule.rs b/rust/track-format/src/schedule.rs new file mode 100644 index 0000000..600c60c --- /dev/null +++ b/rust/track-format/src/schedule.rs @@ -0,0 +1,80 @@ +//! Look-ahead scheduler timing — Stage 2 of the Rust port (see `docs/rust-port.md`). +//! +//! Deterministic: turns a parsed `Track` + bpm into the click timeline. This is the same +//! `durs` math the firmware uses (`app.py` `_prepare_next` / `tick`) and the engine.js +//! scheduler primitives marked "PORTS TO FIRMWARE" — the real-time firmware loop just +//! plays this timeline against the wall clock. Host-testable because the *timing grid* +//! is pure. + +use crate::{Lane, Track}; +use alloc::vec::Vec; + +const NS_PER_MIN: i64 = 60_000_000_000; + +#[derive(Debug, Clone, PartialEq)] +pub struct Click { + pub time_ns: i64, + pub lane: usize, + pub level: u8, +} + +/// The master bar length in ns — defined by the first lane (beats = steps / sub). +pub fn master_bar_ns(track: &Track) -> i64 { + let beat = NS_PER_MIN / track.bpm.max(1); + let m = &track.lanes[0]; + let beats = (m.levels.len().max(1) / m.sub.max(1) as usize) as i64; + beat * beats.max(1) +} + +/// Per-step durations (ns) for a lane. Mirrors the firmware exactly: +/// - poly → the lane's steps are spread evenly across the master bar (true polymeter) +/// - swing → even subdivisions split 2/3 (on-beat) + 1/3 (off-beat) +/// - else → uniform beat/sub +pub fn lane_durs(lane: &Lane, bpm: i64, master_bar_ns: i64) -> Vec { + let beat = NS_PER_MIN / bpm.max(1); + let sub = lane.sub.max(1) as i64; + let steps = lane.levels.len().max(1) as i64; + if lane.poly { + let d = master_bar_ns / steps; + vec![d; steps as usize] + } else if lane.swing && sub % 2 == 0 { + let pair = beat / (sub / 2).max(1); + let lng = pair * 2 / 3; + let sht = pair / 3; + (0..steps) + .map(|s| if (s % sub) % 2 == 0 { lng } else { sht }) + .collect() + } else { + let d = beat / sub; + vec![d; steps as usize] + } +} + +/// Render the deterministic click timeline over `bars` master bars. +/// Every lane starts together at t=0 and loops; muted lanes and rests (level 0) are dropped. +pub fn render(track: &Track, bars: i64) -> Vec { + let mbar = master_bar_ns(track); + let total = mbar * bars.max(1); + let mut out = Vec::new(); + for (li, lane) in track.lanes.iter().enumerate() { + if lane.mute { + continue; + } + let durs = lane_durs(lane, track.bpm, mbar); + if durs.iter().all(|&d| d <= 0) { + continue; + } + let mut t = 0i64; + let mut s = 0usize; + while t < total { + let lvl = lane.levels[s]; + if lvl > 0 { + out.push(Click { time_ns: t, lane: li, level: lvl }); + } + t += durs[s].max(1); + s = (s + 1) % durs.len(); + } + } + out.sort_by_key(|c| (c.time_ns, c.lane)); + out +} diff --git a/rust/track-format/tests/schedule.rs b/rust/track-format/tests/schedule.rs new file mode 100644 index 0000000..c41f724 --- /dev/null +++ b/rust/track-format/tests/schedule.rs @@ -0,0 +1,70 @@ +//! 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![0, S / 2, S, 3 * S / 2]); + assert_eq!(cl.iter().map(|c| c.level).collect::>(), 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![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 = 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 +}