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) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-03 15:50:02 -05:00
parent 7e2a3b181b
commit 36989c96de
2 changed files with 182 additions and 7 deletions

View file

@ -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 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). 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 **Playback-flow auto-advance — ✅ DONE** (`rep`/`end`): at each master-bar boundary, after `bars*rep`
push (`0x10`/`0x21-0x23` — intended: the Grid is UF2-flashed now), on-device practice log, cycles the end-action fires — `end=stop` stops, `end=next`/`end=+N` advances. The next track is
settings.json, playback-flow auto-advance (`rep`/`end`/continue), optional piezo. **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 ### 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 Replace the `.mpy`-level A/B hack (`code.py` loads `app.mpy`, rolls back to `app.bak`) with the

View file

@ -33,6 +33,7 @@ use panic_probe as _; // prints the panic over defmt, then halts
use rp2040_hal as hal; use rp2040_hal as hal;
use rp2040_hal::fugit::RateExtU32; use rp2040_hal::fugit::RateExtU32;
use rp2040_hal::Clock; use rp2040_hal::Clock;
use track_format::End;
use usb_device::prelude::*; use usb_device::prelude::*;
use usb_device::bus::UsbBusAllocator; use usb_device::bus::UsbBusAllocator;
use usbd_midi::UsbMidiClass; use usbd_midi::UsbMidiClass;
@ -229,6 +230,15 @@ enum View {
Pendulum, Pendulum,
} }
/// A next track preloaded (parsed + durations computed) so the playback-flow advance is seamless.
struct Pending {
track: track_format::Track,
durs: Vec<Vec<i64>>,
item: usize,
name: String,
name_cols: Vec<u8>,
}
struct App { struct App {
// current program // current program
track: track_format::Track, track: track_format::Track,
@ -263,6 +273,14 @@ struct App {
sync_armed: bool, // a peer (the editor) is connected sync_armed: bool, // a peer (the editor) is connected
sync_applying: bool, // suppress re-broadcast while applying a received frame sync_applying: bool, // suppress re-broadcast while applying a received frame
sync_hb_next: i64, // next FULL heartbeat time (ns) sync_hb_next: i64, // next FULL heartbeat time (ns)
// --- playback flow (rep/end auto-advance) ---
pending: Option<Pending>, // 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 { fn master_bar_ns(track: &track_format::Track, tempo: i64) -> i64 {
@ -328,6 +346,12 @@ impl App {
sync_armed: false, sync_armed: false,
sync_applying: false, sync_applying: false,
sync_hb_next: 0, 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.load(0, 0, now_ns);
app app
@ -349,6 +373,7 @@ impl App {
self.step = alloc::vec![-1i32; n]; self.step = alloc::vec![-1i32; n];
self.m_steps = 0; self.m_steps = 0;
self.lastbar = -1; self.lastbar = -1;
self.clock_next = now_ns;
} }
fn load(&mut self, sl: usize, item: usize, now_ns: i64) { fn load(&mut self, sl: usize, item: usize, now_ns: i64) {
@ -364,6 +389,8 @@ impl App {
self.tempo = self.track.bpm; self.tempo = self.track.bpm;
self.ramp_base = self.tempo; self.ramp_base = self.tempo;
self.muted = false; self.muted = false;
self.pending = None;
self.advance = false;
self.rebuild_durs(); self.rebuild_durs();
self.reset_clock(now_ns); self.reset_clock(now_ns);
} }
@ -396,6 +423,11 @@ impl App {
self.playing = !self.playing; self.playing = !self.playing;
if self.playing { if self.playing {
self.reset_clock(now_ns); 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" }); 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 /// Ramp + gap-trainer + playback-flow at a master-bar boundary. Returns the new tempo if the
/// (applied by the caller after the lane loop, so we never mutate `durs` mid-iteration). /// 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<i64> { fn on_new_bar(&mut self, bar: i64) -> Option<i64> {
// 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 // gap-trainer mute
if let Some(t) = &self.track.trainer { if let Some(t) = &self.track.trainer {
let cyc = t.play + t.mute; let cyc = t.play + t.mute;
@ -443,6 +507,9 @@ impl App {
let mut fired_best = 0u8; let mut fired_best = 0u8;
let mut pending_tempo: Option<i64> = None; let mut pending_tempo: Option<i64> = None;
for li in 0..nlanes { for li in 0..nlanes {
if self.advance {
break;
}
let steps = self.durs[li].len().max(1) as i32; let steps = self.durs[li].len().max(1) as i32;
while now_ns >= self.next[li] { while now_ns >= self.next[li] {
self.step[li] = (self.step[li] + 1) % steps; self.step[li] = (self.step[li] + 1) % steps;
@ -458,6 +525,9 @@ impl App {
if let Some(t) = self.on_new_bar(bar) { if let Some(t) = self.on_new_bar(bar) {
pending_tempo = Some(t); pending_tempo = Some(t);
} }
if self.advance {
break; // seam reached — stop advancing this lane; do_advance() below
}
} }
} }
let s = self.step[li] as usize; let s = self.step[li] as usize;
@ -482,11 +552,24 @@ impl App {
self.beatflash = fired_best; self.beatflash = fired_best;
self.beatflash_off = now_ns + 70_000_000; 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 // tempo change keeps step counts identical (only durations scale) → safe to swap durs
self.tempo = t; self.tempo = t;
self.rebuild_durs(); 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 { if !self.playing {
self.playing = true; self.playing = true;
self.reset_clock(now_ns); 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" => { "bpm" => {
if let Ok(v) = val.parse::<i64>() { if let Ok(v) = val.parse::<i64>() {
self.set_bpm(v, now_ns); self.set_bpm(v, now_ns);
@ -684,6 +775,79 @@ impl App {
} }
self.sync_applying = false; 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 { fn prio(level: u8) -> u8 {