PM_K-1 0.0.18: split post-seam redraw -- fast bits now, heavy build_grid deferred

0.0.17 fixed the off-by-one so A plays its full final bar, but B's first beats were
still being eaten -- by build_grid + draw_log blocking the main loop for ~hundreds of
ms right at the seam (a full grid teardown + ~64-128 vectorio rectangle allocations +
draw_log text bitmaps + an SPI refresh). tick() doesn't run during that, so B's first
master steps fire only when the catch-up while-loop runs at the next tick, collapsing
into one click.

Split the post-seam refresh:

- Fast pass: draw_meters / draw_bpm / draw_status / draw_train. Cheap (each just swaps
  one TileGrid). Runs immediately so the new title + bpm + status are correct on screen.
- Heavy pass: build_grid + draw_log. Deferred by 0.6s after the seam, gated by
  self._heavy_redraw_at. B's intro plays clean; the grid catches up half a beat or so
  into B with a single brief jitter (rather than B's first 1-2 beats being lost).

Plus gc.collect() at the start of build_grid AND draw_log. Both allocate enough small
objects that a fragmented heap will MemoryError partway through; collecting first gives
them a clean run.

load() / switch_setlist() clear _heavy_redraw_at so a user-initiated navigation doesn't
trigger a second deferred rebuild on top of the immediate one.

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

View file

@ -18,7 +18,7 @@
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor 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 supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.17" # firmware version (the A/B updater pushes/compares this) APP_VERSION = "0.0.18" # firmware version (the A/B updater pushes/compares this)
try: try:
import rtc # set from the editor's clock SysEx so the log has real timestamps import rtc # set from the editor's clock SysEx so the log has real timestamps
except ImportError: except ImportError:
@ -452,6 +452,7 @@ class App:
self._dirty = False; self._overlay = None; self._ovbtns = [] # on-device editing: unsaved edits + modal self._dirty = False; self._overlay = None; self._ovbtns = [] # on-device editing: unsaved edits + modal
self.continue_on = False; self._advance = False; self._grid = {} # auto-advance + pad hit-test geometry 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._next_pending = None; self._seam_t = 0; self._need_redraw = False # gapless seam between tracks
self._heavy_redraw_at = 0 # deferred build_grid + draw_log deadline (so B's intro isn't blocked by SPI/alloc)
self._displayed_bpm = -1; self._clock_next = 0 # lazy BPM redraw + MIDI Clock Out tick scheduler 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._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.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json)
@ -550,7 +551,7 @@ class App:
self.bpm, self.lanes, self.bars, self.ramp, self.trainer = parse_program(prog) self.bpm, self.lanes, self.bars, self.ramp, self.trainer = parse_program(prog)
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._dirty = False; self._overlay = None # fresh load -> no unsaved edits self._dirty = False; self._overlay = None # fresh load -> no unsaved edits
self._next_pending = None; self._need_redraw = False # discard any prepared seam (user navigated away) self._next_pending = None; self._need_redraw = False; self._heavy_redraw_at = 0 # discard any prepared seam (user navigated away)
while len(self.g_overlay): self.g_overlay.pop() # dismiss any open modal while len(self.g_overlay): self.g_overlay.pop() # dismiss any open modal
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train() self._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train()
self.build_grid(); self.draw_log() self.build_grid(); self.draw_log()
@ -1097,7 +1098,8 @@ class App:
while len(self.g_overlay): self.g_overlay.pop() while len(self.g_overlay): self.g_overlay.pop()
seam = self._seam_t seam = self._seam_t
for L in self.lanes: L['next'] = seam; L['step'] = -1 # NEXT tick fires step 0 of the new track at seam_t for L in self.lanes: L['next'] = seam; L['step'] = -1 # NEXT tick fires step 0 of the new track at seam_t
self._need_redraw = True # visuals (grid + draws) catch up on the next refresh self._need_redraw = True # cheap header bits: meters/bpm/status/train -> next refresh
self._heavy_redraw_at = time.monotonic() + 0.6 # heavy: build_grid + draw_log deferred ~0.6s so B's intro plays unblocked
self._seg_start = time.monotonic() # reset the on-screen timer self._seg_start = time.monotonic() # reset the on-screen timer
self.led_rest() self.led_rest()
@ -1208,6 +1210,7 @@ class App:
def build_grid(self): def build_grid(self):
while len(self.g_grid): self.g_grid.pop() while len(self.g_grid): self.g_grid.pop()
self.lane_pads = []; self.lane_lit = [] self.lane_pads = []; self.lane_lit = []
gc.collect() # 64-128 vectorio allocs in this fn want a defragmented heap
n = min(len(self.lanes), MAXLANES) n = min(len(self.lanes), MAXLANES)
top = GRID_TOP; rowh = min(40, ((LOG_TOP - 10) - top) // max(1, n)) top = GRID_TOP; rowh = min(40, ((LOG_TOP - 10) - top) // max(1, n))
px0 = 60; usable = WIDTH - 8 - px0 - 12; gridh = n * rowh px0 = 60; usable = WIDTH - 8 - px0 - 12; gridh = n * rowh
@ -1282,6 +1285,7 @@ class App:
g = self.g_log g = self.g_log
while len(g): g.pop() while len(g): g.pop()
self.log_rows = [] self.log_rows = []
gc.collect() # several text bitmaps allocated below want a clean heap
hdr, w, h = make_text("PRACTICE LOG - THIS TRACK", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr) hdr, w, h = make_text("PRACTICE LOG - THIS TRACK", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr)
rows = [(i, e) for i, e in enumerate(self.log) if e.get("name") == self.name] # current track only rows = [(i, e) for i, e in enumerate(self.log) if e.get("name") == self.name] # current track only
if not rows: if not rows:
@ -1409,10 +1413,12 @@ class App:
except OSError: committed = True except OSError: committed = True
while True: while True:
self.tick(); self.poll() self.tick(); self.poll()
if self._need_redraw: # post-seam: visuals catch up AFTER the audio swap if self._need_redraw: # post-seam fast pass: cheap header/status bits, runs immediately
self._need_redraw = False self._need_redraw = False
self.draw_bpm(); self.draw_status(); self.draw_train() self.draw_bpm(); self.draw_status(); self.draw_train(); self.draw_meters()
self.build_grid(); self.draw_log(); self.draw_meters() if self._heavy_redraw_at and time.monotonic() >= self._heavy_redraw_at:
self._heavy_redraw_at = 0 # post-seam slow pass: build_grid + draw_log are ~hundreds of ms,
self.build_grid(); self.draw_log() # deferred so B's first few beats fire on time
tnow = time.monotonic() tnow = time.monotonic()
if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter
self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm() # bpm follows the continuous ramp self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm() # bpm follows the continuous ramp