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": []
}