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:
parent
d9a2be7389
commit
7ccc75e399
6 changed files with 75 additions and 9 deletions
10
README.md
10
README.md
|
|
@ -185,7 +185,12 @@ flashing steps. Firmware lives in **`pico/`**:
|
|||
WS2812 RGB, PWM buzzer, ADC joystick, baked anti‑aliased 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 anti‑aliased fonts embedded in the firmware.
|
||||
- **`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
|
||||
USB drive carrying the firmware + your `programs.json` + a copy of the editor, with a full lanes/pads
|
||||
touchscreen display. Design grooves on the web and **Save to device** straight onto the drive (the
|
||||
editor's ⋯ menu), and play it **out your computer's speakers over USB‑MIDI** (the editor's
|
||||
**🎹 Device audio** button). The MicroPython build stays the simple, no‑computer option.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
|
|
@ -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 drop‑in 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_K‑1 firmware: `main.py` (MicroPython), `gen_font.py` (font generator), `README.md` |
|
||||
| `pico/` | PM_K‑1 MicroPython firmware: `main.py`, `gen_font.py` (font generator), `README.md` |
|
||||
| `pico-cp/` | PM_K‑1 CircuitPython edition: `code.py`, `programs.json`, `font_*.bin`, `README.md` (bundled + served as `/pm_k1_circuitpy.zip`) |
|
||||
| `build.sh` | resolve markers → self‑contained `dist/` pages (+ `pico-main.py`) |
|
||||
| `deploy.sh` | build, then publish to the Caddy web root |
|
||||
| `release.sh` | tag a formal version |
|
||||
|
|
|
|||
26
editor.html
26
editor.html
|
|
@ -272,7 +272,7 @@
|
|||
</div>
|
||||
<div class="ctx" id="ctxDisplay"> </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(); });
|
||||
|
|
|
|||
|
|
@ -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 anti‑aliased
|
||||
text drives the touchscreen. The MicroPython firmware above stays the simple, rock‑solid option.
|
||||
(USB‑MIDI 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 USB‑MIDI</b> (the
|
||||
editor's <b>🎹 Device audio</b> button voices it, locked to the device clock). The MicroPython
|
||||
firmware above stays the simple, rock‑solid option.</p>
|
||||
<p>
|
||||
<a class="dl" href="/pm_k1_circuitpy.zip" download>Download CircuitPython bundle ↓</a>
|
||||
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-cp" target="_blank" rel="noopener">Source + README ↗</a>
|
||||
|
|
@ -149,6 +150,8 @@
|
|||
set‑list <b>⋯</b> menu → <b>📟 Save to device</b> → pick the <code>CIRCUITPY</code> drive. The Pico
|
||||
auto‑reloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it
|
||||
downloads <code>programs.json</code> to drag on.) <b>📥 Load from device</b> reads it back.</li>
|
||||
<li><b>Play through your computer:</b> in the editor (Chrome/Edge) click <b>🎹 Device audio</b>, then
|
||||
press play on the device — its full groove sounds through your speakers over USB‑MIDI, in sync.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ same program‑string language as <https://metronome.varasys.io>.
|
|||
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)
|
||||
|
||||
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:** on‑screen `◀◀ / ▶ / ▶▶` (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.
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue