PM_K-1 0.0.9: on-device editing (tap beats, save/revert) + Continue auto-advance

- Tap a beat to cycle it (off->normal->accent->ghost); the title turns red (unsaved).
  Tap the title -> SAVE / REVERT modal. Editing a built-in saves a COPY into a "My edits"
  user playlist (built-ins stay read-only); editing a user item updates it in place.
  Saves persist to programs.json (NAKs gracefully in editor mode / read-only).
- New round-trippable serializer (lane_to_str/_prog_str): parser now keeps groups + @db
  gain + ramp start; verified parse->serialize->parse on all 23 built-ins (0 mismatches).
- Continue (CONT) toggle, top-right of the tab line: when on, a playlist auto-advances to
  the next item at the end of each item's b<n> segment (no log spam, keeps the stopwatch).
- Touch routing consolidated: tab=switch playlist / CONT, title=save-revert, pads=cycle,
  log=delete; modal overlay drawn on top.

Verified in the harness: beat cycle+dirty, built-in edit -> My edits persisted (built-ins
untouched), revert, Continue arming at segment end, overlay SAVE-tap, and both renders.

Next (0.1.0): tap the instrument name -> lane-parameter table (reuses this save machinery).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-29 13:23:27 -05:00
parent 2d243c9ef8
commit 7dd567fb44
3 changed files with 158 additions and 13 deletions

View file

@ -58,6 +58,18 @@ voices the groove through its full synth, out your speakers, locked to the devic
listening the screen shows a green **MIDI** badge and the **buzzer automutes** (the computer plays instead).
The editor also syncs the device clock, so the practice log gets real wallclock timestamps.
## Playlists, editing & Continue
- **Built-in playlists** (Styles / Practice / Song) are baked into the firmware — read-only, updated with
firmware. **Your own** playlists live in `programs.json` (synced from the editor's *Save to device*).
- **Switch playlist:** tap the **set-list tab** (above the title; grey = built-in, cyan = yours). **Item:**
joystick left/right.
- **Edit on the device:** **tap a beat** to cycle it (off → normal → accent → ghost). The title turns
**red** (unsaved); **tap the title** to **Save** or **Revert**. Editing a built-in saves a **copy** into
a *My edits* playlist (built-ins never change). Editing your own updates it in place.
- **Continue (auto-advance):** tap **CONT** (top-right of the tab line) — when on, a playlist auto-advances
to the next item at the end of each item's `b<n>` segment (turn it on for the Song playlist).
## Controls & the practice log
- **Joystick:** up/down = tempo, left/right = previous/next groove.

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.8" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.9" # 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:
@ -119,6 +119,7 @@ MIN_LOG_SEC = 5 # don't log plays shorter t
PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost
PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost
C_GRID = 0x1A2330 # faint vertical beat gridlines (beats line up across lanes)
C_RED = 0xFF5A5A # unsaved-edits (dirty) track title
# WS2812 RGB LED - self-contained via the core neopixel_write module (no external library)
class RGB:
@ -233,7 +234,7 @@ def parse_program(s):
if tok.startswith('rmp'): # rmp<start>/<amount>/<everyBars> tempo ramp (amount may be -)
p = tok[3:].split('/')
if len(p) == 3:
try: ramp = {'amt': int(p[1]), 'every': max(1, int(p[2]))}
try: ramp = {'start': int(p[0]), 'amt': int(p[1]), 'every': max(1, int(p[2]))}
except ValueError: pass
continue
if tok.startswith('tr') and '/' in tok and ':' not in tok: # tr<play>/<mute> gap trainer (bars)
@ -251,7 +252,8 @@ def parse_program(s):
def _parse_lane(tok):
poly = '~' in tok; mute = '!' in tok
tok = tok.replace('~', '').replace('!', '')
if '@' in tok: tok = tok.split('@')[0]
gain = ''
if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g # preserve @db for round-trip (engine ignores it)
sound, _, rest = tok.partition(':')
pattern = None
if '=' in rest: rest, _, pattern = rest.partition('=')
@ -273,7 +275,18 @@ def _parse_lane(tok):
for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)
else: levels.append(0)
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'} # level -> pattern char (inverse of PAT)
def lane_to_str(L): # serialize a lane back to the share grammar (round-trips)
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
s += '=' + ''.join(PAT_CH.get(v, '.') for v in L['levels'])
s += L.get('gain', '')
if L['poly']: s += '~'
if L['mute']: s += '!'
return s
def _slkey(t): # normalise a title for built-in/user de-duplication
return "".join(c.lower() for c in t if c.isalnum())
@ -394,6 +407,8 @@ class App:
self._touchDown = False; self._touchSeen = 0
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.bars = 0; self.rgb = (0, 0, 0)
self.ramp = None; self.trainer = None; self._lastbar = -1; self._muted = False; self._ramp_base = 120
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.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
@ -438,12 +453,14 @@ class App:
self.g_time = displayio.Group(); root.append(self.g_time) # elapsed [of total] (left)
self.g_bar = displayio.Group(); root.append(self.g_bar) # bar [of total] (left)
self.g_train = displayio.Group(); root.append(self.g_train) # ramp / gap-trainer indicators
self.g_name = displayio.Group(); root.append(self.g_name) # track title
self.g_idx = displayio.Group(); root.append(self.g_idx) # track number (dim, right) - set apart from the title
self.g_cont = displayio.Group(); root.append(self.g_cont) # CONT (Continue auto-advance) toggle indicator
self.g_name = displayio.Group(); root.append(self.g_name) # track title (red when edited/unsaved)
self.g_idx = displayio.Group(); root.append(self.g_idx) # set-list tab (tap to switch playlist)
self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes x step pads
root.append(rect(0, LOG_TOP - 6, WIDTH, 2, C_PANEL)) # divider above the history log
self.g_log = displayio.Group(); root.append(self.g_log) # practice history (tap a row to delete)
# run/stop is shown by the background tint (black=stopped, gray=running); transport = joystick + buttons A/B
self.g_overlay = displayio.Group(); root.append(self.g_overlay) # modal (save/revert) - drawn on top
# run/stop shows on the RGB LED; tap beats to edit, tap the title to save/revert, tap the tab to switch lists
def _place(self, group, s, x, y, fg, bg, font, right_edge=None):
while len(group): group.pop()
@ -479,8 +496,118 @@ class App:
self.name, prog = items[self.idx]
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._dirty = False; self._overlay = None # fresh load -> no unsaved edits
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.build_grid(); self.draw_log()
def _prog_str(self): # serialize the current (possibly edited) track to a program string
parts = ['t' + str(self.bpm)]
if self.bars: parts.append('b' + str(self.bars))
if self.ramp: parts.append('rmp%d/%d/%d' % (self.ramp.get('start', self.bpm), self.ramp['amt'], self.ramp['every']))
if self.trainer: parts.append('tr%d/%d' % (self.trainer['play'], self.trainer['mute']))
for L in self.lanes: parts.append(lane_to_str(L))
return ';'.join(parts)
# ---------- on-device editing: tap a beat to cycle it; tap the title to save/revert ----------
def _grid_hit(self, tx, ty): # map a touch to (kind, lane[, step]) on the pad grid
g = self._grid
if not g or not (g['top'] <= ty < g['top'] + g['n'] * g['rowh']): return None
li = (ty - g['top']) // g['rowh']
if li >= g['n']: return None
if tx < g['px0']: return ('lane', li) # tapped the lane label (lane editor = 0.1.0)
L = self.lanes[li]; steps = L['steps']
s = int((tx - g['px0'] - 6) * steps / g['usable'] + 0.5)
return ('beat', li, max(0, min(steps - 1, s)))
def _cycle_beat(self, li, s): # off -> normal -> accent -> ghost -> off
L = self.lanes[li]
L['levels'][s] = {0: 1, 1: 2, 2: 3, 3: 0}[L['levels'][s]]
base = self._padbase(L, s); lit = (self.lane_lit[li] == s)
self.lane_pads[li][s].color_index = base + 4 if lit else base
self._set_dirty()
def _set_dirty(self):
if not self._dirty: self._dirty = True; self.draw_status()
self.dirty = True
def toggle_continue(self):
self.continue_on = not self.continue_on; self.draw_status()
def _user_list(self, title): # find or create a user playlist
for s in self.setlists:
if not s['builtin'] and s['title'] == title: return s
s = {'title': title, 'items': [], 'builtin': False}; self.setlists.append(s); return s
def _persist_user(self): # write all user playlists back to /programs.json
user = [s for s in self.setlists if not s['builtin']]
data = {"setlists": [{"title": s['title'],
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]}
try:
with open("/programs.json", "w") as f: json.dump(data, f)
return True
except OSError:
return False # editor mode: the drive is read-only to us
def _save_edit(self):
prog = self._prog_str(); sl = self.setlists[self.sl]
if sl['builtin']: # built-ins are read-only -> save a USER copy
tgt = self._user_list("My edits"); names = [n for n, _ in tgt['items']]
if self.name in names: tgt['items'][names.index(self.name)] = (self.name, prog)
else: tgt['items'].append((self.name, prog))
dest = ("My edits", self.name)
else:
sl['items'] = list(sl['items']); sl['items'][self.idx] = (self.name, prog)
dest = (sl['title'], self.name)
if not self._persist_user():
self._show_msg("Read-only: reboot without holding A"); return
self.rebuild_setlists() # refresh, then jump to the saved (user) copy
for i, s in enumerate(self.setlists):
if not s['builtin'] and s['title'] == dest[0]:
self.sl = i; names = [n for n, _ in s['items']]
self.load(names.index(dest[1]) if dest[1] in names else 0); return
self.load(0)
def _revert(self):
self.load(self.idx) # reload from source -> discard edits
# ---------- modal overlay (save / revert / message) ----------
def _show_saverevert(self):
self._overlay = 'saverevert'; g = self.g_overlay
while len(g): g.pop()
px, py, pw, ph = 24, 178, WIDTH - 48, 116
g.append(rect(px, py, pw, ph, C_PANEL)); g.append(rect(px, py, pw, 2, C_CYAN))
t, w, h = make_text("Unsaved edits", FONT_M, C_TXT, C_PANEL); t.x = px + 14; t.y = py + 12; g.append(t)
self._ovbtns = []; by = py + 44; bh = 50; gap = 12; bw = (pw - 3 * gap) // 2
for i, (lbl, col, act) in enumerate((("SAVE", C_GREEN, self._save_edit), ("REVERT", C_AMBER, self._revert))):
bx = px + gap + i * (bw + gap)
g.append(rect(bx, by, bw, bh, C_BTN)); g.append(rect(bx, by, bw, 2, col))
tt, tw, th = make_text(lbl, FONT_M, col, C_BTN); tt.x = bx + (bw - tw) // 2; tt.y = by + (bh - th) // 2; g.append(tt)
self._ovbtns.append((bx, by, bx + bw, by + bh, act))
c, cw, ch = make_text("tap outside to cancel", FONT_S, C_DIM, C_PANEL); c.x = px + 14; c.y = py + ph - 16; g.append(c)
self.dirty = True
def _show_msg(self, text):
self._overlay = 'msg'; g = self.g_overlay
while len(g): g.pop()
px, py, pw, ph = 24, 200, WIDTH - 48, 64
g.append(rect(px, py, pw, ph, C_PANEL)); g.append(rect(px, py, pw, 2, C_AMBER))
t, w, h = make_text(text[:28], FONT_S, C_TXT, C_PANEL); t.x = px + 12; t.y = py + 14; g.append(t)
t2, w2, h2 = make_text("(tap to dismiss)", FONT_S, C_DIM, C_PANEL); t2.x = px + 12; t2.y = py + 38; g.append(t2)
self.dirty = True
def _close_overlay(self):
self._overlay = None
while len(self.g_overlay): self.g_overlay.pop()
self.dirty = True
def _tap_overlay(self, tx, ty):
if self._overlay == 'msg': self._close_overlay(); return
for x0, y0, x1, y1, act in self._ovbtns:
if x0 <= tx <= x1 and y0 <= ty <= y1:
while len(self.g_overlay): self.g_overlay.pop() # clear the panel, then run the action
self._overlay = None; act(); self.dirty = True; return
self._close_overlay() # tapped outside -> cancel
def _handle_tap(self, tx, ty):
if self._overlay: self._tap_overlay(tx, ty); return
if 112 <= ty <= 126: # set-list tab line
if tx > WIDTH - 56: self.toggle_continue() # right end = CONT (auto-advance) toggle
else: self.switch_setlist(1)
return
if 128 <= ty <= 154: # track-title line
if self._dirty: self._show_saverevert()
return
hit = self._grid_hit(tx, ty)
if hit and hit[0] == 'beat': self._cycle_beat(hit[1], hit[2]); return
self._tap_log(tx, ty) # else the practice log
def _step_dur(self, L, step):
beat = 60_000_000_000 / self.bpm
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar
@ -573,12 +700,17 @@ class App:
b = base[2] + (self.rgb[2]-base[2])*7//10
if abs(r-base[0])+abs(g-base[1])+abs(b-base[2]) < 6: r, g, b = base
self.rgb = (r, g, b); self.led.set(r, g, b)
if self._advance: # Continue: roll to the next item at the segment end
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
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):
@ -605,9 +737,7 @@ class App:
if pt:
self._touchSeen = nowms
if not self._touchDown:
self._touchDown = True
if 112 <= pt[1] <= 154: self.switch_setlist(1) # tap the set-list tab/title -> next playlist
else: self._tap_log(pt[0], pt[1])
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)
@ -629,12 +759,14 @@ class App:
# ---------- drawing ----------
def draw_bpm(self):
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
def draw_status(self): # set-list tab (small, tap to switch) above the item title
def draw_status(self): # set-list tab (tap=switch) + CONT toggle, above the item title
sl = self.setlists[self.sl]
# tab: playlist + position; muted = built-in (read-only), cyan = your own
self._place(self.g_idx, "%s %d/%d" % (sl['title'][:14], self.idx + 1, len(sl['items'])),
self._place(self.g_idx, "%s %d/%d" % (sl['title'][:11], self.idx + 1, len(sl['items'])),
12, 118, C_MUTE if sl['builtin'] else C_CYAN, C_BG, FONT_S)
self._place(self.g_name, self.name[:22], 12, 134, C_TXT, C_BG, FONT_M)
self._place(self.g_cont, "CONT", 0, 118, C_GREEN if self.continue_on else C_DIM, C_BG, FONT_S, right_edge=WIDTH-12)
# title turns red when edited (tap it to save/revert)
self._place(self.g_name, self.name[:20], 12, 134, C_RED if self._dirty else C_TXT, C_BG, FONT_M)
def draw_train(self): # ramp + gap-trainer indicators (symbol + params), when set
g = self.g_train
while len(g): g.pop()
@ -686,6 +818,7 @@ class App:
n = min(len(self.lanes), MAXLANES)
top = GRID_TOP; rowh = min(40, ((LOG_TOP - 10) - top) // max(1, n))
px0 = 60; usable = WIDTH - 8 - px0 - 12; gridh = n * rowh
self._grid = {'top': top, 'rowh': rowh, 'px0': px0, 'usable': usable, 'n': n} # for touch hit-testing
# vertical gridlines at the master lane's beats, full height -> beats line up across lanes
m = self.lanes[0]; mbeats = max(1, m['steps'] // max(1, m['sub']))
for bcol in range(mbeats):