PM_K-1: appliance model — push-programming over USB-MIDI, on-device practice log, swing fix
Firmware (pico-cp/): the Pico now owns its filesystem by default (boot.py), so it can save the
practice log and write editor-pushed set lists; the drive is read-only to the computer, which also
protects the firmware. Hold button A at power-on for editor mode (drive writable; universal drag).
- Replaced the on-screen touch buttons with an on-device PRACTICE LOG (time · BPM · duration ·
track), newest-first, persisted to /history.json next to programs.json. Plays < 5s aren't logged;
tap a row twice to delete it. Real timestamps once the editor syncs the clock.
- USB-MIDI SysEx receiver: clock-set (0x01 -> RTC) and program-push (0x10 -> write programs.json,
reload, ACK/NAK). disable autoreload so our own writes never self-restart.
- Fixed swing: the parser was discarding the 's' flag, so /2s never swung. Now the scheduler uses a
per-step duration with long-short (2:1, SWING_RATIO 2/3) pairs on even subdivisions, matching the
web engine. Verified: ride:4/2s -> 266/133ms vs straight 200/200.
Editor (editor.html): requestMIDIAccess({sysex:true}); Save to device now pushes programs.json as
SysEx to the device (+ clock sync), waits for ACK, shows "Saved ✓", and falls back to downloading the
file (drag onto the drive in editor mode) when no device answers. Heartbeat also keeps the clock synced.
Web MIDI works in Chromium AND Firefox; the drag fallback covers any browser/OS incl. Safari.
Docs (pico-cp/README, info-kit, README) updated for the two modes, push programming, and the log.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5b10af189d
commit
7d743c18a1
7 changed files with 281 additions and 172 deletions
12
README.md
12
README.md
|
|
@ -186,11 +186,13 @@ flashing steps. Firmware lives in **`pico/`**:
|
||||||
It parses the same program strings as the web editor. Flash MicroPython, copy `main.py`,
|
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`.
|
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/gen_font.py`** — generates the baked anti‑aliased fonts (used by both firmwares).
|
||||||
- **`pico-cp/`** — a **CircuitPython** edition (download `/pm_k1_circuitpy.zip`): the Pico mounts as a
|
- **`pico-cp/`** — a **CircuitPython** edition (download `/pm_k1_circuitpy.zip`): a self‑contained
|
||||||
USB drive carrying the firmware + your `programs.json` + a copy of the editor, with a full lanes/pads
|
appliance. The Pico mounts as a USB drive carrying the firmware + your `programs.json` + an offline
|
||||||
touchscreen display. Design grooves on the web and **Save to device** straight onto the drive (the
|
editor, drives a full lanes/pads touchscreen, **logs practice to `history.json`** on the device, takes
|
||||||
editor's ⋯ menu), and play it **out your computer's speakers over USB‑MIDI** (the editor's
|
set lists **pushed from the editor over USB‑MIDI** (with a universal download‑and‑drag fallback), and
|
||||||
**🎹 Device audio** button). The MicroPython build stays the simple, no‑computer option.
|
plays **out your computer's speakers over USB‑MIDI** (the editor's **🎹 Device audio**). By default the
|
||||||
|
firmware owns the drive (read‑only to the computer, so it's protected); hold **button A** at power‑on for
|
||||||
|
editor mode (drive writable). The MicroPython build stays the simple, no‑computer option.
|
||||||
|
|
||||||
## Keyboard shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
|
|
||||||
84
editor.html
84
editor.html
|
|
@ -1078,20 +1078,31 @@ function programsJSON() {
|
||||||
const sl = getSL(); if (!sl) return null;
|
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);
|
return JSON.stringify({ title: sl.title || "PolyMeter", programs: sl.items.map((it) => ({ name: it.name, prog: setupToPatch(it) })) }, null, 2);
|
||||||
}
|
}
|
||||||
async function saveToDevice() {
|
function _downloadPrograms(json) {
|
||||||
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");
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(new Blob([json], { type: "application/json" }));
|
a.href = URL.createObjectURL(new Blob([json], { type: "application/json" }));
|
||||||
a.download = "programs.json"; a.click(); URL.revokeObjectURL(a.href);
|
a.download = "programs.json"; a.click(); URL.revokeObjectURL(a.href);
|
||||||
alert("Downloaded programs.json — drag it onto the device's CIRCUITPY drive (it auto-reloads).");
|
}
|
||||||
|
async function saveToDevice() {
|
||||||
|
const json = programsJSON(); if (!json) return alert("No set list selected to save.");
|
||||||
|
// Primary: push to the device over USB-MIDI SysEx (Chromium/Firefox); the firmware writes programs.json.
|
||||||
|
if (await _ensureMidi() && _midiOutputs().length) {
|
||||||
|
const ascii = json.replace(/[\u0080-\uFFFF]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0")); // 7-bit-safe JSON
|
||||||
|
const bytes = [0xF0, 0x7D, 0x10];
|
||||||
|
for (let i = 0; i < ascii.length; i++) bytes.push(ascii.charCodeAt(i) & 0x7F);
|
||||||
|
bytes.push(0xF7);
|
||||||
|
_send(_clockSysex()); _send(bytes);
|
||||||
|
const ok = await new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, 2500); });
|
||||||
|
if (ok === true) { alert("Saved to device ✓ — it reloaded with your set list."); return; }
|
||||||
|
_downloadPrograms(json);
|
||||||
|
alert(ok === false
|
||||||
|
? "The device is in editor mode (drive writable by the computer), so I downloaded programs.json — drag it onto the CIRCUITPY drive."
|
||||||
|
: "No device answered over USB-MIDI — downloaded programs.json. Connect the device (Chromium/Firefox) and try again, or boot it holding button A and drag the file onto the CIRCUITPY drive.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Universal fallback (any browser / OS): download + drag onto the drive in editor mode (hold A at power-on).
|
||||||
|
_downloadPrograms(json);
|
||||||
|
alert("Downloaded programs.json — boot the device holding button A (editor mode) and drag it onto the CIRCUITPY drive.");
|
||||||
}
|
}
|
||||||
function importPrograms(text) {
|
function importPrograms(text) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1117,41 +1128,56 @@ async function loadFromDevice() {
|
||||||
|
|
||||||
/* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through
|
/* 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;
|
let _midiAccess = null, _midiOn = false, _midiFlash = 0, _midiBeat = 0, _saveCb = null;
|
||||||
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
function _midiInputs() { return _midiAccess ? [..._midiAccess.inputs.values()] : []; }
|
||||||
function _heartbeat(on) { // tell the device a host is listening, so it shows "MIDI" + mutes its buzzer
|
function _midiOutputs() { return _midiAccess ? [..._midiAccess.outputs.values()] : []; }
|
||||||
clearInterval(_midiBeat); _midiBeat = 0;
|
function _send(bytes) { for (const o of _midiOutputs()) { try { o.send(bytes); } catch (_) {} } }
|
||||||
if (on) _midiBeat = setInterval(() => {
|
function _clockSysex() { const d = new Date(); // F0 7D 01 yr-2000 mo dd hh mm ss F7 -> sets the device RTC
|
||||||
if (_midiAccess) for (const out of _midiAccess.outputs.values()) { try { out.send([0xFE]); } catch (_) {} } // Active Sensing
|
return [0xF0, 0x7D, 0x01, d.getFullYear() - 2000, d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), 0xF7]; }
|
||||||
}, 250);
|
async function _ensureMidi() { // MIDI access WITH SysEx (needed to send/receive SysEx); cached
|
||||||
|
if (_midiAccess) return true;
|
||||||
|
if (!navigator.requestMIDIAccess) return false;
|
||||||
|
try { _midiAccess = await navigator.requestMIDIAccess({ sysex: true }); }
|
||||||
|
catch (e) { return false; }
|
||||||
|
_midiAccess.onstatechange = _wireMidi; _wireMidi();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
function _wireMidi() { for (const inp of _midiInputs()) inp.onmidimessage = onDeviceMidi; updateMidiBtn(); }
|
||||||
function onDeviceMidi(e) {
|
function onDeviceMidi(e) {
|
||||||
const d = e.data; if (!d || d.length < 3) return;
|
const d = e.data; if (!d) return;
|
||||||
if ((d[0] & 0xf0) === 0x90 && d[2] > 0) { // Note On
|
if (d[0] === 0xF0 && d[1] === 0x7D) { // our SysEx reply to a program push
|
||||||
const v = d[2], gain = v >= 110 ? 1.0 : v >= 70 ? 0.6 : 0.25; // accent / normal / ghost
|
if (_saveCb) { const cb = _saveCb; _saveCb = null; cb(d[2] === 0x7F); } // 0x7F ACK / 0x7E NAK
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_midiOn && (d[0] & 0xf0) === 0x90 && d.length >= 3 && d[2] > 0) { // Note On -> voice it
|
||||||
|
const v = d[2], gain = v >= 110 ? 1.0 : v >= 70 ? 0.6 : 0.25; // accent / normal / ghost
|
||||||
try { ensureAudio(); playInstrument(GM_NUM[d[1]] || "beep", audioCtx.currentTime, gain); } catch (_) {}
|
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); }
|
const b = $("midiBtn"); if (b) { b.style.boxShadow = "0 0 0 2px #2fe07a"; clearTimeout(_midiFlash); _midiFlash = setTimeout(() => b.style.boxShadow = "", 90); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function _bindMidi() { for (const inp of _midiInputs()) inp.onmidimessage = _midiOn ? onDeviceMidi : null; }
|
function _heartbeat(on) { // while Device audio is on: tell the device a host listens + keep its clock synced
|
||||||
|
clearInterval(_midiBeat); _midiBeat = 0;
|
||||||
|
if (on) { let k = 0; _midiBeat = setInterval(() => {
|
||||||
|
_send([0xFE]); // Active Sensing -> device shows "MIDI" + mutes buzzer
|
||||||
|
if ((k++ % 12) === 0) _send(_clockSysex()); // resync the clock ~every 3s
|
||||||
|
}, 250); }
|
||||||
|
}
|
||||||
function updateMidiBtn() {
|
function updateMidiBtn() {
|
||||||
const b = $("midiBtn"); if (!b) return;
|
const b = $("midiBtn"); if (!b) return;
|
||||||
if (!_midiOn) { b.textContent = "🎹 Device audio"; b.classList.remove("primary"); b.style.boxShadow = ""; return; }
|
if (!_midiOn) { b.textContent = "🎹 Device audio"; b.classList.remove("primary"); b.style.boxShadow = ""; return; }
|
||||||
const names = _midiInputs().map((i) => i.name || "MIDI");
|
const names = _midiInputs().map((i) => i.name || "MIDI");
|
||||||
b.textContent = names.length ? "🎹 " + names[0].slice(0, 16) : "🎹 no device"; // shows the connected MIDI device
|
b.textContent = names.length ? "🎹 " + names[0].slice(0, 16) : "🎹 no device";
|
||||||
b.classList.add("primary");
|
b.classList.add("primary");
|
||||||
}
|
}
|
||||||
async function toggleDeviceAudio() {
|
async function toggleDeviceAudio() {
|
||||||
if (_midiOn) { _midiOn = false; _heartbeat(false); _bindMidi(); updateMidiBtn(); return; }
|
if (_midiOn) { _midiOn = false; _heartbeat(false); updateMidiBtn(); return; } // inputs stay bound (for Save ACKs)
|
||||||
if (!navigator.requestMIDIAccess) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome or Edge.");
|
if (!(await _ensureMidi())) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome, Edge, or Firefox.");
|
||||||
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();
|
ensureAudio(); if (audioCtx && audioCtx.state === "suspended") audioCtx.resume();
|
||||||
_midiOn = true; _heartbeat(true); _bindMidi(); updateMidiBtn();
|
_midiOn = true; _heartbeat(true); updateMidiBtn();
|
||||||
const names = _midiInputs().map((i) => i.name || "MIDI");
|
const names = _midiInputs().map((i) => i.name || "MIDI");
|
||||||
alert(names.length
|
alert(names.length
|
||||||
? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green on each note received."
|
? "Device audio ON.\nMIDI input(s): " + names.join(", ") + "\nPress play on the device — the button pulses green per note."
|
||||||
: "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.)");
|
: "Armed, but no MIDI input yet. Plug in the PM_K-1 (CircuitPython firmware) — it connects automatically.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply a shared link on load. Returns true if it set the metronome state.
|
// Apply a shared link on load. Returns true if it set the metronome state.
|
||||||
|
|
|
||||||
|
|
@ -130,28 +130,28 @@
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="spec">
|
<details class="spec">
|
||||||
<summary>CircuitPython edition — USB drive + editor (experimental)</summary>
|
<summary>CircuitPython edition — self‑contained appliance (USB drive · push programming · MIDI audio · practice log)</summary>
|
||||||
<div class="spec-body">
|
<div class="spec-body">
|
||||||
<p class="sub">An alternative firmware that makes the Pico mount as a <b>USB drive</b> carrying the
|
<p class="sub">An alternative firmware that turns the Pico into a self‑contained appliance: it mounts as a
|
||||||
firmware, your tracks (<code>programs.json</code>) and a copy of this editor — design grooves on the
|
<b>USB drive</b> carrying the firmware, your tracks and an offline copy of this editor; drives a full
|
||||||
web and write them straight to the device, no Thonny. A full lanes/pads display with anti‑aliased
|
lanes/pads touchscreen; <b>logs your practice</b> to <code>history.json</code> on the device; takes new
|
||||||
text drives the touchscreen, and it plays out your <b>computer's speakers over USB‑MIDI</b> (the
|
set lists <b>pushed from the editor over USB‑MIDI</b>; and plays out your <b>computer's speakers over
|
||||||
editor's <b>🎹 Device audio</b> button voices it, locked to the device clock). The MicroPython
|
USB‑MIDI</b>. By default the firmware owns the drive (read‑only to the computer — so it can log and
|
||||||
firmware above stays the simple, rock‑solid option.</p>
|
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>
|
<p>
|
||||||
<a class="dl" href="/pm_k1_circuitpy.zip" download>Download CircuitPython bundle ↓</a>
|
<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>
|
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-cp" target="_blank" rel="noopener">Source + README ↗</a>
|
||||||
</p>
|
</p>
|
||||||
<ol class="steps">
|
<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>)
|
<li>Flash <b>CircuitPython</b> (<a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">raspberry_pi_pico</a>)
|
||||||
via BOOTSEL → the <code>CIRCUITPY</code> drive appears.</li>
|
via BOOTSEL, unzip the bundle onto <code>CIRCUITPY</code>, and power‑cycle. It boots into appliance mode.</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>Program it from the web:</b> build a set list in the <a href="/editor.html">editor</a> (Chrome/Edge/Firefox),
|
||||||
<li><b>Reprogram it from the web:</b> build a set list in the <a href="/editor.html">editor</a>, then the
|
then the set‑list <b>⋯</b> menu → <b>📟 Save to device</b>. It's pushed over USB‑MIDI and the device shows
|
||||||
set‑list <b>⋯</b> menu → <b>📟 Save to device</b> → pick the <code>CIRCUITPY</code> drive. The Pico
|
<b>Saved ✓</b>. (Fallback for any browser: it downloads <code>programs.json</code> — boot holding A and drag it on.)</li>
|
||||||
auto‑reloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it
|
<li><b>Play through your computer:</b> click <b>🎹 Device audio</b>, then press play on the device — the full
|
||||||
downloads <code>programs.json</code> to drag on.) <b>📥 Load from device</b> reads it back.</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>Play through your computer:</b> in the editor (Chrome/Edge) click <b>🎹 Device audio</b>, then
|
<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>
|
||||||
press play on the device — its full groove sounds through your speakers over USB‑MIDI, in sync.</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,57 @@
|
||||||
# PM_K‑1 "Kit" — CircuitPython edition (USB drive + editor)
|
# PM_K‑1 "Kit" — CircuitPython edition (USB drive · push programming · MIDI audio · practice log)
|
||||||
|
|
||||||
The **CircuitPython** firmware for the 52Pi EP‑0172 Pico kit. Unlike the MicroPython version
|
The **CircuitPython** firmware for the 52Pi EP‑0172 Pico kit, set up as a self‑contained appliance.
|
||||||
(`../pico/main.py`), this makes the Pico mount as a **USB drive (`CIRCUITPY`)** that carries the
|
It runs the same program‑string language as <https://metronome.varasys.io>. The simpler
|
||||||
firmware and your tracks — so you can edit on the web and reprogram it without Thonny. It runs the
|
**MicroPython** firmware (`../pico/main.py`) stays as a rock‑solid fallback — and the Pico can't be
|
||||||
same program‑string language as <https://metronome.varasys.io>.
|
bricked (BOOTSEL → drag a MicroPython `.uf2` back).
|
||||||
|
|
||||||
> **Status: experimental, phase 1.** This drives the screen/touch/joystick/buzzer and reads your
|
What it does: drives the 3.5″ touchscreen with a lanes/pads display + anti‑aliased text, plays the
|
||||||
> grooves from `programs.json`. The editor's one‑click "Save to device" and USB‑MIDI audio‑to‑computer
|
buzzer + RGB beat light, **logs your practice to `/history.json`**, accepts new set lists **pushed
|
||||||
> are landing in later phases. The simpler **MicroPython** firmware (`../pico/main.py`) remains the
|
from the web editor over USB‑MIDI**, and plays through your **computer's speakers** over USB‑MIDI.
|
||||||
> rock‑solid fallback — and the Pico can't be bricked (BOOTSEL → drag a MicroPython `.uf2` back).
|
|
||||||
|
## Two power‑on modes (set by `boot.py`)
|
||||||
|
|
||||||
|
- **Appliance mode — default (just plug in / power up).** The *firmware* owns the filesystem, so it
|
||||||
|
saves your practice log and writes set lists the editor pushes over USB‑MIDI. The drive is then
|
||||||
|
**read‑only to the computer** — which also **protects the firmware from accidental deletion**.
|
||||||
|
- **Editor mode — hold BUTTON A while plugging in.** The drive is **writable by the computer**, so you
|
||||||
|
can drag `programs.json` / `code.py` / fonts on from any OS or browser (the universal fallback).
|
||||||
|
Reset afterwards to return to appliance mode.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, and drop the CircuitPython `.uf2` for your board
|
1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, drop the CircuitPython `.uf2` onto `RPI‑RP2`
|
||||||
onto the `RPI‑RP2` drive (<https://circuitpython.org/board/raspberry_pi_pico/> — or the Pico 2 / W
|
(<https://circuitpython.org/board/raspberry_pi_pico/> — Pico 2 / W builds also fine). A `CIRCUITPY`
|
||||||
build). It reboots and a **`CIRCUITPY`** drive appears.
|
drive appears.
|
||||||
2. **Copy everything from the bundle** onto `CIRCUITPY` (drag‑and‑drop — it's a normal drive now):
|
2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py`, `programs.json`,
|
||||||
- `code.py` (this firmware — runs on boot)
|
`font_s.bin` / `font_m.bin` / `font_l.bin`, `editor.html` (offline editor), and the helper scripts.
|
||||||
- `programs.json` (your grooves)
|
3. **Power‑cycle** (so `boot.py` takes effect). It boots into appliance mode and runs.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Play through the computer's speakers (USB-MIDI)
|
## Program it from the web (push over USB‑MIDI)
|
||||||
|
|
||||||
The board also shows up as a **USB-MIDI** device and sends a note on every click (a GM drum note per
|
In the editor (Chrome / Edge / **Firefox**), build a set list → set‑list **⋯** menu → **📟 Save to device**.
|
||||||
lane, velocity by accent). Open the [editor](https://metronome.varasys.io/editor.html) in **Chrome/Edge**,
|
The editor sends it to the Pico over USB‑MIDI (SysEx); the firmware writes `/programs.json`, reloads, and
|
||||||
click **🎹 Device audio**, grant MIDI access, then press play *on the device* — the editor voices the
|
acknowledges — the editor shows **Saved ✓**. **📥 Load from device** reads it back.
|
||||||
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.
|
|
||||||
|
|
||||||
If the editor says **no MIDI input is connected**, copy **`boot.py`** onto `CIRCUITPY` too and
|
*Universal fallback (any browser / OS, even Safari):* Save to device **downloads** `programs.json` when no
|
||||||
**power-cycle** the Pico (`boot.py` only runs on a full reset). It frees a USB endpoint (drops the
|
device answers — boot the Pico in **editor mode** (hold A) and drag the file onto the `CIRCUITPY` drive.
|
||||||
unused HID interface) so the MIDI port is guaranteed to appear alongside the drive.
|
|
||||||
|
|
||||||
## Protect the firmware (so end users only see the editor + their tracks)
|
## Play through the computer's speakers
|
||||||
|
|
||||||
To stop someone accidentally deleting the firmware, **hide it** — the files keep running and
|
The Pico is a USB‑MIDI device and sends a note per click (GM drum note per lane, velocity by accent).
|
||||||
"Save to device" still works, but only `editor.html` + `programs.json` show in the file browser.
|
In the editor click **🎹 Device audio**, grant MIDI access, and press play *on the device* — the editor
|
||||||
On the host, with the drive mounted, run the included helper (needs `fatattr`):
|
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.
|
||||||
|
|
||||||
```
|
## 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.
|
- **Joystick:** up/down = tempo, left/right = previous/next groove.
|
||||||
- **Button A (GP15)** play/stop · **Button B (GP14)** tap tempo.
|
- **Button A (GP15):** play / stop. **Button B (GP14):** tap tempo.
|
||||||
- **RGB LED** flashes each beat; **buzzer** clicks (accent/normal/ghost).
|
- **Touchscreen:** the bottom of the screen shows the **practice log** (time · BPM · duration · track) —
|
||||||
|
newest first. Plays under 5 s aren't logged. **Tap a row to arm it (turns amber), tap again to delete.**
|
||||||
|
- **RGB LED** flashes the beat (amber accent / cyan normal / violet ghost); the **buzzer** clicks to match.
|
||||||
|
- The log is saved to `/history.json` (next to `programs.json`) in appliance mode and survives power‑cycles.
|
||||||
|
|
||||||
## programs.json
|
## programs.json
|
||||||
|
|
||||||
|
|
@ -65,30 +60,24 @@ editor's *Save to device* can't write either, so you'd reprogram by temporarily
|
||||||
"programs": [ { "name": "Four on the floor", "prog": "t120;kick:4;snare:4=.x.x;hatClosed:4/2" } ] }
|
"programs": [ { "name": "Four on the floor", "prog": "t120;kick:4;snare:4=.x.x;hatClosed:4/2" } ] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Each `prog` is a program string from the web editor. Add/replace entries and save — the device reloads.
|
Each `prog` is a program string from the editor (tempo, lanes, patterns, `/2` subdivision, `/2s` swing,
|
||||||
|
`(3,8)` Euclid, `~` polymeter, `@-3` dB). The push above is the easy way to update it.
|
||||||
|
|
||||||
**Easiest way to (re)program it:** in the editor (the web app, or the `editor.html` on the drive), build a
|
## Calibration (flags at the top of `code.py`)
|
||||||
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`.
|
- **Red/blue swapped:** flip `MADCTL` between `0x48` (default) and `0x40`.
|
||||||
- **Colours look negative:** toggle `INVERT_COLORS`.
|
- **Colours look negative:** toggle `INVERT_COLORS`.
|
||||||
- **Taps land wrong:** set `TOUCH_DEBUG = True`, watch the serial output, then set
|
- **Taps land wrong:** set `TOUCH_DEBUG = True`, read the raw coords over USB serial, then set
|
||||||
`TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y`.
|
`TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_INVERT_Y`.
|
||||||
- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`.
|
- **Joystick reversed:** toggle `JOY_INVERT_X` / `JOY_INVERT_Y`.
|
||||||
- **Computer audio:** `MIDI_ENABLED` (default on) sends the MIDI notes; `MUTE_BUZZER` silences the buzzer.
|
- **Computer audio:** `MIDI_ENABLED` (default on); `MUTE_BUZZER` forces the buzzer off even standalone.
|
||||||
- **LED too bright / too dim:** change `LED_BRIGHTNESS` (0..1, default 0.15).
|
- **LED too bright/dim:** `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
|
- **Screen tearing:** SPI panels have no tearing‑effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast
|
||||||
to minimise it — lower it only if the display is unstable.
|
to minimise it — lower only if unstable.
|
||||||
- **Screen blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341
|
- **Blank / garbled:** the panel lot may differ; drop `SPI_BAUD`, and if it's a 240×320 ILI9341 rather than
|
||||||
instead of the 320×480 ST7796, the init/size need changing (this targets the 320×480 you have).
|
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,
|
- **RGB LED** uses the core `neopixel_write` (no library to install).
|
||||||
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** —
|
If `code.py` ever errors, CircuitPython prints the traceback **on the screen and over USB serial** — send
|
||||||
copy that to me and I'll fix it.
|
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.
|
||||||
The fonts are the same baked anti‑aliased blobs as the MicroPython build (see `../pico/gen_font.py`).
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,10 +1,22 @@
|
||||||
# boot.py — runs once before USB connects (hard reset / power-cycle to apply).
|
# boot.py — runs once at power-on (before USB connects); decides who owns the filesystem.
|
||||||
# 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
|
# DEFAULT = appliance mode: the FIRMWARE owns the drive, so it can save your practice log to
|
||||||
# Pico (which also exposes the CIRCUITPY drive + serial at the same time).
|
# /history.json and write /programs.json that the editor pushes over USB-MIDI. The drive is then
|
||||||
import usb_hid, usb_midi
|
# READ-ONLY to the computer — which also protects the firmware from accidental deletion.
|
||||||
try:
|
#
|
||||||
usb_hid.disable()
|
# HOLD BUTTON A (GP15) WHILE PLUGGING IN = editor mode: the drive is writable by the computer, so
|
||||||
except Exception:
|
# you can drag programs.json / code.py on from any OS or browser (the universal fallback). Reset
|
||||||
pass
|
# afterwards to return to appliance mode.
|
||||||
|
#
|
||||||
|
# Also frees a USB endpoint (disables unused HID) and makes sure USB-MIDI is available.
|
||||||
|
import board, digitalio, storage, usb_hid, usb_midi
|
||||||
|
try: usb_hid.disable()
|
||||||
|
except Exception: pass
|
||||||
usb_midi.enable()
|
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,7 +16,12 @@
|
||||||
#
|
#
|
||||||
# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md.
|
# Untested-panel notes & calibration flags are in CONFIG + pico-cp/README.md.
|
||||||
|
|
||||||
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc
|
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
|
||||||
|
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
|
||||||
|
try:
|
||||||
|
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
||||||
|
except ImportError:
|
||||||
|
rtc = None
|
||||||
try: # CircuitPython 9.x
|
try: # CircuitPython 9.x
|
||||||
from fourwire import FourWire
|
from fourwire import FourWire
|
||||||
from busdisplay import BusDisplay
|
from busdisplay import BusDisplay
|
||||||
|
|
@ -79,6 +84,8 @@ SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare
|
||||||
GM_DEFAULT = 37
|
GM_DEFAULT = 37
|
||||||
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
|
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
|
||||||
MAXLANES = 5 # lanes shown on the pad grid (extras still play)
|
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_DIM = (0x10161E, 0x0A3A52, 0x4A3010, 0x2A1D4A) # idle pad: mute / normal / accent / ghost
|
||||||
PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost
|
PAD_LIT = (0x39414D, 0x0AB3F7, 0xFF9B2E, 0x967BFF) # playhead pad: mute / normal / accent / ghost
|
||||||
|
|
||||||
|
|
@ -178,9 +185,10 @@ def _parse_lane(tok):
|
||||||
sound, _, rest = tok.partition(':')
|
sound, _, rest = tok.partition(':')
|
||||||
pattern = None
|
pattern = None
|
||||||
if '=' in rest: rest, _, pattern = rest.partition('=')
|
if '=' in rest: rest, _, pattern = rest.partition('=')
|
||||||
sub = 1
|
sub = 1; swing = False
|
||||||
if '/' in rest:
|
if '/' in rest:
|
||||||
rest, _, sd = rest.partition('/'); sd = sd.rstrip('s')
|
rest, _, sd = rest.partition('/')
|
||||||
|
swing = sd.endswith('s'); sd = sd.rstrip('s') # "/2s" = swung eighths
|
||||||
sub = int(sd) if sd.isdigit() else 1
|
sub = int(sd) if sd.isdigit() else 1
|
||||||
groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
|
groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
|
||||||
beats = sum(groups); starts = set(); acc = 0
|
beats = sum(groups); starts = set(); acc = 0
|
||||||
|
|
@ -195,7 +203,7 @@ def _parse_lane(tok):
|
||||||
for i in range(steps):
|
for i in range(steps):
|
||||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)
|
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1)
|
||||||
else: levels.append(0)
|
else: levels.append(0)
|
||||||
return {'sound': sound, 'sub': sub, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
|
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'poly': poly, 'mute': mute}
|
||||||
|
|
||||||
def load_programs():
|
def load_programs():
|
||||||
try:
|
try:
|
||||||
|
|
@ -290,7 +298,8 @@ class App:
|
||||||
self.touch = GT911(self.i2c)
|
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 = 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.midi_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None
|
||||||
self._mbuf = bytearray(16); self.midi_host = False; self.last_midi_in = 0.0
|
self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0
|
||||||
|
self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs)
|
||||||
self.led = RGB(P_RGB)
|
self.led = RGB(P_RGB)
|
||||||
self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0)
|
self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0)
|
||||||
self.buz_off = 0
|
self.buz_off = 0
|
||||||
|
|
@ -301,13 +310,18 @@ class App:
|
||||||
self._touchDown = False; self._touchSeen = 0
|
self._touchDown = False; self._touchSeen = 0
|
||||||
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0)
|
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.rgb = (0, 0, 0)
|
||||||
self.programs = load_programs()
|
self.programs = load_programs()
|
||||||
self.buttons = []
|
|
||||||
self.dirty = True
|
self.dirty = True
|
||||||
self.pad_pal = displayio.Palette(8)
|
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]
|
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 = []
|
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._build_scene()
|
||||||
self.load(0)
|
self.load(0)
|
||||||
|
self.draw_log()
|
||||||
|
|
||||||
def _btn(self, pin):
|
def _btn(self, pin):
|
||||||
d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP
|
d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP
|
||||||
|
|
@ -325,17 +339,9 @@ class App:
|
||||||
self.g_name = displayio.Group(); root.append(self.g_name) # item index + name
|
self.g_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_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
|
self.g_grid = displayio.Group(); root.append(self.g_grid) # lanes × step pads
|
||||||
# buttons (rects static; labels in per-button groups so play can toggle)
|
root.append(rect(0, LOG_TOP - 6, WIDTH, 2, C_PANEL)) # divider above the history log
|
||||||
bw, bh = 96, 56; gap = (WIDTH - 3*bw)//4; xs = [gap, gap*2+bw, gap*3+bw*2]
|
self.g_log = displayio.Group(); root.append(self.g_log) # practice history (tap a row to delete)
|
||||||
self.btn_lbl = {}
|
# (no on-screen buttons — transport is the joystick + buttons A/B; touch deletes log rows)
|
||||||
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):
|
def _place(self, group, s, x, y, fg, bg, font, right_edge=None):
|
||||||
while len(group): group.pop()
|
while len(group): group.pop()
|
||||||
|
|
@ -347,11 +353,6 @@ class App:
|
||||||
while len(group): group.pop()
|
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)
|
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
|
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 ----------
|
# ---------- program ----------
|
||||||
def load(self, i):
|
def load(self, i):
|
||||||
|
|
@ -360,16 +361,20 @@ class App:
|
||||||
self.bpm, self.lanes = parse_program(prog)
|
self.bpm, self.lanes = parse_program(prog)
|
||||||
self.master = self.lanes[0]
|
self.master = self.lanes[0]
|
||||||
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid()
|
self._reset_clock(); self.draw_bpm(); self.draw_status(); self.build_grid()
|
||||||
def _lane_dur(self, L):
|
def _step_dur(self, L, step):
|
||||||
beat = 60_000_000_000 / self.bpm
|
beat = 60_000_000_000 / self.bpm
|
||||||
if L['poly']: # ~ polymeter: fit this lane's whole cycle into lane 1's bar
|
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'])
|
m = self.lanes[0]; master_bar = beat * (m['steps'] // m['sub'])
|
||||||
return int(master_bar / L['steps'])
|
return int(master_bar / L['steps'])
|
||||||
return int(beat / L['sub']) # straight: a step = one beat / subdivision
|
sub = L['sub']
|
||||||
|
if L['swing'] and sub % 2 == 0: # swing even subdivisions: long–short (2:1) pairs
|
||||||
|
pair = beat / (sub // 2)
|
||||||
|
return int(pair * 2 / 3) if (step % sub) % 2 == 0 else int(pair / 3)
|
||||||
|
return int(beat / sub) # straight: a step = one beat / subdivision
|
||||||
def _reset_clock(self):
|
def _reset_clock(self):
|
||||||
now = time.monotonic_ns()
|
now = time.monotonic_ns()
|
||||||
for L in self.lanes:
|
for L in self.lanes:
|
||||||
L['next'] = now; L['step'] = -1; L['dur'] = self._lane_dur(L)
|
L['next'] = now; L['step'] = -1
|
||||||
|
|
||||||
# ---------- audio + light ----------
|
# ---------- audio + light ----------
|
||||||
def click(self, level):
|
def click(self, level):
|
||||||
|
|
@ -390,18 +395,19 @@ class App:
|
||||||
# ---------- transport ----------
|
# ---------- transport ----------
|
||||||
def toggle(self):
|
def toggle(self):
|
||||||
self.running = not self.running
|
self.running = not self.running
|
||||||
if self.running: self._reset_clock()
|
if self.running: self._reset_clock(); self._start_play()
|
||||||
else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads()
|
else: self.buz.duty_cycle = 0; self.led_off(); self.reset_playheads(); self._log_play()
|
||||||
self.draw_status(); self._label("play")
|
self.draw_status()
|
||||||
def set_bpm(self, v):
|
def set_bpm(self, v):
|
||||||
v = max(30, min(300, v))
|
v = max(30, min(300, v))
|
||||||
if v != self.bpm:
|
if v != self.bpm:
|
||||||
self.bpm = v
|
self.bpm = v
|
||||||
for L in self.lanes: L['dur'] = self._lane_dur(L)
|
|
||||||
self.draw_bpm()
|
self.draw_bpm()
|
||||||
def goto(self, i):
|
def goto(self, i):
|
||||||
was = self.running; self.load(i); self._label("play")
|
was = self.running
|
||||||
if was: self.running = True; self._reset_clock()
|
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):
|
def tap(self):
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if not hasattr(self, '_taps'): self._taps = []
|
if not hasattr(self, '_taps'): self._taps = []
|
||||||
|
|
@ -425,7 +431,7 @@ class App:
|
||||||
if lvl > 0:
|
if lvl > 0:
|
||||||
fired.append(lvl)
|
fired.append(lvl)
|
||||||
self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane
|
self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane
|
||||||
L['next'] += L['dur']; adv = True
|
L['next'] += self._step_dur(L, L['step']); adv = True
|
||||||
if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
|
if adv and li < len(self.lane_pads): self._move_playhead(li, L['step'])
|
||||||
if fired:
|
if fired:
|
||||||
best = max(fired, key=lambda l: PRIO.get(l, 0))
|
best = max(fired, key=lambda l: PRIO.get(l, 0))
|
||||||
|
|
@ -461,31 +467,22 @@ class App:
|
||||||
if pt:
|
if pt:
|
||||||
self._touchSeen = nowms
|
self._touchSeen = nowms
|
||||||
if not self._touchDown:
|
if not self._touchDown:
|
||||||
self._touchDown = True; self.hit(pt[0], pt[1])
|
self._touchDown = True; self._tap_log(pt[0], pt[1])
|
||||||
elif self._touchDown and (nowms - self._touchSeen) > 0.14:
|
elif self._touchDown and (nowms - self._touchSeen) > 0.14:
|
||||||
self._touchDown = False
|
self._touchDown = False
|
||||||
# MIDI host present? the editor sends a heartbeat (Active Sensing) while "Device audio" is on
|
# USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx (clock / pushed programs)
|
||||||
if self.midi_in is not None:
|
if self.midi_in is not None:
|
||||||
try:
|
try: n = self.midi_in.readinto(self._mbuf)
|
||||||
if self.midi_in.readinto(self._mbuf): self.last_midi_in = nowms
|
except Exception: n = 0
|
||||||
except Exception: pass
|
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
|
host = bool(self.last_midi_in) and (nowms - self.last_midi_in) < 1.0
|
||||||
if host != self.midi_host:
|
if host != self.midi_host:
|
||||||
self.midi_host = host
|
self.midi_host = host
|
||||||
if host: self.buz.duty_cycle = 0 # silence the buzzer when the computer takes over
|
if host: self.buz.duty_cycle = 0 # silence the buzzer when the computer takes over
|
||||||
self.led_off(); self.draw_midi()
|
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 ----------
|
# ---------- drawing ----------
|
||||||
def draw_bpm(self):
|
def draw_bpm(self):
|
||||||
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
|
self._place(self.g_bpm, str(self.bpm), 0, 44, C_TXT, C_BG, FONT_L, right_edge=WIDTH-12)
|
||||||
|
|
@ -532,6 +529,89 @@ class App:
|
||||||
self.lane_lit[li] = -1
|
self.lane_lit[li] = -1
|
||||||
self.dirty = True
|
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):
|
def run(self):
|
||||||
if self.touch.addr is None:
|
if self.touch.addr is None:
|
||||||
print("GT911 touch not found")
|
print("GT911 touch not found")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue