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:
parent
f637a65abd
commit
12a31b87a8
2 changed files with 36 additions and 4 deletions
Binary file not shown.
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue