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 84c350f..35efd26 100644 Binary files a/pico-cp/__pycache__/app.cpython-312.pyc and b/pico-cp/__pycache__/app.cpython-312.pyc differ 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):