diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index 0d4042b..e4a7269 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 dbc1287..acc5904 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.11" # firmware version (the A/B updater pushes/compares this) +APP_VERSION = "0.0.12" # 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: @@ -89,15 +89,15 @@ BUILTIN_SETLISTS = [ ("Tempo builder 80 up", "t80;woodblock:4;rmp80/4/4"), ("Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"), ]), - ("Song (continuous)", [ - ("Intro - hats & kick", "t88;b8;kick:4=X.x.;hatClosed:4/2=gggggggg"), - ("Groove in - backbeat", "t88;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"), - ("Half-time shuffle", "t92;b12;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"), - ("Build - ramp 92-120", "t92;b16;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"), - ("Four-on-the-floor (909)", "t124;b18;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"), - ("Samba break (2/4)", "t116;b24;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."), - ("Peak - 16ths", "t132;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"), - ("Outro - ramp down", "t132;b8;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"), + ("Song (continuous)", [ # ~4-bar sections; with Continue on they roll one into the next + ("Intro - hats & kick", "t88;b4;kick:4=X.x.;hatClosed:4/2=gggggggg"), + ("Groove in - backbeat", "t88;b4;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"), + ("Half-time shuffle", "t92;b4;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"), + ("Build - ramp 92-120", "t92;b4;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"), + ("Four-on-the-floor (909)", "t124;b4;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"), + ("Samba break (2/4)", "t116;b4;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."), + ("Peak - 16ths", "t132;b4;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"), + ("Outro - ramp down", "t132;b4;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"), ]), ] @@ -421,6 +421,8 @@ class App: self.lane_pads = []; self.lane_lit = [] self.usb_conn = False; self._m_steps = 0 # USB-connected state; master-lane steps (for the bar counter) self._uiNext = 0.0; self._lastTs = None; self._lastBs = None # throttle the stopwatch/bar redraw + self._seg_start = 0.0 # timer origin; resets with the bar counter (each segment) + self._refreshNext = 0.0; self._touchNext = 0.0 # cap display refresh + touch polling (tighter MIDI timing) self.ic_midi_pal = None; self.ic_usb_pal = None # practice history - persisted to /history.json (next to programs.json) when we own the filesystem self.can_write = self._probe_write() @@ -628,6 +630,7 @@ class App: for L in self.lanes: L['next'] = now; L['step'] = -1 self._m_steps = 0 # restart the bar count + self._seg_start = time.monotonic() # and the on-screen timer (resets with the bar counter) # ---------- audio + light ---------- def click(self, level): @@ -709,13 +712,14 @@ class App: self._advance = False self.load((self.idx + 1) % len(self.setlists[self.sl]['items'])); self.led_rest() def _on_new_bar(self, bar): - if self.ramp and bar > 0: # tempo ramp: reset each segment, else step every N bars - if self.bars and bar % self.bars == 0: self.set_bpm(self._ramp_base) - elif bar % self.ramp['every'] == 0: self.set_bpm(self.bpm + self.ramp['amt']) - t = self.trainer # gap trainer: silence during the rest bars of each cycle + if self.bars and bar > 0 and bar % self.bars == 0: # segment boundary + self._seg_start = time.monotonic() # timer resets with the bar counter + if self.ramp: self.set_bpm(self._ramp_base) # ramp restarts each segment + if self.continue_on: self._advance = True # Continue: roll to the next item + elif self.ramp and bar > 0 and bar % self.ramp['every'] == 0: + self.set_bpm(self.bpm + self.ramp['amt']) # mid-segment ramp step + t = self.trainer # gap trainer: silence during the rest bars self._muted = bool(t and (t['play'] + t['mute']) and (bar % (t['play'] + t['mute'])) >= t['play']) - if self.continue_on and self.bars and bar >= self.bars: # auto-advance at the end of the segment - self._advance = True # ---------- inputs ---------- def poll(self): @@ -737,14 +741,16 @@ class App: self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = now + 350_000_000; return else: self._joyNext = now + 20_000_000 - pt = self.touch.read() nowms = time.monotonic() - if pt: - self._touchSeen = nowms - if not self._touchDown: - self._touchDown = True; self._handle_tap(pt[0], pt[1]) - elif self._touchDown and (nowms - self._touchSeen) > 0.14: - self._touchDown = False + if nowms >= self._touchNext: # poll touch ~30x/s (the I2C read adds loop latency -> MIDI jitter) + self._touchNext = nowms + 0.033 + pt = self.touch.read() + if pt: + self._touchSeen = nowms + if not self._touchDown: + self._touchDown = True; self._handle_tap(pt[0], pt[1]) + elif self._touchDown and (nowms - self._touchSeen) > 0.14: + self._touchDown = False # USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs) if self.midi_in is not None: try: n = self.midi_in.readinto(self._mbuf) @@ -801,7 +807,7 @@ class App: run = self.running and self.play_start is not None mlen = self.lanes[0]['steps'] if self.lanes else 1 bpb = (self.lanes[0]['steps'] // max(1, self.lanes[0]['sub'])) if self.lanes else 4 - el = (time.monotonic() - self.play_start) if run else 0 + el = (time.monotonic() - self._seg_start) if run else 0 # time within the current segment (resets with the bar) mbars = self._m_steps // max(1, mlen) # whole master bars elapsed cur = ("%d" % ((mbars % self.bars + 1) if self.bars else (mbars + 1))) if run else "-" # cycle 1..N if self.bars: # track has a length (b): show "X of TOTAL" @@ -997,10 +1003,12 @@ class App: try: os.remove("/trial") except Exception: pass committed = True - # push a complete frame only when something changed (no mid-update tearing); - # capped at the display's refresh rate, so dirty regions stay small and quick - if self.dirty and self.display.refresh(): - self.dirty = False + # Refresh at most ~30x/s. display.refresh() BLOCKS while it streams pixels over SPI, which + # would otherwise delay the next beat's MIDI note and make the audio stutter; throttling it + # keeps the click timing tight (the visuals lag a few ms, which is imperceptible). + if self.dirty and tnow >= self._refreshNext: + if self.display.refresh(): self.dirty = False + self._refreshNext = tnow + 0.033 time.sleep(0.0005) App().run()