//! 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, pub sub: u32, pub swing: bool, pub poly: bool, pub mute: bool, pub gain_db: f64, pub levels: Vec, /// per-step ornament parallel to `levels`: 0 none / 1 flam / 2 drag / 3 roll. pub orns: Vec, } #[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, pub count_ms: i64, pub ramp: Option, pub trainer: Option, pub rep: Option, pub end: Option, pub lanes: Vec, } /// 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 { 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::().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::() { 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> = None; if let Some(lp) = rest.find('(') { if let Some(rel) = rest[lp..].find(')') { let rp = lp + rel; let nums: Vec = rest[lp + 1..rp] .split(',') .filter_map(|x| x.trim().parse::().ok()) .collect(); rest = format!("{}{}", &rest[..lp], &rest[rp + 1..]); if !nums.is_empty() { euc = Some(nums); } } } let mut pattern: Option = 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::().unwrap_or(1); rest = rest[..sl].to_string(); } let mut groups: Vec = rest.split('+').filter_map(|g| g.parse::().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, Vec) = 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 = 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 = cells.iter().map(|c| c.0).collect(); let mut orn: Vec = 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 = (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 = None; let mut end: Option = None; let mut volume: Option = 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::(), p[1].parse::(), p[2].parse::()) { 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::(), p[1].parse::()) { trainer = Some(Trainer { play: a.max(0), mute: b.max(0) }); } } continue; } if tok.starts_with("vol") { volume = Some(tok[3..].parse::().unwrap_or(0) as f64 / 100.0); continue; } if tok.starts_with("cd") { count_ms = tok[2..].parse::().unwrap_or(0) * 1000; continue; } if tok.starts_with("rep=") { rep = Some(tok[4..].parse::().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::().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::>().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(";") }