PM_K-1 0.0.12: Song = 4-bar sections, timer resets with the bar, smoother MIDI

- Built-in Song playlist: every section is now b4 (~4 bars) so Continue rolls one into
  the next quickly.
- On-screen timer now counts WITHIN the current segment and resets every time the bar
  counter wraps (new _seg_start, reset at each b<n> boundary + on _reset_clock). The
  practice-log duration still uses play_start (total). Unified the segment-boundary
  handling (timer reset + ramp restart + Continue advance) in _on_new_bar.
- MIDI stutter: display.refresh() BLOCKS on the SPI stream and was delaying the next
  beat's note. Cap refresh to ~30Hz and poll the GT911 touch ~30Hz (was every loop) so
  the scheduler fires notes on time; visuals lag a few ms (imperceptible).

Verified in harness: Build(b4,rmp92/4/2) bpm 92->96->reset@bar4, seg_start resets only at
the boundary, Continue arms there; edit tests pass; app.mpy builds (C/v6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-29 14:36:03 -05:00
parent 13318daf5b
commit ecd1d2a189
2 changed files with 36 additions and 28 deletions

View file

@ -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<n>): 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()