PM_K-1 0.0.15: MIDI Clock In (slave) - follow an external 24 PPQN master

New config: MIDI_CLOCK_IN (default OFF) + MIDI_CLOCK_IN_TRANSPORT (default ON).

_feed_midi now intercepts 0xF8 (clock tick) -> smoothed bpm tracker (exponential, a=1/8;
rejects out-of-range intervals so noise can't drag bpm wild), 0xFA / 0xFB (Start /
Continue) -> start playback without echoing 0xFA on output (would feedback), 0xFC ->
stop. _slaved flag is True while ticks are arriving and decays after 1s of silence.

While slaved: per-master-step continuous ramp is OFF (the master's tempo wins) and
Clock Out emission is suppressed (no feedback loop if the user enables both).

Verified in the harness: target 60/120/180 BPM all track exactly after 60 ticks
(2.5 quarter notes of smoothing); 0xFA -> running, 0xFC -> stopped; slave flag
correctly decays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-30 07:38:54 -05:00
parent f637a65abd
commit 12a31b87a8
2 changed files with 36 additions and 4 deletions

View file

@ -18,7 +18,7 @@
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.14" # firmware version (the A/B updater pushes/compares this) APP_VERSION = "0.0.15" # firmware version (the A/B updater pushes/compares this)
try: try:
import rtc # set from the editor's clock SysEx so the log has real timestamps import rtc # set from the editor's clock SysEx so the log has real timestamps
except ImportError: except ImportError:
@ -49,6 +49,8 @@ MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web
MIDI_CHANNEL = 10 # 1..16 - GM channel 10 is the drum channel (what DAWs auto-route to drums) MIDI_CHANNEL = 10 # 1..16 - GM channel 10 is the drum channel (what DAWs auto-route to drums)
MIDI_CLOCK_OUT = False # send 24 PPQN MIDI Clock so a DAW can slave its tempo to the metronome MIDI_CLOCK_OUT = False # send 24 PPQN MIDI Clock so a DAW can slave its tempo to the metronome
MIDI_CLOCK_OUT_TRANSPORT = True # also send Start (0xFA) / Stop (0xFC) on play / stop (relevant if MIDI_CLOCK_OUT) MIDI_CLOCK_OUT_TRANSPORT = True # also send Start (0xFA) / Stop (0xFC) on play / stop (relevant if MIDI_CLOCK_OUT)
MIDI_CLOCK_IN = False # follow an external 24 PPQN clock (DAW / sequencer becomes the master)
MIDI_CLOCK_IN_TRANSPORT = True # also follow Start (0xFA) / Stop (0xFC) from the master (relevant if MIDI_CLOCK_IN)
MUTE_SPEAKER = False # always silence the on-board speaker MUTE_SPEAKER = False # always silence the on-board speaker
SPEAKER_AUTO_MUTE = True # auto-mute the speaker when a MIDI host is listening (computer plays it instead) SPEAKER_AUTO_MUTE = True # auto-mute the speaker when a MIDI host is listening (computer plays it instead)
WIDTH, HEIGHT = 320, 480 WIDTH, HEIGHT = 320, 480
@ -422,6 +424,7 @@ class App:
self.continue_on = False; self._advance = False; self._grid = {} # auto-advance + pad hit-test geometry self.continue_on = False; self._advance = False; self._grid = {} # auto-advance + pad hit-test geometry
self._next_pending = None; self._seam_t = 0; self._need_redraw = False # gapless seam between tracks self._next_pending = None; self._seam_t = 0; self._need_redraw = False # gapless seam between tracks
self._displayed_bpm = -1; self._clock_next = 0 # lazy BPM redraw + MIDI Clock Out tick scheduler self._displayed_bpm = -1; self._clock_next = 0 # lazy BPM redraw + MIDI Clock Out tick scheduler
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False # MIDI Clock In: smoothed tracker + slave flag
self.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json) self.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json)
self.dirty = True self.dirty = True
self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead
@ -768,6 +771,8 @@ class App:
def tick(self): def tick(self):
now = time.monotonic_ns() now = time.monotonic_ns()
if self.spk_off and now >= self.spk_off: self.spk.duty_cycle = 0; self.spk_off = 0 if self.spk_off and now >= self.spk_off: self.spk.duty_cycle = 0; self.spk_off = 0
# Slave decay: if no Clock In tick in the last 1s, fall back to internal tempo
if self._slaved and (now - self._clock_in_last_t) > 1_000_000_000: self._slaved = False
if self.running: if self.running:
fired = [] fired = []
for li, L in enumerate(self.lanes): for li, L in enumerate(self.lanes):
@ -780,7 +785,7 @@ class App:
nb = self._m_steps // L['steps'] nb = self._m_steps // L['steps']
if nb != self._lastbar: self._lastbar = nb; self._on_new_bar(nb) if nb != self._lastbar: self._lastbar = nb; self._on_new_bar(nb)
if self._advance: break # seam armed - suppress this step's firing if self._advance: break # seam armed - suppress this step's firing
if self.ramp and L['steps'] > 0: # CONTINUOUS ramp: interpolate bpm at every master step if self.ramp and L['steps'] > 0 and not self._slaved: # CONTINUOUS ramp (off when slaved)
mlen = L['steps'] mlen = L['steps']
bar_pos = self._m_steps / mlen bar_pos = self._m_steps / mlen
seg_bar = (bar_pos % self.bars) if self.bars else bar_pos seg_bar = (bar_pos % self.bars) if self.bars else bar_pos
@ -809,7 +814,7 @@ class App:
self._advance = False self._advance = False
self._do_advance() self._do_advance()
# MIDI Clock Out (master): 24 PPQN; interval follows the live bpm (so continuous ramps carry through) # MIDI Clock Out (master): 24 PPQN; interval follows the live bpm (so continuous ramps carry through)
if self.running and MIDI_CLOCK_OUT and self.midi is not None: if self.running and MIDI_CLOCK_OUT and self.midi is not None and not self._slaved: # don't echo to the master
while now >= self._clock_next: while now >= self._clock_next:
try: self.midi.write(bytes([0xF8])) try: self.midi.write(bytes([0xF8]))
except Exception: pass except Exception: pass
@ -1057,16 +1062,43 @@ class App:
# ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs) ---------- # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs) ----------
def _feed_midi(self, buf, n): def _feed_midi(self, buf, n):
now_ns = time.monotonic_ns() if MIDI_CLOCK_IN else 0 # only timestamp when slave is enabled
for i in range(n): for i in range(n):
b = buf[i] b = buf[i]
if b == 0xF0: self._sx = bytearray(); self._sxon = True if b == 0xF0: self._sx = bytearray(); self._sxon = True
elif b == 0xF7: elif b == 0xF7:
if self._sxon: self._handle_sysex(self._sx) if self._sxon: self._handle_sysex(self._sx)
self._sxon = False self._sxon = False
elif b >= 0xF8: pass # real-time (e.g. Active Sensing 0xFE) - ignore elif b == 0xF8 and MIDI_CLOCK_IN: self._slave_tick(now_ns) # 24 PPQN clock tick from a master
elif b == 0xFA and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_start() # Start
elif b == 0xFB and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_start() # Continue (no SPP -> treat as Start)
elif b == 0xFC and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_stop() # Stop
elif b >= 0xF8: pass # other real-time (Active Sensing 0xFE etc.) - ignore
elif self._sxon: elif self._sxon:
if len(self._sx) < 60000: self._sx.append(b) # big enough for a pushed firmware (app.py) if len(self._sx) < 60000: self._sx.append(b) # big enough for a pushed firmware (app.py)
else: self._sxon = False # overflow guard else: self._sxon = False # overflow guard
def _slave_tick(self, now_ns): # one 24 PPQN tick: smooth the interval -> bpm
if self._clock_in_last_t == 0:
self._clock_in_last_t = now_ns; self._slaved = True; return # first tick: just record the timestamp
interval = now_ns - self._clock_in_last_t
self._clock_in_last_t = now_ns
# reject out-of-range intervals (30..300 BPM at 24 PPQN -> 8.33..83.3 ms per tick)
if interval < 8_300_000 or interval > 83_400_000: return
if self._clock_in_avg == 0: self._clock_in_avg = interval
else: self._clock_in_avg = (self._clock_in_avg * 7 + interval) // 8 # exponential smoothing, alpha = 1/8
new_bpm = max(30, min(300, int(60_000_000_000 // (self._clock_in_avg * 24))))
if new_bpm != self.bpm: self.bpm = new_bpm
self._slaved = True
def _slave_start(self): # master sent Start (or Continue) -> start playback
if not self.running:
self.running = True; self._reset_clock(); self._start_play()
self.led_rest(); self.draw_meters() # NOTE: do not echo 0xFA on output (we're slaved)
self._clock_in_last_t = 0; self._clock_in_avg = 0 # next tick re-establishes the smoothed interval
def _slave_stop(self): # master sent Stop -> stop playback
if self.running:
self.running = False; self.spk.duty_cycle = 0; self.reset_playheads(); self._log_play()
self.led_rest(); self.draw_meters()
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False
def _handle_sysex(self, sx): def _handle_sysex(self, sx):
if len(sx) < 2 or sx[0] != 0x7D: return # 0x7D = our (educational) manufacturer id if len(sx) < 2 or sx[0] != 0x7D: return # 0x7D = our (educational) manufacturer id
cmd = sx[1] cmd = sx[1]