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:
parent
2d243c9ef8
commit
7dd567fb44
3 changed files with 158 additions and 13 deletions
|
|
@ -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<n>` segment (turn it on for the Song playlist).
|
||||
|
||||
## Controls & the practice log
|
||||
|
||||
- **Joystick:** up/down = tempo, left/right = previous/next groove.
|
||||
|
|
|
|||
Binary file not shown.
159
pico-cp/app.py
159
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<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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue