From 7dd567fb44359558a2c724fb8aa3f3c5ca2d9d42 Mon Sep 17 00:00:00 2001 From: Me Here Date: Fri, 29 May 2026 13:23:27 -0500 Subject: [PATCH] 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 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) --- pico-cp/README.md | 12 ++ pico-cp/__pycache__/app.cpython-312.pyc | Bin 65560 -> 79562 bytes pico-cp/app.py | 159 ++++++++++++++++++++++-- 3 files changed, 158 insertions(+), 13 deletions(-) diff --git a/pico-cp/README.md b/pico-cp/README.md index 649a8bb..1c473d4 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -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 auto‑mutes** (the computer plays instead). The editor also syncs the device clock, so the practice log gets real wall‑clock 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` segment (turn it on for the Song playlist). + ## Controls & the practice log - **Joystick:** up/down = tempo, left/right = previous/next groove. diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index 84c350f15dbaf49c7bbccdc6e745012c83622753..35efd266c807a2fb4dd93c1a6f17f18e67e6d3c7 100644 GIT binary patch delta 21010 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 diff --git a/pico-cp/app.py b/pico-cp/app.py index 4a2a7e7..1e25da2 100644 --- a/pico-cp/app.py +++ b/pico-cp/app.py @@ -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// 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/ 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):