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) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-03 16:07:55 -05:00
parent 36989c96de
commit d035ee2a06

View file

@ -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,23 +588,84 @@ 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 {
/// 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;
} else if b == 0xF7 {
}
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 {
}
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);
}
}
}
}
/// 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 <op> <text(ASCII-clamped)> F7`, packetize into 4-byte USB-MIDI events, queue them.
fn sx_send(&mut self, op: u8, text: &str) {
@ -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;
}