PM_K-1: appliance model — push-programming over USB-MIDI, on-device practice log, swing fix

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) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-29 00:38:08 -05:00
parent 5b10af189d
commit 7d743c18a1
7 changed files with 281 additions and 172 deletions

View file

@ -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 antialiased 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 USBMIDI** (the editor's
**🎹 Device audio** button). The MicroPython build stays the simple, nocomputer option.
- **`pico-cp/`** — a **CircuitPython** edition (download `/pm_k1_circuitpy.zip`): a selfcontained
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 USBMIDI** (with a universal downloadanddrag fallback), and
plays **out your computer's speakers over USBMIDI** (the editor's **🎹 Device audio**). By default the
firmware owns the drive (readonly to the computer, so it's protected); hold **button A** at poweron for
editor mode (drive writable). The MicroPython build stays the simple, nocomputer option.
## Keyboard shortcuts

View file

@ -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.

View file

@ -130,28 +130,28 @@
</details>
<details class="spec">
<summary>CircuitPython edition — USB drive + editor (experimental)</summary>
<summary>CircuitPython edition — selfcontained appliance (USB drive · push programming · MIDI audio · practice log)</summary>
<div class="spec-body">
<p class="sub">An alternative firmware that makes the Pico mount as a <b>USB drive</b> carrying the
firmware, your tracks (<code>programs.json</code>) 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 antialiased
text drives the touchscreen, and it plays out your <b>computer's speakers over USBMIDI</b> (the
editor's <b>🎹 Device audio</b> button voices it, locked to the device clock). The MicroPython
firmware above stays the simple, rocksolid option.</p>
<p class="sub">An alternative firmware that turns the Pico into a selfcontained appliance: it mounts as a
<b>USB drive</b> carrying the firmware, your tracks and an offline copy of this editor; drives a full
lanes/pads touchscreen; <b>logs your practice</b> to <code>history.json</code> on the device; takes new
set lists <b>pushed from the editor over USBMIDI</b>; and plays out your <b>computer's speakers over
USBMIDI</b>. By default the firmware owns the drive (readonly to the computer — so it can log and
can't be accidentally erased); hold <b>button A</b> at poweron for editor mode (drive writable). The
MicroPython firmware above stays the simple, rocksolid option.</p>
<p>
<a class="dl" href="/pm_k1_circuitpy.zip" download>Download CircuitPython bundle ↓</a>
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-cp" target="_blank" rel="noopener">Source + README ↗</a>
</p>
<ol class="steps">
<li>Flash <b>CircuitPython</b> (<a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">raspberry_pi_pico</a>)
via BOOTSEL → the <code>CIRCUITPY</code> drive appears.</li>
<li>Unzip the bundle onto <code>CIRCUITPY</code> (it's a normal drive — just drag everything on). It runs on boot.</li>
<li><b>Reprogram it from the web:</b> build a set list in the <a href="/editor.html">editor</a>, then the
setlist <b></b> menu → <b>📟 Save to device</b> → pick the <code>CIRCUITPY</code> drive. The Pico
autoreloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it
downloads <code>programs.json</code> to drag on.) <b>📥 Load from device</b> reads it back.</li>
<li><b>Play through your computer:</b> in the editor (Chrome/Edge) click <b>🎹 Device audio</b>, then
press play on the device — its full groove sounds through your speakers over USBMIDI, in sync.</li>
via BOOTSEL, unzip the bundle onto <code>CIRCUITPY</code>, and powercycle. It boots into appliance mode.</li>
<li><b>Program it from the web:</b> build a set list in the <a href="/editor.html">editor</a> (Chrome/Edge/Firefox),
then the setlist <b></b> menu → <b>📟 Save to device</b>. It's pushed over USBMIDI and the device shows
<b>Saved ✓</b>. (Fallback for any browser: it downloads <code>programs.json</code> — boot holding A and drag it on.)</li>
<li><b>Play through your computer:</b> click <b>🎹 Device audio</b>, then press play on the device — the full
groove sounds through your speakers over USBMIDI, in sync; the screen shows a <b>MIDI</b> badge and the buzzer mutes.</li>
<li><b>Practice log:</b> plays over 5 s appear at the bottom of the screen (time · BPM · duration · track); tap a row twice to delete.</li>
</ol>
</div>
</details>

View file

@ -1,62 +1,57 @@
# PM_K1 "Kit" — CircuitPython edition (USB drive + editor)
# PM_K1 "Kit" — CircuitPython edition (USB drive · push programming · MIDI audio · practice log)
The **CircuitPython** firmware for the 52Pi EP0172 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 programstring language as <https://metronome.varasys.io>.
The **CircuitPython** firmware for the 52Pi EP0172 Pico kit, set up as a selfcontained appliance.
It runs the same programstring language as <https://metronome.varasys.io>. The simpler
**MicroPython** firmware (`../pico/main.py`) stays as a rocksolid 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 oneclick "Save to device" and USBMIDI audiotocomputer
> are landing in later phases. The simpler **MicroPython** firmware (`../pico/main.py`) remains the
> rocksolid 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 + antialiased text, plays the
buzzer + RGB beat light, **logs your practice to `/history.json`**, accepts new set lists **pushed
from the web editor over USBMIDI**, and plays through your **computer's speakers** over USBMIDI.
## Two poweron 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 USBMIDI. The drive is then
**readonly 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 `RPIRP2` drive (<https://circuitpython.org/board/raspberry_pi_pico/> — or the Pico 2 / W
build). It reboots and a **`CIRCUITPY`** drive appears.
2. **Copy everything from the bundle** onto `CIRCUITPY` (draganddrop — 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 antialiased 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 resaving it from the editor) makes CircuitPython
**autoreload** with the new tracks.
1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, drop the CircuitPython `.uf2` onto `RPIRP2`
(<https://circuitpython.org/board/raspberry_pi_pico/> — 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. **Powercycle** (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 USBMIDI)
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 → setlist **⋯** menu → **📟 Save to device**.
The editor sends it to the Pico over USBMIDI (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 USBMIDI 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 automutes** (the computer plays instead).
The editor also syncs the device clock, so the practice log gets real wallclock 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 <file>`.) 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:** onscreen `◀◀ / ▶ / ▶▶` (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 powercycles.
## 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 setlist **⋯** menu → **📟 Save to device** → pick the `CIRCUITPY` drive. In Chrome/Edge it
writes `programs.json` straight onto the drive (the Pico autoreloads); 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 tearingeffect 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 antialiased 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 antialiased blobs from `../pico/gen_font.py`. `protect-firmware.sh` (hide
the firmware files) is mainly for editor mode — appliance mode already keeps the drive readonly.

View file

@ -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

View file

@ -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: longshort (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")