diff --git a/build.sh b/build.sh
index fc5461c..9b8919f 100755
--- a/build.sh
+++ b/build.sh
@@ -41,5 +41,6 @@ import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIR
with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
for f in ("code.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", "README.md"):
z.write("pico-cp/" + f, f)
+ z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive
print("zipped pm_k1_circuitpy.zip")
PY
diff --git a/editor.html b/editor.html
index 4c23048..85bbe3d 100644
--- a/editor.html
+++ b/editor.html
@@ -347,6 +347,8 @@
+
+
@@ -1069,6 +1071,50 @@ function shareSetlist() {
openShare("Share “" + (sl.title || "set list") + "”", shareLink("sl=" + setlistToCode(sl)), "Shares the whole set list (each item's settings).");
}
+/* 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. */
+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);
+}
+async function saveToDevice() {
+ const json = programsJSON(); if (!json) return alert("No set list selected to save.");
+ if (window.showSaveFilePicker) {
+ try {
+ const h = await showSaveFilePicker({ suggestedName: "programs.json",
+ types: [{ description: "PolyMeter programs", accept: { "application/json": [".json"] } }] });
+ const w = await h.createWritable(); await w.write(json); await w.close();
+ return alert("Saved programs.json — pick your CIRCUITPY drive and the device auto-reloads with these grooves.");
+ } catch (e) { if (e.name === "AbortError") return; } // cancelled or unsupported → fall through to download
+ }
+ const a = document.createElement("a");
+ a.href = URL.createObjectURL(new Blob([json], { type: "application/json" }));
+ a.download = "programs.json"; a.click(); URL.revokeObjectURL(a.href);
+ alert("Downloaded programs.json — drag it onto the device's CIRCUITPY drive (it auto-reloads).");
+}
+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.");
+ } catch (e) { alert("Load failed: " + e.message); }
+}
+async function loadFromDevice() {
+ if (window.showOpenFilePicker) {
+ try {
+ const [h] = await showOpenFilePicker({ types: [{ description: "PolyMeter programs", accept: { "application/json": [".json"] } }] });
+ return importPrograms(await (await h.getFile()).text());
+ } catch (e) { if (e.name === "AbortError") return; }
+ }
+ const inp = document.createElement("input"); inp.type = "file"; inp.accept = ".json,application/json";
+ inp.onchange = () => { if (inp.files[0]) inp.files[0].text().then(importPrograms); };
+ inp.click();
+}
+
// Apply a shared link on load. Returns true if it set the metronome state.
function applyHashShare() {
const h = location.hash || "";
@@ -1292,6 +1338,8 @@ $("shortcutsOverlay").addEventListener("click", (e) => { if (e.target.id === "sh
$("exportBtn").addEventListener("click", () => { $("trayMenu").hidden = true; exportAll(); });
$("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $("importFile").click(); });
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
+$("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); });
+$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
$("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); });
diff --git a/info-kit.html b/info-kit.html
index ccd2e6f..1286a4b 100644
--- a/info-kit.html
+++ b/info-kit.html
@@ -133,9 +133,10 @@
An alternative firmware that makes the Pico mount as a USB drive carrying the
- firmware and your tracks (programs.json) — edit on the web and reprogram it without
- Thonny. Coming next: one‑click "Save to device" and USB‑MIDI audio out to your computer's speakers.
- The MicroPython firmware above stays the simple, rock‑solid option.
programs.json) and a copy of this editor — design grooves on the
+ web and write them straight to the device, no Thonny. A full lanes/pads display with anti‑aliased
+ text drives the touchscreen. The MicroPython firmware above stays the simple, rock‑solid option.
+ (USB‑MIDI audio out to your computer's speakers is the next step.)
Download CircuitPython bundle ↓ Source + README ↗ @@ -143,8 +144,11 @@
CIRCUITPY drive appears.CIRCUITPY (code.py + programs.json) — it's a normal drive, just drag them on.programs.json auto‑reloads the grooves. See the bundle's README for calibration flags.CIRCUITPY (it's a normal drive — just drag everything on). It runs on boot.CIRCUITPY drive. The Pico
+ auto‑reloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it
+ downloads programs.json to drag on.) 📥 Load from device reads it back.