diff --git a/docs/rust-port.md b/docs/rust-port.md index 93f594d..0fa4b6c 100644 --- a/docs/rust-port.md +++ b/docs/rust-port.md @@ -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 diff --git a/rust/pm-grid/src/main.rs b/rust/pm-grid/src/main.rs index a88830f..efd56c4 100644 --- a/rust/pm-grid/src/main.rs +++ b/rust/pm-grid/src/main.rs @@ -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, // 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(&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 F7`, packetize into 4-byte USB-MIDI events, queue them. + fn sx_send(&mut self, op: u8, text: &str) { + let mut f: Vec = 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;" + 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::() { + 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::(), b.parse::()) { + 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::(), b.parse::(), c.parse::()) + { + 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 - } - }); - // 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() { + // ---- 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 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; }