metronome/rust/track-format/src/lib.rs
Me Here cb54b4d689 Preserve notation + grammar feature work (verified complete + green)
The parallel agent's full session, committed now that it's solo:
- Grammar: flam/drag/roll ornaments (f/F d/D z/Z, per-lane orns channel) across
  src/engine.js, pico-cp/pico-explorer/pico-scroll app.py, pico/main.py, rust/track-format,
  + golden vectors / conformance (tests/, rust/track-format/tests).
- Live-sync deep-sync: SysEx 0x44 SLSYNC + 0x45 LOGSYNC (docs/livesync-protocol.md, src/livesync.js).
- PM_E-2 notation: web engine (pm_e-2.html, build/deploy/index/embed wiring) + Rust device port
  (pm-ui draw_notation rewrite + LaneView.groups, pm-kit ViewMode, uisim notesim).

Verified: node tests/run.mjs 47 pass / 1 known; ./rust/run.sh green; pm-kit firmware + uisim compile.
2026-06-02 13:45:26 -05:00

419 lines
12 KiB
Rust

//! PM track-format codec — Stage 1 of the Rust port (see `docs/rust-port.md`).
//!
//! Pure parse/serialize for the track DSL, validated against
//! `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.
//!
//! `no_std` + `alloc`: builds for the RP2350 firmware target
//! (`thumbv8m.main-none-eabihf`) as well as on the host for tests.
#![no_std]
#[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 {
pub sound: String,
pub groups: Vec<u32>,
pub sub: u32,
pub swing: bool,
pub poly: bool,
pub mute: bool,
pub gain_db: f64,
pub levels: Vec<u8>,
/// per-step ornament parallel to `levels`: 0 none / 1 flam / 2 drag / 3 roll.
pub orns: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct Ramp {
pub start: i64,
pub amt: i64,
pub every: i64,
}
#[derive(Debug, Clone)]
pub struct Trainer {
pub play: i64,
pub mute: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum End {
Stop,
Goto(i64), // relative track offset; +1 == "next"
}
#[derive(Debug, Clone)]
pub struct Track {
pub bpm: i64,
pub bars: i64,
pub volume: Option<f64>,
pub count_ms: i64,
pub ramp: Option<Ramp>,
pub trainer: Option<Trainer>,
pub rep: Option<i64>,
pub end: Option<End>,
pub lanes: Vec<Lane>,
}
/// General-MIDI percussion note number -> voice name (matches GM_NUM in engine.js / app.py).
fn gm_num(n: u32) -> Option<&'static str> {
Some(match n {
35 | 36 => "kick",
37 => "rim",
38 | 40 => "snare",
39 => "clap",
41 | 43 => "tomLow",
42 | 44 => "hatClosed",
45 | 47 => "tomMid",
46 => "hatOpen",
48 | 50 => "tomHigh",
49 => "crash",
51 | 53 => "ride",
54 => "tambourine",
56 => "cowbell",
75 => "claves",
76 | 77 => "woodblock",
_ => return None,
})
}
/// Known voices (matches SOUND_GM keys in app.py); anything else falls back to `beep`.
fn known_sound(s: &str) -> bool {
matches!(
s,
"kick" | "kick808" | "kick909" | "snare" | "snare808" | "snare909"
| "clap" | "clap808" | "clap909" | "rim" | "hatClosed" | "hat808" | "hat909"
| "hatOpen" | "openHat808" | "ride" | "ride909" | "crash" | "crash909"
| "tomLow" | "tom808" | "tomMid" | "tomHigh" | "tambourine" | "cowbell"
| "cowbell808" | "woodblock" | "jamblock" | "claves" | "beep"
)
}
/// Euclidean distribution: k hits over n steps, rotated by rot (matches euclid() in engine.js).
fn euclid(k: i64, n: i64, rot: i64) -> Vec<u8> {
let n = n.max(1);
let k = k.clamp(0, n);
let rot = ((rot % n) + n) % n;
(0..n)
.map(|i| {
let j = (i + rot) % n;
if (j * k) % n < k { 1 } else { 0 }
})
.collect()
}
/// Pattern char -> (level, ornament). Ornament letters: UPPER = accented, lower = normal
/// (the case carries the dynamic, so dynamics stay orthogonal): f/F flam, d/D drag, z/Z roll.
fn cell(c: char) -> (u8, u8) {
match c {
'X' => (2, 0),
'x' | '1' => (1, 0),
'g' => (3, 0),
'f' => (1, 1),
'F' => (2, 1),
'd' => (1, 2),
'D' => (2, 2),
'z' => (1, 3),
'Z' => (2, 3),
_ => (0, 0),
}
}
/// (level, ornament) -> pattern char (inverse of `cell`).
fn cell_ch(v: u8, o: u8) -> char {
match o {
1 => if v >= 2 { 'F' } else { 'f' },
2 => if v >= 2 { 'D' } else { 'd' },
3 => if v >= 2 { 'Z' } else { 'z' },
_ => match v {
2 => 'X',
1 => 'x',
3 => 'g',
_ => '.',
},
}
}
fn parse_lane(tok: &str) -> Lane {
let poly = tok.contains('~');
let mute = tok.contains('!');
let cleaned: String = tok.chars().filter(|&c| c != '~' && c != '!').collect();
let mut gain_db = 0.0;
let body = if let Some(at) = cleaned.find('@') {
gain_db = cleaned[at + 1..].parse::<f64>().unwrap_or(0.0);
cleaned[..at].to_string()
} else {
cleaned
};
let (sound0, rest0) = match body.find(':') {
Some(i) => (body[..i].to_string(), body[i + 1..].to_string()),
None => (body.clone(), String::new()),
};
let mut sound = sound0;
if let Ok(n) = sound.parse::<u32>() {
if let Some(name) = gm_num(n) {
sound = name.to_string();
}
}
let mut rest = rest0;
// euclid (k[,n[,rot]]) shorthand — pulled before the =/ splits
let mut euc: Option<Vec<i64>> = None;
if let Some(lp) = rest.find('(') {
if let Some(rel) = rest[lp..].find(')') {
let rp = lp + rel;
let nums: Vec<i64> = rest[lp + 1..rp]
.split(',')
.filter_map(|x| x.trim().parse::<i64>().ok())
.collect();
rest = format!("{}{}", &rest[..lp], &rest[rp + 1..]);
if !nums.is_empty() {
euc = Some(nums);
}
}
}
let mut pattern: Option<String> = None;
if let Some(eq) = rest.find('=') {
pattern = Some(rest[eq + 1..].to_string());
rest = rest[..eq].to_string();
}
let mut sub: u32 = 1;
let mut swing = false;
if let Some(sl) = rest.find('/') {
let sd = &rest[sl + 1..];
swing = sd.ends_with('s');
sub = sd.trim_end_matches('s').parse::<u32>().unwrap_or(1);
rest = rest[..sl].to_string();
}
let mut groups: Vec<u32> = rest.split('+').filter_map(|g| g.parse::<u32>().ok()).collect();
if groups.is_empty() {
groups = vec![4];
}
let beats: u32 = groups.iter().sum();
let mut starts = BTreeSet::new();
let mut acc = 0u32;
for &g in &groups {
starts.insert(acc);
acc += g;
}
let (levels, orns): (Vec<u8>, Vec<u8>) = if let Some(e) = euc {
// euclidean: k hits over n steps, first hit accented (no ornaments)
let k = e[0];
let n = if e.len() > 1 { e[1] } else { (beats * sub) as i64 };
let rot = if e.len() > 2 { e[2] } else { 0 };
if e.len() > 1 {
if n % (beats as i64) == 0 {
sub = (n / beats as i64) as u32;
} else {
groups = vec![n as u32];
sub = 1;
}
}
let mut first = true;
let lv: Vec<u8> = euclid(k, n, rot)
.into_iter()
.map(|h| {
if h != 0 {
let v = if first { 2 } else { 1 };
first = false;
v
} else {
0
}
})
.collect();
let orn = vec![0u8; lv.len()];
(lv, orn)
} else if let Some(p) = pattern {
let steps = (beats * sub) as usize;
let cells: Vec<(u8, u8)> = p.chars().map(cell).collect();
let mut lv: Vec<u8> = cells.iter().map(|c| c.0).collect();
let mut orn: Vec<u8> = cells.iter().map(|c| c.1).collect();
if lv.len() < steps {
lv.resize(steps, 0);
orn.resize(steps, 0);
}
(lv, orn)
} else {
// default: every subdivision sounds at normal, accent only on group starts
let steps = beats * sub;
let lv: Vec<u8> = (0..steps)
.map(|i| {
if i % sub == 0 {
if starts.contains(&(i / sub)) { 2 } else { 1 }
} else {
1
}
})
.collect();
let orn = vec![0u8; lv.len()];
(lv, orn)
};
if !known_sound(&sound) {
sound = "beep".to_string();
}
Lane { sound, groups, sub, swing, poly, mute, gain_db, levels, orns }
}
pub fn parse(s: &str) -> Track {
let mut bpm = 120i64;
let mut bars = 0i64;
let mut ramp = None;
let mut trainer = None;
let mut rep: Option<i64> = None;
let mut end: Option<End> = None;
let mut volume: Option<f64> = None;
let mut count_ms = 0i64;
let mut lanes = Vec::new();
for raw in s.trim().split(';') {
let tok = raw.trim();
if tok.is_empty() {
continue;
}
if tok.len() > 1 && tok.starts_with('t') && tok[1..].bytes().all(|b| b.is_ascii_digit()) {
bpm = tok[1..].parse().unwrap_or(120);
continue;
}
if tok.len() > 1 && tok.starts_with('b') && tok[1..].bytes().all(|b| b.is_ascii_digit()) {
bars = tok[1..].parse().unwrap_or(0);
continue;
}
if tok.starts_with("rmp") {
let p: Vec<&str> = tok[3..].split('/').collect();
if p.len() == 3 {
if let (Ok(a), Ok(b), Ok(c)) =
(p[0].parse::<i64>(), p[1].parse::<i64>(), p[2].parse::<i64>())
{
ramp = Some(Ramp { start: a, amt: b, every: c.max(1) });
}
}
continue;
}
if tok.starts_with("tr") && tok.contains('/') && !tok.contains(':') {
let p: Vec<&str> = tok[2..].split('/').collect();
if p.len() == 2 {
if let (Ok(a), Ok(b)) = (p[0].parse::<i64>(), p[1].parse::<i64>()) {
trainer = Some(Trainer { play: a.max(0), mute: b.max(0) });
}
}
continue;
}
if tok.starts_with("vol") {
volume = Some(tok[3..].parse::<i64>().unwrap_or(0) as f64 / 100.0);
continue;
}
if tok.starts_with("cd") {
count_ms = tok[2..].parse::<i64>().unwrap_or(0) * 1000;
continue;
}
if tok.starts_with("rep=") {
rep = Some(tok[4..].parse::<i64>().unwrap_or(1).max(1));
continue;
}
if tok.starts_with("end=") {
let v = &tok[4..];
end = Some(if v == "stop" {
End::Stop
} else if v == "next" {
End::Goto(1)
} else {
End::Goto(v.parse::<i64>().unwrap_or(0))
});
continue;
}
if !tok.contains(':') {
continue;
}
lanes.push(parse_lane(tok));
}
if lanes.is_empty() {
lanes.push(parse_lane("beep:4"));
}
Track {
bpm: bpm.clamp(5, 300),
bars,
volume,
count_ms,
ramp,
trainer,
rep,
end,
lanes,
}
}
fn lane_to_str(l: &Lane) -> String {
let mut s = format!(
"{}:{}",
l.sound,
l.groups.iter().map(|g| g.to_string()).collect::<Vec<_>>().join("+")
);
if l.sub != 1 || l.swing {
s.push_str(&format!("/{}{}", l.sub, if l.swing { "s" } else { "" }));
}
s.push('=');
s.extend(l.levels.iter().enumerate().map(|(i, &v)| cell_ch(v, l.orns.get(i).copied().unwrap_or(0))));
if l.gain_db != 0.0 {
s.push_str(&format!("@{}", l.gain_db));
}
if l.poly {
s.push('~');
}
if l.mute {
s.push('!');
}
s
}
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 + 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));
}
if t.bars != 0 {
parts.push(format!("b{}", t.bars));
}
if let Some(r) = &t.ramp {
parts.push(format!("rmp{}/{}/{}", r.start, r.amt, r.every));
}
if let Some(tr) = &t.trainer {
parts.push(format!("tr{}/{}", tr.play, tr.mute));
}
for l in &t.lanes {
parts.push(lane_to_str(l));
}
if let Some(e) = &t.end {
if let Some(rep) = t.rep {
if rep > 1 {
parts.push(format!("rep={}", rep));
}
}
parts.push(match e {
End::Stop => "end=stop".to_string(),
End::Goto(1) => "end=next".to_string(),
End::Goto(n) if *n > 0 => format!("end=+{}", n),
End::Goto(n) => format!("end={}", n),
});
}
parts.join(";")
}