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) ---
|
// --- MIDI clock out (24 PPQN, so a DAW can slave to the Grid) ---
|
||||||
clock_out: bool,
|
clock_out: bool,
|
||||||
clock_next: i64,
|
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 {
|
fn master_bar_ns(track: &track_format::Track, tempo: i64) -> i64 {
|
||||||
|
|
@ -352,6 +357,10 @@ impl App {
|
||||||
continue_on: false,
|
continue_on: false,
|
||||||
clock_out: true,
|
clock_out: true,
|
||||||
clock_next: 0,
|
clock_next: 0,
|
||||||
|
clock_in: true,
|
||||||
|
clock_in_last: 0,
|
||||||
|
clock_in_avg: 0,
|
||||||
|
slaved: false,
|
||||||
};
|
};
|
||||||
app.load(0, 0, now_ns);
|
app.load(0, 0, now_ns);
|
||||||
app
|
app
|
||||||
|
|
@ -480,8 +489,8 @@ impl App {
|
||||||
let cyc = t.play + t.mute;
|
let cyc = t.play + t.mute;
|
||||||
self.muted = cyc > 0 && (bar % cyc) >= t.play;
|
self.muted = cyc > 0 && (bar % cyc) >= t.play;
|
||||||
}
|
}
|
||||||
// tempo ramp
|
// tempo ramp (suppressed while slaved to an external clock — it owns the tempo)
|
||||||
if let Some(r) = &self.track.ramp {
|
if let (false, Some(r)) = (self.slaved, &self.track.ramp) {
|
||||||
let steps0 = self.track.lanes[0].levels.len().max(1) as i64;
|
let steps0 = self.track.lanes[0].levels.len().max(1) as i64;
|
||||||
let bar_pos = self.m_steps / steps0;
|
let bar_pos = self.m_steps / steps0;
|
||||||
let seg_bar = if self.track.bars > 0 {
|
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
|
/// 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).
|
/// 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) {
|
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 {
|
if !self.playing {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -560,8 +573,8 @@ impl App {
|
||||||
self.tempo = t;
|
self.tempo = t;
|
||||||
self.rebuild_durs();
|
self.rebuild_durs();
|
||||||
}
|
}
|
||||||
// MIDI clock out: emit 24-PPQN F8 ticks against the wall clock
|
// MIDI clock out: emit 24-PPQN F8 ticks against the wall clock (not while we're the slave)
|
||||||
if self.clock_out {
|
if self.clock_out && !self.slaved {
|
||||||
let tick_ns = ((NS_PER_MIN / self.tempo.max(1)) / 24).max(1);
|
let tick_ns = ((NS_PER_MIN / self.tempo.max(1)) / 24).max(1);
|
||||||
while now_ns >= self.clock_next {
|
while now_ns >= self.clock_next {
|
||||||
if self.tx_q.len() < 200 {
|
if self.tx_q.len() < 200 {
|
||||||
|
|
@ -575,24 +588,85 @@ impl App {
|
||||||
|
|
||||||
// ============================== LIVE-SYNC (USB-MIDI SysEx; docs/livesync-protocol.md) ==============================
|
// ============================== LIVE-SYNC (USB-MIDI SysEx; docs/livesync-protocol.md) ==============================
|
||||||
impl App {
|
impl App {
|
||||||
/// Feed one received MIDI byte into the SysEx assembler (called for the data bytes of incoming
|
/// Feed one received MIDI byte: assembles SysEx (0xF0..0xF7) and handles realtime clock
|
||||||
/// USB-MIDI packets). On a complete frame (0xF0..0xF7) it dispatches.
|
/// (0xF8 tick, 0xFA/0xFB start, 0xFC stop). Called for the data bytes of incoming USB-MIDI packets.
|
||||||
fn feed_sx(&mut self, b: u8, now_ns: i64) {
|
fn feed_midi(&mut self, b: u8, now_ns: i64) {
|
||||||
if b == 0xF0 {
|
match b {
|
||||||
self.sx_buf.clear();
|
0xF0 => {
|
||||||
self.sx_on = true;
|
self.sx_buf.clear();
|
||||||
} else if b == 0xF7 {
|
self.sx_on = true;
|
||||||
if self.sx_on {
|
}
|
||||||
self.handle_sysex(now_ns);
|
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 <op> <text(ASCII-clamped)> F7`, packetize into 4-byte USB-MIDI events, queue them.
|
/// 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) {
|
fn sx_send(&mut self, op: u8, text: &str) {
|
||||||
let mut f: Vec<u8> = Vec::with_capacity(text.len() + 4);
|
let mut f: Vec<u8> = 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)
|
// USB-MIDI event packet: low nibble = Code Index Number (how many data bytes)
|
||||||
match rxbuf[i] & 0x0F {
|
match rxbuf[i] & 0x0F {
|
||||||
0x4 | 0x7 => {
|
0x4 | 0x7 => {
|
||||||
app.feed_sx(rxbuf[i + 1], now_ns);
|
app.feed_midi(rxbuf[i + 1], now_ns);
|
||||||
app.feed_sx(rxbuf[i + 2], now_ns);
|
app.feed_midi(rxbuf[i + 2], now_ns);
|
||||||
app.feed_sx(rxbuf[i + 3], now_ns);
|
app.feed_midi(rxbuf[i + 3], now_ns);
|
||||||
}
|
}
|
||||||
0x6 => {
|
0x6 => {
|
||||||
app.feed_sx(rxbuf[i + 1], now_ns);
|
app.feed_midi(rxbuf[i + 1], now_ns);
|
||||||
app.feed_sx(rxbuf[i + 2], now_ns);
|
app.feed_midi(rxbuf[i + 2], now_ns);
|
||||||
}
|
}
|
||||||
0x5 => app.feed_sx(rxbuf[i + 1], now_ns),
|
0x5 | 0xF => app.feed_midi(rxbuf[i + 1], now_ns), // single byte (SysEx end / realtime)
|
||||||
_ => {} // channel-voice / realtime — not SysEx
|
_ => {} // channel-voice — ignored
|
||||||
}
|
}
|
||||||
i += 4;
|
i += 4;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue