From 7d743c18a113664dde8ce621a01c172917cdbbb7 Mon Sep 17 00:00:00 2001 From: Me Here Date: Fri, 29 May 2026 00:38:08 -0500 Subject: [PATCH] =?UTF-8?q?PM=5FK-1:=20appliance=20model=20=E2=80=94=20pus?= =?UTF-8?q?h-programming=20over=20USB-MIDI,=20on-device=20practice=20log,?= =?UTF-8?q?=20swing=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware (pico-cp/): the Pico now owns its filesystem by default (boot.py), so it can save the practice log and write editor-pushed set lists; the drive is read-only to the computer, which also protects the firmware. Hold button A at power-on for editor mode (drive writable; universal drag). - Replaced the on-screen touch buttons with an on-device PRACTICE LOG (time · BPM · duration · track), newest-first, persisted to /history.json next to programs.json. Plays < 5s aren't logged; tap a row twice to delete it. Real timestamps once the editor syncs the clock. - USB-MIDI SysEx receiver: clock-set (0x01 -> RTC) and program-push (0x10 -> write programs.json, reload, ACK/NAK). disable autoreload so our own writes never self-restart. - Fixed swing: the parser was discarding the 's' flag, so /2s never swung. Now the scheduler uses a per-step duration with long-short (2:1, SWING_RATIO 2/3) pairs on even subdivisions, matching the web engine. Verified: ride:4/2s -> 266/133ms vs straight 200/200. Editor (editor.html): requestMIDIAccess({sysex:true}); Save to device now pushes programs.json as SysEx to the device (+ clock sync), waits for ACK, shows "Saved ✓", and falls back to downloading the file (drag onto the drive in editor mode) when no device answers. Heartbeat also keeps the clock synced. Web MIDI works in Chromium AND Firefox; the drag fallback covers any browser/OS incl. Safari. Docs (pico-cp/README, info-kit, README) updated for the two modes, push programming, and the log. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 +- editor.html | 84 +++++++---- info-kit.html | 30 ++-- pico-cp/README.md | 121 +++++++--------- pico-cp/__pycache__/code.cpython-312.pyc | Bin 39094 -> 45320 bytes pico-cp/boot.py | 30 ++-- pico-cp/code.py | 176 ++++++++++++++++------- 7 files changed, 281 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 252e714..2be39e2 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,13 @@ flashing steps. Firmware lives in **`pico/`**: It parses the same program strings as the web editor. Flash MicroPython, copy `main.py`, edit the `PROGRAMS` list to change grooves. Download: `/pico-main.py`. - **`pico/gen_font.py`** — generates the baked anti‑aliased fonts (used by both firmwares). -- **`pico-cp/`** — a **CircuitPython** edition (download `/pm_k1_circuitpy.zip`): the Pico mounts as a - USB drive carrying the firmware + your `programs.json` + a copy of the editor, with a full lanes/pads - touchscreen display. Design grooves on the web and **Save to device** straight onto the drive (the - editor's ⋯ menu), and play it **out your computer's speakers over USB‑MIDI** (the editor's - **🎹 Device audio** button). The MicroPython build stays the simple, no‑computer option. +- **`pico-cp/`** — a **CircuitPython** edition (download `/pm_k1_circuitpy.zip`): a self‑contained + appliance. The Pico mounts as a USB drive carrying the firmware + your `programs.json` + an offline + editor, drives a full lanes/pads touchscreen, **logs practice to `history.json`** on the device, takes + set lists **pushed from the editor over USB‑MIDI** (with a universal download‑and‑drag fallback), and + plays **out your computer's speakers over USB‑MIDI** (the editor's **🎹 Device audio**). By default the + firmware owns the drive (read‑only to the computer, so it's protected); hold **button A** at power‑on for + editor mode (drive writable). The MicroPython build stays the simple, no‑computer option. ## Keyboard shortcuts diff --git a/editor.html b/editor.html index e82f8d7..1f51cc8 100644 --- a/editor.html +++ b/editor.html @@ -1078,20 +1078,31 @@ 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 - } +function _downloadPrograms(json) { 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)."); +} +async function saveToDevice() { + const json = programsJSON(); if (!json) return alert("No set list selected to save."); + // 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 + const bytes = [0xF0, 0x7D, 0x10]; + for (let i = 0; i < ascii.length; i++) bytes.push(ascii.charCodeAt(i) & 0x7F); + 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; } + _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." + : "No device answered over USB-MIDI — downloaded programs.json. Connect the device (Chromium/Firefox) and try again, or boot it holding button A and drag the file onto the CIRCUITPY drive."); + return; + } + // Universal fallback (any browser / OS): download + drag onto the drive in editor mode (hold A at power-on). + _downloadPrograms(json); + alert("Downloaded programs.json — boot the device holding button A (editor mode) and drag it onto the CIRCUITPY drive."); } function importPrograms(text) { try { @@ -1117,41 +1128,56 @@ async function loadFromDevice() { /* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through this page's synth, so the device drives sound out the computer's speakers, locked to its clock. */ -let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0; +let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null; function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; } -function _heartbeat(on) { // tell the device a host is listening, so it shows "MIDI" + mutes its buzzer - clearInterval(_midiBeat); _midiBeat = 0; - if (on) _midiBeat = setInterval(() => { - if (_midiAccess) for (const out of _midiAccess.outputs.values()) { try { out.send([0xFE]); } catch (_) {} } // Active Sensing - }, 250); +function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; } +function _send(bytes) { for (const o of _midiOutputs()) { try { o.send(bytes); } catch (_) {} } } +function _clockSysex() { const d = new Date(); // F0 7D 01 yr-2000 mo dd hh mm ss F7 -> sets the device RTC + return [0xF0, 0x7D, 0x01, d.getFullYear() - 2000, d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), 0xF7]; } +async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/receive SysEx); cached + if (_midiAccess) return true; + if (!navigator.requestMIDIAccess) return false; + try { _midiAccess = await navigator.requestMIDIAccess({ sysex: true }); } + catch (e) { return false; } + _midiAccess.onstatechange = _wireMidi; _wireMidi(); + return true; } +function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); } function onDeviceMidi(e) { - const d = e.data; if (!d || d.length < 3) return; - if ((d[0] & 0xf0) === 0x90 && d[2] > 0) { // Note On - const v = d[2], gain = v >= 110 ? 1.0 : v >= 70 ? 0.6 : 0.25; // accent / normal / ghost + const d = e.data; if (!d) return; + if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply to a program push + if (_saveCb) { const cb = _saveCb; _saveCb = null; cb(d[2] === 0x7F); } // 0x7F ACK / 0x7E NAK + return; + } + if (_midiOn && (d[0] & 0xf0) === 0x90 && d.length >= 3 && d[2] > 0) { // Note On -> voice it + const v = d[2], gain = v >= 110 ? 1.0 : v >= 70 ? 0.6 : 0.25; // accent / normal / ghost try { ensureAudio(); playInstrument(GM_NUM[d[1]] || "beep", audioCtx.currentTime, gain); } catch (_) {} const b = $("midiBtn"); if (b) { b.style.boxShadow = "0 0 0 2px #2fe07a"; clearTimeout(_midiFlash); _midiFlash = setTimeout(() => b.style.boxShadow = "", 90); } } } -function _bindMidi() { for (const inp of _midiInputs()) inp.onmidimessage = _midiOn ? onDeviceMidi : null; } +function _heartbeat(on) { // while Device audio is on: tell the device a host listens + keep its clock synced + clearInterval(_midiBeat); _midiBeat = 0; + if (on) { let k = 0; _midiBeat = setInterval(() => { + _send([0xFE]); // Active Sensing -> device shows "MIDI" + mutes buzzer + if ((k++ % 12) === 0) _send(_clockSysex()); // resync the clock ~every 3s + }, 250); } +} function updateMidiBtn() { const b = $("midiBtn"); if (!b) return; if (!_midiOn) { b.textContent = "🎹 Device audio"; b.classList.remove("primary"); b.style.boxShadow = ""; return; } const names = _midiInputs().map((i) => i.name || "MIDI"); - b.textContent = names.length ? "🎹 " + names[0].slice(0, 16) : "🎹 no device"; // shows the connected MIDI device + b.textContent = names.length ? "🎹 " + names[0].slice(0, 16) : "🎹 no device"; b.classList.add("primary"); } async function toggleDeviceAudio() { - if (_midiOn) { _midiOn = false; _heartbeat(false); _bindMidi(); updateMidiBtn(); return; } - if (!navigator.requestMIDIAccess) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome or Edge."); - try { if (!_midiAccess) { _midiAccess = await navigator.requestMIDIAccess(); _midiAccess.onstatechange = () => { _bindMidi(); updateMidiBtn(); }; } } - catch (e) { return alert("MIDI access was denied."); } + if (_midiOn) { _midiOn = false; _heartbeat(false); updateMidiBtn(); return; } // inputs stay bound (for Save ACKs) + if (!(await _ensureMidi())) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome, Edge, or Firefox."); ensureAudio(); if (audioCtx && audioCtx.state === "suspended") audioCtx.resume(); - _midiOn = true; _heartbeat(true); _bindMidi(); updateMidiBtn(); + _midiOn = true; _heartbeat(true); updateMidiBtn(); const names = _midiInputs().map((i) => i.name || "MIDI"); alert(names.length - ? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green on each note received." - : "Device audio is armed, but NO MIDI input is connected.\nPlug in the PM_K-1 running the CircuitPython firmware — it should appear here as a MIDI device. (New devices connect automatically.)"); + ? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green per note." + : "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically."); } // Apply a shared link on load. Returns true if it set the metronome state. diff --git a/info-kit.html b/info-kit.html index c939e49..2cd9c94 100644 --- a/info-kit.html +++ b/info-kit.html @@ -130,28 +130,28 @@
- CircuitPython edition — USB drive + editor (experimental) + CircuitPython edition — self‑contained appliance (USB drive · push programming · MIDI audio · practice log)
-

An alternative firmware that makes the Pico mount as a USB drive carrying the - firmware, your tracks (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, and it plays out your computer's speakers over USB‑MIDI (the - editor's 🎹 Device audio button voices it, locked to the device clock). The MicroPython - firmware above stays the simple, rock‑solid option.

+

An alternative firmware that turns the Pico into a self‑contained appliance: it mounts as a + USB drive carrying the firmware, your tracks and an offline copy of this editor; drives a full + lanes/pads touchscreen; logs your practice to history.json on the device; takes new + set lists pushed from the editor over USB‑MIDI; and plays out your computer's speakers over + USB‑MIDI. By default the firmware owns the drive (read‑only to the computer — so it can log and + can't be accidentally erased); hold button A at power‑on for editor mode (drive writable). The + MicroPython firmware above stays the simple, rock‑solid option.

Download CircuitPython bundle ↓ Source + README ↗

  1. Flash CircuitPython (raspberry_pi_pico) - via BOOTSEL → the CIRCUITPY drive appears.
  2. -
  3. Unzip the bundle onto CIRCUITPY (it's a normal drive — just drag everything on). It runs on boot.
  4. -
  5. Reprogram it from the web: build a set list in the editor, then the - set‑list menu → 📟 Save to device → pick the 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.
  6. -
  7. Play through your computer: in the editor (Chrome/Edge) click 🎹 Device audio, then - press play on the device — its full groove sounds through your speakers over USB‑MIDI, in sync.
  8. + via BOOTSEL, unzip the bundle onto CIRCUITPY, and power‑cycle. It boots into appliance mode. +
  9. Program it from the web: build a set list in the editor (Chrome/Edge/Firefox), + then the set‑list menu → 📟 Save to device. It's pushed over USB‑MIDI and the device shows + Saved ✓. (Fallback for any browser: it downloads programs.json — boot holding A and drag it on.)
  10. +
  11. Play through your computer: click 🎹 Device audio, then press play on the device — the full + groove sounds through your speakers over USB‑MIDI, in sync; the screen shows a MIDI badge and the buzzer mutes.
  12. +
  13. Practice log: plays over 5 s appear at the bottom of the screen (time · BPM · duration · track); tap a row twice to delete.
diff --git a/pico-cp/README.md b/pico-cp/README.md index 81047b5..3768677 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -1,62 +1,57 @@ -# PM_K‑1 "Kit" — CircuitPython edition (USB drive + editor) +# PM_K‑1 "Kit" — CircuitPython edition (USB drive · push programming · MIDI audio · practice log) -The **CircuitPython** firmware for the 52Pi EP‑0172 Pico kit. Unlike the MicroPython version -(`../pico/main.py`), this makes the Pico mount as a **USB drive (`CIRCUITPY`)** that carries the -firmware and your tracks — so you can edit on the web and reprogram it without Thonny. It runs the -same program‑string language as . +The **CircuitPython** firmware for the 52Pi EP‑0172 Pico kit, set up as a self‑contained appliance. +It runs the same program‑string language as . The simpler +**MicroPython** firmware (`../pico/main.py`) stays as a rock‑solid fallback — and the Pico can't be +bricked (BOOTSEL → drag a MicroPython `.uf2` back). -> **Status: experimental, phase 1.** This drives the screen/touch/joystick/buzzer and reads your -> grooves from `programs.json`. The editor's one‑click "Save to device" and USB‑MIDI audio‑to‑computer -> are landing in later phases. The simpler **MicroPython** firmware (`../pico/main.py`) remains the -> rock‑solid fallback — and the Pico can't be bricked (BOOTSEL → drag a MicroPython `.uf2` back). +What it does: drives the 3.5″ touchscreen with a lanes/pads display + anti‑aliased text, plays the +buzzer + RGB beat light, **logs your practice to `/history.json`**, accepts new set lists **pushed +from the web editor over USB‑MIDI**, and plays through your **computer's speakers** over USB‑MIDI. + +## Two power‑on modes (set by `boot.py`) + +- **Appliance mode — default (just plug in / power up).** The *firmware* owns the filesystem, so it + saves your practice log and writes set lists the editor pushes over USB‑MIDI. The drive is then + **read‑only to the computer** — which also **protects the firmware from accidental deletion**. +- **Editor mode — hold BUTTON A while plugging in.** The drive is **writable by the computer**, so you + can drag `programs.json` / `code.py` / fonts on from any OS or browser (the universal fallback). + Reset afterwards to return to appliance mode. ## Install -1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, and drop the CircuitPython `.uf2` for your board - onto the `RPI‑RP2` drive ( — or the Pico 2 / W - build). It reboots and a **`CIRCUITPY`** drive appears. -2. **Copy everything from the bundle** onto `CIRCUITPY` (drag‑and‑drop — it's a normal drive now): - - `code.py` (this firmware — runs on boot) - - `programs.json` (your grooves) - - `font_s.bin`, `font_m.bin`, `font_l.bin` (the anti‑aliased fonts — kept as files to save RAM) - - `editor.html` (an offline copy of the web editor, so the drive carries its own programmer) -3. It starts immediately. Editing `programs.json` (or re‑saving it from the editor) makes CircuitPython - **auto‑reload** with the new tracks. +1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, drop the CircuitPython `.uf2` onto `RPI‑RP2` + ( — Pico 2 / W builds also fine). A `CIRCUITPY` + drive appears. +2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py`, `programs.json`, + `font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the helper scripts. +3. **Power‑cycle** (so `boot.py` takes effect). It boots into appliance mode and runs. -## Play through the computer's speakers (USB-MIDI) +## Program it from the web (push over USB‑MIDI) -The board also shows up as a **USB-MIDI** device and sends a note on every click (a GM drum note per -lane, velocity by accent). Open the [editor](https://metronome.varasys.io/editor.html) in **Chrome/Edge**, -click **🎹 Device audio**, grant MIDI access, then press play *on the device* — the editor voices the -groove through its full synth, out your computer's speakers, locked to the device's clock. The button -shows the connected device's name and pulses green on each note; set `MUTE_BUZZER = True` in `code.py` -if you'd rather silence the on-board buzzer while doing this. +In the editor (Chrome / Edge / **Firefox**), build a set list → set‑list **⋯** menu → **📟 Save to device**. +The editor sends it to the Pico over USB‑MIDI (SysEx); the firmware writes `/programs.json`, reloads, and +acknowledges — the editor shows **Saved ✓**. **📥 Load from device** reads it back. -If the editor says **no MIDI input is connected**, copy **`boot.py`** onto `CIRCUITPY` too and -**power-cycle** the Pico (`boot.py` only runs on a full reset). It frees a USB endpoint (drops the -unused HID interface) so the MIDI port is guaranteed to appear alongside the drive. +*Universal fallback (any browser / OS, even Safari):* Save to device **downloads** `programs.json` when no +device answers — boot the Pico in **editor mode** (hold A) and drag the file onto the `CIRCUITPY` drive. -## Protect the firmware (so end users only see the editor + their tracks) +## Play through the computer's speakers -To stop someone accidentally deleting the firmware, **hide it** — the files keep running and -"Save to device" still works, but only `editor.html` + `programs.json` show in the file browser. -On the host, with the drive mounted, run the included helper (needs `fatattr`): +The Pico is a USB‑MIDI device and sends a note per click (GM drum note per lane, velocity by accent). +In the editor click **🎹 Device audio**, grant MIDI access, and press play *on the device* — the editor +voices the groove through its full synth, out your speakers, locked to the device's clock. While a host is +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. -``` -./protect-firmware.sh /media/$USER/CIRCUITPY # hides code.py, boot.py, font_*.bin, README, itself -``` +## Controls & the practice log -(Reveal again with `fatattr -h `.) For a **hard lock** — nothing on the drive can be changed -from the computer at all — put `storage.remount("/", readonly=True)` in `boot.py`; but then the -editor's *Save to device* can't write either, so you'd reprogram by temporarily removing that line -(or gating it behind a held button at power-on). Hiding is usually the right balance. - -## Controls (same as the MicroPython build) - -- **Touch:** on‑screen `◀◀ / ▶ / ▶▶` (prev · play/stop · next) and `− / TAP / +`. - **Joystick:** up/down = tempo, left/right = previous/next groove. -- **Button A (GP15)** play/stop · **Button B (GP14)** tap tempo. -- **RGB LED** flashes each beat; **buzzer** clicks (accent/normal/ghost). +- **Button A (GP15):** play / stop. **Button B (GP14):** tap tempo. +- **Touchscreen:** the bottom of the screen shows the **practice log** (time · BPM · duration · track) — + newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.** +- **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **buzzer** clicks to match. +- The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives power‑cycles. ## programs.json @@ -65,30 +60,24 @@ editor's *Save to device* can't write either, so you'd reprogram by temporarily "programs": [ { "name": "Four on the floor", "prog": "t120;kick:4;snare:4=.x.x;hatClosed:4/2" } ] } ``` -Each `prog` is a program string from the web editor. Add/replace entries and save — the device reloads. +Each `prog` is a program string from the editor (tempo, lanes, patterns, `/2` subdivision, `/2s` swing, +`(3,8)` Euclid, `~` polymeter, `@-3` dB). The push above is the easy way to update it. -**Easiest way to (re)program it:** in the editor (the web app, or the `editor.html` on the drive), build a -set list, then the set‑list **⋯** menu → **📟 Save to device** → pick the `CIRCUITPY` drive. In Chrome/Edge it -writes `programs.json` straight onto the drive (the Pico auto‑reloads); elsewhere it downloads the file to drag -on. **📥 Load from device** reads a `programs.json` back into a new set list. - -## Calibration (flip flags at the top of `code.py`) +## Calibration (flags at the top of `code.py`) - **Red/blue swapped:** flip `MADCTL` between `0x48` (default) and `0x40`. - **Colours look negative:** toggle `INVERT_COLORS`. -- **Taps land wrong:** set `TOUCH_DEBUG = True`, watch the serial output, then set +- **Taps land wrong:** set `TOUCH_DEBUG = True`, read the raw coords over USB serial, then set `TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y`. - **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`. -- **Computer audio:** `MIDI_ENABLED` (default on) sends the MIDI notes; `MUTE_BUZZER` silences the buzzer. -- **LED too bright / too dim:** change `LED_BRIGHTNESS` (0..1, default 0.15). -- **Screen tearing:** the SPI panel has no tearing-effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast - to minimise it — lower it only if the display is unstable. -- **Screen blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341 - instead of the 320×480 ST7796, the init/size need changing (this targets the 320×480 you have). -- **RGB LED** is driven by the core `neopixel_write` module — no library to install. If it stays dark, - your CircuitPython build is unusually missing that module (everything else still works). +- **Computer audio:** `MIDI_ENABLED` (default on); `MUTE_BUZZER` forces the buzzer off even standalone. +- **LED too bright/dim:** `LED_BRIGHTNESS` (0..1, default 0.15). +- **Screen tearing:** SPI panels have no tearing‑effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast + to minimise it — lower only if unstable. +- **Blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341 rather than + the 320×480 ST7796, the init/size need changing (this targets the 320×480 you have). +- **RGB LED** uses the core `neopixel_write` (no library to install). -If `code.py` ever errors, CircuitPython prints the traceback **on the screen and over USB serial** — -copy that to me and I'll fix it. - -The fonts are the same baked anti‑aliased blobs as the MicroPython build (see `../pico/gen_font.py`). +If `code.py` ever errors, CircuitPython prints the traceback **on the screen and over USB serial** — send +me that. The fonts are the baked anti‑aliased blobs from `../pico/gen_font.py`. `protect-firmware.sh` (hide +the firmware files) is mainly for editor mode — appliance mode already keeps the drive read‑only. diff --git a/pico-cp/__pycache__/code.cpython-312.pyc b/pico-cp/__pycache__/code.cpython-312.pyc index 9b95a0147b4d9dbfeb1af0914575bcbf940b84a7..c2baf00aaa27bb6e2a4c45ad7dab6aea13152337 100644 GIT binary patch delta 16772 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 dY>gY7;bZ6l8`UR{$ZzMa7X{QvY4B(nej delta 11698 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 R0*{yyv}2j(%2FQie*oBkEC>Jq diff --git a/pico-cp/boot.py b/pico-cp/boot.py index 095b8ad..4e3f74f 100644 --- a/pico-cp/boot.py +++ b/pico-cp/boot.py @@ -1,10 +1,22 @@ -# boot.py — runs once before USB connects (hard reset / power-cycle to apply). -# Guarantees the device shows up as a USB-MIDI port so the web editor's "Device audio" -# can hear it. We don't use HID, so disabling it frees a USB endpoint for MIDI on the -# Pico (which also exposes the CIRCUITPY drive + serial at the same time). -import usb_hid, usb_midi -try: - usb_hid.disable() -except Exception: - pass +# boot.py — runs once at power-on (before USB connects); decides who owns the filesystem. +# +# DEFAULT = appliance mode: the FIRMWARE owns the drive, so it can save your practice log to +# /history.json and write /programs.json that the editor pushes over USB-MIDI. The drive is then +# READ-ONLY to the computer — which also protects the firmware from accidental deletion. +# +# HOLD BUTTON A (GP15) WHILE PLUGGING IN = editor mode: the drive is writable by the computer, so +# you can drag programs.json / code.py on from any OS or browser (the universal fallback). Reset +# afterwards to return to appliance mode. +# +# Also frees a USB endpoint (disables unused HID) and makes sure USB-MIDI is available. +import board, digitalio, storage, usb_hid, usb_midi +try: usb_hid.disable() +except Exception: pass usb_midi.enable() +a = digitalio.DigitalInOut(board.GP15) +a.switch_to_input(pull=digitalio.Pull.UP) +appliance = a.value # value True (pull-up, not pressed) -> appliance mode +a.deinit() +if appliance: + try: storage.remount("/", readonly=False) # writable by code, read-only to the computer + except Exception: pass diff --git a/pico-cp/code.py b/pico-cp/code.py index 1c305bb..3f9dd87 100644 --- a/pico-cp/code.py +++ b/pico-cp/code.py @@ -16,7 +16,12 @@ # # Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md. -import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc +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 +try: + import rtc # set from the editor's clock SysEx so the log has real timestamps +except ImportError: + rtc = None try: # CircuitPython 9.x from fourwire import FourWire from busdisplay import BusDisplay @@ -79,6 +84,8 @@ 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) +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 PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost @@ -178,9 +185,10 @@ def _parse_lane(tok): sound, _, rest = tok.partition(':') pattern = None if '=' in rest: rest, _, pattern = rest.partition('=') - sub = 1 + sub = 1; swing = False if '/' in rest: - rest, _, sd = rest.partition('/'); sd = sd.rstrip('s') + rest, _, sd = rest.partition('/') + swing = sd.endswith('s'); sd = sd.rstrip('s') # "/2s" = swung eighths sub = int(sd) if sd.isdigit() else 1 groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4] beats = sum(groups); starts = set(); acc = 0 @@ -195,7 +203,7 @@ 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, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute} + return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute} def load_programs(): try: @@ -290,7 +298,8 @@ class App: self.touch = GT911(self.i2c) self.midi = usb_midi.ports[1] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 1) else None self.midi_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None - self._mbuf = bytearray(16); self.midi_host = False; self.last_midi_in = 0.0 + self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0 + self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs) self.led = RGB(P_RGB) self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) self.buz_off = 0 @@ -301,13 +310,18 @@ class App: self._touchDown = False; self._touchSeen = 0 self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0) self.programs = load_programs() - self.buttons = [] self.dirty = True self.pad_pal = displayio.Palette(8) for i in range(4): self.pad_pal[i] = PAD_DIM[i]; self.pad_pal[i + 4] = PAD_LIT[i] self.lane_pads = []; self.lane_lit = [] + # practice history — persisted to /history.json (next to programs.json) when we own the filesystem + self.can_write = self._probe_write() + self.log = self._load_log() + self.play_start = None; self.play_bpm = 0; self.play_name = "" + self._armed = None; self.log_rows = [] self._build_scene() self.load(0) + self.draw_log() def _btn(self, pin): d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP @@ -325,17 +339,9 @@ class App: self.g_name = displayio.Group(); root.append(self.g_name) # item index + name self.g_midi = displayio.Group(); root.append(self.g_midi) # "MIDI" indicator (top-right) when a host is listening self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes × step pads - # buttons (rects static; labels in per-button groups so play can toggle) - bw, bh = 96, 56; gap = (WIDTH - 3*bw)//4; xs = [gap, gap*2+bw, gap*3+bw*2] - self.btn_lbl = {} - rows = [(300, ("prev", "play", "next")), (372, ("minus", "tap", "plus"))] - for y, keys in rows: - for x, key in zip(xs, keys): - root.append(rect(x, y, bw, bh, C_BTN)) - root.append(rect(x, y, bw, 2, C_PANEL)); root.append(rect(x, y+bh-2, bw, 2, C_PANEL)) - lg = displayio.Group(); root.append(lg); self.btn_lbl[key] = (lg, x+bw//2, y+bh//2) - self.buttons.append((x, y, bw, bh, key)) - self._label(key) + 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) + # (no on-screen buttons — transport is the joystick + buttons A/B; touch deletes log rows) def _place(self, group, s, x, y, fg, bg, font, right_edge=None): while len(group): group.pop() @@ -347,11 +353,6 @@ class App: while len(group): group.pop() tg, w, h = make_text(s, font, fg, bg); tg.x = cx - w//2; tg.y = cy - h//2; group.append(tg) self.dirty = True - def _label(self, key): - sym = {"prev": "◀◀", "next": "▶▶", "minus": "-", "plus": "+", "tap": "TAP", - "play": "■" if self.running else "▶"}[key] - lg, cx, cy = self.btn_lbl[key] - self._center(lg, sym, cx, cy, C_GREEN if key == "play" else C_TXT, C_BTN, FONT_M) # ---------- program ---------- def load(self, i): @@ -360,16 +361,20 @@ class App: self.bpm, self.lanes = parse_program(prog) self.master = self.lanes[0] self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid() - def _lane_dur(self, L): + 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 m = self.lanes[0]; master_bar = beat * (m['steps'] // m['sub']) return int(master_bar / L['steps']) - return int(beat / L['sub']) # straight: a step = one beat / subdivision + sub = L['sub'] + if L['swing'] and sub % 2 == 0: # swing even subdivisions: long–short (2:1) pairs + pair = beat / (sub // 2) + return int(pair * 2 / 3) if (step % sub) % 2 == 0 else int(pair / 3) + return int(beat / sub) # straight: a step = one beat / subdivision def _reset_clock(self): now = time.monotonic_ns() for L in self.lanes: - L['next'] = now; L['step'] = -1; L['dur'] = self._lane_dur(L) + L['next'] = now; L['step'] = -1 # ---------- audio + light ---------- def click(self, level): @@ -390,18 +395,19 @@ class App: # ---------- transport ---------- def toggle(self): self.running = not self.running - if self.running: self._reset_clock() - else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads() - self.draw_status(); self._label("play") + if self.running: self._reset_clock(); self._start_play() + else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads(); self._log_play() + self.draw_status() def set_bpm(self, v): v = max(30, min(300, v)) if v != self.bpm: self.bpm = v - for L in self.lanes: L['dur'] = self._lane_dur(L) self.draw_bpm() def goto(self, i): - was = self.running; self.load(i); self._label("play") - if was: self.running = True; self._reset_clock() + was = self.running + if was: self.running = False; self._log_play() # close out the track that was playing + self.load(i) + if was: self.running = True; self._reset_clock(); self._start_play() def tap(self): now = time.monotonic() if not hasattr(self, '_taps'): self._taps = [] @@ -425,7 +431,7 @@ class App: if lvl > 0: fired.append(lvl) self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane - L['next'] += L['dur']; adv = True + L['next'] += self._step_dur(L, L['step']); adv = True if adv and li < len(self.lane_pads): self._move_playhead(li, L['step']) if fired: best = max(fired, key=lambda l: PRIO.get(l, 0)) @@ -461,31 +467,22 @@ class App: if pt: self._touchSeen = nowms if not self._touchDown: - self._touchDown = True; self.hit(pt[0], pt[1]) + self._touchDown = True; self._tap_log(pt[0], pt[1]) elif self._touchDown and (nowms - self._touchSeen) > 0.14: self._touchDown = False - # MIDI host present? the editor sends a heartbeat (Active Sensing) while "Device audio" is on + # USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs) if self.midi_in is not None: - try: - if self.midi_in.readinto(self._mbuf): self.last_midi_in = nowms - except Exception: pass + try: n = self.midi_in.readinto(self._mbuf) + except Exception: n = 0 + if n: + self.last_midi_in = nowms + self._feed_midi(self._mbuf, n) host = bool(self.last_midi_in) and (nowms - self.last_midi_in) < 1.0 if host != self.midi_host: self.midi_host = host if host: self.buz.duty_cycle = 0 # silence the buzzer when the computer takes over self.led_off(); self.draw_midi() - def hit(self, x, y): - for bx, by, bw, bh, key in self.buttons: - if bx <= x <= bx+bw and by <= y <= by+bh: - if key == 'play': self.toggle() - elif key == 'prev': self.goto(self.idx - 1) - elif key == 'next': self.goto(self.idx + 1) - elif key == 'minus': self.set_bpm(self.bpm - 1) - elif key == 'plus': self.set_bpm(self.bpm + 1) - elif key == 'tap': self.tap() - return - # ---------- 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) @@ -532,6 +529,89 @@ class App: self.lane_lit[li] = -1 self.dirty = True + # ---------- practice history (saved to /history.json, next to programs.json) ---------- + def _probe_write(self): + try: + with open("/.wtest", "w") as f: f.write("1") + try: os.remove("/.wtest") + except Exception: pass + return True + except OSError: + return False # editor mode: the computer owns the FS + def _load_log(self): + try: + with open("/history.json") as f: return json.load(f).get("log", []) + except Exception: + return [] + def _save_log(self): + if not self.can_write: return + try: + with open("/history.json", "w") as f: json.dump({"log": self.log[:200]}, f) + except OSError: + self.can_write = False + def _start_play(self): + self.play_start = time.monotonic(); self.play_bpm = self.bpm; self.play_name = self.name + def _log_play(self): + if self.play_start is None: return + dur = int(time.monotonic() - self.play_start); self.play_start = None + if dur < MIN_LOG_SEC: return # skip plays under 5 seconds + t = time.localtime() + self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm, + "dur": dur, "name": self.play_name}) + del self.log[200:]; self._armed = None + self._save_log(); self.draw_log() + def draw_log(self): + g = self.g_log + while len(g): g.pop() + self.log_rows = [] + hdr, w, h = make_text("PRACTICE LOG", FONT_S, C_MUTE, C_BG); hdr.x = 10; hdr.y = LOG_TOP; g.append(hdr) + if not self.log: + tg, w, h = make_text("plays over 5s show here", FONT_S, C_DIM, C_BG); tg.x = 10; tg.y = LOG_TOP + LOG_ROWH; g.append(tg) + self.dirty = True; return + y = LOG_TOP + LOG_ROWH + 2 + for idx in range(min(LOG_ROWS, len(self.log))): + e = self.log[idx]; armed = (idx == self._armed) + dur = "%d:%02d" % (e["dur"] // 60, e["dur"] % 60) + line = "%s%s %3d %5s %s" % ("x " if armed else "", e.get("t", "--:--"), e["bpm"], dur, e["name"][:16]) + tg, w, h = make_text(line, FONT_S, C_AMBER if armed else C_TXT, C_BG); tg.x = 10; tg.y = y; g.append(tg) + self.log_rows.append((y - 2, y + LOG_ROWH - 2, idx)) + y += LOG_ROWH + self.dirty = True + def _tap_log(self, x, ty): + for y0, y1, idx in self.log_rows: + if y0 <= ty <= y1: + if self._armed == idx: del self.log[idx]; self._armed = None; self._save_log(); self.draw_log() # confirm delete + else: self._armed = idx; self.draw_log() # arm (tap again) + return + if self._armed is not None: self._armed = None; self.draw_log() # tapped elsewhere -> cancel + + # ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs) ---------- + def _feed_midi(self, buf, n): + for i in range(n): + b = buf[i] + if b == 0xF0: self._sx = bytearray(); self._sxon = True + elif b == 0xF7: + if self._sxon: self._handle_sysex(self._sx) + self._sxon = False + elif b >= 0xF8: pass # real-time (e.g. Active Sensing 0xFE) — ignore + elif self._sxon: + if len(self._sx) < 6000: self._sx.append(b) + else: self._sxon = False # overflow guard + def _handle_sysex(self, sx): + if len(sx) < 2 or sx[0] != 0x7D: return # 0x7D = our (educational) manufacturer id + cmd = sx[1] + if cmd == 0x01 and len(sx) >= 8 and rtc is not None: # set clock: yr-2000, mo, dd, hh, mm, ss + try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1)) + except Exception: pass + elif cmd == 0x10: # write /programs.json pushed from the editor, then reload + 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) + def run(self): if self.touch.addr is None: print("GT911 touch not found")