pm-grid: live-sync over USB-MIDI SysEx (editor <-> device)

Port pico-scroll's live-sync to Rust (docs/livesync-protocol.md):
- Reassemble SysEx from incoming USB-MIDI 4-byte packets (by Code Index Number);
  dispatch manufacturer 0x7D frames.
- Version query 0x02 -> 0x03 'G;0.1.0' (editor now identifies the device).
- HELLO 0x40 -> reply FULL; FULL 0x41 -> parse patch + running and adopt it;
  DELTA 0x42 -> apply play/stop/bpm/sel/beat; BYE 0x43 -> disarm.
- Broadcast a DELTA from each on-device input (play/stop, sel, bpm) + a FULL
  heartbeat ~5s (track-format::serialize). Echo-guarded by a boot-derived origin;
  sync_applying flag suppresses re-broadcast while applying.
- Unify all USB-MIDI TX (notes + SysEx) onto one tx_q drained one-per-poll.
- defmt info! on every received op for probe debugging.

Structural lane= edits aren't applied incrementally (arrive as a fresh FULL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-03 15:40:23 -05:00
parent 47ffb46aa2
commit 7e2a3b181b
2 changed files with 282 additions and 22 deletions

View file

@ -182,10 +182,19 @@ raw 4-byte packets, sidestepping the named-`Note` enum so arbitrary GM drum note
every loop iteration **and during the boot splash** (1.5 ms cadence) so the host can enumerate. Play every loop iteration **and during the boot splash** (1.5 ms cadence) so the host can enumerate. Play
through the editor's **Device audio**. through the editor's **Device audio**.
**Still deferred**: MIDI clock in/out, **live-sync SysEx** (0x40-0x43 + version query), firmware push **Live-sync — ✅ DONE** (`docs/livesync-protocol.md`, ported from `pico-scroll`): reads the USB-MIDI
(0x10/0x21-0x23), on-device practice log, settings.json, playback-flow auto-advance (`rep`/`end`/ RX endpoint, reassembles SysEx from the 4-byte event packets (by Code Index Number), and dispatches
continue), optional piezo. Note: without the SysEx version query, the editor's firmware-push won't manufacturer `0x7D` frames. **Version query** `0x02`→`0x03 "G;0.1.0"` (so the editor identifies it).
target the Grid (intended — it's UF2-flashed now). **HELLO** `0x40`→reply FULL; **FULL** `0x41`→parse the patch (`track-format::parse`) + running and
adopt it; **DELTA** `0x42`→apply `play`/`stop`/`bpm`/`sel`/`beat`; **BYE** `0x43`→disarm. **Broadcasts**
a DELTA from each on-device input (A/B/X/Y → play/stop, sel, bpm) and a **FULL heartbeat every ~5 s**
(`track-format::serialize`). Echo-guarded by a boot-derived origin; an `sync_applying` flag stops
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.
### 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

@ -20,7 +20,7 @@
extern crate alloc; extern crate alloc;
use alloc::collections::VecDeque; use alloc::collections::VecDeque;
use alloc::string::String; use alloc::string::{String, ToString};
use alloc::vec::Vec; use alloc::vec::Vec;
use embedded_alloc::LlffHeap as Heap; use embedded_alloc::LlffHeap as Heap;
@ -47,6 +47,8 @@ pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
const XTAL_FREQ_HZ: u32 = 12_000_000; const XTAL_FREQ_HZ: u32 = 12_000_000;
const MATRIX_ADDR: u8 = 0x74; const MATRIX_ADDR: u8 = 0x74;
const DEVICE_ID: &str = "G"; // reported on the SysEx version query (0x02→0x03)
const APP_VERSION: &str = "0.1.0";
// Brightness ladder (matches pico-scroll/app.py BRIGHTNESS=160 with the same scaling). // Brightness ladder (matches pico-scroll/app.py BRIGHTNESS=160 with the same scaling).
const BRIGHTNESS: u8 = 160; // accent const BRIGHTNESS: u8 = 160; // accent
@ -252,6 +254,15 @@ struct App {
beatflash_off: i64, beatflash_off: i64,
bpm_flash_off: i64, // while >0 and active, Grid/Pendulum briefly show the Ticker so nudges are visible bpm_flash_off: i64, // while >0 and active, Grid/Pendulum briefly show the Ticker so nudges are visible
full_flash_off: i64, // strobe the WHOLE matrix bright on the downbeat ("the 1") full_flash_off: i64, // strobe the WHOLE matrix bright on the downbeat ("the 1")
// --- USB-MIDI TX queue (notes + SysEx, drained one-per-poll) + live-sync state ---
tx_q: VecDeque<[u8; 4]>, // outgoing USB-MIDI event packets
sx_buf: Vec<u8>, // incoming SysEx assembler (between 0xF0 and 0xF7)
sx_on: bool,
sync_origin: String, // our session id; we ignore frames stamped with it (echo guard)
sync_seq: u32,
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)
} }
fn master_bar_ns(track: &track_format::Track, tempo: i64) -> i64 { fn master_bar_ns(track: &track_format::Track, tempo: i64) -> i64 {
@ -308,6 +319,15 @@ impl App {
beatflash_off: 0, beatflash_off: 0,
bpm_flash_off: 0, bpm_flash_off: 0,
full_flash_off: 0, full_flash_off: 0,
tx_q: VecDeque::new(),
sx_buf: Vec::new(),
sx_on: false,
// origin derived from the boot timer (distinct from the editor's "e…"/"d…" ids)
sync_origin: alloc::format!("g{:06x}", (now_ns / 1000) as u32 & 0xff_ffff),
sync_seq: 0,
sync_armed: false,
sync_applying: false,
sync_hb_next: 0,
}; };
app.load(0, 0, now_ns); app.load(0, 0, now_ns);
app app
@ -351,10 +371,14 @@ impl App {
fn next_track(&mut self, now_ns: i64) { fn next_track(&mut self, now_ns: i64) {
let n = SETLISTS[self.sl].1.len(); let n = SETLISTS[self.sl].1.len();
self.load(self.sl, (self.item + 1) % n, now_ns); self.load(self.sl, (self.item + 1) % n, now_ns);
let sel = alloc::format!("sel={}/{}", self.sl, self.item);
self.sync_broadcast(&sel);
} }
fn next_setlist(&mut self, now_ns: i64) { fn next_setlist(&mut self, now_ns: i64) {
self.load((self.sl + 1) % SETLISTS.len(), 0, now_ns); self.load((self.sl + 1) % SETLISTS.len(), 0, now_ns);
let sel = alloc::format!("sel={}/{}", self.sl, self.item);
self.sync_broadcast(&sel);
} }
fn set_bpm(&mut self, v: i64, now_ns: i64) { fn set_bpm(&mut self, v: i64, now_ns: i64) {
@ -363,6 +387,8 @@ impl App {
self.tempo = v; self.tempo = v;
self.rebuild_durs(); self.rebuild_durs();
self.bpm_flash_off = now_ns + 700_000_000; self.bpm_flash_off = now_ns + 700_000_000;
let evt = alloc::format!("bpm={}", v);
self.sync_broadcast(&evt);
} }
} }
@ -371,6 +397,7 @@ impl App {
if self.playing { if self.playing {
self.reset_clock(now_ns); self.reset_clock(now_ns);
} }
self.sync_broadcast(if self.playing { "play" } else { "stop" });
} }
fn cycle_view(&mut self) { fn cycle_view(&mut self) {
@ -407,8 +434,8 @@ impl App {
} }
/// Advance the per-lane step clocks up to `now_ns`. Sets `beatflash` for the loudest hit and /// Advance the per-lane step clocks up to `now_ns`. Sets `beatflash` for the loudest hit and
/// calls `emit(note, velocity)` for every lane hit (the caller sends it over USB-MIDI). /// queues a USB-MIDI note-on (ch10) per lane hit onto `tx_q` (drained by the main loop).
fn tick<F: FnMut(u8, u8)>(&mut self, now_ns: i64, mut emit: F) { fn tick(&mut self, now_ns: i64) {
if !self.playing { if !self.playing {
return; return;
} }
@ -441,7 +468,9 @@ impl App {
}; };
if lvl > 0 && !self.muted { if lvl > 0 && !self.muted {
let note = gm_note(self.track.lanes[li].sound.as_str()); let note = gm_note(self.track.lanes[li].sound.as_str());
emit(note, midi_vel(lvl)); // → USB-MIDI note-on if self.tx_q.len() < 128 {
self.tx_q.push_back([0x09, 0x99, note, midi_vel(lvl)]); // note-on, ch10
}
if prio(lvl) > prio(fired_best) { if prio(lvl) > prio(fired_best) {
fired_best = lvl; fired_best = lvl;
} }
@ -461,6 +490,202 @@ impl App {
} }
} }
// ============================== LIVE-SYNC (USB-MIDI SysEx; docs/livesync-protocol.md) ==============================
impl App {
/// Feed one received MIDI byte into the SysEx assembler (called for the data bytes of incoming
/// USB-MIDI packets). On a complete frame (0xF0..0xF7) it dispatches.
fn feed_sx(&mut self, b: u8, now_ns: i64) {
if b == 0xF0 {
self.sx_buf.clear();
self.sx_on = true;
} else if b == 0xF7 {
if self.sx_on {
self.handle_sysex(now_ns);
}
self.sx_on = false;
} else if b >= 0xF8 {
// realtime (clock etc.) — ignored for now
} else if self.sx_on && self.sx_buf.len() < 2048 {
self.sx_buf.push(b);
}
}
/// Build `F0 7D <op> <text(ASCII-clamped)> F7`, packetize into 4-byte USB-MIDI events, queue them.
fn sx_send(&mut self, op: u8, text: &str) {
let mut f: Vec<u8> = Vec::with_capacity(text.len() + 4);
f.push(0xF0);
f.push(0x7D);
f.push(op);
for &c in text.as_bytes() {
f.push(if c < 0x80 { c } else { 0x3F });
}
f.push(0xF7);
let mut i = 0;
while f.len() - i > 3 {
self.tx_q.push_back([0x04, f[i], f[i + 1], f[i + 2]]); // SysEx start/continue
i += 3;
}
match f.len() - i {
1 => self.tx_q.push_back([0x05, f[i], 0, 0]),
2 => self.tx_q.push_back([0x06, f[i], f[i + 1], 0]),
3 => self.tx_q.push_back([0x07, f[i], f[i + 1], f[i + 2]]),
_ => {}
}
}
fn handle_sysex(&mut self, now_ns: i64) {
if self.sx_buf.len() < 2 || self.sx_buf[0] != 0x7D {
return;
}
let cmd = self.sx_buf[1];
if cmd == 0x02 {
// version query → reply 0x03 "G;<version>"
let reply = alloc::format!("{};{}", DEVICE_ID, APP_VERSION);
info!("sysex: version query → {}", reply.as_str());
self.sx_send(0x03, &reply);
return;
}
if !(0x40..=0x43).contains(&cmd) {
return;
}
// payload text (printable ASCII only)
let text: String = self.sx_buf[2..]
.iter()
.cloned()
.filter(|&b| (0x20..0x7F).contains(&b))
.map(|b| b as char)
.collect();
let origin = text.splitn(2, ';').next().unwrap_or("");
if origin == self.sync_origin.as_str() {
return; // ignore our own echo
}
self.sync_armed = true;
match cmd {
0x40 => {
info!("sysex: HELLO → FULL");
self.sync_broadcast_full(now_ns);
}
0x43 => {
info!("sysex: BYE");
self.sync_armed = false;
}
0x41 => {
// origin;seq;running;sl;item;patch (patch may contain ';' → splitn 6)
let mut it = text.splitn(6, ';');
let _ = it.next(); // origin
let _ = it.next(); // seq
let running = it.next() == Some("1");
let _ = it.next(); // sl
let _ = it.next(); // item
if let Some(patch) = it.next() {
let patch = patch.to_string();
info!("sysex: FULL running={} ({} chars)", running, patch.len());
self.apply_full(running, &patch, now_ns);
}
}
0x42 => {
// origin;seq;evt
let mut it = text.splitn(3, ';');
let _ = it.next();
let _ = it.next();
if let Some(evt) = it.next() {
let evt = evt.to_string();
info!("sysex: DELTA {}", evt.as_str());
self.apply_delta(&evt, now_ns);
}
}
_ => {}
}
}
/// DELTA broadcast (transport/tempo/track changes from on-device input).
fn sync_broadcast(&mut self, evt: &str) {
if !self.sync_armed || self.sync_applying {
return;
}
let text = alloc::format!("{};{};{}", self.sync_origin, self.sync_seq, evt);
self.sync_seq = self.sync_seq.wrapping_add(1);
self.sx_send(0x42, &text);
}
/// FULL broadcast: our whole program + transport (HELLO reply + ~5s heartbeat).
fn sync_broadcast_full(&mut self, now_ns: i64) {
if !self.sync_armed {
return;
}
self.track.bpm = self.tempo; // reflect the live tempo in the serialized patch
let patch = track_format::serialize(&self.track);
let text = alloc::format!(
"{};{};{};{};{};{}",
self.sync_origin,
self.sync_seq,
if self.playing { 1 } else { 0 },
self.sl,
self.item,
patch
);
self.sync_seq = self.sync_seq.wrapping_add(1);
self.sx_send(0x41, &text);
self.sync_hb_next = now_ns + 5_000_000_000;
}
fn apply_full(&mut self, running: bool, patch: &str, now_ns: i64) {
self.sync_applying = true;
self.track = track_format::parse(patch);
self.tempo = self.track.bpm;
self.ramp_base = self.tempo;
self.muted = false;
self.rebuild_durs();
self.reset_clock(now_ns);
self.playing = running;
self.sync_applying = false;
}
fn apply_delta(&mut self, evt: &str, now_ns: i64) {
self.sync_applying = true;
let (key, val) = evt.split_once('=').unwrap_or((evt, ""));
match key {
"play" => {
if !self.playing {
self.playing = true;
self.reset_clock(now_ns);
}
}
"stop" => self.playing = false,
"bpm" => {
if let Ok(v) = val.parse::<i64>() {
self.set_bpm(v, now_ns);
}
}
"sel" => {
let mut p = val.split('/');
if let (Some(a), Some(b)) = (p.next(), p.next()) {
if let (Ok(sl), Ok(item)) = (a.parse::<usize>(), b.parse::<usize>()) {
if sl < SETLISTS.len() {
self.load(sl, item, now_ns);
}
}
}
}
"beat" => {
// li/s/lvl — single-cell level edit
let mut p = val.split('/');
if let (Some(a), Some(b), Some(c)) = (p.next(), p.next(), p.next()) {
if let (Ok(li), Ok(s), Ok(lvl)) =
(a.parse::<usize>(), b.parse::<usize>(), c.parse::<u8>())
{
if li < self.track.lanes.len() && s < self.track.lanes[li].levels.len() {
self.track.lanes[li].levels[s] = lvl & 3;
}
}
}
}
_ => {} // structural lane= edits arrive as a fresh FULL; nothing to do here
}
self.sync_applying = false;
}
}
fn prio(level: u8) -> u8 { fn prio(level: u8) -> u8 {
match level { match level {
2 => 3, // accent 2 => 3, // accent
@ -782,16 +1007,40 @@ fn main() -> ! {
let (mut nextrep_x, mut nextrep_y) = (0i64, 0i64); let (mut nextrep_x, mut nextrep_y) = (0i64, 0i64);
let mut last_frame_us = 0i64; let mut last_frame_us = 0i64;
let mut hb_us = 0i64; let mut hb_us = 0i64;
// pending USB-MIDI packets: the bulk endpoint holds one 4-byte packet at a time, so simultaneous let mut rxbuf = [0u8; 64];
// notes are queued here and drained one-per-poll (otherwise the 2nd note of a chord is dropped).
let mut note_q: VecDeque<[u8; 4]> = VecDeque::new();
loop { loop {
let us = now_us(); let us = now_us();
let now_ns = us * 1000; let now_ns = us * 1000;
// ---- USB: poll every iteration to stay enumerated and flush MIDI ---- // ---- USB: poll, then drain the RX endpoint, feeding SysEx (live-sync) bytes ----
usb_dev.poll(&mut [&mut midi]); usb_dev.poll(&mut [&mut midi]);
while let Ok(n) = midi.read(&mut rxbuf) {
if n == 0 {
break;
}
let mut i = 0;
while i + 4 <= n {
// USB-MIDI event packet: low nibble = Code Index Number (how many data bytes)
match rxbuf[i] & 0x0F {
0x4 | 0x7 => {
app.feed_sx(rxbuf[i + 1], now_ns);
app.feed_sx(rxbuf[i + 2], now_ns);
app.feed_sx(rxbuf[i + 3], now_ns);
}
0x6 => {
app.feed_sx(rxbuf[i + 1], now_ns);
app.feed_sx(rxbuf[i + 2], now_ns);
}
0x5 => app.feed_sx(rxbuf[i + 1], now_ns),
_ => {} // channel-voice / realtime — not SysEx
}
i += 4;
}
if n < rxbuf.len() {
break;
}
}
// ---- inputs (active-low) ---- // ---- inputs (active-low) ----
let a = btn_a.is_low().unwrap_or(false); let a = btn_a.is_low().unwrap_or(false);
@ -846,17 +1095,19 @@ fn main() -> ! {
px = x; px = x;
py = y; py = y;
// ---- scheduler: advance clocks, queue a USB-MIDI note-on per lane hit (ch10) ---- // ---- scheduler: advance clocks (queues note-ons onto app.tx_q) ----
app.tick(now_ns, |note, vel| { app.tick(now_ns);
if note_q.len() < 64 {
note_q.push_back([0x09, 0x99, note, vel]); // cable 0, note-on, channel 10 // ---- live-sync FULL heartbeat (~5s while a peer is connected) ----
} if app.sync_armed && now_ns >= app.sync_hb_next {
}); app.sync_broadcast_full(now_ns);
// drain the queue to the endpoint: send until it's busy (WouldBlock), keep the rest for the }
// next poll. This is why chords play in full instead of dropping all but the first note.
while let Some(&pkt) = note_q.front() { // drain the USB-MIDI TX queue (notes + SysEx) to the endpoint: send until it's busy
// (WouldBlock), keep the rest for the next poll. This is why chords play in full.
while let Some(&pkt) = app.tx_q.front() {
if midi.send_bytes(pkt).is_ok() { if midi.send_bytes(pkt).is_ok() {
note_q.pop_front(); app.tx_q.pop_front();
} else { } else {
break; break;
} }