- 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>
80 lines
2.7 KiB
Rust
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
|
|
}
|