pm-grid: USB-MIDI audio + make Rust the shipping Grid firmware

Audio (the Scroll Pack has no speaker, so MIDI is the only path):
- usb-device 0.3 + usbd-midi 0.5 on the rp2040-hal UsbBus; enumerates as a
  class-compliant MIDI device 'PM_G-1 Grid'.
- tick() emits a GM note-on per lane hit on channel 10 (note from the ported
  SOUND_GM map, velocity by level) via send_bytes([0x09,0x99,note,vel]) — raw
  4-byte packets, so arbitrary GM drum notes work without the named Note enum.
- USB polled every loop iteration AND during the boot splash (so the host can
  enumerate during the ~2.5s animation).

Debug: defmt/defmt-rtt + panic-probe + flip-link; runner probe-rs run --chip
RP2040 (Pi Debug Probe). build.sh emits pm-grid.uf2 + pm-grid.elf; deploy serves
both; key info! log points + 1Hz heartbeat.

Web: drop CircuitPython from the PM_G-1 product. info-grid.html features the
Rust .uf2 download + accurate controls/views (X/Y swap, Ticker); build.sh +
deploy.sh no longer bundle/serve pm_g1_circuitpy.zip or pico-scroll-app.{py,mpy}.
pico-scroll/ stays as the reference port; editor FW_PATHS.G left for graceful
degradation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-03 15:19:38 -05:00
parent 512890baa2
commit 56bff7e599
8 changed files with 185 additions and 81 deletions

View file

@ -23,9 +23,9 @@ echo "precompiled dist/app.mpy ($(stat -c%s dist/app.mpy) bytes <- $(stat -c%s p
# bundles each ship their own precompiled binary; the served URLs follow the same one-target-per-file rule. # bundles each ship their own precompiled binary; the served URLs follow the same one-target-per-file rule.
( cd pico-explorer && "$MPYC" app.py -o "$ROOT/dist/explorer-app.mpy" ) ( cd pico-explorer && "$MPYC" app.py -o "$ROOT/dist/explorer-app.mpy" )
echo "precompiled dist/explorer-app.mpy ($(stat -c%s dist/explorer-app.mpy) bytes <- $(stat -c%s pico-explorer/app.py) source)" echo "precompiled dist/explorer-app.mpy ($(stat -c%s dist/explorer-app.mpy) bytes <- $(stat -c%s pico-explorer/app.py) source)"
# PM_G-1 "Grid" firmware (Pimoroni Pico Scroll Pack on a plain RP2040 Pico). Same mpy-cross (CircuitPython 10.2.1). # PM_G-1 "Grid" ships the native Rust firmware now (rust/pm-grid → pm-grid.uf2, built by
( cd pico-scroll && "$MPYC" app.py -o "$ROOT/dist/scroll-app.mpy" ) # rust/pm-grid/build.sh and served by deploy.sh). The old CircuitPython build (pico-scroll/app.py)
echo "precompiled dist/scroll-app.mpy ($(stat -c%s dist/scroll-app.mpy) bytes <- $(stat -c%s pico-scroll/app.py) source)" # is no longer bundled or served; the source stays in-repo as the reference port.
python3 - <<'PY' python3 - <<'PY'
import os, pathlib, re import os, pathlib, re
@ -69,12 +69,6 @@ assert not _xbad, "pico-explorer/app.py has non-ASCII at %r -- keep it ASCII (ve
pathlib.Path("dist/pico-explorer-app.py").write_text(_xsrc) # editor reads APP_VERSION from here pathlib.Path("dist/pico-explorer-app.py").write_text(_xsrc) # editor reads APP_VERSION from here
pathlib.Path("dist/pico-explorer-app.mpy").write_bytes(pathlib.Path("dist/explorer-app.mpy").read_bytes()) pathlib.Path("dist/pico-explorer-app.mpy").write_bytes(pathlib.Path("dist/explorer-app.mpy").read_bytes())
print("copied pico-explorer-app.py + pico-explorer-app.mpy") print("copied pico-explorer-app.py + pico-explorer-app.mpy")
_gsrc = pathlib.Path("pico-scroll/app.py").read_text() # PM_G-1 Grid firmware (Pico Scroll Pack)
_gbad = [(i, c) for i, c in enumerate(_gsrc) if ord(c) > 0x7F]
assert not _gbad, "pico-scroll/app.py has non-ASCII at %r -- keep it ASCII (version regex + clean source)" % (_gbad[:5],)
pathlib.Path("dist/pico-scroll-app.py").write_text(_gsrc) # editor reads APP_VERSION from here
pathlib.Path("dist/pico-scroll-app.mpy").write_bytes(pathlib.Path("dist/scroll-app.mpy").read_bytes())
print("copied pico-scroll-app.py + pico-scroll-app.mpy")
import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY) import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY)
with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin",
@ -92,11 +86,5 @@ with zipfile.ZipFile("dist/pm_x1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z
z.write("dist/explorer-app.mpy", "app.mpy") z.write("dist/explorer-app.mpy", "app.mpy")
z.write("dist/editor.html", "editor.html") z.write("dist/editor.html", "editor.html")
print("zipped pm_x1_circuitpy.zip") print("zipped pm_x1_circuitpy.zip")
# PM_G-1 Grid drive bundle (Pico Scroll Pack on a plain Pico). No font/icon blobs - the LED firmware draws directly. # PM_G-1 Grid is the native Rust firmware now — no CircuitPython bundle (see rust/pm-grid).
with zipfile.ZipFile("dist/pm_g1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
for f in ("code.py", "boot.py", "programs.json", "README.md"):
z.write("pico-scroll/" + f, f)
z.write("dist/scroll-app.mpy", "app.mpy")
z.write("dist/editor.html", "editor.html")
print("zipped pm_g1_circuitpy.zip")
PY PY

View file

@ -63,7 +63,13 @@ fi
# BOOTSEL-drag pm-grid.uf2 onto the RPI-RP2 drive to flash the PM_G-1 Grid LED metronome. # BOOTSEL-drag pm-grid.uf2 onto the RPI-RP2 drive to flash the PM_G-1 Grid LED metronome.
if [[ -f "$SRC_DIR/rust/pm-grid/pm-grid.uf2" ]]; then if [[ -f "$SRC_DIR/rust/pm-grid/pm-grid.uf2" ]]; then
cp "$SRC_DIR/rust/pm-grid/pm-grid.uf2" "$DEST_DIR/pm-grid.uf2" cp "$SRC_DIR/rust/pm-grid/pm-grid.uf2" "$DEST_DIR/pm-grid.uf2"
echo " pm-grid.uf2 ($(stat -c '%s' "$DEST_DIR/pm-grid.uf2") bytes) # Rust RP2040 firmware (LED-first alpha)" echo " pm-grid.uf2 ($(stat -c '%s' "$DEST_DIR/pm-grid.uf2") bytes) # Rust RP2040 firmware (the PM_G-1 Grid)"
fi
# ELF with defmt info — `probe-rs run --chip RP2040 pm-grid.elf` flashes over the Pi Debug Probe and
# streams defmt RTT logs/panics (decoded from the ELF). Served for local probe-based debugging.
if [[ -f "$SRC_DIR/rust/pm-grid/pm-grid.elf" ]]; then
cp "$SRC_DIR/rust/pm-grid/pm-grid.elf" "$DEST_DIR/pm-grid.elf"
echo " pm-grid.elf ($(stat -c '%s' "$DEST_DIR/pm-grid.elf") bytes) # probe-rs flash + defmt RTT logging"
fi fi
cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle
cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # served for version reading + reference cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # served for version reading + reference
@ -71,9 +77,9 @@ cp "$DIST_DIR/pico-cp-app.mpy" "$DEST_DIR/pico-cp-app.mpy"; echo " pico-cp-app.
cp "$DIST_DIR/pm_x1_circuitpy.zip" "$DEST_DIR/pm_x1_circuitpy.zip"; echo " pm_x1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_x1_circuitpy.zip") bytes)" # PM_X-1 Explorer CircuitPython bundle cp "$DIST_DIR/pm_x1_circuitpy.zip" "$DEST_DIR/pm_x1_circuitpy.zip"; echo " pm_x1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_x1_circuitpy.zip") bytes)" # PM_X-1 Explorer CircuitPython bundle
cp "$DIST_DIR/pico-explorer-app.py" "$DEST_DIR/pico-explorer-app.py"; echo " pico-explorer-app.py ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.py") bytes)" # served for version reading cp "$DIST_DIR/pico-explorer-app.py" "$DEST_DIR/pico-explorer-app.py"; echo " pico-explorer-app.py ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.py") bytes)" # served for version reading
cp "$DIST_DIR/pico-explorer-app.mpy" "$DEST_DIR/pico-explorer-app.mpy"; echo " pico-explorer-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.mpy") bytes)" # PM_X-1 firmware (the editor pushes this when device id = X) cp "$DIST_DIR/pico-explorer-app.mpy" "$DEST_DIR/pico-explorer-app.mpy"; echo " pico-explorer-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.mpy") bytes)" # PM_X-1 firmware (the editor pushes this when device id = X)
cp "$DIST_DIR/pm_g1_circuitpy.zip" "$DEST_DIR/pm_g1_circuitpy.zip"; echo " pm_g1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_g1_circuitpy.zip") bytes)" # PM_G-1 Grid CircuitPython bundle # PM_G-1 Grid is the native Rust firmware now (pm-grid.uf2 above) — the CircuitPython grid
cp "$DIST_DIR/pico-scroll-app.py" "$DEST_DIR/pico-scroll-app.py"; echo " pico-scroll-app.py ($(stat -c '%s' "$DEST_DIR/pico-scroll-app.py") bytes)" # served for version reading # artifacts (pm_g1_circuitpy.zip / pico-scroll-app.{py,mpy}) are no longer built or served.
cp "$DIST_DIR/pico-scroll-app.mpy" "$DEST_DIR/pico-scroll-app.mpy"; echo " pico-scroll-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-scroll-app.mpy") bytes)" # PM_G-1 firmware (the editor pushes this when device id = G) rm -f "$DEST_DIR/pm_g1_circuitpy.zip" "$DEST_DIR/pico-scroll-app.py" "$DEST_DIR/pico-scroll-app.mpy" # remove stale Python grid downloads
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/) rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
# info-*.html are first-class pages again: each form factor has a lean widget page # info-*.html are first-class pages again: each form factor has a lean widget page

View file

@ -147,12 +147,16 @@ On `embassy` / `rp-hal`:
- WS2812 → `ws2812-pio`. USB-MIDI → `usbd-midi` / `embassy-usb`. - WS2812 → `ws2812-pio`. USB-MIDI → `usbd-midi` / `embassy-usb`.
- GT911 touch (Kit) over I²C. - GT911 touch (Kit) over I²C.
#### `pm-grid` — Scroll Pack firmware 🟢 BUILT (LED-first milestone), pending on-device check #### `pm-grid` — Scroll Pack firmware 🟢 BUILT (LED + USB-MIDI audio), pending on-device check
The Rust sibling of `pico-scroll/app.py` (`rust/pm-grid/`). Target is a **plain RP2040** (Cortex-M0+, The Rust sibling of `pico-scroll/app.py` (`rust/pm-grid/`), and **the firmware the PM_G-1 ships now**
the CircuitPython build is dropped from the product (info-grid.html / build.sh / deploy.sh no longer
bundle or serve it; source stays as the reference port). Target is a **plain RP2040** (Cortex-M0+,
`thumbv6m-none-eabi`) — NOT the Pico 2 — so it has its own HAL (`rp2040-hal` 0.10 + `rp2040-boot2`), `thumbv6m-none-eabi`) — NOT the Pico 2 — so it has its own HAL (`rp2040-hal` 0.10 + `rp2040-boot2`),
`.cargo/config.toml`, `memory.x` (BOOT2 + flash + 264 KB RAM) and `build.sh`+`uf2.py` (RP2040 family `.cargo/config.toml`, `memory.x` (BOOT2 + flash + 264 KB RAM) and `build.sh`+`uf2.py` (RP2040 family
id `0xe48bff56`). `thumbv6m-none-eabi` added to `rust/Containerfile`. Compiles clean → **48 KB id `0xe48bff56`). `thumbv6m-none-eabi` added to `rust/Containerfile`. Compiles clean → **`pm-grid.uf2`**
`pm-grid.uf2`** (BOOTSEL drag-flash; no probe needed). Kept out of the host workspace like `pm-kit`. (BOOTSEL drag-flash) + **`pm-grid.elf`** (probe-rs + defmt). Both served by deploy.sh; the info page
links the `.uf2`. Kept out of the host workspace like `pm-kit`. Debug build uses `defmt`/`defmt-rtt` +
`panic-probe` + `flip-link`, runner `probe-rs run --chip RP2040` (the user's Pi Debug Probe).
What's implemented (faithful port of `pico-scroll`): the **IS31FL3731 driver** (vendored bulk What's implemented (faithful port of `pico-scroll`): the **IS31FL3731 driver** (vendored bulk
144-byte framebuffer, one I²C block write per frame — the right architecture, mirrors the 144-byte framebuffer, one I²C block write per frame — the right architecture, mirrors the
@ -170,10 +174,18 @@ list; X/Y=tempo ∓ with auto-repeat), the **built-in set lists**, and three LED
`_render_grid` / `_render_pendulum`. `_render_grid` / `_render_pendulum`.
Boot splash scrolls "PM-G1 GRID" (liveness + pixel-map check). Boot splash scrolls "PM-G1 GRID" (liveness + pixel-map check).
**Deferred to the next milestone** (matches pm-kit's order — none built yet): **USB-MIDI** note-out **Audio — USB-MIDI ✅ DONE** (the Scroll Pack has NO speaker, so this is the real audio path):
(the Scroll Pack has NO speaker, so this is the real audio path) + MIDI clock, **live-sync SysEx** `usb-device` 0.3 + `usbd-midi` 0.5 (the `rp2040-hal` `UsbBus`). Enumerates as a class-compliant MIDI
(0x40-0x43 + version query), firmware push (0x10/0x21-0x23), on-device practice log, settings.json, device ("PM_G-1 Grid"); `tick` emits a **GM note-on per lane hit on channel 10** (note from the ported
and playback-flow auto-advance (`rep`/`end`/continue). An optional piezo on a free GPIO is also TODO. `SOUND_GM` map, velocity by level 120/90/45) via `UsbMidiClass::send_bytes([0x09,0x99,note,vel])`
raw 4-byte packets, sidestepping the named-`Note` enum so arbitrary GM drum notes work. USB is polled
every loop iteration **and during the boot splash** (1.5 ms cadence) so the host can enumerate. Play
through the editor's **Device audio**.
**Still deferred**: MIDI clock in/out, **live-sync SysEx** (0x40-0x43 + version query), firmware push
(0x10/0x21-0x23), on-device practice log, settings.json, playback-flow auto-advance (`rep`/`end`/
continue), optional piezo. Note: without the SysEx version query, the editor's firmware-push won't
target the Grid (intended — it's UF2-flashed now).
### Stage 4 — native A/B + secure boot ### Stage 4 — native A/B + secure boot
Replace the `.mpy`-level A/B hack (`code.py` loads `app.mpy`, rolls back to `app.bak`) with the Replace the `.mpy`-level A/B hack (`code.py` loads `app.mpy`, rolls back to `app.bak`) with the

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VARASYS PM_G-1 Grid - wiring, parts &amp; firmware (Pimoroni Pico Scroll Pack / RP2040)</title> <title>VARASYS PM_G-1 Grid - wiring, parts &amp; firmware (Pimoroni Pico Scroll Pack / RP2040)</title>
<meta name="description" content="PM_G-1 Grid - the Pimoroni Pico Scroll Pack (PIM545, 17x7 LED matrix + 4 buttons) on a Raspberry Pi Pico as a polymeter metronome with live-sync to the web editor. Pinout, parts list, and the precompiled CircuitPython firmware bundle." /> <meta name="description" content="PM_G-1 Grid - the Pimoroni Pico Scroll Pack (PIM545, 17x7 LED matrix + 4 buttons) on a Raspberry Pi Pico as a polymeter metronome. Pinout, parts list, and the native Rust firmware (flash the .uf2)." />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script> <script>
/* ?embed=1 -> strip site chrome (base.css [data-embed]) + auto-size to the host iframe */ /* ?embed=1 -> strip site chrome (base.css [data-embed]) + auto-size to the host iframe */
@ -59,12 +59,11 @@
<b>IS31FL3731</b> over I&sup2;C (individually brightness-controlled), and <b>4 buttons</b> (A / B / X / Y). <b>IS31FL3731</b> over I&sup2;C (individually brightness-controlled), and <b>4 buttons</b> (A / B / X / Y).
No soldering, no touchscreen, no joystick, no RGB - and <b>no onboard speaker</b>. About 65 &times; 25 &times; 10 mm No soldering, no touchscreen, no joystick, no RGB - and <b>no onboard speaker</b>. About 65 &times; 25 &times; 10 mm
on top of the Pico. Power is over VSYS (USB or battery).</p> on top of the Pico. Power is over VSYS (USB or battery).</p>
<p>It runs the same <b>polymeter engine</b> and <b>program strings</b> as the web editor. The 7-row &times; 17-column <p>It runs the <b>native Rust firmware</b> (<code>rust/pm-grid</code>), built on the same <b>polymeter engine</b>
(the shared <code>track-format</code> crate) and <b>program strings</b> as the web editor. The 7-row &times; 17-column
matrix maps directly onto the editor's <b>lane &times; step</b> pad grid: each lane is a row, each step a column, and matrix maps directly onto the editor's <b>lane &times; step</b> pad grid: each lane is a row, each step a column, and
LED brightness encodes accent / normal / ghost. Beat editing is done in the browser; <b>Live sync</b> mirrors edits LED brightness encodes accent / normal / ghost. <b>Audio is over USB-MIDI</b> - turn on the editor's
to the device in real time (HELLO/FULL/DELTA over USB-MIDI) and the device mirrors play/stop/tempo/track back. <b>&#x1f3b9; Device audio</b> to hear the clicks through your computer (the Scroll Pack has no speaker of its own).</p>
<b>Audio is over USB-MIDI</b> - turn on the editor's <b>&#x1f3b9; Device audio</b> to hear the clicks through your
computer (or solder a piezo to a free GPIO and set <code>P_BUZZER</code> in <code>app.py</code>).</p>
</section> </section>
<details class="spec" open> <details class="spec" open>
@ -78,9 +77,8 @@
<tr><td class="part">SDA / SCL</td><td>GP4 / GP5</td></tr> <tr><td class="part">SDA / SCL</td><td>GP4 / GP5</td></tr>
<tr class="grp"><td colspan="2">Buttons (digital, pull-up)</td></tr> <tr class="grp"><td colspan="2">Buttons (digital, pull-up)</td></tr>
<tr><td class="part">A (play/stop) / B (track)</td><td>GP12 / GP13</td></tr> <tr><td class="part">A (play/stop) / B (track)</td><td>GP12 / GP13</td></tr>
<tr><td class="part">X (-bpm) / Y (+bpm)</td><td>GP14 / GP15</td></tr> <tr><td class="part">X (+bpm) / Y (-bpm)</td><td>GP14 / GP15</td></tr>
<tr class="grp"><td colspan="2">Audio (optional - not on the Scroll Pack)</td></tr> <tr class="grp"><td colspan="2">Audio - over USB-MIDI (the Scroll Pack has no speaker)</td></tr>
<tr><td class="part">Piezo PWM <span class="spec">- only if you wire one; set <code>P_BUZZER</code></span></td><td>any free GPIO</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -92,21 +90,21 @@
<table class="bom"> <table class="bom">
<thead><tr><th>Button</th><th>Tap</th><th>Hold</th></tr></thead> <thead><tr><th>Button</th><th>Tap</th><th>Hold</th></tr></thead>
<tbody> <tbody>
<tr><td class="part">A</td><td>play / stop</td><td>cycle view (Grid &rarr; Pendulum &rarr; BPM)</td></tr> <tr><td class="part">A</td><td>play / stop</td><td>cycle view (Ticker &rarr; Grid &rarr; Pendulum)</td></tr>
<tr><td class="part">B</td><td>next track</td><td>next set list</td></tr> <tr><td class="part">B</td><td>next track</td><td>next set list</td></tr>
<tr><td class="part">X</td><td>tempo -1</td><td>repeat (-5 after ~1.5 s)</td></tr> <tr><td class="part">X</td><td>tempo +1</td><td>repeat (+5 after ~1.5 s)</td></tr>
<tr><td class="part">Y</td><td>tempo +1</td><td>repeat (+5 after ~1.5 s)</td></tr> <tr><td class="part">Y</td><td>tempo -1</td><td>repeat (-5 after ~1.5 s)</td></tr>
</tbody> </tbody>
</table> </table>
<table class="bom" style="margin-top:10px"> <table class="bom" style="margin-top:10px">
<thead><tr><th>View</th><th>What the 17&times;7 matrix shows</th></tr></thead> <thead><tr><th>View</th><th>What the 17&times;7 matrix shows</th></tr></thead>
<tbody> <tbody>
<tr><td class="part">Ticker</td><td>the track name infinite-scrolls along the left; a beat strip on the top row marks the beats with a bright playhead; the BPM is pinned right, rotated 90&deg; CCW (a hundreds dot-bar + the last two digits). The whole matrix flashes on the downbeat.</td></tr>
<tr><td class="part">Grid</td><td>lanes as rows, steps as columns; brightness = accent / normal / ghost; a bright playhead column tracks the beat (bars &gt; 17 steps scale to fit - no steps dropped)</td></tr> <tr><td class="part">Grid</td><td>lanes as rows, steps as columns; brightness = accent / normal / ghost; a bright playhead column tracks the beat (bars &gt; 17 steps scale to fit - no steps dropped)</td></tr>
<tr><td class="part">Pendulum</td><td>a column bounces across the bar like a metronome arm, full-height flash on each beat</td></tr> <tr><td class="part">Pendulum</td><td>a column bounces across the bar like a metronome arm, full-height flash on each beat</td></tr>
<tr><td class="part">BPM</td><td>the current tempo as three 3&times;5 digits</td></tr>
</tbody> </tbody>
</table> </table>
<p class="sub" style="margin-top:8px">Tap tempo lives in the web editor. The mapping is deliberately simple (this is a UI prototype) and easy to re-bind at the top of <code>app.py</code>.</p> <p class="sub" style="margin-top:8px">The button mapping is deliberately simple (this is a UI prototype) and easy to re-bind in <code>rust/pm-grid/src/main.rs</code>.</p>
</div> </div>
</details> </details>
@ -124,40 +122,36 @@
</tbody> </tbody>
</table> </table>
<p class="sub" style="margin-top:10px">Reference: <a href="https://shop.pimoroni.com/products/pico-scroll-pack" target="_blank" rel="noopener">Pico Scroll Pack product page</a> <p class="sub" style="margin-top:10px">Reference: <a href="https://shop.pimoroni.com/products/pico-scroll-pack" target="_blank" rel="noopener">Pico Scroll Pack product page</a>
&middot; <a href="https://github.com/pimoroni/pimoroni-pico/tree/main/libraries/pico_scroll" target="_blank" rel="noopener">vendor code (pico_scroll)</a> &middot; <a href="https://github.com/pimoroni/pimoroni-pico/tree/main/libraries/pico_scroll" target="_blank" rel="noopener">vendor code (pico_scroll)</a>.</p>
&middot; <a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">CircuitPython for Raspberry Pi Pico</a>.</p>
</div> </div>
</details> </details>
<details class="spec" open> <details class="spec" open>
<summary>Firmware - self-contained appliance (USB drive &middot; web-driven editing via Live sync &middot; MIDI audio &middot; practice log)</summary> <summary>Firmware - native Rust (flash the <code>.uf2</code>)</summary>
<div class="spec-body"> <div class="spec-body">
<p class="sub">The firmware turns the Pico + Scroll Pack into a self-contained appliance: it mounts as a <p class="sub">The Grid runs <b>native Rust firmware</b> (<code>rust/pm-grid</code>), sharing the same
<b>USB drive</b> carrying the (precompiled) firmware, your tracks and an offline copy of this editor; <code>track-format</code> engine crate as the web editor and the other devices. It's a button-driven LED
drives the <b>17&times;7 matrix</b> with <b>web-driven editing</b> via <b>Live sync</b>; <b>logs your practice</b> to metronome: <b>three views</b> (Ticker, Grid, Pendulum), the <b>built-in set lists</b>, tempo <b>ramp</b> +
<code>history.json</code>; takes new set lists <b>pushed from the editor over USB-MIDI</b>; and plays <b>gap-trainer</b>, and a whole-matrix flash on the downbeat. <b>Audio plays through your computer over
out your <b>computer's speakers over USB-MIDI</b>. By default the firmware owns the drive (read-only to USB-MIDI</b> - the Scroll Pack has no speaker. Flashing is a one-step <code>.uf2</code> drag (no CircuitPython,
the computer - so it can log and can't be accidentally erased); hold <b>button A</b> at power-on for no drive bundle).</p>
editor mode (drive writable).</p>
<p> <p>
<a class="dl" href="/pm_g1_circuitpy.zip" download>Download CircuitPython bundle &darr;</a> <a class="dl" href="/pm-grid.uf2" download>Download firmware (pm-grid.uf2) &darr;</a>
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-scroll" target="_blank" rel="noopener">Source + README &nearr;</a> <a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/rust/pm-grid" target="_blank" rel="noopener">Source &nearr;</a>
</p> </p>
<ol class="steps"> <ol class="steps">
<li>Flash <b>CircuitPython for Raspberry Pi Pico</b> <li><b>Flash it:</b> hold <b>BOOTSEL</b> on the Pico, plug in USB (it appears as the <code>RPI-RP2</code> drive),
(<a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">download</a>) and drag <b>pm-grid.uf2</b> onto it. It flashes and reboots - "PM-G1 GRID" scrolls once, then the Ticker view.</li>
via BOOTSEL, unzip the bundle onto <code>CIRCUITPY</code>, and power-cycle. It boots into appliance mode.</li> <li><b>Play through your computer:</b> plug the Pico into your computer, open the
<li><b>Edit on the web:</b> open the <a href="/editor-beta.html">editor (beta)</a> in Chrome / Edge / Firefox, <a href="/editor.html">editor</a> in Chrome / Edge / Firefox, click <b>&#x1f3b9; Device audio</b>, and press
click <b>&#x1f517; Live sync</b>, and the matrix mirrors your edits live (beats, tempo, track changes).</li> <b>A</b> on the device - each click sounds through your speakers over USB-MIDI (GM channel 10).</li>
<li><b>Save a set list to the device</b> for offline use: set-list <b>&middot;&middot;&middot;</b> menu &rarr; <li><b>Controls:</b> <b>A</b> tap = play/stop, hold = cycle view; <b>B</b> tap = next track, hold = next set list;
<b>&#x1f4DF; Save to device</b>. It's pushed over USB-MIDI; the device persists it to <b>X / Y</b> = tempo up / down (auto-repeats while held).</li>
<code>/programs.json</code>.</li>
<li><b>Play through your computer:</b> click <b>&#x1f3b9; Device audio</b>, then press <b>A</b> on the device -
the full groove sounds through your speakers over USB-MIDI, in sync (the Scroll Pack has no speaker of its own).</li>
<li><b>Firmware updates:</b> &middot;&middot;&middot; menu &rarr; <b>&#x2B06; Update firmware</b> - the editor reads
the device id (G = Grid), fetches the matching <code>pico-scroll-app.mpy</code>, and pushes it
over USB-MIDI. The device A/B-updates with automatic rollback if a build won't boot.</li>
</ol> </ol>
<p class="sub" style="margin-top:8px">Built from <code>rust/pm-grid</code> via its <code>build.sh</code> (RP2040 /
<code>thumbv6m</code>). For development, a Raspberry Pi Debug Probe flashes it and streams logs over
<code>probe-rs</code> + <code>defmt</code> (the <code>pm-grid.elf</code> is served alongside for log decoding).
Live-sync editing, on-device practice log and editor firmware-push are on the roadmap (not in the Rust build yet).</p>
</div> </div>
</details> </details>

View file

@ -2,7 +2,15 @@
target = "thumbv6m-none-eabi" # RP2040 = Cortex-M0+ (the plain Pico on the Scroll Pack) target = "thumbv6m-none-eabi" # RP2040 = Cortex-M0+ (the plain Pico on the Scroll Pack)
[target.thumbv6m-none-eabi] [target.thumbv6m-none-eabi]
# flip-link: stack below statics, so an overflow faults cleanly instead of corrupting .bss/.data.
linker = "flip-link"
# `cargo run` flashes over the Raspberry Pi Debug Probe and streams defmt logs/panics via RTT.
runner = "probe-rs run --chip RP2040"
rustflags = [ rustflags = [
"-C", "link-arg=--nmagic", "-C", "link-arg=--nmagic",
"-C", "link-arg=-Tlink.x", # cortex-m-rt's linker script (INCLUDEs our memory.x) "-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x", # defmt's linker section for log-string interning
] ]
[env]
DEFMT_LOG = "debug"

View file

@ -5,14 +5,18 @@ edition = "2021"
description = "PM_G-1 'Grid' firmware (RP2040 / Pimoroni Pico Scroll Pack PIM545). 17x7 IS31FL3731 LED metronome — Rust sibling of pico-scroll/app.py." description = "PM_G-1 'Grid' firmware (RP2040 / Pimoroni Pico Scroll Pack PIM545). 17x7 IS31FL3731 LED metronome — Rust sibling of pico-scroll/app.py."
[dependencies] [dependencies]
rp2040-hal = { version = "0.10", features = ["rt", "critical-section-impl"] } rp2040-hal = { version = "0.10", features = ["rt", "critical-section-impl", "defmt"] }
rp2040-boot2 = "0.3" rp2040-boot2 = "0.3"
cortex-m = "0.7" cortex-m = "0.7"
cortex-m-rt = "0.7" cortex-m-rt = "0.7"
panic-halt = "0.2" # plain Pico is flashed via UF2/BOOTSEL — no probe, so just halt on panic defmt = "0.3"
defmt-rtt = "0.4" # logs over RTT, read by `probe-rs run` (the Pi Debug Probe)
panic-probe = { version = "0.3", features = ["print-defmt"] }
embedded-hal = "1" embedded-hal = "1"
track-format = { path = "../track-format" } track-format = { path = "../track-format" }
embedded-alloc = "0.6" # track-format parses into Vec/String → needs a global allocator embedded-alloc = "0.6" # track-format parses into Vec/String → needs a global allocator
usb-device = "0.3" # USB device stack (rp2040-hal provides the UsbBus)
usbd-midi = "0.5" # USB-MIDI class — the Scroll Pack's only audio path (no speaker)
[profile.release] [profile.release]
opt-level = "s" opt-level = "s"

View file

@ -18,6 +18,7 @@ IMG="pm-rust:2"
cargo build --release cargo build --release
OBJCOPY="$(rustc --print sysroot)/lib/rustlib/$(rustc -vV | sed -n "s/host: //p")/bin/llvm-objcopy" OBJCOPY="$(rustc --print sysroot)/lib/rustlib/$(rustc -vV | sed -n "s/host: //p")/bin/llvm-objcopy"
"$OBJCOPY" -O binary target/thumbv6m-none-eabi/release/pm-grid pm-grid.bin "$OBJCOPY" -O binary target/thumbv6m-none-eabi/release/pm-grid pm-grid.bin
cp target/thumbv6m-none-eabi/release/pm-grid pm-grid.elf # ELF for probe-rs flash + defmt decode
' '
python3 "$DIR/uf2.py" "$DIR/pm-grid.bin" "$DIR/pm-grid.uf2" python3 "$DIR/uf2.py" "$DIR/pm-grid.bin" "$DIR/pm-grid.uf2"
echo "$DIR/pm-grid.uf2 (hold BOOTSEL on the Pico, drag this onto the RPI-RP2 drive)" echo "$DIR/pm-grid.uf2 (hold BOOTSEL on the Pico, drag this onto the RPI-RP2 drive)"

View file

@ -24,12 +24,17 @@ use alloc::vec::Vec;
use embedded_alloc::LlffHeap as Heap; use embedded_alloc::LlffHeap as Heap;
use cortex_m::delay::Delay; use cortex_m::delay::Delay;
use defmt::info;
use defmt_rtt as _; // global defmt logger over RTT (read by `probe-rs run`)
use embedded_hal::digital::InputPin; use embedded_hal::digital::InputPin;
use embedded_hal::i2c::I2c; use embedded_hal::i2c::I2c;
use panic_halt as _; use panic_probe as _; // prints the panic over defmt, then halts
use rp2040_hal as hal; use rp2040_hal as hal;
use rp2040_hal::fugit::RateExtU32; use rp2040_hal::fugit::RateExtU32;
use rp2040_hal::Clock; use rp2040_hal::Clock;
use usb_device::prelude::*;
use usb_device::bus::UsbBusAllocator;
use usbd_midi::UsbMidiClass;
#[global_allocator] #[global_allocator]
static HEAP: Heap = Heap::empty(); static HEAP: Heap = Heap::empty();
@ -400,8 +405,9 @@ impl App {
None None
} }
/// Advance the per-lane step clocks up to `now_ns`. Sets `beatflash` for the loudest hit. /// Advance the per-lane step clocks up to `now_ns`. Sets `beatflash` for the loudest hit and
fn tick(&mut self, now_ns: i64) { /// calls `emit(note, velocity)` for every lane hit (the caller sends it over USB-MIDI).
fn tick<F: FnMut(u8, u8)>(&mut self, now_ns: i64, mut emit: F) {
if !self.playing { if !self.playing {
return; return;
} }
@ -432,9 +438,13 @@ impl App {
} else { } else {
self.track.lanes[li].levels[s] self.track.lanes[li].levels[s]
}; };
if lvl > 0 && !self.muted && prio(lvl) > prio(fired_best) { if lvl > 0 && !self.muted {
let note = gm_note(self.track.lanes[li].sound.as_str());
emit(note, midi_vel(lvl)); // → USB-MIDI note-on
if prio(lvl) > prio(fired_best) {
fired_best = lvl; fired_best = lvl;
} }
}
self.next[li] += self.durs[li][s].max(1); self.next[li] += self.durs[li][s].max(1);
} }
} }
@ -468,6 +478,38 @@ fn lvl_bright(lvl: u8) -> u8 {
} }
} }
/// Sound name → General MIDI drum note (channel 10). Ports pico-scroll/app.py's SOUND_GM.
fn gm_note(sound: &str) -> u8 {
match sound {
"kick" | "kick808" | "kick909" => 36,
"snare" | "snare808" | "snare909" => 38,
"clap" | "clap808" | "clap909" => 39,
"rim" => 37,
"hatClosed" | "hat808" | "hat909" => 42,
"hatOpen" | "openHat808" => 46,
"ride" | "ride909" => 51,
"crash" | "crash909" => 49,
"tomLow" => 41,
"tom808" | "tomMid" => 45,
"tomHigh" => 48,
"tambourine" => 54,
"cowbell" | "cowbell808" => 56,
"woodblock" | "jamblock" => 76,
"claves" => 75,
_ => 37, // beep / unknown → GM_DEFAULT
}
}
/// Level → MIDI velocity (accent / normal / ghost). Ports pico-scroll/app.py's MIDI_VEL.
fn midi_vel(level: u8) -> u8 {
match level {
2 => 120,
1 => 90,
3 => 45,
_ => 90,
}
}
// ============================== RENDERING ============================== // ============================== RENDERING ==============================
fn render<I: I2c>(m: &mut Matrix<I>, app: &App, now_ns: i64) { fn render<I: I2c>(m: &mut Matrix<I>, app: &App, now_ns: i64) {
// downbeat strobe: the whole matrix at full brightness on "the 1" // downbeat strobe: the whole matrix at full brightness on "the 1"
@ -624,7 +666,8 @@ fn draw_pendulum<I: I2c>(m: &mut Matrix<I>, app: &App, now_ns: i64) {
} }
/// Boot splash: scroll "PM-G1 GRID" once, right-to-left. Doubles as a liveness + pixel-map check. /// Boot splash: scroll "PM-G1 GRID" once, right-to-left. Doubles as a liveness + pixel-map check.
fn splash<I: I2c>(m: &mut Matrix<I>, delay: &mut Delay) { /// `poll` is called frequently during the per-frame delay so USB enumeration isn't stalled.
fn splash<I: I2c, P: FnMut()>(m: &mut Matrix<I>, delay: &mut Delay, mut poll: P) {
let cols = build_name_cols("PM-G1 GRID"); let cols = build_name_cols("PM-G1 GRID");
let n = cols.len() as i32; let n = cols.len() as i32;
let mut off = -16i32; let mut off = -16i32;
@ -642,7 +685,10 @@ fn splash<I: I2c>(m: &mut Matrix<I>, delay: &mut Delay) {
} }
} }
m.show(); m.show();
delay.delay_ms(45); for _ in 0..30 {
poll();
delay.delay_us(1500); // ~45ms/frame, polling USB every 1.5ms
}
off += 1; off += 1;
} }
} }
@ -657,6 +703,7 @@ fn main() -> ! {
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE]; static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) } unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) }
} }
info!("== pm-grid boot == heap {} free", HEAP.free());
let mut pac = hal::pac::Peripherals::take().unwrap(); let mut pac = hal::pac::Peripherals::take().unwrap();
let core = hal::pac::CorePeripherals::take().unwrap(); let core = hal::pac::CorePeripherals::take().unwrap();
@ -691,7 +738,31 @@ fn main() -> ! {
); );
let mut mtx = Matrix::new(i2c, &mut delay); let mut mtx = Matrix::new(i2c, &mut delay);
let _ = splash(&mut mtx, &mut delay);
// --- USB-MIDI: the Scroll Pack has no speaker, so clicks play through the host (the editor's
// "Device audio"). We send a GM note-on per lane hit on channel 10. ---
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USBCTRL_REGS,
pac.USBCTRL_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut midi = UsbMidiClass::new(&usb_bus, 1, 1).unwrap();
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x5e4))
.strings(&[StringDescriptors::default()
.manufacturer("VARASYS")
.product("PM_G-1 Grid")
.serial_number("PMG1")])
.unwrap()
.device_class(0)
.build();
info!("usb-midi configured (channel 10)");
// boot splash (polls USB throughout so the host can enumerate during the animation)
splash(&mut mtx, &mut delay, || {
usb_dev.poll(&mut [&mut midi]);
});
// buttons (active-low, internal pull-ups): A=GP12 B=GP13 X=GP14 Y=GP15 // buttons (active-low, internal pull-ups): A=GP12 B=GP13 X=GP14 Y=GP15
let mut btn_a = pins.gpio12.into_pull_up_input(); let mut btn_a = pins.gpio12.into_pull_up_input();
@ -701,6 +772,7 @@ fn main() -> ! {
let now_us = || timer.get_counter().ticks() as i64; let now_us = || timer.get_counter().ticks() as i64;
let mut app = App::new(now_us() * 1000); let mut app = App::new(now_us() * 1000);
info!("groove: bpm={} lanes={}", app.tempo, app.track.lanes.len());
// input edge/hold state // input edge/hold state
let (mut pa, mut pb, mut px, mut py) = (false, false, false, false); let (mut pa, mut pb, mut px, mut py) = (false, false, false, false);
@ -708,11 +780,15 @@ fn main() -> ! {
let (mut held_x, mut held_y) = (0i64, 0i64); let (mut held_x, mut held_y) = (0i64, 0i64);
let (mut nextrep_x, mut nextrep_y) = (0i64, 0i64); let (mut nextrep_x, mut nextrep_y) = (0i64, 0i64);
let mut last_frame_us = 0i64; let mut last_frame_us = 0i64;
let mut hb_us = 0i64;
loop { loop {
let us = now_us(); let us = now_us();
let now_ns = us * 1000; let now_ns = us * 1000;
// ---- USB: poll every iteration to stay enumerated and flush MIDI ----
usb_dev.poll(&mut [&mut midi]);
// ---- inputs (active-low) ---- // ---- inputs (active-low) ----
let a = btn_a.is_low().unwrap_or(false); let a = btn_a.is_low().unwrap_or(false);
let b = btn_b.is_low().unwrap_or(false); let b = btn_b.is_low().unwrap_or(false);
@ -766,8 +842,10 @@ fn main() -> ! {
px = x; px = x;
py = y; py = y;
// ---- scheduler ---- // ---- scheduler: advance clocks, send a USB-MIDI note-on per lane hit (ch10) ----
app.tick(now_ns); app.tick(now_ns, |note, vel| {
let _ = midi.send_bytes([0x09, 0x99, note, vel]); // cable 0, note-on, channel 10
});
// ---- ticker scroll advance (~120ms) ---- // ---- ticker scroll advance (~120ms) ----
// (uses the frame clock implicitly; scroll_off wraps mod scroll_total) // (uses the frame clock implicitly; scroll_off wraps mod scroll_total)
@ -780,6 +858,19 @@ fn main() -> ! {
render(&mut mtx, &app, now_ns); render(&mut mtx, &app, now_ns);
} }
delay.delay_us(2000); // heartbeat (~1 Hz) for probe-rs/defmt debugging
if us - hb_us > 1_000_000 {
hb_us = us;
info!(
"alive: playing={} bpm={} step={} usb={} heap={}",
app.playing,
app.tempo,
app.step.first().copied().unwrap_or(-1),
usb_dev.state() as u8,
HEAP.free()
);
}
delay.delay_us(1000); // ~1 kHz loop: tight enough for USB polling + click timing
} }
} }