diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index f1ddf6e..50a24c0 100644 Binary files a/pico-cp/__pycache__/app.cpython-312.pyc and b/pico-cp/__pycache__/app.cpython-312.pyc differ 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]