Compare commits
2 commits
5b10af189d
...
72ea70da59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ea70da59 | ||
|
|
7d743c18a1 |
8 changed files with 282 additions and 173 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
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.0.66
|
0.0.67
|
||||||
|
|
|
||||||
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