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:
parent
7e2a3b181b
commit
36989c96de
2 changed files with 182 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Vec<i64>>,
|
||||
item: usize,
|
||||
name: String,
|
||||
name_cols: Vec<u8>,
|
||||
}
|
||||
|
||||
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<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 {
|
||||
|
|
@ -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<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
|
||||
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<i64> = 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::<i64>() {
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue