From 12a31b87a8cda8767181ecb54f3293f6d201fc2b Mon Sep 17 00:00:00 2001 From: Me Here Date: Sat, 30 May 2026 07:38:54 -0500 Subject: [PATCH] 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) --- pico-cp/__pycache__/app.cpython-312.pyc | Bin 94521 -> 97074 bytes pico-cp/app.py | 40 +++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index f1ddf6e582c39ab379d29ac21491adca90d25725..50a24c002a19ca848ffcfd9b0eae349e137257a0 100644 GIT binary patch delta 11040 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 UahTy`C=fdXY9e{A!~mwK<>)m5*n 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 TB*JjQ3WU*?l2pTqu7LRek@^d} diff --git a/pico-cp/app.py b/pico-cp/app.py index 8a923a2..b84ee50 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -18,7 +18,7 @@ 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 -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: import rtc # set from the editor's clock SysEx so the log has real timestamps 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_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_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 SPEAKER_AUTO_MUTE = True # auto-mute the speaker when a MIDI host is listening (computer plays it instead) 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._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._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.dirty = True 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): now = time.monotonic_ns() 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: fired = [] for li, L in enumerate(self.lanes): @@ -780,7 +785,7 @@ class App: nb = self._m_steps // L['steps'] 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.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'] bar_pos = self._m_steps / mlen seg_bar = (bar_pos % self.bars) if self.bars else bar_pos @@ -809,7 +814,7 @@ class App: self._advance = False self._do_advance() # 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: try: self.midi.write(bytes([0xF8])) except Exception: pass @@ -1057,16 +1062,43 @@ class App: # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs) ---------- 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): b = buf[i] if b == 0xF0: self._sx = bytearray(); self._sxon = True elif b == 0xF7: if self._sxon: self._handle_sysex(self._sx) 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: if len(self._sx) < 60000: self._sx.append(b) # big enough for a pushed firmware (app.py) 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): if len(sx) < 2 or sx[0] != 0x7D: return # 0x7D = our (educational) manufacturer id cmd = sx[1]