diff --git a/editor.html b/editor.html index 724271f..95f32a6 100644 --- a/editor.html +++ b/editor.html @@ -347,7 +347,7 @@ - + @@ -1075,9 +1075,16 @@ function shareSetlist() { /* Device (PM_K-1) programs.json β€” the same grooves the firmware reads. Save: writes the active set list straight onto the CIRCUITPY drive (File System Access, Chrome/Edge) or downloads it to drag on. Load: reads a programs.json into a new set list. */ +// The PM_K-1's built-in playlists are baked into its firmware (they update with firmware and are +// read-only). "Save to device" syncs only YOUR set lists β€” i.e. the ones that aren't the built-in demos. +function userSetlists() { + const seed = new Set(SEED_SETLISTS.map((s) => s.title)); + return setlists.filter((sl) => !seed.has(sl.title)); +} function programsJSON() { - const sl = getSL(); if (!sl) return null; - return JSON.stringify({ title: sl.title || "PolyMeter", programs: sl.items.map((it) => ({ name: it.name, prog: setupToPatch(it) })) }, null, 2); + return JSON.stringify({ setlists: userSetlists().map((sl) => ({ + title: sl.title || "My set list", + programs: sl.items.map((it) => ({ name: it.name, prog: setupToPatch(it) })) })) }, null, 2); } function _downloadPrograms(json) { const a = document.createElement("a"); @@ -1085,7 +1092,9 @@ function _downloadPrograms(json) { a.download = "programs.json"; a.click(); URL.revokeObjectURL(a.href); } async function saveToDevice() { - const json = programsJSON(); if (!json) return alert("No set list selected to save."); + const uls = userSetlists(); + if (!uls.length) return alert("No custom set lists to save.\n\nThe built-in playlists (Styles / Practice / Song) are baked into the device firmware β€” they're always there, update with firmware, and can't be changed. Create your own set list and it'll save here."); + const json = programsJSON(); // Primary: push to the device over USB-MIDI SysEx (Chromium/Firefox); the firmware writes programs.json. if (await _ensureMidi() && _midiOutputs().length) { const ascii = json.replace(/[\u0080-\uFFFF]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")); // 7-bit-safe JSON @@ -1094,7 +1103,7 @@ async function saveToDevice() { bytes.push(0xF7); _send(_clockSysex()); _send(bytes); const ok = await new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, 2500); }); - if (ok === true) { alert("Saved to device βœ“ β€” it reloaded with your set list."); return; } + if (ok === true) { alert("Saved to device βœ“ β€” " + uls.length + " set list(s) synced (alongside the built-ins)."); return; } _downloadPrograms(json); alert(ok === false ? "The device is in editor mode (drive writable by the computer), so I downloaded programs.json β€” drag it onto the CIRCUITPY drive." @@ -1108,11 +1117,17 @@ async function saveToDevice() { function importPrograms(text) { try { const d = JSON.parse(text); - const items = (d.programs || []).map((p) => ({ name: p.name || "Item", ...patchToSetup(p.prog) })); - if (!items.length) return alert("No programs found in that file."); - setlists.push({ title: d.title || "Device", description: "", items }); activeSL = setlists.length - 1; activeItem = 0; - saveSetlists(); renderSetlists(); applySetup(items[0]); - alert("Loaded " + items.length + " grooves from the device into a new set list."); + const lists = Array.isArray(d.setlists) ? d.setlists : [{ title: d.title, programs: d.programs || [] }]; + let added = 0; + for (const sl of lists) { + const items = (sl.programs || []).map((p) => ({ name: p.name || "Item", ...patchToSetup(p.prog) })); + if (!items.length) continue; + setlists.push({ title: sl.title || "Device", description: "", items }); added++; + } + if (!added) return alert("No programs found in that file."); + activeSL = setlists.length - 1; activeItem = 0; + saveSetlists(); renderSetlists(); applySetup(setlists[activeSL].items[0]); + alert("Loaded " + added + " set list(s) from the device."); } catch (e) { alert("Load failed: " + e.message); } } async function loadFromDevice() { diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index 779707f..84c350f 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 75b4771..4a2a7e7 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.7" # firmware version (the A/B updater pushes/compares this) +APP_VERSION = "0.0.8" # 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: @@ -62,13 +62,39 @@ P_SDA, P_SCL = board.GP8, board.GP9 P_RGB, P_BUZ, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14 P_JOYX, P_JOYY = board.GP26, board.GP27 -# ----- baked default grooves (used only if programs.json is missing/bad) ----- -DEFAULT_PROGRAMS = [ - ("Four on the floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"), - ("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"), - ("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"), - ("5 over 4", "t100;kick:4;claves:5~"), - ("Straight click", "t120;beep:4"), +# ----- BUILT-IN playlists: the standard defaults from the web editor, baked in here so they update with +# firmware and the user can't change/delete them. User playlists live separately in programs.json +# (pushed from the editor) and never touch these. (ASCII only - it's pushed 7-bit + the fonts are ASCII.) +BUILTIN_SETLISTS = [ + ("Styles", [ + ("Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"), + ("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"), + ("Purdie half-time shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"), + ("Samba (2/4)", "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."), + ("Nanigo (6/8 bembe)", "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"), + ("6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"), + ("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"), + ("5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"), + ]), + ("Practice", [ + ("5 over 4 polyrhythm", "t100;kick:4;claves:5~"), + ("3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"), + ("2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"), + ("Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"), + ("Accents - cycle the pads", "t92;kick:4=X..X;snare:4=.X.X;hatClosed:4/2"), + ("Tempo builder 80 up", "t80;woodblock:4;rmp80/4/4"), + ("Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"), + ]), + ("Song (continuous)", [ + ("Intro - hats & kick", "t88;b8;kick:4=X.x.;hatClosed:4/2=gggggggg"), + ("Groove in - backbeat", "t88;b16;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"), + ("Half-time shuffle", "t92;b12;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"), + ("Build - ramp 92-120", "t92;b16;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"), + ("Four-on-the-floor (909)", "t124;b18;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"), + ("Samba break (2/4)", "t116;b24;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."), + ("Peak - 16ths", "t132;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"), + ("Outro - ramp down", "t132;b8;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"), + ]), ] # ============================== COLOURS (0xRRGGBB; displayio handles 565) ============================== @@ -87,7 +113,7 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare GM_DEFAULT = 37 MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost MAXLANES = 5 # lanes shown on the pad grid (extras still play) -GRID_TOP = 158 # top of the pad grid (leaves room for time/bar/ramp rows) +GRID_TOP = 158 # top of the pad grid (leaves room for time/bar/ramp/tab rows) LOG_TOP, LOG_ROWH, LOG_ROWS = 302, 16, 9 # practice-history log area (below the pad grid) MIN_LOG_SEC = 5 # don't log plays shorter than this PAD_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost @@ -249,15 +275,28 @@ def _parse_lane(tok): else: levels.append(0) return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute} -def load_programs(): +def _slkey(t): # normalise a title for built-in/user de-duplication + return "".join(c.lower() for c in t if c.isalnum()) +def load_user_setlists(): + # User playlists from /programs.json (pushed by the editor). New {setlists:[{title,programs:[..]}]} form, + # or the old flat {programs:[..]} (one list). Built-ins are baked in BUILTIN_SETLISTS, never here. try: - with open("/programs.json") as f: - d = json.load(f) - progs = [(p["name"], p["prog"]) for p in d["programs"]] - if progs: return progs + with open("/programs.json") as f: d = json.load(f) except Exception as e: - print("programs.json:", e) - return DEFAULT_PROGRAMS + print("programs.json:", e); return [] + def items_of(pl): return [(p.get("name", "?"), p.get("prog", "")) for p in pl if p.get("prog")] + out = [] + try: + if isinstance(d.get("setlists"), list): + for sl in d["setlists"]: + it = items_of(sl.get("programs", [])) + if it: out.append((sl.get("title", "My set list"), it)) + elif isinstance(d.get("programs"), list): + it = items_of(d["programs"]) + if it: out.append((d.get("title", "My set list"), it)) + except Exception as e: + print("setlists:", e) + return out # ============================== GT911 TOUCH ============================== class GT911: @@ -355,7 +394,7 @@ 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.programs = load_programs() + 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 for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i] @@ -418,9 +457,26 @@ class App: self.dirty = True # ---------- program ---------- + def rebuild_setlists(self): + # built-in playlists first (read-only), then user playlists from programs.json (a baked title always wins) + self.setlists = [{'title': t, 'items': it, 'builtin': True} for t, it in BUILTIN_SETLISTS] + seen = set(_slkey(t) for t, _ in BUILTIN_SETLISTS) + for t, it in load_user_setlists(): + if _slkey(t) in seen: continue + seen.add(_slkey(t)); self.setlists.append({'title': t, 'items': it, 'builtin': False}) + if self.sl >= len(self.setlists): self.sl = 0 + def switch_setlist(self, delta=1): + if len(self.setlists) < 2: return + was = self.running + if was: self.running = False; self._log_play() + self.sl = (self.sl + delta) % len(self.setlists) + self.load(0) + if was: self.running = True; self._reset_clock(); self._start_play() + self.led_rest(); self.draw_meters() def load(self, i): - n = len(self.programs); self.idx = i % n - self.name, prog = self.programs[self.idx] + items = self.setlists[self.sl]['items'] + self.idx = i % len(items) + 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._reset_clock(); self.draw_bpm(); self.draw_status(); self.draw_train() @@ -549,7 +605,9 @@ class App: if pt: self._touchSeen = nowms if not self._touchDown: - self._touchDown = True; self._tap_log(pt[0], pt[1]) + 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]) 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) @@ -571,14 +629,16 @@ 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): # title (bright) + track number set apart (dim, right) - self._place(self.g_name, self.name[:22], 12, 130, C_TXT, C_BG, FONT_M) - self._place(self.g_idx, "%d/%d" % (self.idx + 1, len(self.programs)), 0, 134, - C_DIM, C_BG, FONT_S, right_edge=WIDTH - 12) + def draw_status(self): # set-list tab (small, tap to switch) 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'])), + 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) def draw_train(self): # ramp + gap-trainer indicators (symbol + params), when set g = self.g_train while len(g): g.pop() - x = 12; y = 104 + x = 12; y = 100 if self.ramp: up = self.ramp['amt'] >= 0 pts = [(0, 9), (12, 9), (12, 0)] if up else [(0, 0), (0, 9), (12, 9)] # rising / falling ramp @@ -739,14 +799,13 @@ class App: except Exception: pass elif cmd == 0x02: # version query -> reply 0x03 + APP_VERSION if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x03]) + APP_VERSION.encode() + bytes([0xF7])) - elif cmd == 0x10: # write /programs.json pushed from the editor, then reload + elif cmd == 0x10: # write /programs.json (user playlists) pushed from the editor try: with open("/programs.json", "wb") as f: f.write(bytes(sx[2:])) - self.programs = load_programs(); self.idx = min(self.idx, len(self.programs) - 1) - self.load(self.idx) - if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK ok - except OSError: - if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: read-only (editor mode) + self.rebuild_setlists(); self.load(0) # built-ins untouched; show the refreshed lists + self._ack(True) + except Exception: + self._ack(False) # read-only (editor mode) etc. # A/B firmware update, sent as small flow-controlled chunks (a single huge SysEx overruns the # USB-MIDI input buffer and arrives corrupt). begin(0x21,len) -> data(0x22)* -> commit(0x23). elif cmd == 0x21: # BEGIN: open the staging file; sx[2:6] = expected length (7-bit) diff --git a/pico-cp/programs.json b/pico-cp/programs.json index dd42d4d..2a81802 100644 --- a/pico-cp/programs.json +++ b/pico-cp/programs.json @@ -1,97 +1,3 @@ { - "title": "PolyMeter", - "programs": [ - { - "name": "Four on the floor", - "prog": "t120;kick:4;snare:4=.x.x;hatClosed:4/2" - }, - { - "name": "Swing ride", - "prog": "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x" - }, - { - "name": "Purdie half shuffle", - "prog": "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x" - }, - { - "name": "Samba (2/4)", - "prog": "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX." - }, - { - "name": "Nanigo (6/8 bembe)", - "prog": "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x" - }, - { - "name": "6/8 groove", - "prog": "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2" - }, - { - "name": "7/8 (2+2+3)", - "prog": "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2" - }, - { - "name": "5/4 (3+2)", - "prog": "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2" - }, - { - "name": "5 over 4 polyrhythm", - "prog": "t100;kick:4;claves:5~" - }, - { - "name": "3 over 2 hemiola", - "prog": "t96;woodblock:2;cowbell:3~" - }, - { - "name": "2 & 4 & 3 per bar", - "prog": "t100;kick:3;cowbell:2~;claves:4~" - }, - { - "name": "Triplet hats", - "prog": "t100;kick:4;snare:4=.x.x;hatClosed:4/3" - }, - { - "name": "Accents", - "prog": "t92;kick:4=X..X;snare:4=.X.X;hatClosed:4/2" - }, - { - "name": "Tempo builder 80+", - "prog": "t80;woodblock:4;rmp80/4/4" - }, - { - "name": "Gap trainer 2/2", - "prog": "t100;kick:4;hatClosed:4/2;tr2/2" - }, - { - "name": "Intro - hats+kick", - "prog": "t88;kick:4=X.x.;hatClosed:4/2=gggggggg" - }, - { - "name": "Groove in", - "prog": "t88;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2" - }, - { - "name": "Half-time shuffle", - "prog": "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x" - }, - { - "name": "Build 92 to 120", - "prog": "t92;kick:4;snare:4=.X.X;hatClosed:4/2" - }, - { - "name": "Four-floor (909)", - "prog": "t124;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X" - }, - { - "name": "Samba break", - "prog": "t116;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX." - }, - { - "name": "Peak - 16ths", - "prog": "t132;kick:4=X..x;snare:4=.X.X;hatClosed:4/4" - }, - { - "name": "Outro", - "prog": "t132;kick:4=X..x;hatClosed:4/2=gggggggg" - } - ] + "setlists": [] }