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). 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. 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 ## Controls & the practice log
- **Joystick:** up/down = tempo, left/right = previous/next groove. - **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 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.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: 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:
@ -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_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost
PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead 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_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) # WS2812 RGB LED - self-contained via the core neopixel_write module (no external library)
class RGB: class RGB:
@ -233,7 +234,7 @@ def parse_program(s):
if tok.startswith('rmp'): # rmp<start>/<amount>/<everyBars> tempo ramp (amount may be -) if tok.startswith('rmp'): # rmp<start>/<amount>/<everyBars> tempo ramp (amount may be -)
p = tok[3:].split('/') p = tok[3:].split('/')
if len(p) == 3: 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 except ValueError: pass
continue continue
if tok.startswith('tr') and '/' in tok and ':' not in tok: # tr<play>/<mute> gap trainer (bars) 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): def _parse_lane(tok):
poly = '~' in tok; mute = '!' in tok poly = '~' in tok; mute = '!' in tok
tok = tok.replace('~', '').replace('!', '') 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(':') sound, _, rest = tok.partition(':')
pattern = None pattern = None
if '=' in rest: rest, _, pattern = rest.partition('=') if '=' in rest: rest, _, pattern = rest.partition('=')
@ -273,7 +275,18 @@ def _parse_lane(tok):
for i in range(steps): for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)
else: levels.append(0) 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 def _slkey(t): # normalise a title for built-in/user de-duplication
return "".join(c.lower() for c in t if c.isalnum()) return "".join(c.lower() for c in t if c.isalnum())
@ -394,6 +407,8 @@ class App:
self._touchDown = False; self._touchSeen = 0 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.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.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.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json)
self.dirty = True self.dirty = True
self.pad_pal = displayio.Palette(8) # 0-3 idle levels (mute/normal/accent/ghost), 4-7 the lit playhead 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_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_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_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_cont = displayio.Group(); root.append(self.g_cont) # CONT (Continue auto-advance) toggle indicator
self.g_idx = displayio.Group(); root.append(self.g_idx) # track number (dim, right) - set apart from the title 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 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 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) 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): def _place(self, group, s, x, y, fg, bg, font, right_edge=None):
while len(group): group.pop() while len(group): group.pop()
@ -479,8 +496,118 @@ class App:
self.name, prog = items[self.idx] self.name, prog = items[self.idx]
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
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()
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): def _step_dur(self, L, step):
beat = 60_000_000_000 / self.bpm beat = 60_000_000_000 / self.bpm
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar 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 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 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) 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): def _on_new_bar(self, bar):
if self.ramp and bar > 0: # tempo ramp: reset each segment, else step every N bars 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) 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']) 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 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']) 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 ---------- # ---------- inputs ----------
def poll(self): def poll(self):
@ -605,9 +737,7 @@ class App:
if pt: if pt:
self._touchSeen = nowms self._touchSeen = nowms
if not self._touchDown: if not self._touchDown:
self._touchDown = True self._touchDown = True; self._handle_tap(pt[0], pt[1])
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])
elif self._touchDown and (nowms - self._touchSeen) > 0.14: elif self._touchDown and (nowms - self._touchSeen) > 0.14:
self._touchDown = False self._touchDown = False
# USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs) # USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs)
@ -629,12 +759,14 @@ class App:
# ---------- drawing ---------- # ---------- drawing ----------
def draw_bpm(self): def draw_bpm(self):
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12) 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] sl = self.setlists[self.sl]
# tab: playlist + position; muted = built-in (read-only), cyan = your own # 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) 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 def draw_train(self): # ramp + gap-trainer indicators (symbol + params), when set
g = self.g_train g = self.g_train
while len(g): g.pop() while len(g): g.pop()
@ -686,6 +818,7 @@ class App:
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
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 # 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'])) m = self.lanes[0]; mbeats = max(1, m['steps'] // max(1, m['sub']))
for bcol in range(mbeats): for bcol in range(mbeats):