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:
parent
47ffb46aa2
commit
7e2a3b181b
2 changed files with 282 additions and 22 deletions
|
|
@ -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
|
||||
through the editor's **Device audio**.
|
||||
|
||||
**Still deferred**: MIDI clock in/out, **live-sync SysEx** (0x40-0x43 + version query), firmware push
|
||||
(0x10/0x21-0x23), on-device practice log, settings.json, playback-flow auto-advance (`rep`/`end`/
|
||||
continue), optional piezo. Note: without the SysEx version query, the editor's firmware-push won't
|
||||
target the Grid (intended — it's UF2-flashed now).
|
||||
**Live-sync — ✅ DONE** (`docs/livesync-protocol.md`, ported from `pico-scroll`): reads the USB-MIDI
|
||||
RX endpoint, reassembles SysEx from the 4-byte event packets (by Code Index Number), and dispatches
|
||||
manufacturer `0x7D` frames. **Version query** `0x02`→`0x03 "G;0.1.0"` (so the editor identifies it).
|
||||
**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
|
||||
Replace the `.mpy`-level A/B hack (`code.py` loads `app.mpy`, rolls back to `app.bak`) with the
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
extern crate alloc;
|
||||
use alloc::collections::VecDeque;
|
||||
use alloc::string::String;
|
||||
use alloc::string::{String, ToString};
|
||||
use alloc::vec::Vec;
|
||||
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 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).
|
||||
const BRIGHTNESS: u8 = 160; // accent
|
||||
|
|
@ -252,6 +254,15 @@ struct App {
|
|||
beatflash_off: i64,
|
||||
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")
|
||||
// --- 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 {
|
||||
|
|
@ -308,6 +319,15 @@ impl App {
|
|||
beatflash_off: 0,
|
||||
bpm_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
|
||||
|
|
@ -351,10 +371,14 @@ impl App {
|
|||
fn next_track(&mut self, now_ns: i64) {
|
||||
let n = SETLISTS[self.sl].1.len();
|
||||
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) {
|
||||
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) {
|
||||
|
|
@ -363,6 +387,8 @@ impl App {
|
|||
self.tempo = v;
|
||||
self.rebuild_durs();
|
||||
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 {
|
||||
self.reset_clock(now_ns);
|
||||
}
|
||||
self.sync_broadcast(if self.playing { "play" } else { "stop" });
|
||||
}
|
||||
|
||||
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
|
||||
/// calls `emit(note, velocity)` for every lane hit (the caller sends it over USB-MIDI).
|
||||
fn tick<F: FnMut(u8, u8)>(&mut self, now_ns: i64, mut emit: F) {
|
||||
/// queues a USB-MIDI note-on (ch10) per lane hit onto `tx_q` (drained by the main loop).
|
||||
fn tick(&mut self, now_ns: i64) {
|
||||
if !self.playing {
|
||||
return;
|
||||
}
|
||||
|
|
@ -441,7 +468,9 @@ impl App {
|
|||
};
|
||||
if lvl > 0 && !self.muted {
|
||||
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) {
|
||||
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 {
|
||||
match level {
|
||||
2 => 3, // accent
|
||||
|
|
@ -782,16 +1007,40 @@ fn main() -> ! {
|
|||
let (mut nextrep_x, mut nextrep_y) = (0i64, 0i64);
|
||||
let mut last_frame_us = 0i64;
|
||||
let mut hb_us = 0i64;
|
||||
// pending USB-MIDI packets: the bulk endpoint holds one 4-byte packet at a time, so simultaneous
|
||||
// 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();
|
||||
let mut rxbuf = [0u8; 64];
|
||||
|
||||
loop {
|
||||
let us = now_us();
|
||||
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]);
|
||||
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) ----
|
||||
let a = btn_a.is_low().unwrap_or(false);
|
||||
|
|
@ -846,17 +1095,19 @@ fn main() -> ! {
|
|||
px = x;
|
||||
py = y;
|
||||
|
||||
// ---- scheduler: advance clocks, queue a USB-MIDI note-on per lane hit (ch10) ----
|
||||
app.tick(now_ns, |note, vel| {
|
||||
if note_q.len() < 64 {
|
||||
note_q.push_back([0x09, 0x99, note, vel]); // cable 0, note-on, channel 10
|
||||
// ---- scheduler: advance clocks (queues note-ons onto app.tx_q) ----
|
||||
app.tick(now_ns);
|
||||
|
||||
// ---- 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() {
|
||||
note_q.pop_front();
|
||||
app.tx_q.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue