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:
parent
36989c96de
commit
d035ee2a06
1 changed files with 99 additions and 25 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue