metronome/rust/track-format/src/schedule.rs
Me Here c1601d9e46 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>
2026-05-31 19:34:02 -05:00

80 lines
2.7 KiB
Rust

//! 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
}