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 9b95a01..c2baf00 100644 Binary files a/pico-cp/__pycache__/code.cpython-312.pyc and b/pico-cp/__pycache__/code.cpython-312.pyc differ 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")