PM_K-1 0.0.17: fix gapless-seam off-by-one + GC hygiene around modals / parse

The continuous-playback gap between tracks was a one-master-step early trigger of
_on_new_bar(N): nb = _m_steps // steps maps to the LAST step of bar N-1, not the
downbeat of bar N. So _seam_t (= lanes[0]['next'] at that moment) was one master step
short of the proper boundary -- A lost its last step, and B's "bar 0 step 0" landed at
that early slot. With kick:2 (2 steps/bar) that's half a bar each side; with kick:4 a
quarter; with kick:1 a whole bar.

Fix: nb = (self._m_steps - 1) // L['steps'] -- fires at the downbeat of the new bar.
Same adjustment applied to the on-screen "bar X of N" counter (mbars) so it advances
on the downbeat, not on the last beat of the previous bar.

Memory hygiene: gc.collect() at the entry of every modal (_show_menu / _show_settings /
_show_help / _show_about / _show_saverevert / _show_laneedit) so each modal allocates
its bitmaps against a defragmented heap. _prepare_next gc.collect()s before
parse_program and catches MemoryError -> the segment loops instead of crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-30 08:13:49 -05:00
parent c9f2288bdd
commit da71604c0d

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.16" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.17" # 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:
@ -618,6 +618,7 @@ class App:
self.load(self.idx) # reload from source -> discard edits
# ---------- modal overlay (save / revert / message) ----------
def _show_saverevert(self):
gc.collect()
self._overlay = 'saverevert'; g = self.g_overlay
while len(g): g.pop()
px, py, pw, ph = 24, 178, WIDTH - 48, 116
@ -665,6 +666,7 @@ class App:
self._tap_log(tx, ty) # else the practice log
# ---------- lane editor (tap the instrument name): sound / beats / sub / swing / mute + add / remove ----------
def _show_laneedit(self, li):
gc.collect()
self._overlay = 'lane'; self._edit_li = li; self._draw_laneedit()
def _draw_laneedit(self):
li = self._edit_li; L = self.lanes[li]; g = self.g_overlay
@ -736,6 +738,7 @@ class App:
self._close_overlay()
# ---------- hamburger menu (main) + sub-modals (Settings / Help / About) ----------
def _show_menu(self):
gc.collect() # defragment before allocating modal bitmaps
self._overlay = 'menu'; self._draw_menu()
def _draw_menu(self):
g = self.g_overlay
@ -768,6 +771,7 @@ class App:
# ---------- Settings sub-modal (LED / Speaker / MIDI Out / Channel / Clock Out / Clock In) ----------
def _show_settings(self):
gc.collect()
self._overlay = 'settings'; self._draw_settings()
def _draw_settings(self):
g = self.g_overlay
@ -836,6 +840,7 @@ class App:
# ---------- Help sub-modal (paginated) ----------
def _show_help(self):
gc.collect()
self._overlay = 'help'; self._help_page = 0; self._draw_help()
def _draw_help(self):
g = self.g_overlay
@ -872,6 +877,7 @@ class App:
# ---------- About sub-modal ----------
def _show_about(self):
gc.collect()
self._overlay = 'about'; self._draw_about()
def _draw_about(self):
import sys
@ -1017,7 +1023,7 @@ class App:
L['step'] = (L['step'] + 1) % L['steps']
if li == 0:
self._m_steps += 1 # count master-lane steps -> bars
nb = self._m_steps // L['steps']
nb = (self._m_steps - 1) // L['steps'] # bar of THIS step (off-by-one fix vs 0.0.16)
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 and not self._slaved: # CONTINUOUS ramp (off when slaved)
@ -1073,7 +1079,11 @@ class App:
nxt = (self.idx + 1) % len(items)
if nxt == self.idx: return # 1-item playlist -> just loop, no swap
name, prog = items[nxt]
gc.collect() # defragment before parse_program allocates new lanes
try:
bpm, lanes, bars, ramp, trainer = parse_program(prog)
except MemoryError:
gc.collect(); return # leave _next_pending None -> the segment just loops
self._next_pending = {'lanes': lanes, 'bpm': bpm, 'bars': bars, 'ramp': ramp,
'trainer': trainer, 'name': name, 'idx': nxt}
def _do_advance(self): # gapless seam: swap the prepared track in at seam_t
@ -1180,7 +1190,7 @@ class App:
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._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
mbars = max(0, self._m_steps - 1) // max(1, mlen) # bar containing THIS step (off-by-one fix vs 0.0.16)
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"
ts = "%s of %s" % (self._fmt_t(el), self._fmt_t(self.bars * bpb * 60.0 / self.bpm))