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.
It parses the same program strings as the web editor. Flash MicroPython, copy `main.py`,
edit the `PROGRAMS` list to change grooves. Download: `/pico-main.py`.
- **`pico/gen_font.py`** — generates the baked 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
@ -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 |
| `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`) |
| `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`) |
| `deploy.sh` | build, then publish to the Caddy web root |
| `release.sh` | tag a formal version |

View file

@ -272,7 +272,7 @@
</div>
<div class="ctx" id="ctxDisplay">&nbsp;</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 style="flex:1; min-width:200px">
@ -1115,6 +1115,29 @@ async function loadFromDevice() {
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.
function applyHashShare() {
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 = ""; });
$("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); });
$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
$("midiBtn").addEventListener("click", toggleDeviceAudio);
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
$("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
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
text drives the touchscreen. The MicroPython firmware above stays the simple, rocksolid option.
(USBMIDI audio out to your computer's speakers is the next step.)</p>
text drives the touchscreen, and it plays out your <b>computer's speakers over USBMIDI</b> (the
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>
<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>
@ -149,6 +150,8 @@
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
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>
</div>
</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
**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)
- **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
`TOUCH_SWAP_XY` / `TOUCH_INVERT_X` / `TOUCH_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).
- **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.

View file

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