Compare commits
No commits in common. "72ea70da5977e85343fb86c0830a9c34b39f4697" and "5b10af189d4c35a016a7c5e005423d6bb462c680" have entirely different histories.
72ea70da59
...
5b10af189d
8 changed files with 173 additions and 282 deletions
12
README.md
12
README.md
|
|
@ -186,13 +186,11 @@ 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`): 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.
|
||||
- **`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.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.0.67
|
||||
0.0.66
|
||||
|
|
|
|||
84
editor.html
84
editor.html
|
|
@ -1078,31 +1078,20 @@ 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);
|
||||
}
|
||||
function _downloadPrograms(json) {
|
||||
async function saveToDevice() {
|
||||
const json = programsJSON(); if (!json) return alert("No set list selected to save.");
|
||||
if (window.showSaveFilePicker) {
|
||||
try {
|
||||
const h = await showSaveFilePicker({ suggestedName: "programs.json",
|
||||
types: [{ description: "PolyMeter programs", accept: { "application/json": [".json"] } }] });
|
||||
const w = await h.createWritable(); await w.write(json); await w.close();
|
||||
return alert("Saved programs.json — pick your CIRCUITPY drive and the device auto-reloads with these grooves.");
|
||||
} catch (e) { if (e.name === "AbortError") return; } // cancelled or unsupported → fall through to download
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([json], { type: "application/json" }));
|
||||
a.download = "programs.json"; a.click(); URL.revokeObjectURL(a.href);
|
||||
}
|
||||
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.");
|
||||
alert("Downloaded programs.json — drag it onto the device's CIRCUITPY drive (it auto-reloads).");
|
||||
}
|
||||
function importPrograms(text) {
|
||||
try {
|
||||
|
|
@ -1128,56 +1117,41 @@ 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, _saveCb = null;
|
||||
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0;
|
||||
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
||||
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 _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 _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); }
|
||||
function onDeviceMidi(e) {
|
||||
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
|
||||
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
|
||||
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 _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 _bindMidi() { for (const inp of _midiInputs()) inp.onmidimessage = _midiOn ? onDeviceMidi : null; }
|
||||
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";
|
||||
b.textContent = names.length ? "🎹 " + names[0].slice(0, 16) : "🎹 no device"; // shows the connected MIDI device
|
||||
b.classList.add("primary");
|
||||
}
|
||||
async function toggleDeviceAudio() {
|
||||
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.");
|
||||
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."); }
|
||||
ensureAudio(); if (audioCtx && audioCtx.state === "suspended") audioCtx.resume();
|
||||
_midiOn = true; _heartbeat(true); updateMidiBtn();
|
||||
_midiOn = true; _heartbeat(true); _bindMidi(); 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 per note."
|
||||
: "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically.");
|
||||
? "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.)");
|
||||
}
|
||||
|
||||
// Apply a shared link on load. Returns true if it set the metronome state.
|
||||
|
|
|
|||
|
|
@ -130,28 +130,28 @@
|
|||
</details>
|
||||
|
||||
<details class="spec">
|
||||
<summary>CircuitPython edition — self‑contained appliance (USB drive · push programming · MIDI audio · practice log)</summary>
|
||||
<summary>CircuitPython edition — USB drive + editor (experimental)</summary>
|
||||
<div class="spec-body">
|
||||
<p class="sub">An alternative firmware that turns the Pico into a self‑contained appliance: it mounts as a
|
||||
<b>USB drive</b> carrying the firmware, your tracks and an offline copy of this editor; drives a full
|
||||
lanes/pads touchscreen; <b>logs your practice</b> to <code>history.json</code> on the device; takes new
|
||||
set lists <b>pushed from the editor over USB‑MIDI</b>; and plays out your <b>computer's speakers over
|
||||
USB‑MIDI</b>. By default the firmware owns the drive (read‑only to the computer — so it can log and
|
||||
can't be accidentally erased); hold <b>button A</b> at power‑on for editor mode (drive writable). The
|
||||
MicroPython firmware above stays the simple, rock‑solid option.</p>
|
||||
<p class="sub">An alternative firmware that makes the Pico mount as a <b>USB drive</b> carrying the
|
||||
firmware, your tracks (<code>programs.json</code>) and a copy of this editor — design grooves on the
|
||||
web and write them straight to the device, no Thonny. A full lanes/pads display with anti‑aliased
|
||||
text drives the touchscreen, and it plays out your <b>computer's speakers over USB‑MIDI</b> (the
|
||||
editor's <b>🎹 Device audio</b> button voices it, locked to the device clock). The MicroPython
|
||||
firmware above stays the simple, rock‑solid option.</p>
|
||||
<p>
|
||||
<a class="dl" href="/pm_k1_circuitpy.zip" download>Download CircuitPython bundle ↓</a>
|
||||
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-cp" target="_blank" rel="noopener">Source + README ↗</a>
|
||||
</p>
|
||||
<ol class="steps">
|
||||
<li>Flash <b>CircuitPython</b> (<a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">raspberry_pi_pico</a>)
|
||||
via BOOTSEL, unzip the bundle onto <code>CIRCUITPY</code>, and power‑cycle. It boots into appliance mode.</li>
|
||||
<li><b>Program it from the web:</b> build a set list in the <a href="/editor.html">editor</a> (Chrome/Edge/Firefox),
|
||||
then the set‑list <b>⋯</b> menu → <b>📟 Save to device</b>. It's pushed over USB‑MIDI and the device shows
|
||||
<b>Saved ✓</b>. (Fallback for any browser: it downloads <code>programs.json</code> — boot holding A and drag it on.)</li>
|
||||
<li><b>Play through your computer:</b> click <b>🎹 Device audio</b>, then press play on the device — the full
|
||||
groove sounds through your speakers over 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>
|
||||
via BOOTSEL → the <code>CIRCUITPY</code> drive appears.</li>
|
||||
<li>Unzip the bundle onto <code>CIRCUITPY</code> (it's a normal drive — just drag everything on). It runs on boot.</li>
|
||||
<li><b>Reprogram it from the web:</b> build a set list in the <a href="/editor.html">editor</a>, then the
|
||||
set‑list <b>⋯</b> menu → <b>📟 Save to device</b> → pick the <code>CIRCUITPY</code> drive. The Pico
|
||||
auto‑reloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it
|
||||
downloads <code>programs.json</code> to drag on.) <b>📥 Load from device</b> reads it back.</li>
|
||||
<li><b>Play through your computer:</b> in the editor (Chrome/Edge) click <b>🎹 Device audio</b>, then
|
||||
press play on the device — its full groove sounds through your speakers over USB‑MIDI, in sync.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -1,57 +1,62 @@
|
|||
# PM_K‑1 "Kit" — CircuitPython edition (USB drive · push programming · MIDI audio · practice log)
|
||||
# PM_K‑1 "Kit" — CircuitPython edition (USB drive + editor)
|
||||
|
||||
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 <https://metronome.varasys.io>. 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).
|
||||
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 <https://metronome.varasys.io>.
|
||||
|
||||
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.
|
||||
> **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).
|
||||
|
||||
## Install
|
||||
|
||||
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`
|
||||
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.
|
||||
1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, and drop the CircuitPython `.uf2` for your board
|
||||
onto the `RPI‑RP2` drive (<https://circuitpython.org/board/raspberry_pi_pico/> — or the Pico 2 / W
|
||||
build). It reboots and a **`CIRCUITPY`** drive appears.
|
||||
2. **Copy everything from the bundle** onto `CIRCUITPY` (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.
|
||||
|
||||
## Program it from the web (push over USB‑MIDI)
|
||||
## Play through the computer's speakers (USB-MIDI)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
*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.
|
||||
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.
|
||||
|
||||
## Play through the computer's speakers
|
||||
## Protect the firmware (so end users only see the editor + their tracks)
|
||||
|
||||
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.
|
||||
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`):
|
||||
|
||||
## Controls & the practice log
|
||||
```
|
||||
./protect-firmware.sh /media/$USER/CIRCUITPY # hides code.py, boot.py, font_*.bin, README, itself
|
||||
```
|
||||
|
||||
(Reveal again with `fatattr -h <file>`.) For a **hard lock** — nothing on the drive can be changed
|
||||
from the computer at all — put `storage.remount("/", readonly=True)` in `boot.py`; but then the
|
||||
editor's *Save to device* can't write either, so you'd reprogram by temporarily removing that line
|
||||
(or gating it behind a held button at power-on). Hiding is usually the right balance.
|
||||
|
||||
## Controls (same as the MicroPython build)
|
||||
|
||||
- **Touch:** 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.
|
||||
- **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.
|
||||
- **Button A (GP15)** play/stop · **Button B (GP14)** tap tempo.
|
||||
- **RGB LED** flashes each beat; **buzzer** clicks (accent/normal/ghost).
|
||||
|
||||
## programs.json
|
||||
|
||||
|
|
@ -60,24 +65,30 @@ The editor also syncs the device clock, so the practice log gets real wall‑clo
|
|||
"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 editor (tempo, lanes, patterns, `/2` subdivision, `/2s` swing,
|
||||
`(3,8)` Euclid, `~` polymeter, `@-3` dB). The push above is the easy way to update it.
|
||||
Each `prog` is a program string from the web editor. Add/replace entries and save — the device reloads.
|
||||
|
||||
## Calibration (flags at the top of `code.py`)
|
||||
**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`)
|
||||
|
||||
- **Red/blue swapped:** flip `MADCTL` between `0x48` (default) and `0x40`.
|
||||
- **Colours look negative:** toggle `INVERT_COLORS`.
|
||||
- **Taps land wrong:** set `TOUCH_DEBUG = True`, read the raw coords over USB serial, then set
|
||||
- **Taps land wrong:** set `TOUCH_DEBUG = True`, watch the serial output, 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); `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).
|
||||
- **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).
|
||||
|
||||
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.
|
||||
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`).
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,22 +1,10 @@
|
|||
# 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
|
||||
# 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
|
||||
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
|
||||
|
|
|
|||
176
pico-cp/code.py
176
pico-cp/code.py
|
|
@ -16,12 +16,7 @@
|
|||
#
|
||||
# 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:
|
||||
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
||||
except ImportError:
|
||||
rtc = None
|
||||
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc
|
||||
try: # CircuitPython 9.x
|
||||
from fourwire import FourWire
|
||||
from busdisplay import BusDisplay
|
||||
|
|
@ -84,8 +79,6 @@ 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
|
||||
|
||||
|
|
@ -185,10 +178,9 @@ def _parse_lane(tok):
|
|||
sound, _, rest = tok.partition(':')
|
||||
pattern = None
|
||||
if '=' in rest: rest, _, pattern = rest.partition('=')
|
||||
sub = 1; swing = False
|
||||
sub = 1
|
||||
if '/' in rest:
|
||||
rest, _, sd = rest.partition('/')
|
||||
swing = sd.endswith('s'); sd = sd.rstrip('s') # "/2s" = swung eighths
|
||||
rest, _, sd = rest.partition('/'); sd = sd.rstrip('s')
|
||||
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
|
||||
|
|
@ -203,7 +195,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, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
|
||||
return {'sound': sound, 'sub': sub, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
|
||||
|
||||
def load_programs():
|
||||
try:
|
||||
|
|
@ -298,8 +290,7 @@ 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(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._mbuf = bytearray(16); self.midi_host = False; self.last_midi_in = 0.0
|
||||
self.led = RGB(P_RGB)
|
||||
self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0)
|
||||
self.buz_off = 0
|
||||
|
|
@ -310,18 +301,13 @@ 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
|
||||
|
|
@ -339,9 +325,17 @@ 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
|
||||
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)
|
||||
# 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)
|
||||
|
||||
def _place(self, group, s, x, y, fg, bg, font, right_edge=None):
|
||||
while len(group): group.pop()
|
||||
|
|
@ -353,6 +347,11 @@ 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):
|
||||
|
|
@ -361,20 +360,16 @@ 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 _step_dur(self, L, step):
|
||||
def _lane_dur(self, L):
|
||||
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
|
||||
return int(beat / L['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['next'] = now; L['step'] = -1; L['dur'] = self._lane_dur(L)
|
||||
|
||||
# ---------- audio + light ----------
|
||||
def click(self, level):
|
||||
|
|
@ -395,19 +390,18 @@ class App:
|
|||
# ---------- 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()
|
||||
if self.running: self._reset_clock()
|
||||
else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads()
|
||||
self.draw_status(); self._label("play")
|
||||
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
|
||||
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()
|
||||
was = self.running; self.load(i); self._label("play")
|
||||
if was: self.running = True; self._reset_clock()
|
||||
def tap(self):
|
||||
now = time.monotonic()
|
||||
if not hasattr(self, '_taps'): self._taps = []
|
||||
|
|
@ -431,7 +425,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'] += self._step_dur(L, L['step']); adv = True
|
||||
L['next'] += L['dur']; 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))
|
||||
|
|
@ -467,22 +461,31 @@ class App:
|
|||
if pt:
|
||||
self._touchSeen = nowms
|
||||
if not self._touchDown:
|
||||
self._touchDown = True; self._tap_log(pt[0], pt[1])
|
||||
self._touchDown = True; self.hit(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)
|
||||
# MIDI host present? the editor sends a heartbeat (Active Sensing) while "Device audio" is on
|
||||
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)
|
||||
try:
|
||||
if self.midi_in.readinto(self._mbuf): self.last_midi_in = nowms
|
||||
except Exception: pass
|
||||
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)
|
||||
|
|
@ -529,89 +532,6 @@ 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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue