Rust port Stage 2: scheduler timing + no_std (builds for RP2350)

- 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>
This commit is contained in:
Me Here 2026-05-31 19:34:02 -05:00
parent adc92c7c02
commit c1601d9e46
4 changed files with 171 additions and 8 deletions

View file

@ -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`:

View file

@ -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));

View file

@ -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<i64> {
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<Click> {
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
}

View file

@ -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<_>>(), 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
}