Phase 3: USB-MIDI audio — play the device through the computer's speakers

Firmware (pico-cp/code.py): on every click, send a USB-MIDI note-on per firing lane —
GM drum note by voice (SOUND_GM), velocity by level (accent/normal/ghost) — via the
default-enabled usb_midi.ports[1]. Polyphonic, so the computer plays the full groove.
New CONFIG: MIDI_ENABLED (default on), MUTE_BUZZER (silence the buzzer when using
computer audio).

Editor (editor.html): a '🎹 Device audio' toggle uses the Web MIDI API
(requestMIDIAccess) to voice incoming notes through the existing synth — Note-On ->
GM_NUM[note] / velocity-to-gain -> playInstrument(). The device is the clock; the
browser is the sound module, locked in sync. Chrome/Edge.

Verified: firmware emits the right notes (kick+hat on beat 1 of four-on-the-floor,
snare's rest skipped); editor loads clean with the toggle + handlers present. Docs
(info-kit, both READMEs) updated. The on-device buzzer/screen still work standalone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 22:40:08 -05:00
parent d9a2be7389
commit 7ccc75e399
6 changed files with 75 additions and 9 deletions

View file

@ -185,7 +185,12 @@ flashing steps. Firmware lives in **`pico/`**:
WS2812 RGB, PWM buzzer, ADC joystick, baked antialiased fonts, and the polymeter engine. WS2812 RGB, PWM buzzer, ADC joystick, baked antialiased fonts, and the polymeter engine.
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 antialiased fonts embedded in the firmware. - **`pico/gen_font.py`** — generates the baked antialiased fonts (used by both firmwares).
- **`pico-cp/`** — a **CircuitPython** edition (download `/pm_k1_circuitpy.zip`): the Pico mounts as a
USB drive carrying the firmware + your `programs.json` + a copy of the editor, with a full lanes/pads
touchscreen display. Design grooves on the web and **Save to device** straight onto the drive (the
editor's ⋯ menu), and play it **out your computer's speakers over USBMIDI** (the editor's
**🎹 Device audio** button). The MicroPython build stays the simple, nocomputer option.
## Keyboard shortcuts ## Keyboard shortcuts
@ -261,7 +266,8 @@ tags the current commit `v<VERSION>` (requires a clean tree). Push the tag, then
| `embed.html` · `embed.js` | embed docs and the dropin widget loader | | `embed.html` · `embed.js` | embed docs and the dropin widget loader |
| `src/` | shared partials inlined into every page: `engine.js`, `setlists.js`, `base.css`, `header.html`, `footer.html`, `chrome.js`, `progbox.{html,js}`, `infoembed.{html,js}` | | `src/` | shared partials inlined into every page: `engine.js`, `setlists.js`, `base.css`, `header.html`, `footer.html`, `chrome.js`, `progbox.{html,js}`, `infoembed.{html,js}` |
| `assets/` | base64 blobs inlined at build (`favicon`, `logo-dark`, `logo-light`) | | `assets/` | base64 blobs inlined at build (`favicon`, `logo-dark`, `logo-light`) |
| `pico/` | PM_K1 firmware: `main.py` (MicroPython), `gen_font.py` (font generator), `README.md` | | `pico/` | PM_K1 MicroPython firmware: `main.py`, `gen_font.py` (font generator), `README.md` |
| `pico-cp/` | PM_K1 CircuitPython edition: `code.py`, `programs.json`, `font_*.bin`, `README.md` (bundled + served as `/pm_k1_circuitpy.zip`) |
| `build.sh` | resolve markers → selfcontained `dist/` pages (+ `pico-main.py`) | | `build.sh` | resolve markers → selfcontained `dist/` pages (+ `pico-main.py`) |
| `deploy.sh` | build, then publish to the Caddy web root | | `deploy.sh` | build, then publish to the Caddy web root |
| `release.sh` | tag a formal version | | `release.sh` | tag a formal version |

View file

@ -272,7 +272,7 @@
</div> </div>
<div class="ctx" id="ctxDisplay">&nbsp;</div> <div class="ctx" id="ctxDisplay">&nbsp;</div>
</div> </div>
<div class="btnrow" style="margin-top:10px"><button class="primary" id="startBtn">▶ Start</button><button id="tapBtn">Tap</button><span id="saveItemWrap" style="display:inline-flex" title="Select a set-list item to enable Save"><button id="saveItemBtn" disabled>💾 Save</button></span></div> <div class="btnrow" style="margin-top:10px"><button class="primary" id="startBtn">▶ Start</button><button id="tapBtn">Tap</button><span id="saveItemWrap" style="display:inline-flex" title="Select a set-list item to enable Save"><button id="saveItemBtn" disabled>💾 Save</button></span><button id="midiBtn" title="Play a connected PM_K-1 device through this computer's speakers (Web MIDI · Chrome/Edge)">🎹 Device audio</button></div>
</div> </div>
<div style="flex:1; min-width:200px"> <div style="flex:1; min-width:200px">
@ -1115,6 +1115,29 @@ async function loadFromDevice() {
inp.click(); inp.click();
} }
/* Device audio (Phase 3): a connected PM_K-1 sends a USB-MIDI note per click; we voice it through
this page's synth, so the device drives sound out the computer's speakers, locked to its clock. */
let _midiAccess = null, _midiOn = false;
function onDeviceMidi(e) {
const d = e.data; if (!d || d.length < 3) return;
if ((d[0] & 0xf0) === 0x90 && d[2] > 0) { // Note On
const v = d[2], gain = v >= 110 ? 1.0 : v >= 70 ? 0.6 : 0.25; // accent / normal / ghost
try { playInstrument(GM_NUM[d[1]] || "beep", audioCtx.currentTime, gain); } catch (_) {}
}
}
function _bindMidi() { if (_midiAccess) for (const inp of _midiAccess.inputs.values()) inp.onmidimessage = _midiOn ? onDeviceMidi : null; }
function updateMidiBtn() { const b = $("midiBtn"); if (b) { b.textContent = _midiOn ? "🎹 Device audio: ON" : "🎹 Device audio"; b.classList.toggle("primary", _midiOn); } }
async function toggleDeviceAudio() {
if (_midiOn) { _midiOn = false; _bindMidi(); updateMidiBtn(); return; }
if (!navigator.requestMIDIAccess) return alert("Playing the device through this computer needs the Web MIDI API — use Chrome or Edge.");
try { if (!_midiAccess) { _midiAccess = await navigator.requestMIDIAccess(); _midiAccess.onstatechange = _bindMidi; } }
catch (e) { return alert("MIDI access was denied."); }
ensureAudio(); if (audioCtx && audioCtx.state === "suspended") audioCtx.resume();
_midiOn = true; _bindMidi(); updateMidiBtn();
if (![..._midiAccess.inputs.values()].length)
alert("No MIDI device detected yet — plug in the PM_K-1 (CircuitPython firmware) and press play on it. (It stays armed; new devices connect 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.
function applyHashShare() { function applyHashShare() {
const h = location.hash || ""; const h = location.hash || "";
@ -1340,6 +1363,7 @@ $("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $(
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; }); $("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
$("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); }); $("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); });
$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); }); $("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
$("midiBtn").addEventListener("click", toggleDeviceAudio);
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); }); $("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); }); $("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
$("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); }); $("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); });

View file

@ -135,8 +135,9 @@
<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 makes the Pico mount as a <b>USB drive</b> carrying the
firmware, your tracks (<code>programs.json</code>) and a copy of this editor — design grooves on the firmware, your tracks (<code>programs.json</code>) and a copy of this editor — design grooves on the
web and write them straight to the device, no Thonny. A full lanes/pads display with antialiased web and write them straight to the device, no Thonny. A full lanes/pads display with antialiased
text drives the touchscreen. The MicroPython firmware above stays the simple, rocksolid option. text drives the touchscreen, and it plays out your <b>computer's speakers over USBMIDI</b> (the
(USBMIDI audio out to your computer's speakers is the next step.)</p> editor's <b>🎹 Device audio</b> button voices it, locked to the device clock). The MicroPython
firmware above stays the simple, rocksolid 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>
@ -149,6 +150,8 @@
setlist <b></b> menu → <b>📟 Save to device</b> → pick the <code>CIRCUITPY</code> drive. The Pico setlist <b></b> menu → <b>📟 Save to device</b> → pick the <code>CIRCUITPY</code> drive. The Pico
autoreloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it autoreloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it
downloads <code>programs.json</code> to drag on.) <b>📥 Load from device</b> reads it back.</li> downloads <code>programs.json</code> to drag on.) <b>📥 Load from device</b> reads it back.</li>
<li><b>Play through your computer:</b> in the editor (Chrome/Edge) click <b>🎹 Device audio</b>, then
press play on the device — its full groove sounds through your speakers over USBMIDI, in sync.</li>
</ol> </ol>
</div> </div>
</details> </details>

View file

@ -23,6 +23,14 @@ same programstring language as <https://metronome.varasys.io>.
3. It starts immediately. Editing `programs.json` (or resaving it from the editor) makes CircuitPython 3. It starts immediately. Editing `programs.json` (or resaving it from the editor) makes CircuitPython
**autoreload** with the new tracks. **autoreload** with the new tracks.
## Play through the computer's speakers (USB-MIDI)
The board also shows up as a **USB-MIDI** device and sends a note on every click (a GM drum note per
lane, velocity by accent). Open the [editor](https://metronome.varasys.io/editor.html) in **Chrome/Edge**,
click **🎹 Device audio**, grant MIDI access, then press play *on the device* — the editor voices the
groove through its full synth, out your computer's speakers, locked to the device's clock. Set
`MUTE_BUZZER = True` in `code.py` if you'd rather silence the on-board buzzer while doing this.
## Controls (same as the MicroPython build) ## Controls (same as the MicroPython build)
- **Touch:** onscreen `◀◀ / ▶ / ▶▶` (prev · play/stop · next) and ` / TAP / +`. - **Touch:** onscreen `◀◀ / ▶ / ▶▶` (prev · play/stop · next) and ` / TAP / +`.
@ -51,6 +59,7 @@ on. **📥 Load from device** reads a `programs.json` back into a new set list.
- **Taps land wrong:** set `TOUCH_DEBUG = True`, watch the serial output, then set - **Taps land wrong:** set `TOUCH_DEBUG = True`, watch the serial output, 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.
- **LED too bright / too dim:** change `LED_BRIGHTNESS` (0..1, default 0.15). - **LED too bright / too dim:** change `LED_BRIGHTNESS` (0..1, default 0.15).
- **Screen tearing:** the SPI panel has no tearing-effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast - **Screen tearing:** the SPI panel has no tearing-effect sync; `SPI_BAUD` (default 62.5 MHz) is pushed fast
to minimise it — lower it only if the display is unstable. to minimise it — lower it only if the display is unstable.

View file

@ -4,8 +4,9 @@
# #
# WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your # WHY CIRCUITPYTHON: the board then mounts as a USB drive (CIRCUITPY) carrying this code, your
# tracks (programs.json) and a copy of the editor — edit on the web, "Save to device" writes # tracks (programs.json) and a copy of the editor — edit on the web, "Save to device" writes
# programs.json here, and CircuitPython auto-reloads with the new grooves. (USB-MIDI audio out # programs.json here, and CircuitPython auto-reloads with the new grooves. It also sends USB-MIDI
# to the computer comes in a later phase.) Runs the SAME program strings as metronome.varasys.io. # (a note per click) so the web editor can play it out the computer's speakers ("Device audio").
# Runs the SAME program strings as metronome.varasys.io.
# #
# INSTALL: flash CircuitPython (https://circuitpython.org/board/raspberry_pi_pico/), then copy # INSTALL: flash CircuitPython (https://circuitpython.org/board/raspberry_pi_pico/), then copy
# this file as code.py plus programs.json onto the CIRCUITPY drive. It runs on boot. # this file as code.py plus programs.json onto the CIRCUITPY drive. It runs on boot.
@ -26,10 +27,16 @@ try:
import neopixel_write # core module on RP2040 — drives WS2812 with no external library import neopixel_write # core module on RP2040 — drives WS2812 with no external library
except ImportError: except ImportError:
neopixel_write = None neopixel_write = None
try:
import usb_midi # default-enabled on RP2040 — sends a MIDI note per click to the computer
except ImportError:
usb_midi = None
# ============================== CONFIG (tweak if needed) ============================== # ============================== CONFIG (tweak if needed) ==============================
SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable SPI_BAUD = 62_500_000 # faster SPI = smaller tearing window; drop to 40_000_000 if unstable
LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you — keep it dim (0..1) LED_BRIGHTNESS = 0.15 # WS2812 sits right next to you — keep it dim (0..1)
MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio")
MUTE_BUZZER = False # silence the on-board buzzer (e.g. when using computer audio)
WIDTH, HEIGHT = 320, 480 WIDTH, HEIGHT = 320, 480
MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed. MADCTL = 0x48 # portrait; 0x48 swaps R/B for this BGR panel (cyan reads cyan). Use 0x40 if reversed.
INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative INVERT_COLORS = True # most ST7796 modules need inversion ON; set False if colours look negative
@ -63,6 +70,14 @@ C_BG, C_PANEL, C_TXT, C_MUTE = 0x06090E, 0x1C222C, 0xC7D0DB, 0x788494
C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240 C_CYAN, C_AMBER, C_GREEN, C_DIM = 0x0AB3F7, 0xFF9B2E, 0x2FE07A, 0x243240
C_BTN = 0x1C222C C_BTN = 0x1C222C
LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)} LEVEL_RGB = {2: (255, 110, 0), 1: (0, 150, 255), 3: (130, 70, 255)}
# voice -> General-MIDI note (USB-MIDI bridge), and level -> MIDI velocity
SOUND_GM = {"kick":36,"kick808":36,"kick909":36, "snare":38,"snare808":38,"snare909":38,
"clap":39,"clap808":39,"clap909":39, "rim":37, "hatClosed":42,"hat808":42,"hat909":42,
"hatOpen":46,"openHat808":46, "ride":51,"ride909":51, "crash":49,"crash909":49,
"tomLow":41,"tom808":45,"tomMid":45,"tomHigh":48, "tambourine":54,
"cowbell":56,"cowbell808":56, "woodblock":76,"jamblock":76, "claves":75, "beep":37}
GM_DEFAULT = 37
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
MAXLANES = 5 # lanes shown on the pad grid (extras still play) MAXLANES = 5 # lanes shown on the pad grid (extras still play)
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
@ -273,6 +288,7 @@ class App:
self.display = make_display() self.display = make_display()
self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000) self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000)
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.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
@ -357,6 +373,10 @@ class App:
def led_off(self): def led_off(self):
self.rgb = (0, 0, 0) self.rgb = (0, 0, 0)
self.led.set(0, 0, 0) self.led.set(0, 0, 0)
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
if self.midi is None: return
try: self.midi.write(bytes([0x90, note, vel])) # Note On (percussive — no Note Off needed)
except Exception: pass
# ---------- transport ---------- # ---------- transport ----------
def toggle(self): def toggle(self):
@ -393,11 +413,15 @@ class App:
while now >= L['next']: while now >= L['next']:
L['step'] = (L['step'] + 1) % L['steps'] L['step'] = (L['step'] + 1) % L['steps']
lvl = 0 if L['mute'] else L['levels'][L['step']] lvl = 0 if L['mute'] else L['levels'][L['step']]
if lvl > 0: fired.append(lvl) if lvl > 0:
fired.append(lvl)
self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90)) # one note per lane
L['next'] += L['dur']; adv = True L['next'] += L['dur']; 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)); self.click(best); self.flash(best) best = max(fired, key=lambda l: PRIO.get(l, 0))
if not MUTE_BUZZER: self.click(best)
self.flash(best)
if self.rgb != (0, 0, 0): if self.rgb != (0, 0, 0):
r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10 r, g, b = self.rgb; r = r*7//10; g = g*7//10; b = b*7//10
self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0) self.rgb = (r, g, b) if (r+g+b) > 12 else (0, 0, 0)