From 36989c96de55e237accbaaaceae3a1f762606110 Mon Sep 17 00:00:00 2001 From: Me Here Date: Wed, 3 Jun 2026 15:50:02 -0500 Subject: [PATCH] pm-grid: playback-flow auto-advance (rep/end) + MIDI clock out Playback flow (rep/end), ported from pico-scroll: - At each master-bar boundary, after bars*rep cycles the end-action fires: end=stop stops; end=next / end=+N advances through the set list. - The next track is preloaded one bar early (parsed + per-lane durs) into a pending slot, then swapped at the exact seam (master lane bar boundary; all lanes restart there) for a gapless handoff. load()/manual nav clears pending. MIDI clock out (default on, so a DAW can slave to the Grid): - 24-PPQN 0xF8 against the wall clock + 0xFA/0xFC Start/Stop on play/stop (button or live-sync). Queued on tx_q as CIN 0xF single-byte packets. Deferred items needing persistent storage (no CIRCUITPY drive in the Rust build, needs a flash KV layer - separate milestone): practice log, settings.json, SLSYNC/LOGSYNC. Also deferred: MIDI clock in, optional piezo. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/rust-port.md | 17 +++- rust/pm-grid/src/main.rs | 172 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 182 insertions(+), 7 deletions(-) diff --git a/docs/rust-port.md b/docs/rust-port.md index 0fa4b6c..a4f82d3 100644 --- a/docs/rust-port.md +++ b/docs/rust-port.md @@ -192,9 +192,20 @@ a DELTA from each on-device input (A/B/X/Y → play/stop, sel, bpm) and a **FULL re-broadcast while applying. All TX (notes + SysEx) shares the one-per-poll `tx_q` drain. `info!` logs every received op. Structural `lane=` edits aren't applied incrementally (they arrive as a fresh FULL). -**Still deferred**: MIDI clock in/out, SLSYNC/LOGSYNC (`0x44`/`0x45` set-list + log merge), firmware -push (`0x10`/`0x21-0x23` — intended: the Grid is UF2-flashed now), on-device practice log, -settings.json, playback-flow auto-advance (`rep`/`end`/continue), optional piezo. +**Playback-flow auto-advance — ✅ DONE** (`rep`/`end`): at each master-bar boundary, after `bars*rep` +cycles the end-action fires — `end=stop` stops, `end=next`/`end=+N` advances. The next track is +**preloaded one bar early** (parsed + durs) into `pending`, then swapped at the exact seam +(`seam_ns` = the master lane's bar boundary; all lanes restart there) for a gapless handoff. A +`continue_on` flag (default off, no UI yet) would make a `bars` track with no `end=` auto-`next`. + +**MIDI clock out — ✅ DONE** (default on): 24-PPQN `0xF8` against the wall clock + `0xFA`/`0xFC` +Start/Stop on play/stop (button or live-sync), so a DAW can slave its tempo to the Grid. Queued on +`tx_q` like everything else (CIN `0xF` single-byte packets). + +**Still deferred** (these need persistent storage the Rust build doesn't have yet — there's no +`CIRCUITPY` drive; would need a flash KV layer, a separate milestone): on-device practice log, +`settings.json`, SLSYNC/LOGSYNC (`0x44`/`0x45` set-list + log merge). Also: **MIDI clock in** (slaving +the Grid to an external clock), firmware push (intended: UF2-flashed now), optional piezo. ### Stage 4 — native A/B + secure boot Replace the `.mpy`-level A/B hack (`code.py` loads `app.mpy`, rolls back to `app.bak`) with the diff --git a/rust/pm-grid/src/main.rs b/rust/pm-grid/src/main.rs index efd56c4..a5d0e98 100644 --- a/rust/pm-grid/src/main.rs +++ b/rust/pm-grid/src/main.rs @@ -33,6 +33,7 @@ use panic_probe as _; // prints the panic over defmt, then halts use rp2040_hal as hal; use rp2040_hal::fugit::RateExtU32; use rp2040_hal::Clock; +use track_format::End; use usb_device::prelude::*; use usb_device::bus::UsbBusAllocator; use usbd_midi::UsbMidiClass; @@ -229,6 +230,15 @@ enum View { Pendulum, } +/// A next track preloaded (parsed + durations computed) so the playback-flow advance is seamless. +struct Pending { + track: track_format::Track, + durs: Vec>, + item: usize, + name: String, + name_cols: Vec, +} + struct App { // current program track: track_format::Track, @@ -263,6 +273,14 @@ struct App { sync_armed: bool, // a peer (the editor) is connected sync_applying: bool, // suppress re-broadcast while applying a received frame sync_hb_next: i64, // next FULL heartbeat time (ns) + // --- playback flow (rep/end auto-advance) --- + pending: Option, // next track, preloaded one bar early + advance: bool, // set at a seam boundary → do_advance() after the lane loop + seam_ns: i64, // the exact bar-boundary time the next track starts at + continue_on: bool, // "continuous" mode: treat a bars-track with no end= as end=next + // --- MIDI clock out (24 PPQN, so a DAW can slave to the Grid) --- + clock_out: bool, + clock_next: i64, } fn master_bar_ns(track: &track_format::Track, tempo: i64) -> i64 { @@ -328,6 +346,12 @@ impl App { sync_armed: false, sync_applying: false, sync_hb_next: 0, + pending: None, + advance: false, + seam_ns: 0, + continue_on: false, + clock_out: true, + clock_next: 0, }; app.load(0, 0, now_ns); app @@ -349,6 +373,7 @@ impl App { self.step = alloc::vec![-1i32; n]; self.m_steps = 0; self.lastbar = -1; + self.clock_next = now_ns; } fn load(&mut self, sl: usize, item: usize, now_ns: i64) { @@ -364,6 +389,8 @@ impl App { self.tempo = self.track.bpm; self.ramp_base = self.tempo; self.muted = false; + self.pending = None; + self.advance = false; self.rebuild_durs(); self.reset_clock(now_ns); } @@ -396,6 +423,11 @@ impl App { self.playing = !self.playing; if self.playing { self.reset_clock(now_ns); + if self.clock_out { + self.tx_q.push_back([0x0F, 0xFA, 0, 0]); // MIDI Start + } + } else if self.clock_out { + self.tx_q.push_back([0x0F, 0xFC, 0, 0]); // MIDI Stop } self.sync_broadcast(if self.playing { "play" } else { "stop" }); } @@ -408,9 +440,41 @@ impl App { }; } - /// Ramp + gap-trainer at a master-bar boundary. Returns the new tempo if the ramp changed it - /// (applied by the caller after the lane loop, so we never mutate `durs` mid-iteration). + /// Ramp + gap-trainer + playback-flow at a master-bar boundary. Returns the new tempo if the + /// ramp changed it (applied by the caller after the lane loop, so we never mutate `durs` mid-tick). fn on_new_bar(&mut self, bar: i64) -> Option { + // playback flow: rep/end auto-advance (total = bars * rep cycles) + if let Some((total, action)) = self.end_plan() { + let goto_off = match action { + End::Goto(o) => Some(o), + End::Stop => None, + }; + // preload the next track one bar early so the swap is seamless + if let Some(o) = goto_off { + if self.pending.is_none() && bar == total - 1 { + let tgt = self.goto_target(o); + self.prepare_next(tgt); + } + } + if bar > 0 && bar == total { + match goto_off { + None => { + self.playing = false; + self.sync_broadcast("stop"); + } + Some(o) => { + if self.pending.is_none() { + let tgt = self.goto_target(o); + self.prepare_next(tgt); + } + if self.pending.is_some() { + self.seam_ns = self.next[0]; // the master lane's current bar boundary + self.advance = true; + } + } + } + } + } // gap-trainer mute if let Some(t) = &self.track.trainer { let cyc = t.play + t.mute; @@ -443,6 +507,9 @@ impl App { let mut fired_best = 0u8; let mut pending_tempo: Option = None; for li in 0..nlanes { + if self.advance { + break; + } let steps = self.durs[li].len().max(1) as i32; while now_ns >= self.next[li] { self.step[li] = (self.step[li] + 1) % steps; @@ -458,6 +525,9 @@ impl App { if let Some(t) = self.on_new_bar(bar) { pending_tempo = Some(t); } + if self.advance { + break; // seam reached — stop advancing this lane; do_advance() below + } } } let s = self.step[li] as usize; @@ -482,11 +552,24 @@ impl App { self.beatflash = fired_best; self.beatflash_off = now_ns + 70_000_000; } - if let Some(t) = pending_tempo { + if self.advance { + self.advance = false; + self.do_advance(); // swap to the preloaded next track at the seam + } else if let Some(t) = pending_tempo { // tempo change keeps step counts identical (only durations scale) → safe to swap durs self.tempo = t; self.rebuild_durs(); } + // MIDI clock out: emit 24-PPQN F8 ticks against the wall clock + if self.clock_out { + let tick_ns = ((NS_PER_MIN / self.tempo.max(1)) / 24).max(1); + while now_ns >= self.clock_next { + if self.tx_q.len() < 200 { + self.tx_q.push_back([0x0F, 0xF8, 0, 0]); // MIDI Clock + } + self.clock_next += tick_ns; + } + } } } @@ -649,9 +732,17 @@ impl App { if !self.playing { self.playing = true; self.reset_clock(now_ns); + if self.clock_out { + self.tx_q.push_back([0x0F, 0xFA, 0, 0]); // MIDI Start + } } } - "stop" => self.playing = false, + "stop" => { + if self.playing && self.clock_out { + self.tx_q.push_back([0x0F, 0xFC, 0, 0]); // MIDI Stop + } + self.playing = false; + } "bpm" => { if let Ok(v) = val.parse::() { self.set_bpm(v, now_ns); @@ -684,6 +775,79 @@ impl App { } self.sync_applying = false; } + + // ---------- playback flow (rep/end auto-advance) ---------- + /// `(total_bars, action)`: the end-action fires after `bars * rep` cycles. `None` = loop forever. + fn end_plan(&self) -> Option<(i64, End)> { + let end = match &self.track.end { + Some(e) => e.clone(), + None => { + if self.continue_on && self.track.bars > 0 { + End::Goto(1) + } else { + return None; + } + } + }; + let cyc = if self.track.bars > 0 { self.track.bars } else { 1 }; + let reps = self.track.rep.unwrap_or(1).max(1); + Some((cyc * reps, end)) + } + + /// Resolve a relative track offset into a set-list item index (clamps below 0, wraps above). + fn goto_target(&self, offset: i64) -> usize { + let n = SETLISTS[self.sl].1.len() as i64; + let t = self.item as i64 + offset; + (if t < 0 { + 0 + } else if t >= n { + t % n + } else { + t + }) as usize + } + + /// Preload `target` (parse + per-lane durations) so `do_advance` is just a pointer swap. + fn prepare_next(&mut self, target: usize) { + if target == self.item { + return; + } + let (name, prog) = SETLISTS[self.sl].1[target]; + let track = track_format::parse(prog); + let mbar = master_bar_ns(&track, track.bpm); + let durs = track + .lanes + .iter() + .map(|l| track_format::schedule::lane_durs(l, track.bpm, mbar)) + .collect(); + let nm = String::from(name); + let name_cols = build_name_cols(&nm); + self.pending = Some(Pending { track, durs, item: target, name: nm, name_cols }); + } + + /// Swap to the preloaded next track at the seam time (gapless: every lane restarts at `seam_ns`). + fn do_advance(&mut self) { + if let Some(p) = self.pending.take() { + let seam = self.seam_ns; + self.track = p.track; + self.tempo = self.track.bpm; + self.durs = p.durs; + self.item = p.item; + self.name = p.name; + self.name_cols = p.name_cols; + self.scroll_total = self.name_cols.len() as i32 + SCROLL_GAP; + self.ramp_base = self.tempo; + self.muted = false; + self.m_steps = 0; + self.lastbar = -1; + let n = self.track.lanes.len(); + self.next = alloc::vec![seam; n]; + self.step = alloc::vec![-1i32; n]; + self.clock_next = seam; + let sel = alloc::format!("sel={}/{}", self.sl, self.item); + self.sync_broadcast(&sel); + } + } } fn prio(level: u8) -> u8 {