PM_K-1: one-click A/B firmware updates over USB-MIDI (+ version check)
Split the CircuitPython firmware into a tiny stable loader (code.py) + the application (app.py, carries APP_VERSION). The editor's ⋯ → "⬆ Update firmware" queries the device version (SysEx 0x02 -> 0x03 reply), fetches the latest app from the site (/pico-cp-app.py), shows device-vs-latest, and pushes the new app.py over USB-MIDI (SysEx 0x20). The device installs it to a trial slot (old build kept as app.bak), reboots, and the loader AUTO-ROLLS-BACK to app.bak if the new build fails to start; a build that runs cleanly ~5s is confirmed (clears /trial). No BOOTSEL, no dragging; Chromium/Firefox. app.py forced to pure ASCII so it pushes raw (no base64); SysEx buffer raised to 60KB. build.sh/deploy.sh: bundle code.py+app.py and serve /pico-cp-app.py. Docs updated. Verified in CPython: version reply, update install+reboot+ACK, rollback file dance; editor loads clean with the updater wired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
72ea70da59
commit
e8945ee1d1
10 changed files with 720 additions and 628 deletions
|
|
@ -192,7 +192,9 @@ flashing steps. Firmware lives in **`pico/`**:
|
||||||
set lists **pushed from the editor over USB‑MIDI** (with a universal download‑and‑drag fallback), and
|
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
|
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
|
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.
|
editor mode (drive writable). **Firmware updates are one click** from the editor (⋯ → Update firmware) —
|
||||||
|
pushed over USB‑MIDI as an A/B update with automatic rollback. The MicroPython build stays the simple,
|
||||||
|
no‑computer option.
|
||||||
|
|
||||||
## Keyboard shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
|
|
||||||
4
build.sh
4
build.sh
|
|
@ -37,9 +37,11 @@ pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text())
|
||||||
print("copied embed.js")
|
print("copied embed.js")
|
||||||
pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable
|
pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable
|
||||||
print("copied pico-main.py")
|
print("copied pico-main.py")
|
||||||
|
pathlib.Path("dist/pico-cp-app.py").write_text(pathlib.Path("pico-cp/app.py").read_text()) # served for the editor's A/B firmware updater
|
||||||
|
print("copied pico-cp-app.py")
|
||||||
import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY)
|
import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY)
|
||||||
with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin",
|
for f in ("code.py", "app.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin",
|
||||||
"README.md", "protect-firmware.sh"):
|
"README.md", "protect-firmware.sh"):
|
||||||
z.write("pico-cp/" + f, f)
|
z.write("pico-cp/" + f, f)
|
||||||
z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive
|
z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ done
|
||||||
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
|
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
|
||||||
cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download
|
cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download
|
||||||
cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle
|
cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle
|
||||||
|
cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # PM_K-1 firmware for the editor's A/B updater
|
||||||
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
|
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
|
||||||
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
|
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
|
||||||
# info-*.html are first-class pages again: each form factor has a lean widget page
|
# info-*.html are first-class pages again: each form factor has a lean widget page
|
||||||
|
|
|
||||||
36
editor.html
36
editor.html
|
|
@ -349,6 +349,7 @@
|
||||||
<input type="file" id="importFile" accept="application/json" style="display:none">
|
<input type="file" id="importFile" accept="application/json" style="display:none">
|
||||||
<button id="saveDeviceBtn" title="Write this set list to a PM_K-1 device drive (programs.json)">📟 Save to device</button>
|
<button id="saveDeviceBtn" title="Write this set list to a PM_K-1 device drive (programs.json)">📟 Save to device</button>
|
||||||
<button id="loadDeviceBtn" title="Load a device's programs.json into a new set list">📥 Load from device</button>
|
<button id="loadDeviceBtn" title="Load a device's programs.json into a new set list">📥 Load from device</button>
|
||||||
|
<button id="updateFwBtn" title="Check & update the PM_K-1 firmware over USB-MIDI (A/B with auto-rollback)">⬆ Update firmware…</button>
|
||||||
<button id="clearLogBtn">🗑 Clear log</button>
|
<button id="clearLogBtn">🗑 Clear log</button>
|
||||||
<button id="resetAllBtn" style="color:#ff7b6b">♻ Reset everything…</button>
|
<button id="resetAllBtn" style="color:#ff7b6b">♻ Reset everything…</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1128,7 +1129,7 @@ async function loadFromDevice() {
|
||||||
|
|
||||||
/* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through
|
/* 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. */
|
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, _saveCb = null;
|
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null, _verCb = null;
|
||||||
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
||||||
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
|
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
|
||||||
function _send(bytes) { for (const o of _midiOutputs()) { try { o.send(bytes); } catch (_) {} } }
|
function _send(bytes) { for (const o of _midiOutputs()) { try { o.send(bytes); } catch (_) {} } }
|
||||||
|
|
@ -1145,8 +1146,10 @@ async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/
|
||||||
function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); }
|
function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); }
|
||||||
function onDeviceMidi(e) {
|
function onDeviceMidi(e) {
|
||||||
const d = e.data; if (!d) return;
|
const d = e.data; if (!d) return;
|
||||||
if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply to a program push
|
if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply
|
||||||
if (_saveCb) { const cb = _saveCb; _saveCb = null; cb(d[2] === 0x7F); } // 0x7F ACK / 0x7E NAK
|
const cmd = d[2];
|
||||||
|
if (cmd === 0x03 && _verCb) { const cb = _verCb; _verCb = null; cb(String.fromCharCode(...d.slice(3, d.length - 1))); } // version
|
||||||
|
else if ((cmd === 0x7F || cmd === 0x7E) && _saveCb) { const cb = _saveCb; _saveCb = null; cb(cmd === 0x7F); } // ACK/NAK
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_midiOn && (d[0] & 0xf0) === 0x90 && d.length >= 3 && d[2] > 0) { // Note On -> voice it
|
if (_midiOn && (d[0] & 0xf0) === 0x90 && d.length >= 3 && d[2] > 0) { // Note On -> voice it
|
||||||
|
|
@ -1179,6 +1182,32 @@ async function toggleDeviceAudio() {
|
||||||
? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green per note."
|
? "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.");
|
: "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically.");
|
||||||
}
|
}
|
||||||
|
function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03)
|
||||||
|
return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); });
|
||||||
|
}
|
||||||
|
async function updateFirmware() { // A/B firmware update over USB-MIDI, with a version check
|
||||||
|
if (!(await _ensureMidi()) || !_midiOutputs().length)
|
||||||
|
return alert("Connect the PM_K-1 (Chrome/Edge/Firefox), then try again.");
|
||||||
|
const dev = await _queryDeviceVersion();
|
||||||
|
let src;
|
||||||
|
try { src = await (await fetch("/pico-cp-app.py", { cache: "no-store" })).text(); }
|
||||||
|
catch (e) { return alert("Couldn't fetch the latest firmware from the site."); }
|
||||||
|
const m = src.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); const latest = m && m[1];
|
||||||
|
if (!latest) return alert("Couldn't read the latest firmware version.");
|
||||||
|
const upToDate = dev && dev === latest;
|
||||||
|
if (!confirm("Device firmware: " + (dev || "unknown") + "\nLatest: " + latest +
|
||||||
|
(upToDate ? "\n\nYou're up to date. Re-install anyway?"
|
||||||
|
: "\n\nUpdate now? The device reboots, runs the new build, and auto-rolls-back if it fails to start."))) return;
|
||||||
|
const bytes = [0xF0, 0x7D, 0x20];
|
||||||
|
for (let i = 0; i < src.length; i++) bytes.push(src.charCodeAt(i) & 0x7F); // app.py is ASCII
|
||||||
|
bytes.push(0xF7);
|
||||||
|
const p = new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, 5000); });
|
||||||
|
_send(bytes);
|
||||||
|
const ok = await p;
|
||||||
|
alert(ok === true ? "Update sent ✓ — the device is rebooting into the new build (v" + latest + "). It auto-confirms after a few seconds, or rolls back if it won't start."
|
||||||
|
: ok === false ? "The device is in editor mode (read-only to the updater). Reboot it normally (don't hold A) and try again."
|
||||||
|
: "No acknowledgement from the device. Make sure it's connected and not in editor mode — or drag app.py onto the drive in editor mode.");
|
||||||
|
}
|
||||||
|
|
||||||
// Apply a shared link on load. Returns true if it set the metronome state.
|
// Apply a shared link on load. Returns true if it set the metronome state.
|
||||||
function applyHashShare() {
|
function applyHashShare() {
|
||||||
|
|
@ -1405,6 +1434,7 @@ $("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $(
|
||||||
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
|
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
|
||||||
$("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); });
|
$("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); });
|
||||||
$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
|
$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
|
||||||
|
$("updateFwBtn").addEventListener("click", () => { $("trayMenu").hidden = true; updateFirmware(); });
|
||||||
$("midiBtn").addEventListener("click", toggleDeviceAudio);
|
$("midiBtn").addEventListener("click", toggleDeviceAudio);
|
||||||
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
|
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
|
||||||
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
|
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@
|
||||||
<li><b>Play through your computer:</b> click <b>🎹 Device audio</b>, then press play on the device — the full
|
<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 USB‑MIDI, in sync; the screen shows a <b>MIDI</b> badge and the buzzer mutes.</li>
|
groove sounds through your speakers over USB‑MIDI, 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>
|
<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>
|
||||||
|
<li><b>Firmware updates:</b> ⋯ menu → <b>⬆ Update firmware</b> — it checks your version, pushes the latest over USB‑MIDI, and the device A/B‑updates with automatic rollback if a build won't boot.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ from the web editor over USB‑MIDI**, and plays through your **computer's speak
|
||||||
1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, drop the CircuitPython `.uf2` onto `RPI‑RP2`
|
1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, drop the CircuitPython `.uf2` onto `RPI‑RP2`
|
||||||
(<https://circuitpython.org/board/raspberry_pi_pico/> — Pico 2 / W builds also fine). A `CIRCUITPY`
|
(<https://circuitpython.org/board/raspberry_pi_pico/> — Pico 2 / W builds also fine). A `CIRCUITPY`
|
||||||
drive appears.
|
drive appears.
|
||||||
2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py`, `programs.json`,
|
2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py` (loader) + `app.py` (the application),
|
||||||
`font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the helper scripts.
|
`programs.json`, `font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the
|
||||||
|
helper scripts. (`code.py` is a tiny stable loader; `app.py` is what firmware updates replace.)
|
||||||
3. **Power‑cycle** (so `boot.py` takes effect). It boots into appliance mode and runs.
|
3. **Power‑cycle** (so `boot.py` takes effect). It boots into appliance mode and runs.
|
||||||
|
|
||||||
## Program it from the web (push over USB‑MIDI)
|
## Program it from the web (push over USB‑MIDI)
|
||||||
|
|
@ -36,6 +37,16 @@ acknowledges — the editor shows **Saved ✓**. **📥 Load from device** reads
|
||||||
*Universal fallback (any browser / OS, even Safari):* Save to device **downloads** `programs.json` when no
|
*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.
|
device answers — boot the Pico in **editor mode** (hold A) and drag the file onto the `CIRCUITPY` drive.
|
||||||
|
|
||||||
|
## Firmware updates (one‑click, A/B with auto‑rollback)
|
||||||
|
|
||||||
|
`code.py` is a small stable **loader**; the application is `app.py` (it carries `APP_VERSION`). To update:
|
||||||
|
the editor's ⋯ menu → **⬆ Update firmware…** queries the device's version, fetches the latest from the site,
|
||||||
|
shows *device vs latest*, and on confirm **pushes the new `app.py` over USB‑MIDI**. The device installs it to
|
||||||
|
a **trial slot** (keeping the old build as `app.bak`) and reboots; if the new build **doesn't boot, the loader
|
||||||
|
automatically rolls back** to `app.bak`. A build that runs cleanly for ~5 s is confirmed. No BOOTSEL, no
|
||||||
|
dragging. (Updating CircuitPython *itself* still uses BOOTSEL + a `.uf2`, but that's rare. And the Pico is
|
||||||
|
unbrickable as the ultimate backstop.)
|
||||||
|
|
||||||
## Play through the computer's speakers
|
## Play through the computer's speakers
|
||||||
|
|
||||||
The Pico is a USB‑MIDI device and sends a note per click (GM drum note per lane, velocity by accent).
|
The Pico is a USB‑MIDI device and sends a note per click (GM drum note per lane, velocity by accent).
|
||||||
|
|
|
||||||
BIN
pico-cp/__pycache__/app.cpython-312.pyc
Normal file
BIN
pico-cp/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
648
pico-cp/app.py
Normal file
648
pico-cp/app.py
Normal file
|
|
@ -0,0 +1,648 @@
|
||||||
|
# VARASYS PolyMeter - PM_K-1 "Kit" firmware (CircuitPython edition)
|
||||||
|
# Raspberry Pi Pico (Pico / Pico W / Pico 2) on the 52Pi EP-0172 "Pico Breadboard Kit Plus":
|
||||||
|
# 3.5" ST7796 320x480 cap-touch (GT911), PSP joystick, WS2812 RGB, buzzer, 2 buttons.
|
||||||
|
#
|
||||||
|
# WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your
|
||||||
|
# tracks (programs.json) and a copy of the editor - edit on the web, "Save to device" writes
|
||||||
|
# programs.json here, and CircuitPython auto-reloads with the new grooves. It also sends USB-MIDI
|
||||||
|
# (a note per click) so the web editor can play it out the computer's speakers ("Device audio").
|
||||||
|
# Runs the SAME program strings as metronome.varasys.io.
|
||||||
|
#
|
||||||
|
# INSTALL: flash CircuitPython (https://circuitpython.org/board/raspberry_pi_pico/), then copy
|
||||||
|
# this file as code.py plus programs.json onto the CIRCUITPY drive. It runs on boot.
|
||||||
|
#
|
||||||
|
# Fallback: the simpler MicroPython firmware (pico/main.py) is always available - BOOTSEL +
|
||||||
|
# drag a MicroPython .uf2 to go back. The Pico cannot be bricked.
|
||||||
|
#
|
||||||
|
# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md.
|
||||||
|
|
||||||
|
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
|
||||||
|
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
|
||||||
|
APP_VERSION = "1.0.0" # firmware version (the A/B updater pushes/compares this)
|
||||||
|
try:
|
||||||
|
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
||||||
|
except ImportError:
|
||||||
|
rtc = None
|
||||||
|
try: # CircuitPython 9.x
|
||||||
|
from fourwire import FourWire
|
||||||
|
from busdisplay import BusDisplay
|
||||||
|
except ImportError: # CircuitPython 8.x
|
||||||
|
from displayio import FourWire
|
||||||
|
from displayio import Display as BusDisplay
|
||||||
|
try:
|
||||||
|
import neopixel_write # core module on RP2040 - drives WS2812 with no external library
|
||||||
|
except ImportError:
|
||||||
|
neopixel_write = None
|
||||||
|
try:
|
||||||
|
import usb_midi # default-enabled on RP2040 - sends a MIDI note per click to the computer
|
||||||
|
except ImportError:
|
||||||
|
usb_midi = None
|
||||||
|
|
||||||
|
# ============================== CONFIG (tweak if needed) ==============================
|
||||||
|
SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable
|
||||||
|
LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you - keep it dim (0..1)
|
||||||
|
MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio")
|
||||||
|
MUTE_BUZZER = False # silence the on-board buzzer (e.g. when using computer audio)
|
||||||
|
WIDTH, HEIGHT = 320, 480
|
||||||
|
MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed.
|
||||||
|
INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative
|
||||||
|
# Touch (GT911) - flip if taps land wrong:
|
||||||
|
TOUCH_SWAP_XY = False
|
||||||
|
TOUCH_INVERT_X = False
|
||||||
|
TOUCH_INVERT_Y = False
|
||||||
|
TOUCH_DEBUG = False
|
||||||
|
# Joystick:
|
||||||
|
JOY_INVERT_X = False
|
||||||
|
JOY_INVERT_Y = False
|
||||||
|
JOY_DEADZONE = 9000
|
||||||
|
|
||||||
|
# ----- pins (fixed by the EP-0172 board) -----
|
||||||
|
P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7
|
||||||
|
P_SDA, P_SCL = board.GP8, board.GP9
|
||||||
|
P_RGB, P_BUZ, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14
|
||||||
|
P_JOYX, P_JOYY = board.GP26, board.GP27
|
||||||
|
|
||||||
|
# ----- baked default grooves (used only if programs.json is missing/bad) -----
|
||||||
|
DEFAULT_PROGRAMS = [
|
||||||
|
("Four on the floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
|
||||||
|
("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"),
|
||||||
|
("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"),
|
||||||
|
("5 over 4", "t100;kick:4;claves:5~"),
|
||||||
|
("Straight click", "t120;beep:4"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ============================== COLOURS (0xRRGGBB; displayio handles 565) ==============================
|
||||||
|
C_BG, C_PANEL, C_TXT, C_MUTE = 0x06090E, 0x1C222C, 0xC7D0DB, 0x788494
|
||||||
|
C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240
|
||||||
|
C_BTN = 0x1C222C
|
||||||
|
LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)}
|
||||||
|
# voice -> General-MIDI note (USB-MIDI bridge), and level -> MIDI velocity
|
||||||
|
SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare909":38,
|
||||||
|
"clap":39,"clap808":39,"clap909":39, "rim":37, "hatClosed":42,"hat808":42,"hat909":42,
|
||||||
|
"hatOpen":46,"openHat808":46, "ride":51,"ride909":51, "crash":49,"crash909":49,
|
||||||
|
"tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54,
|
||||||
|
"cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37}
|
||||||
|
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
|
||||||
|
|
||||||
|
# WS2812 RGB LED - self-contained via the core neopixel_write module (no external library)
|
||||||
|
class RGB:
|
||||||
|
def __init__(self, pin):
|
||||||
|
self.ok = neopixel_write is not None
|
||||||
|
if self.ok:
|
||||||
|
self.io = digitalio.DigitalInOut(pin); self.io.direction = digitalio.Direction.OUTPUT
|
||||||
|
self.buf = bytearray(3)
|
||||||
|
def set(self, r, g, b):
|
||||||
|
if not self.ok: return
|
||||||
|
# WS2812 wants GRB order; scale down so it isn't blinding
|
||||||
|
self.buf[0] = int(g * LED_BRIGHTNESS); self.buf[1] = int(r * LED_BRIGHTNESS); self.buf[2] = int(b * LED_BRIGHTNESS)
|
||||||
|
try: neopixel_write.neopixel_write(self.io, self.buf)
|
||||||
|
except Exception: self.ok = False
|
||||||
|
|
||||||
|
# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ==============================
|
||||||
|
def load_font(path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
blob = f.read()
|
||||||
|
count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {}
|
||||||
|
for _ in range(count):
|
||||||
|
cp = (blob[p] << 8) | blob[p+1]; w = blob[p+2]; h = blob[p+3]
|
||||||
|
xoff = blob[p+4]; xoff = xoff - 256 if xoff > 127 else xoff
|
||||||
|
top = blob[p+5]; adv = blob[p+6]; p += 7
|
||||||
|
glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2
|
||||||
|
return (glyphs, blob)
|
||||||
|
|
||||||
|
FONT_S = load_font("/font_s.bin") # small - pad-grid lane labels
|
||||||
|
FONT_M = load_font("/font_m.bin") # labels / buttons
|
||||||
|
FONT_L = load_font("/font_l.bin") # big BPM
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
def _blend(bg, fg, i):
|
||||||
|
t = i * 17
|
||||||
|
r = (((bg >> 16) & 0xFF)*(255-t) + ((fg >> 16) & 0xFF)*t) // 255
|
||||||
|
g = (((bg >> 8) & 0xFF)*(255-t) + ((fg >> 8) & 0xFF)*t) // 255
|
||||||
|
b = ((bg & 0xFF)*(255-t) + (fg & 0xFF)*t) // 255
|
||||||
|
return (r << 16) | (g << 8) | b
|
||||||
|
|
||||||
|
def make_text(s, font, fg, bg):
|
||||||
|
"""Render a string into a displayio TileGrid (anti-aliased via a 16-step blend palette)."""
|
||||||
|
glyphs, blob = font
|
||||||
|
w = 0; top0 = 999; bot = 0
|
||||||
|
for c in s:
|
||||||
|
g = glyphs.get(ord(c))
|
||||||
|
if not g: continue
|
||||||
|
w += g[4]
|
||||||
|
if g[1]:
|
||||||
|
if g[3] < top0: top0 = g[3]
|
||||||
|
if g[3] + g[1] > bot: bot = g[3] + g[1]
|
||||||
|
if top0 == 999: top0 = 0
|
||||||
|
w = max(1, w); h = max(1, bot - top0)
|
||||||
|
gc.collect()
|
||||||
|
bmp = displayio.Bitmap(w, h, 16)
|
||||||
|
pal = displayio.Palette(16)
|
||||||
|
for i in range(16): pal[i] = _blend(bg, fg, i)
|
||||||
|
pen = 0
|
||||||
|
for c in s:
|
||||||
|
g = glyphs.get(ord(c))
|
||||||
|
if not g: continue
|
||||||
|
gw, gh, xoff, gtop, adv, off = g
|
||||||
|
for j in range(gh):
|
||||||
|
row = (gtop - top0) + j
|
||||||
|
for i in range(gw):
|
||||||
|
k = j * gw + i
|
||||||
|
byte = blob[off + (k >> 1)]
|
||||||
|
nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF)
|
||||||
|
if nib:
|
||||||
|
x = pen + xoff + i
|
||||||
|
if 0 <= x < w and 0 <= row < h: bmp[x, row] = nib
|
||||||
|
pen += adv
|
||||||
|
return displayio.TileGrid(bmp, pixel_shader=pal), w, h
|
||||||
|
|
||||||
|
# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ==============================
|
||||||
|
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
|
||||||
|
PRIO = {2: 3, 1: 2, 3: 1}
|
||||||
|
|
||||||
|
def parse_program(s):
|
||||||
|
bpm = 120; lanes = []
|
||||||
|
for tok in s.strip().split(';'):
|
||||||
|
tok = tok.strip()
|
||||||
|
if not tok: continue
|
||||||
|
if tok[0] == 't' and tok[1:].isdigit():
|
||||||
|
bpm = int(tok[1:]); continue
|
||||||
|
if ':' not in tok: continue
|
||||||
|
lane = _parse_lane(tok)
|
||||||
|
if lane: lanes.append(lane)
|
||||||
|
if not lanes: lanes = [_parse_lane("beep:4")]
|
||||||
|
return max(30, min(300, bpm)), lanes
|
||||||
|
|
||||||
|
def _parse_lane(tok):
|
||||||
|
poly = '~' in tok; mute = '!' in tok
|
||||||
|
tok = tok.replace('~', '').replace('!', '')
|
||||||
|
if '@' in tok: tok = tok.split('@')[0]
|
||||||
|
sound, _, rest = tok.partition(':')
|
||||||
|
pattern = None
|
||||||
|
if '=' in rest: rest, _, pattern = rest.partition('=')
|
||||||
|
sub = 1; swing = False
|
||||||
|
if '/' in rest:
|
||||||
|
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
|
||||||
|
for gp in groups: starts.add(acc); acc += gp
|
||||||
|
steps = beats * sub
|
||||||
|
if pattern:
|
||||||
|
levels = [PAT.get(ch, 0) for ch in pattern]
|
||||||
|
if len(levels) < steps: levels += [0] * (steps - len(levels))
|
||||||
|
steps = len(levels)
|
||||||
|
else:
|
||||||
|
levels = []
|
||||||
|
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, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
|
||||||
|
|
||||||
|
def load_programs():
|
||||||
|
try:
|
||||||
|
with open("/programs.json") as f:
|
||||||
|
d = json.load(f)
|
||||||
|
progs = [(p["name"], p["prog"]) for p in d["programs"]]
|
||||||
|
if progs: return progs
|
||||||
|
except Exception as e:
|
||||||
|
print("programs.json:", e)
|
||||||
|
return DEFAULT_PROGRAMS
|
||||||
|
|
||||||
|
# ============================== GT911 TOUCH ==============================
|
||||||
|
class GT911:
|
||||||
|
def __init__(self, i2c):
|
||||||
|
self.i2c = i2c; self.addr = None
|
||||||
|
while not i2c.try_lock(): pass
|
||||||
|
try: found = i2c.scan()
|
||||||
|
finally: i2c.unlock()
|
||||||
|
for a in (0x5D, 0x14):
|
||||||
|
if a in found: self.addr = a; break
|
||||||
|
if self.addr is None and found: self.addr = found[0]
|
||||||
|
def _rd(self, reg, n):
|
||||||
|
b = bytearray(n)
|
||||||
|
while not self.i2c.try_lock(): pass
|
||||||
|
try:
|
||||||
|
self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF]))
|
||||||
|
self.i2c.readfrom_into(self.addr, b)
|
||||||
|
finally: self.i2c.unlock()
|
||||||
|
return b
|
||||||
|
def _wr(self, reg, val):
|
||||||
|
while not self.i2c.try_lock(): pass
|
||||||
|
try: self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF, val]))
|
||||||
|
finally: self.i2c.unlock()
|
||||||
|
def read(self):
|
||||||
|
if self.addr is None: return None
|
||||||
|
try: st = self._rd(0x814E, 1)[0]
|
||||||
|
except OSError: return None
|
||||||
|
if not (st & 0x80): return None
|
||||||
|
n = st & 0x0F; pt = None
|
||||||
|
if n >= 1:
|
||||||
|
b = self._rd(0x8150, 4); tx = b[0] | (b[1] << 8); ty = b[2] | (b[3] << 8)
|
||||||
|
pt = self._map(tx, ty)
|
||||||
|
try: self._wr(0x814E, 0)
|
||||||
|
except OSError: pass
|
||||||
|
return pt
|
||||||
|
def _map(self, tx, ty):
|
||||||
|
if TOUCH_DEBUG: print("touch raw", tx, ty)
|
||||||
|
if TOUCH_SWAP_XY: tx, ty = ty, tx
|
||||||
|
if TOUCH_INVERT_X: tx = WIDTH - 1 - tx
|
||||||
|
if TOUCH_INVERT_Y: ty = HEIGHT - 1 - ty
|
||||||
|
if 0 <= tx < WIDTH and 0 <= ty < HEIGHT: return (tx, ty)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ============================== DISPLAY SETUP ==============================
|
||||||
|
def st7796_init():
|
||||||
|
inv = b'\x21\x00' if INVERT_COLORS else b'\x20\x00'
|
||||||
|
return (
|
||||||
|
b'\x01\x80\x78' # SWRESET + 120ms
|
||||||
|
b'\x11\x80\x78' # SLPOUT + 120ms
|
||||||
|
b'\xF0\x01\xC3' b'\xF0\x01\x96' # command-set unlock
|
||||||
|
+ bytes([0x36, 0x01, MADCTL]) +
|
||||||
|
b'\x3A\x01\x55' # 16bpp
|
||||||
|
b'\xB4\x01\x01'
|
||||||
|
b'\xB6\x03\x80\x02\x3B'
|
||||||
|
b'\xE8\x08\x40\x8A\x00\x00\x29\x19\xA5\x33'
|
||||||
|
b'\xC1\x01\x06' b'\xC2\x01\xA7'
|
||||||
|
b'\xC5\x81\x18\x78' # VCOM + 120ms
|
||||||
|
b'\xE0\x0E\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B'
|
||||||
|
b'\xE1\x0E\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B'
|
||||||
|
b'\xF0\x01\x3C' b'\xF0\x81\x69\x78' # lock + 120ms
|
||||||
|
+ inv +
|
||||||
|
b'\x29\x80\x32' # DISPON + 50ms
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_display():
|
||||||
|
displayio.release_displays()
|
||||||
|
spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI)
|
||||||
|
bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD)
|
||||||
|
return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=False)
|
||||||
|
|
||||||
|
def solid(color):
|
||||||
|
p = displayio.Palette(1); p[0] = color; return p
|
||||||
|
|
||||||
|
def rect(x, y, w, h, color):
|
||||||
|
return vectorio.Rectangle(pixel_shader=solid(color), width=w, height=h, x=x, y=y)
|
||||||
|
|
||||||
|
# ============================== APP ==============================
|
||||||
|
class App:
|
||||||
|
def __init__(self):
|
||||||
|
self.display = make_display()
|
||||||
|
self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000)
|
||||||
|
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(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
|
||||||
|
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
|
||||||
|
self._aPrev = True; self._bPrev = True
|
||||||
|
self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY)
|
||||||
|
self._joyNext = 0
|
||||||
|
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.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
|
||||||
|
return d
|
||||||
|
|
||||||
|
# ---------- scene graph ----------
|
||||||
|
def _build_scene(self):
|
||||||
|
root = displayio.Group(); self.display.root_group = root
|
||||||
|
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG))
|
||||||
|
tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg)
|
||||||
|
root.append(rect(0, 38, WIDTH, 2, C_PANEL))
|
||||||
|
# dynamic groups
|
||||||
|
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right)
|
||||||
|
self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left)
|
||||||
|
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 x step pads
|
||||||
|
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()
|
||||||
|
self.dirty = True
|
||||||
|
if not s: return
|
||||||
|
tg, w, h = make_text(s, font, fg, bg)
|
||||||
|
tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg)
|
||||||
|
def _center(self, group, s, cx, cy, fg, bg, font):
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---------- program ----------
|
||||||
|
def load(self, i):
|
||||||
|
n = len(self.programs); self.idx = i % n
|
||||||
|
self.name, prog = self.programs[self.idx]
|
||||||
|
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 _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'])
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---------- audio + light ----------
|
||||||
|
def click(self, level):
|
||||||
|
self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
|
||||||
|
self.buz.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
|
||||||
|
self.buz_off = time.monotonic_ns() + 22_000_000
|
||||||
|
def flash(self, level):
|
||||||
|
self.rgb = LEVEL_RGB.get(level, (0, 150, 255))
|
||||||
|
self.led.set(*self.rgb)
|
||||||
|
def led_off(self):
|
||||||
|
self.rgb = (0, 0, 0)
|
||||||
|
self.led.set(0, 0, 0)
|
||||||
|
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
|
||||||
|
if self.midi is None: return
|
||||||
|
try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive - no Note Off needed)
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
# ---------- transport ----------
|
||||||
|
def toggle(self):
|
||||||
|
self.running = not self.running
|
||||||
|
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
|
||||||
|
self.draw_bpm()
|
||||||
|
def goto(self, i):
|
||||||
|
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 = []
|
||||||
|
self._taps = [t for t in self._taps if now - t < 2.4]
|
||||||
|
self._taps.append(now)
|
||||||
|
if len(self._taps) >= 2:
|
||||||
|
span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1)
|
||||||
|
if span > 0: self.set_bpm(round(60 / span))
|
||||||
|
|
||||||
|
# ---------- scheduler ----------
|
||||||
|
def tick(self):
|
||||||
|
now = time.monotonic_ns()
|
||||||
|
if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0
|
||||||
|
if self.running:
|
||||||
|
fired = []
|
||||||
|
for li, L in enumerate(self.lanes):
|
||||||
|
adv = False
|
||||||
|
while now >= L['next']:
|
||||||
|
L['step'] = (L['step'] + 1) % L['steps']
|
||||||
|
lvl = 0 if L['mute'] else L['levels'][L['step']]
|
||||||
|
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'] += 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))
|
||||||
|
if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead
|
||||||
|
self.flash(best)
|
||||||
|
if self.rgb != (0, 0, 0):
|
||||||
|
r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10
|
||||||
|
self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0)
|
||||||
|
self.led.set(*self.rgb)
|
||||||
|
|
||||||
|
# ---------- inputs ----------
|
||||||
|
def poll(self):
|
||||||
|
a = self.btnA.value
|
||||||
|
if (not a) and self._aPrev: self.toggle()
|
||||||
|
self._aPrev = a
|
||||||
|
b = self.btnB.value
|
||||||
|
if (not b) and self._bPrev: self.tap()
|
||||||
|
self._bPrev = b
|
||||||
|
now = time.monotonic_ns()
|
||||||
|
if now >= self._joyNext:
|
||||||
|
x = self.jx.value - 32768; y = self.jy.value - 32768
|
||||||
|
if JOY_INVERT_X: x = -x
|
||||||
|
if JOY_INVERT_Y: y = -y
|
||||||
|
if abs(y) > JOY_DEADZONE:
|
||||||
|
self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1))
|
||||||
|
self._joyNext = now + 70_000_000
|
||||||
|
elif abs(x) > JOY_DEADZONE:
|
||||||
|
self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = now + 350_000_000; return
|
||||||
|
else:
|
||||||
|
self._joyNext = now + 20_000_000
|
||||||
|
pt = self.touch.read()
|
||||||
|
nowms = time.monotonic()
|
||||||
|
if pt:
|
||||||
|
self._touchSeen = nowms
|
||||||
|
if not self._touchDown:
|
||||||
|
self._touchDown = True; self._tap_log(pt[0], pt[1])
|
||||||
|
elif self._touchDown and (nowms - self._touchSeen) > 0.14:
|
||||||
|
self._touchDown = False
|
||||||
|
# USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs)
|
||||||
|
if self.midi_in is not None:
|
||||||
|
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()
|
||||||
|
|
||||||
|
# ---------- drawing ----------
|
||||||
|
def draw_bpm(self):
|
||||||
|
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
|
||||||
|
def draw_status(self):
|
||||||
|
self._place(self.g_run, "RUN" if self.running else "STOP", 12, 48,
|
||||||
|
C_GREEN if self.running else C_MUTE, C_BG, FONT_M)
|
||||||
|
self._place(self.g_name, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]),
|
||||||
|
12, 112, C_TXT, C_BG, FONT_M)
|
||||||
|
def draw_midi(self):
|
||||||
|
self._place(self.g_midi, "MIDI" if self.midi_host else "", 0, 12, C_GREEN, C_BG, FONT_M, right_edge=WIDTH-12)
|
||||||
|
|
||||||
|
# ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ----------
|
||||||
|
def _padbase(self, L, s):
|
||||||
|
return 0 if L['mute'] else L['levels'][s]
|
||||||
|
def build_grid(self):
|
||||||
|
while len(self.g_grid): self.g_grid.pop()
|
||||||
|
self.lane_pads = []; self.lane_lit = []
|
||||||
|
n = min(len(self.lanes), MAXLANES)
|
||||||
|
top = 140; rowh = min(40, (296 - top) // max(1, n))
|
||||||
|
for li in range(n):
|
||||||
|
L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2
|
||||||
|
tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG)
|
||||||
|
tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg)
|
||||||
|
steps = L['steps']; sub = L['sub']; px0 = 60
|
||||||
|
usable = WIDTH - 8 - px0 - 12; stepw = max(1, usable // steps)
|
||||||
|
r_big = max(2, min(6, stepw // 2, (rowh - 8) // 2)); r_sml = max(2, r_big - 2)
|
||||||
|
pads = []
|
||||||
|
for s in range(steps):
|
||||||
|
rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision
|
||||||
|
cxp = px0 + 6 + (s * usable) // steps # proportional -> beats line up across lanes
|
||||||
|
c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy)
|
||||||
|
c.color_index = self._padbase(L, s); self.g_grid.append(c); pads.append(c)
|
||||||
|
self.lane_pads.append(pads); self.lane_lit.append(-1)
|
||||||
|
self.dirty = True
|
||||||
|
def _move_playhead(self, li, step):
|
||||||
|
pads = self.lane_pads[li]; prev = self.lane_lit[li]
|
||||||
|
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
|
||||||
|
if step < len(pads): pads[step].color_index = self._padbase(self.lanes[li], step) + 4
|
||||||
|
self.lane_lit[li] = step; self.dirty = True
|
||||||
|
def reset_playheads(self):
|
||||||
|
for li, pads in enumerate(self.lane_pads):
|
||||||
|
prev = self.lane_lit[li]
|
||||||
|
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
|
||||||
|
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) < 60000: self._sx.append(b) # big enough for a pushed firmware (app.py)
|
||||||
|
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 == 0x02: # version query -> reply 0x03 + APP_VERSION
|
||||||
|
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x03]) + APP_VERSION.encode() + bytes([0xF7]))
|
||||||
|
elif cmd == 0x10: # write /programs.json pushed from the editor, then reload
|
||||||
|
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)
|
||||||
|
elif cmd == 0x20: # A/B firmware update: install new app.py to the trial slot
|
||||||
|
try:
|
||||||
|
data = bytes(sx[2:])
|
||||||
|
try: os.remove("/app.bak")
|
||||||
|
except OSError: pass
|
||||||
|
os.rename("/app.py", "/app.bak") # keep the current build as the rollback
|
||||||
|
with open("/app.py", "wb") as f: f.write(data)
|
||||||
|
open("/trial", "w").close() # arm the trial; the loader reverts if it won't boot
|
||||||
|
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK -> rebooting
|
||||||
|
time.sleep(0.3); supervisor.reload()
|
||||||
|
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")
|
||||||
|
boot = time.monotonic()
|
||||||
|
try: os.stat("/trial"); committed = False # we're a freshly-pushed build on trial
|
||||||
|
except OSError: committed = True
|
||||||
|
while True:
|
||||||
|
self.tick(); self.poll()
|
||||||
|
if not committed and time.monotonic() - boot > 5: # booted & ran fine for 5s -> confirm the update
|
||||||
|
try: os.remove("/trial")
|
||||||
|
except Exception: pass
|
||||||
|
committed = True
|
||||||
|
# push a complete frame only when something changed (no mid-update tearing);
|
||||||
|
# capped at the display's refresh rate, so dirty regions stay small and quick
|
||||||
|
if self.dirty and self.display.refresh():
|
||||||
|
self.dirty = False
|
||||||
|
time.sleep(0.0005)
|
||||||
|
|
||||||
|
App().run()
|
||||||
637
pico-cp/code.py
637
pico-cp/code.py
|
|
@ -1,626 +1,23 @@
|
||||||
# VARASYS PolyMeter — PM_K-1 "Kit" firmware (CircuitPython edition)
|
# code.py - PM_K-1 A/B firmware loader (stable; rarely changes).
|
||||||
# Raspberry Pi Pico (Pico / Pico W / Pico 2) on the 52Pi EP-0172 "Pico Breadboard Kit Plus":
|
|
||||||
# 3.5" ST7796 320x480 cap-touch (GT911), PSP joystick, WS2812 RGB, buzzer, 2 buttons.
|
|
||||||
#
|
#
|
||||||
# WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your
|
# The real application lives in app.py; app.bak holds the previous known-good build. The web editor
|
||||||
# tracks (programs.json) and a copy of the editor — edit on the web, "Save to device" writes
|
# pushes a new app.py to a "trial" slot over USB-MIDI; this loader runs it, and if the new build
|
||||||
# programs.json here, and CircuitPython auto-reloads with the new grooves. It also sends USB-MIDI
|
# fails to boot it AUTOMATICALLY ROLLS BACK to app.bak. (The Pico is also unbrickable: BOOTSEL ->
|
||||||
# (a note per click) so the web editor can play it out the computer's speakers ("Device audio").
|
# drag a CircuitPython .uf2.) app.py clears the /trial marker once it has run healthily for ~5s.
|
||||||
# Runs the SAME program strings as metronome.varasys.io.
|
import supervisor, os
|
||||||
#
|
supervisor.runtime.autoreload = False # updates reboot explicitly; never auto-restart on our own writes
|
||||||
# INSTALL: flash CircuitPython (https://circuitpython.org/board/raspberry_pi_pico/), then copy
|
|
||||||
# this file as code.py plus programs.json onto the CIRCUITPY drive. It runs on boot.
|
def _trial():
|
||||||
#
|
try: os.stat("/trial"); return True
|
||||||
# Fallback: the simpler MicroPython firmware (pico/main.py) is always available — BOOTSEL +
|
except OSError: return False
|
||||||
# drag a MicroPython .uf2 to go back. The Pico cannot be bricked.
|
|
||||||
#
|
|
||||||
# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md.
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
import app # runs the application (app.py ends with App().run())
|
||||||
except ImportError:
|
|
||||||
rtc = None
|
|
||||||
try: # CircuitPython 9.x
|
|
||||||
from fourwire import FourWire
|
|
||||||
from busdisplay import BusDisplay
|
|
||||||
except ImportError: # CircuitPython 8.x
|
|
||||||
from displayio import FourWire
|
|
||||||
from displayio import Display as BusDisplay
|
|
||||||
try:
|
|
||||||
import neopixel_write # core module on RP2040 — drives WS2812 with no external library
|
|
||||||
except ImportError:
|
|
||||||
neopixel_write = None
|
|
||||||
try:
|
|
||||||
import usb_midi # default-enabled on RP2040 — sends a MIDI note per click to the computer
|
|
||||||
except ImportError:
|
|
||||||
usb_midi = None
|
|
||||||
|
|
||||||
# ============================== CONFIG (tweak if needed) ==============================
|
|
||||||
SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable
|
|
||||||
LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you — keep it dim (0..1)
|
|
||||||
MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio")
|
|
||||||
MUTE_BUZZER = False # silence the on-board buzzer (e.g. when using computer audio)
|
|
||||||
WIDTH, HEIGHT = 320, 480
|
|
||||||
MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed.
|
|
||||||
INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative
|
|
||||||
# Touch (GT911) — flip if taps land wrong:
|
|
||||||
TOUCH_SWAP_XY = False
|
|
||||||
TOUCH_INVERT_X = False
|
|
||||||
TOUCH_INVERT_Y = False
|
|
||||||
TOUCH_DEBUG = False
|
|
||||||
# Joystick:
|
|
||||||
JOY_INVERT_X = False
|
|
||||||
JOY_INVERT_Y = False
|
|
||||||
JOY_DEADZONE = 9000
|
|
||||||
|
|
||||||
# ----- pins (fixed by the EP-0172 board) -----
|
|
||||||
P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7
|
|
||||||
P_SDA, P_SCL = board.GP8, board.GP9
|
|
||||||
P_RGB, P_BUZ, P_BTNA, P_BTNB = board.GP12, board.GP13, board.GP15, board.GP14
|
|
||||||
P_JOYX, P_JOYY = board.GP26, board.GP27
|
|
||||||
|
|
||||||
# ----- baked default grooves (used only if programs.json is missing/bad) -----
|
|
||||||
DEFAULT_PROGRAMS = [
|
|
||||||
("Four on the floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
|
|
||||||
("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"),
|
|
||||||
("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"),
|
|
||||||
("5 over 4", "t100;kick:4;claves:5~"),
|
|
||||||
("Straight click", "t120;beep:4"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ============================== COLOURS (0xRRGGBB; displayio handles 565) ==============================
|
|
||||||
C_BG, C_PANEL, C_TXT, C_MUTE = 0x06090E, 0x1C222C, 0xC7D0DB, 0x788494
|
|
||||||
C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240
|
|
||||||
C_BTN = 0x1C222C
|
|
||||||
LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)}
|
|
||||||
# voice -> General-MIDI note (USB-MIDI bridge), and level -> MIDI velocity
|
|
||||||
SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare909":38,
|
|
||||||
"clap":39,"clap808":39,"clap909":39, "rim":37, "hatClosed":42,"hat808":42,"hat909":42,
|
|
||||||
"hatOpen":46,"openHat808":46, "ride":51,"ride909":51, "crash":49,"crash909":49,
|
|
||||||
"tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54,
|
|
||||||
"cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37}
|
|
||||||
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
|
|
||||||
|
|
||||||
# WS2812 RGB LED — self-contained via the core neopixel_write module (no external library)
|
|
||||||
class RGB:
|
|
||||||
def __init__(self, pin):
|
|
||||||
self.ok = neopixel_write is not None
|
|
||||||
if self.ok:
|
|
||||||
self.io = digitalio.DigitalInOut(pin); self.io.direction = digitalio.Direction.OUTPUT
|
|
||||||
self.buf = bytearray(3)
|
|
||||||
def set(self, r, g, b):
|
|
||||||
if not self.ok: return
|
|
||||||
# WS2812 wants GRB order; scale down so it isn't blinding
|
|
||||||
self.buf[0] = int(g * LED_BRIGHTNESS); self.buf[1] = int(r * LED_BRIGHTNESS); self.buf[2] = int(b * LED_BRIGHTNESS)
|
|
||||||
try: neopixel_write.neopixel_write(self.io, self.buf)
|
|
||||||
except Exception: self.ok = False
|
|
||||||
|
|
||||||
# ============================== ANTI-ALIASED FONTS (binary blobs on the drive; see pico/gen_font.py) ==============================
|
|
||||||
def load_font(path):
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
blob = f.read()
|
|
||||||
count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {}
|
|
||||||
for _ in range(count):
|
|
||||||
cp = (blob[p] << 8) | blob[p+1]; w = blob[p+2]; h = blob[p+3]
|
|
||||||
xoff = blob[p+4]; xoff = xoff - 256 if xoff > 127 else xoff
|
|
||||||
top = blob[p+5]; adv = blob[p+6]; p += 7
|
|
||||||
glyphs[cp] = (w, h, xoff, top, adv, pixoff); pixoff += (w * h + 1) // 2
|
|
||||||
return (glyphs, blob)
|
|
||||||
|
|
||||||
FONT_S = load_font("/font_s.bin") # small — pad-grid lane labels
|
|
||||||
FONT_M = load_font("/font_m.bin") # labels / buttons
|
|
||||||
FONT_L = load_font("/font_l.bin") # big BPM
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
def _blend(bg, fg, i):
|
|
||||||
t = i * 17
|
|
||||||
r = (((bg >> 16) & 0xFF)*(255-t) + ((fg >> 16) & 0xFF)*t) // 255
|
|
||||||
g = (((bg >> 8) & 0xFF)*(255-t) + ((fg >> 8) & 0xFF)*t) // 255
|
|
||||||
b = ((bg & 0xFF)*(255-t) + (fg & 0xFF)*t) // 255
|
|
||||||
return (r << 16) | (g << 8) | b
|
|
||||||
|
|
||||||
def make_text(s, font, fg, bg):
|
|
||||||
"""Render a string into a displayio TileGrid (anti-aliased via a 16-step blend palette)."""
|
|
||||||
glyphs, blob = font
|
|
||||||
w = 0; top0 = 999; bot = 0
|
|
||||||
for c in s:
|
|
||||||
g = glyphs.get(ord(c))
|
|
||||||
if not g: continue
|
|
||||||
w += g[4]
|
|
||||||
if g[1]:
|
|
||||||
if g[3] < top0: top0 = g[3]
|
|
||||||
if g[3] + g[1] > bot: bot = g[3] + g[1]
|
|
||||||
if top0 == 999: top0 = 0
|
|
||||||
w = max(1, w); h = max(1, bot - top0)
|
|
||||||
gc.collect()
|
|
||||||
bmp = displayio.Bitmap(w, h, 16)
|
|
||||||
pal = displayio.Palette(16)
|
|
||||||
for i in range(16): pal[i] = _blend(bg, fg, i)
|
|
||||||
pen = 0
|
|
||||||
for c in s:
|
|
||||||
g = glyphs.get(ord(c))
|
|
||||||
if not g: continue
|
|
||||||
gw, gh, xoff, gtop, adv, off = g
|
|
||||||
for j in range(gh):
|
|
||||||
row = (gtop - top0) + j
|
|
||||||
for i in range(gw):
|
|
||||||
k = j * gw + i
|
|
||||||
byte = blob[off + (k >> 1)]
|
|
||||||
nib = (byte >> 4) if (k & 1) == 0 else (byte & 0xF)
|
|
||||||
if nib:
|
|
||||||
x = pen + xoff + i
|
|
||||||
if 0 <= x < w and 0 <= row < h: bmp[x, row] = nib
|
|
||||||
pen += adv
|
|
||||||
return displayio.TileGrid(bmp, pixel_shader=pal), w, h
|
|
||||||
|
|
||||||
# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ==============================
|
|
||||||
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
|
|
||||||
PRIO = {2: 3, 1: 2, 3: 1}
|
|
||||||
|
|
||||||
def parse_program(s):
|
|
||||||
bpm = 120; lanes = []
|
|
||||||
for tok in s.strip().split(';'):
|
|
||||||
tok = tok.strip()
|
|
||||||
if not tok: continue
|
|
||||||
if tok[0] == 't' and tok[1:].isdigit():
|
|
||||||
bpm = int(tok[1:]); continue
|
|
||||||
if ':' not in tok: continue
|
|
||||||
lane = _parse_lane(tok)
|
|
||||||
if lane: lanes.append(lane)
|
|
||||||
if not lanes: lanes = [_parse_lane("beep:4")]
|
|
||||||
return max(30, min(300, bpm)), lanes
|
|
||||||
|
|
||||||
def _parse_lane(tok):
|
|
||||||
poly = '~' in tok; mute = '!' in tok
|
|
||||||
tok = tok.replace('~', '').replace('!', '')
|
|
||||||
if '@' in tok: tok = tok.split('@')[0]
|
|
||||||
sound, _, rest = tok.partition(':')
|
|
||||||
pattern = None
|
|
||||||
if '=' in rest: rest, _, pattern = rest.partition('=')
|
|
||||||
sub = 1; swing = False
|
|
||||||
if '/' in rest:
|
|
||||||
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
|
|
||||||
for gp in groups: starts.add(acc); acc += gp
|
|
||||||
steps = beats * sub
|
|
||||||
if pattern:
|
|
||||||
levels = [PAT.get(ch, 0) for ch in pattern]
|
|
||||||
if len(levels) < steps: levels += [0] * (steps - len(levels))
|
|
||||||
steps = len(levels)
|
|
||||||
else:
|
|
||||||
levels = []
|
|
||||||
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, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
|
|
||||||
|
|
||||||
def load_programs():
|
|
||||||
try:
|
|
||||||
with open("/programs.json") as f:
|
|
||||||
d = json.load(f)
|
|
||||||
progs = [(p["name"], p["prog"]) for p in d["programs"]]
|
|
||||||
if progs: return progs
|
|
||||||
except Exception as e:
|
|
||||||
print("programs.json:", e)
|
|
||||||
return DEFAULT_PROGRAMS
|
|
||||||
|
|
||||||
# ============================== GT911 TOUCH ==============================
|
|
||||||
class GT911:
|
|
||||||
def __init__(self, i2c):
|
|
||||||
self.i2c = i2c; self.addr = None
|
|
||||||
while not i2c.try_lock(): pass
|
|
||||||
try: found = i2c.scan()
|
|
||||||
finally: i2c.unlock()
|
|
||||||
for a in (0x5D, 0x14):
|
|
||||||
if a in found: self.addr = a; break
|
|
||||||
if self.addr is None and found: self.addr = found[0]
|
|
||||||
def _rd(self, reg, n):
|
|
||||||
b = bytearray(n)
|
|
||||||
while not self.i2c.try_lock(): pass
|
|
||||||
try:
|
|
||||||
self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF]))
|
|
||||||
self.i2c.readfrom_into(self.addr, b)
|
|
||||||
finally: self.i2c.unlock()
|
|
||||||
return b
|
|
||||||
def _wr(self, reg, val):
|
|
||||||
while not self.i2c.try_lock(): pass
|
|
||||||
try: self.i2c.writeto(self.addr, bytes([reg >> 8, reg & 0xFF, val]))
|
|
||||||
finally: self.i2c.unlock()
|
|
||||||
def read(self):
|
|
||||||
if self.addr is None: return None
|
|
||||||
try: st = self._rd(0x814E, 1)[0]
|
|
||||||
except OSError: return None
|
|
||||||
if not (st & 0x80): return None
|
|
||||||
n = st & 0x0F; pt = None
|
|
||||||
if n >= 1:
|
|
||||||
b = self._rd(0x8150, 4); tx = b[0] | (b[1] << 8); ty = b[2] | (b[3] << 8)
|
|
||||||
pt = self._map(tx, ty)
|
|
||||||
try: self._wr(0x814E, 0)
|
|
||||||
except OSError: pass
|
|
||||||
return pt
|
|
||||||
def _map(self, tx, ty):
|
|
||||||
if TOUCH_DEBUG: print("touch raw", tx, ty)
|
|
||||||
if TOUCH_SWAP_XY: tx, ty = ty, tx
|
|
||||||
if TOUCH_INVERT_X: tx = WIDTH - 1 - tx
|
|
||||||
if TOUCH_INVERT_Y: ty = HEIGHT - 1 - ty
|
|
||||||
if 0 <= tx < WIDTH and 0 <= ty < HEIGHT: return (tx, ty)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ============================== DISPLAY SETUP ==============================
|
|
||||||
def st7796_init():
|
|
||||||
inv = b'\x21\x00' if INVERT_COLORS else b'\x20\x00'
|
|
||||||
return (
|
|
||||||
b'\x01\x80\x78' # SWRESET + 120ms
|
|
||||||
b'\x11\x80\x78' # SLPOUT + 120ms
|
|
||||||
b'\xF0\x01\xC3' b'\xF0\x01\x96' # command-set unlock
|
|
||||||
+ bytes([0x36, 0x01, MADCTL]) +
|
|
||||||
b'\x3A\x01\x55' # 16bpp
|
|
||||||
b'\xB4\x01\x01'
|
|
||||||
b'\xB6\x03\x80\x02\x3B'
|
|
||||||
b'\xE8\x08\x40\x8A\x00\x00\x29\x19\xA5\x33'
|
|
||||||
b'\xC1\x01\x06' b'\xC2\x01\xA7'
|
|
||||||
b'\xC5\x81\x18\x78' # VCOM + 120ms
|
|
||||||
b'\xE0\x0E\xF0\x09\x0B\x06\x04\x15\x2F\x54\x42\x3C\x17\x14\x18\x1B'
|
|
||||||
b'\xE1\x0E\xE0\x09\x0B\x06\x04\x03\x2B\x43\x42\x3B\x16\x14\x17\x1B'
|
|
||||||
b'\xF0\x01\x3C' b'\xF0\x81\x69\x78' # lock + 120ms
|
|
||||||
+ inv +
|
|
||||||
b'\x29\x80\x32' # DISPON + 50ms
|
|
||||||
)
|
|
||||||
|
|
||||||
def make_display():
|
|
||||||
displayio.release_displays()
|
|
||||||
spi = busio.SPI(clock=P_SCK, MOSI=P_MOSI)
|
|
||||||
bus = FourWire(spi, command=P_DC, chip_select=P_CS, reset=P_RST, baudrate=SPI_BAUD)
|
|
||||||
return BusDisplay(bus, st7796_init(), width=WIDTH, height=HEIGHT, auto_refresh=False)
|
|
||||||
|
|
||||||
def solid(color):
|
|
||||||
p = displayio.Palette(1); p[0] = color; return p
|
|
||||||
|
|
||||||
def rect(x, y, w, h, color):
|
|
||||||
return vectorio.Rectangle(pixel_shader=solid(color), width=w, height=h, x=x, y=y)
|
|
||||||
|
|
||||||
# ============================== APP ==============================
|
|
||||||
class App:
|
|
||||||
def __init__(self):
|
|
||||||
self.display = make_display()
|
|
||||||
self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000)
|
|
||||||
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(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
|
|
||||||
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
|
|
||||||
self._aPrev = True; self._bPrev = True
|
|
||||||
self.jx = analogio.AnalogIn(P_JOYX); self.jy = analogio.AnalogIn(P_JOYY)
|
|
||||||
self._joyNext = 0
|
|
||||||
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.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
|
|
||||||
return d
|
|
||||||
|
|
||||||
# ---------- scene graph ----------
|
|
||||||
def _build_scene(self):
|
|
||||||
root = displayio.Group(); self.display.root_group = root
|
|
||||||
root.append(rect(0, 0, WIDTH, HEIGHT, C_BG))
|
|
||||||
tg, w, h = make_text("PM_K-1 KIT", FONT_M, C_CYAN, C_BG); tg.x = 12; tg.y = 8; root.append(tg)
|
|
||||||
root.append(rect(0, 38, WIDTH, 2, C_PANEL))
|
|
||||||
# dynamic groups
|
|
||||||
self.g_bpm = displayio.Group(); root.append(self.g_bpm) # big tempo (right)
|
|
||||||
self.g_run = displayio.Group(); root.append(self.g_run) # RUN / STOP (left)
|
|
||||||
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
|
|
||||||
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()
|
|
||||||
self.dirty = True
|
|
||||||
if not s: return
|
|
||||||
tg, w, h = make_text(s, font, fg, bg)
|
|
||||||
tg.x = (right_edge - w) if right_edge is not None else x; tg.y = y; group.append(tg)
|
|
||||||
def _center(self, group, s, cx, cy, fg, bg, font):
|
|
||||||
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
|
|
||||||
|
|
||||||
# ---------- program ----------
|
|
||||||
def load(self, i):
|
|
||||||
n = len(self.programs); self.idx = i % n
|
|
||||||
self.name, prog = self.programs[self.idx]
|
|
||||||
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 _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'])
|
|
||||||
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
|
|
||||||
|
|
||||||
# ---------- audio + light ----------
|
|
||||||
def click(self, level):
|
|
||||||
self.buz.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
|
|
||||||
self.buz.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
|
|
||||||
self.buz_off = time.monotonic_ns() + 22_000_000
|
|
||||||
def flash(self, level):
|
|
||||||
self.rgb = LEVEL_RGB.get(level, (0, 150, 255))
|
|
||||||
self.led.set(*self.rgb)
|
|
||||||
def led_off(self):
|
|
||||||
self.rgb = (0, 0, 0)
|
|
||||||
self.led.set(0, 0, 0)
|
|
||||||
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
|
|
||||||
if self.midi is None: return
|
|
||||||
try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive — no Note Off needed)
|
|
||||||
except Exception: pass
|
|
||||||
|
|
||||||
# ---------- transport ----------
|
|
||||||
def toggle(self):
|
|
||||||
self.running = not self.running
|
|
||||||
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
|
|
||||||
self.draw_bpm()
|
|
||||||
def goto(self, i):
|
|
||||||
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 = []
|
|
||||||
self._taps = [t for t in self._taps if now - t < 2.4]
|
|
||||||
self._taps.append(now)
|
|
||||||
if len(self._taps) >= 2:
|
|
||||||
span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1)
|
|
||||||
if span > 0: self.set_bpm(round(60 / span))
|
|
||||||
|
|
||||||
# ---------- scheduler ----------
|
|
||||||
def tick(self):
|
|
||||||
now = time.monotonic_ns()
|
|
||||||
if self.buz_off and now >= self.buz_off: self.buz.duty_cycle = 0; self.buz_off = 0
|
|
||||||
if self.running:
|
|
||||||
fired = []
|
|
||||||
for li, L in enumerate(self.lanes):
|
|
||||||
adv = False
|
|
||||||
while now >= L['next']:
|
|
||||||
L['step'] = (L['step'] + 1) % L['steps']
|
|
||||||
lvl = 0 if L['mute'] else L['levels'][L['step']]
|
|
||||||
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'] += 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))
|
|
||||||
if not MUTE_BUZZER and not self.midi_host: self.click(best) # computer plays it instead
|
|
||||||
self.flash(best)
|
|
||||||
if self.rgb != (0, 0, 0):
|
|
||||||
r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10
|
|
||||||
self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0)
|
|
||||||
self.led.set(*self.rgb)
|
|
||||||
|
|
||||||
# ---------- inputs ----------
|
|
||||||
def poll(self):
|
|
||||||
a = self.btnA.value
|
|
||||||
if (not a) and self._aPrev: self.toggle()
|
|
||||||
self._aPrev = a
|
|
||||||
b = self.btnB.value
|
|
||||||
if (not b) and self._bPrev: self.tap()
|
|
||||||
self._bPrev = b
|
|
||||||
now = time.monotonic_ns()
|
|
||||||
if now >= self._joyNext:
|
|
||||||
x = self.jx.value - 32768; y = self.jy.value - 32768
|
|
||||||
if JOY_INVERT_X: x = -x
|
|
||||||
if JOY_INVERT_Y: y = -y
|
|
||||||
if abs(y) > JOY_DEADZONE:
|
|
||||||
self.set_bpm(self.bpm + (1 if y > 0 else -1) * (5 if abs(y) > 26000 else 1))
|
|
||||||
self._joyNext = now + 70_000_000
|
|
||||||
elif abs(x) > JOY_DEADZONE:
|
|
||||||
self.goto(self.idx + (1 if x > 0 else -1)); self._joyNext = now + 350_000_000; return
|
|
||||||
else:
|
|
||||||
self._joyNext = now + 20_000_000
|
|
||||||
pt = self.touch.read()
|
|
||||||
nowms = time.monotonic()
|
|
||||||
if pt:
|
|
||||||
self._touchSeen = nowms
|
|
||||||
if not self._touchDown:
|
|
||||||
self._touchDown = True; self._tap_log(pt[0], pt[1])
|
|
||||||
elif self._touchDown and (nowms - self._touchSeen) > 0.14:
|
|
||||||
self._touchDown = False
|
|
||||||
# USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs)
|
|
||||||
if self.midi_in is not None:
|
|
||||||
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()
|
|
||||||
|
|
||||||
# ---------- drawing ----------
|
|
||||||
def draw_bpm(self):
|
|
||||||
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
|
|
||||||
def draw_status(self):
|
|
||||||
self._place(self.g_run, "RUN" if self.running else "STOP", 12, 48,
|
|
||||||
C_GREEN if self.running else C_MUTE, C_BG, FONT_M)
|
|
||||||
self._place(self.g_name, "%d/%d %s" % (self.idx+1, len(self.programs), self.name[:18]),
|
|
||||||
12, 112, C_TXT, C_BG, FONT_M)
|
|
||||||
def draw_midi(self):
|
|
||||||
self._place(self.g_midi, "MIDI" if self.midi_host else "", 0, 12, C_GREEN, C_BG, FONT_M, right_edge=WIDTH-12)
|
|
||||||
|
|
||||||
# ---------- pad grid (each lane = a row of step pads; playhead lit as it plays) ----------
|
|
||||||
def _padbase(self, L, s):
|
|
||||||
return 0 if L['mute'] else L['levels'][s]
|
|
||||||
def build_grid(self):
|
|
||||||
while len(self.g_grid): self.g_grid.pop()
|
|
||||||
self.lane_pads = []; self.lane_lit = []
|
|
||||||
n = min(len(self.lanes), MAXLANES)
|
|
||||||
top = 140; rowh = min(40, (296 - top) // max(1, n))
|
|
||||||
for li in range(n):
|
|
||||||
L = self.lanes[li]; y = top + li * rowh; cy = y + rowh // 2
|
|
||||||
tg, w, h = make_text((L.get('sound', '') or '?')[:7], FONT_S, C_MUTE, C_BG)
|
|
||||||
tg.x = 8; tg.y = cy - h // 2; self.g_grid.append(tg)
|
|
||||||
steps = L['steps']; sub = L['sub']; px0 = 60
|
|
||||||
usable = WIDTH - 8 - px0 - 12; stepw = max(1, usable // steps)
|
|
||||||
r_big = max(2, min(6, stepw // 2, (rowh - 8) // 2)); r_sml = max(2, r_big - 2)
|
|
||||||
pads = []
|
|
||||||
for s in range(steps):
|
|
||||||
rad = r_big if (s % sub == 0) else r_sml # big = beat (division), small = subdivision
|
|
||||||
cxp = px0 + 6 + (s * usable) // steps # proportional → beats line up across lanes
|
|
||||||
c = vectorio.Circle(pixel_shader=self.pad_pal, radius=rad, x=cxp, y=cy)
|
|
||||||
c.color_index = self._padbase(L, s); self.g_grid.append(c); pads.append(c)
|
|
||||||
self.lane_pads.append(pads); self.lane_lit.append(-1)
|
|
||||||
self.dirty = True
|
|
||||||
def _move_playhead(self, li, step):
|
|
||||||
pads = self.lane_pads[li]; prev = self.lane_lit[li]
|
|
||||||
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
|
|
||||||
if step < len(pads): pads[step].color_index = self._padbase(self.lanes[li], step) + 4
|
|
||||||
self.lane_lit[li] = step; self.dirty = True
|
|
||||||
def reset_playheads(self):
|
|
||||||
for li, pads in enumerate(self.lane_pads):
|
|
||||||
prev = self.lane_lit[li]
|
|
||||||
if 0 <= prev < len(pads): pads[prev].color_index = self._padbase(self.lanes[li], prev)
|
|
||||||
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:
|
except Exception:
|
||||||
return []
|
if _trial(): # a freshly-pushed build crashed on startup -> roll back
|
||||||
def _save_log(self):
|
|
||||||
if not self.can_write: return
|
|
||||||
try:
|
try:
|
||||||
with open("/history.json", "w") as f: json.dump({"log": self.log[:200]}, f)
|
os.remove("/app.py"); os.rename("/app.bak", "/app.py"); os.remove("/trial")
|
||||||
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
|
except Exception: pass
|
||||||
elif cmd == 0x10: # write /programs.json pushed from the editor, then reload
|
supervisor.reload() # reboot into the restored known-good build
|
||||||
try:
|
else:
|
||||||
with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))
|
raise # the active build failed unexpectedly (rare) -> on-screen traceback
|
||||||
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")
|
|
||||||
while True:
|
|
||||||
self.tick(); self.poll()
|
|
||||||
# push a complete frame only when something changed (no mid-update tearing);
|
|
||||||
# capped at the display's refresh rate, so dirty regions stay small and quick
|
|
||||||
if self.dirty and self.display.refresh():
|
|
||||||
self.dirty = False
|
|
||||||
time.sleep(0.0005)
|
|
||||||
|
|
||||||
App().run()
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue