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:
parent
adc92c7c02
commit
c1601d9e46
4 changed files with 171 additions and 8 deletions
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
80
rust/track-format/src/schedule.rs
Normal file
80
rust/track-format/src/schedule.rs
Normal 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
|
||||
}
|
||||
70
rust/track-format/tests/schedule.rs
Normal file
70
rust/track-format/tests/schedule.rs
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue