From d035ee2a064a1cad2b4cbcc72f22e833fdb2cf8e Mon Sep 17 00:00:00 2001 From: Me Here Date: Wed, 3 Jun 2026 16:07:55 -0500 Subject: [PATCH] pm-grid: MIDI clock in (slave tempo to external 24-PPQN clock) - feed_midi (was feed_sx) now also handles realtime: 0xF8 tick -> slave_tick (EMA of the inter-tick interval -> derived BPM, 5..300 clamp, jitter reject), 0xFA/0xFB -> start, 0xFC -> stop. RX loop feeds CIN 0xF single-byte packets too. - While slaved: the tempo ramp and our own clock-OUT are suppressed (no feedback); the lock drops after a >1s gap in incoming ticks. - Default on; only engages when a host actually sends clock (the editor does not). Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/pm-grid/src/main.rs | 124 +++++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 25 deletions(-) diff --git a/rust/pm-grid/src/main.rs b/rust/pm-grid/src/main.rs index a5d0e98..606b75c 100644 --- a/rust/pm-grid/src/main.rs +++ b/rust/pm-grid/src/main.rs @@ -281,6 +281,11 @@ struct App { // --- MIDI clock out (24 PPQN, so a DAW can slave to the Grid) --- clock_out: bool, clock_next: i64, + // --- MIDI clock in (slave the Grid's tempo to an external 24-PPQN clock) --- + clock_in: bool, + clock_in_last: i64, // timestamp of the last F8 (ns); 0 = waiting for the first + clock_in_avg: i64, // EMA of the F8 interval (ns) + slaved: bool, // currently following an external clock } fn master_bar_ns(track: &track_format::Track, tempo: i64) -> i64 { @@ -352,6 +357,10 @@ impl App { continue_on: false, clock_out: true, clock_next: 0, + clock_in: true, + clock_in_last: 0, + clock_in_avg: 0, + slaved: false, }; app.load(0, 0, now_ns); app @@ -480,8 +489,8 @@ impl App { let cyc = t.play + t.mute; self.muted = cyc > 0 && (bar % cyc) >= t.play; } - // tempo ramp - if let Some(r) = &self.track.ramp { + // tempo ramp (suppressed while slaved to an external clock — it owns the tempo) + if let (false, Some(r)) = (self.slaved, &self.track.ramp) { let steps0 = self.track.lanes[0].levels.len().max(1) as i64; let bar_pos = self.m_steps / steps0; let seg_bar = if self.track.bars > 0 { @@ -500,6 +509,10 @@ impl App { /// Advance the per-lane step clocks up to `now_ns`. Sets `beatflash` for the loudest hit and /// 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) { + // drop the external-clock lock if ticks stopped arriving (>1s gap) + if self.slaved && now_ns - self.clock_in_last > 1_000_000_000 { + self.slaved = false; + } if !self.playing { return; } @@ -560,8 +573,8 @@ impl App { self.tempo = t; self.rebuild_durs(); } - // MIDI clock out: emit 24-PPQN F8 ticks against the wall clock - if self.clock_out { + // MIDI clock out: emit 24-PPQN F8 ticks against the wall clock (not while we're the slave) + if self.clock_out && !self.slaved { 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 { @@ -575,24 +588,85 @@ 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); + /// Feed one received MIDI byte: assembles SysEx (0xF0..0xF7) and handles realtime clock + /// (0xF8 tick, 0xFA/0xFB start, 0xFC stop). Called for the data bytes of incoming USB-MIDI packets. + fn feed_midi(&mut self, b: u8, now_ns: i64) { + match b { + 0xF0 => { + self.sx_buf.clear(); + self.sx_on = true; + } + 0xF7 => { + if self.sx_on { + self.handle_sysex(now_ns); + } + self.sx_on = false; + } + 0xF8 => { + if self.clock_in { + self.slave_tick(now_ns); + } + } + 0xFA | 0xFB => { + if self.clock_in { + self.slave_start(now_ns); + } + } + 0xFC => { + if self.clock_in { + self.slave_stop(); + } + } + _ if b >= 0xF8 => {} // other realtime (active sensing etc.) + _ => { + if self.sx_on && self.sx_buf.len() < 2048 { + self.sx_buf.push(b); + } } - 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); } } + /// One external clock tick (0xF8): track the EMA interval and derive tempo (24 PPQN). + fn slave_tick(&mut self, now_ns: i64) { + if self.clock_in_last == 0 { + self.clock_in_last = now_ns; + self.slaved = true; + return; + } + let interval = now_ns - self.clock_in_last; + self.clock_in_last = now_ns; + if !(8_300_000..=500_000_000).contains(&interval) { + return; // out of 5..300 BPM range → ignore (jitter/glitch) + } + self.clock_in_avg = if self.clock_in_avg == 0 { + interval + } else { + (self.clock_in_avg * 7 + interval) / 8 + }; + let new_bpm = (NS_PER_MIN / (self.clock_in_avg * 24)).clamp(5, 300); + if new_bpm != self.tempo { + self.tempo = new_bpm; + self.rebuild_durs(); + } + self.slaved = true; + } + + fn slave_start(&mut self, now_ns: i64) { + if !self.playing { + self.playing = true; + self.reset_clock(now_ns); + } + self.clock_in_last = 0; + self.clock_in_avg = 0; + } + + fn slave_stop(&mut self) { + self.playing = false; + self.clock_in_last = 0; + self.clock_in_avg = 0; + self.slaved = false; + } + /// 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); @@ -1188,16 +1262,16 @@ fn main() -> ! { // 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); + app.feed_midi(rxbuf[i + 1], now_ns); + app.feed_midi(rxbuf[i + 2], now_ns); + app.feed_midi(rxbuf[i + 3], now_ns); } 0x6 => { - app.feed_sx(rxbuf[i + 1], now_ns); - app.feed_sx(rxbuf[i + 2], now_ns); + app.feed_midi(rxbuf[i + 1], now_ns); + app.feed_midi(rxbuf[i + 2], now_ns); } - 0x5 => app.feed_sx(rxbuf[i + 1], now_ns), - _ => {} // channel-voice / realtime — not SysEx + 0x5 | 0xF => app.feed_midi(rxbuf[i + 1], now_ns), // single byte (SysEx end / realtime) + _ => {} // channel-voice — ignored } i += 4; }