diff --git a/README.md b/README.md index 17950d1..252e714 100644 --- a/README.md +++ b/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` (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 | diff --git a/editor.html b/editor.html index 85bbe3d..d9f4ce4 100644 --- a/editor.html +++ b/editor.html @@ -272,7 +272,7 @@
 
-
+
@@ -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(); }); diff --git a/info-kit.html b/info-kit.html index 1286a4b..c939e49 100644 --- a/info-kit.html +++ b/info-kit.html @@ -135,8 +135,9 @@

An alternative firmware that makes the Pico mount as a USB drive carrying the firmware, your tracks (programs.json) 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.)

+ text drives the touchscreen, and it plays out your computer's speakers over USB‑MIDI (the + editor's 🎹 Device audio button voices it, locked to the device clock). The MicroPython + firmware above stays the simple, rock‑solid option.

Download CircuitPython bundle ↓ Source + README ↗ @@ -149,6 +150,8 @@ set‑list menu → 📟 Save to device → pick the CIRCUITPY drive. The Pico auto‑reloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it downloads programs.json to drag on.) 📥 Load from device reads it back. +

  • Play through your computer: in the editor (Chrome/Edge) click 🎹 Device audio, then + press play on the device — its full groove sounds through your speakers over USB‑MIDI, in sync.
  • diff --git a/pico-cp/README.md b/pico-cp/README.md index 9340e0f..ffd4f56 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -23,6 +23,14 @@ same program‑string language as . 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. diff --git a/pico-cp/__pycache__/code.cpython-312.pyc b/pico-cp/__pycache__/code.cpython-312.pyc index 0d95bcd..736f663 100644 Binary files a/pico-cp/__pycache__/code.cpython-312.pyc and b/pico-cp/__pycache__/code.cpython-312.pyc differ diff --git a/pico-cp/code.py b/pico-cp/code.py index 04ce3e5..3b42d0f 100644 --- a/pico-cp/code.py +++ b/pico-cp/code.py @@ -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)