Add PM_G-1 "Grid" form factor (Pimoroni Pico Scroll Pack) + Rust core/driver plan

New form factor: a plain RP2040 Pico + Pico Scroll Pack (PIM545) -- a 17x7
single-colour LED matrix + 4 buttons. The 7x17 matrix maps onto the editor's
lane x step pad grid.

- pico-scroll/: CircuitPython firmware (DEVICE_ID "G"). Engine/scheduler/SysEx/
  live-sync copied verbatim from pico-explorer (engine byte-identical, so it stays
  on the track-format conformance lineage); vendored bulk-framebuffer IS31FL3731
  driver (pins/map verified from pimoroni-pico); three LED views (Grid/Pendulum/BPM);
  4-button input. Audio over USB-MIDI (no onboard speaker); optional P_BUZZER.
- grid.html + info-grid.html: widget page (canvas mirrors the 3 LED views) + spec
  page with a ~$29 BOM.
- Registered in build.sh (precompile + ASCII assert + pm_g1_circuitpy.zip), deploy.sh,
  embed.js, embed.html, index.html gallery, and both editors' FW_PATHS (device id G).
- docs/rust-port.md: core/driver architecture (pm-core no_std engine+protocol; per-board
  drivers behind embedded-hal/embedded-graphics traits). CLAUDE.md + livesync-protocol.md
  note the new edition + device id.

Python firmware stays in parallel with Rust (no abandonment yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-31 20:30:15 -05:00
parent c1601d9e46
commit 400d896518
17 changed files with 1704 additions and 7 deletions

69
CLAUDE.md Normal file
View file

@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
VARASYS PolyMeter: a polymetric groove-trainer / metronome **engine** that ships in three guises from one repo — a set of self-contained web pages (editor + form-factor gallery + embeddable widget), MicroPython/CircuitPython **firmware** for real hardware, and a native **Rust** port. `README.md` is the product-level reference (share-language grammar, page list, features, keyboard shortcuts) — read it for "what does this token mean"; this file is for how the pieces fit and how to work on them.
## Commands
```sh
./build.sh # assemble self-contained pages into dist/ (also precompiles firmware .mpy + zips bundles)
./deploy.sh # build, stamp version, copy to the Caddy web root, smoke-test (auto-run after changes — see memory)
./release.sh [X.Y.Z] # bump VERSION (optional) + tag v<VERSION>; requires clean tree
node tests/run.mjs # track-format conformance: every golden vector through engine.js AND pico-cp/app.py
node tests/run.mjs -v # + print expected/actual diffs for unexpected failures
./rust/run.sh # cargo test (Rust track-format crate vs the same golden vectors), in container
./rust/run.sh cargo build # or `bash` for a shell
./hardware/eda/run.sh # interactive shell in the KiCad/ngspice container (lands in hardware/kicad/)
./hardware/eda/run.sh kicad-cli sch erc pm_k1_core.kicad_sch # ERC on the board schematic
./hardware/eda/run.sh ngspice -b ../eda/sim/<deck>.cir # run a SPICE deck
```
There is no lint step and no single-test filter — `node tests/run.mjs` is the one gate; its exit code is non-zero on any unexpected failure or round-trip break, so it doubles as CI.
## The track format is the contract
The "program" / "patch" / share-language string is the spine of the whole project: the same string drives the web editor, a hardware device, and the Rust crate. Its grammar is **hand-implemented in three places that can silently drift**, so it is formally specced and conformance-tested:
- **Spec (source of truth):** `docs/track-format.md`
- **Web:** `src/engine.js``patchToSetup` / `laneStrToCfg` / `setupToPatch` / `laneCfgToStr`
- **Firmware:** `pico-cp/app.py``parse_program` / `_parse_lane` / `lane_to_str` / `_prog_str`
- **Rust:** `rust/track-format/src/lib.rs`
- **Golden vectors:** `tests/fixtures/track-format.json` — each case has `in` (a patch), `norm` (expected meaning), a `status`, and optional `expectFail` listing impls known to differ today.
**Rule: any change to the grammar or its meaning must update the spec, add/adjust a golden vector, and keep all three implementations passing.** When you fix a divergence in one impl, delete it from that case's `expectFail`. The test adapters parse the *real* `engine.js` and `app.py` (the Python one via `ast` extraction) rather than copies, so a code change is what the suite actually sees.
## Web build system
Every deployed page is **one self-contained `.html` file, zero runtime dependencies** — no framework, no CDN, no audio samples (all voices are synthesized in Web Audio). Pages stay in sync by sharing code through build markers that `build.sh` resolves:
- `/*@BUILD:include:src/<file>@*/` inlines a shared partial. The important ones: `engine.js` (audio scheduler + DSL), `setlists.js` (seed set lists baked into every page), `base.css`, `header.html`/`footer.html`/`chrome.js`, `progbox.{html,js}` (program box), `infoembed.{html,js}` (info-page live widget), `livesync.js` (beta only).
- `@BUILD:favicon@` / `@BUILD:logo-dark@` / `@BUILD:logo-light@` inline base64 blobs from `assets/`.
`build.sh` asserts no `@BUILD:` markers survive. **`dist/` is generated and git-ignored — never edit it by hand.** Edit the source `*.html` in the repo root and the partials in `src/`; `deploy.sh` always builds first. Each page exists as a lean widget (`<device>.html`, what `?embed=1` serves) plus a spec page (`info-<device>.html` that embeds it). `editor.html` is the main app; `editor-beta.html` is identical plus `livesync.js` (live mirror to a connected device over Web-MIDI SysEx — protocol in `docs/livesync-protocol.md`).
State (set lists, practice log, theme) lives in `localStorage`; nothing is uploaded — share links encode everything in the URL hash (`#p=` patch, `#sl=` base64url set list).
## Firmware editions
All run the same DSL and `programs.json`. When you touch the grammar, the `pico-cp/app.py` half is covered by the conformance suite; the others are not, so keep them in step by hand.
- `pico/main.py` — single-file **MicroPython**, the simple no-computer fallback (52Pi EP-0172 Kit).
- `pico-cp/`**CircuitPython** appliance (USB-drive, push-programming over USB-MIDI, on-device practice log, one-click A/B firmware update). `build.sh` precompiles `app.py``app.mpy` (the RP2040 OOMs compiling the ~56KB source at boot).
- `pico-explorer/` — CircuitPython sibling for the Pimoroni Explorer (RP2350, buttons-only, no touch).
- `pico-scroll/` — CircuitPython sibling for the Pimoroni Pico Scroll Pack (PIM545: 17×7 mono LED matrix + 4 buttons on a plain RP2040 Pico). Engine/scheduler/SysEx copied from `pico-explorer/`; vendors a bulk-framebuffer IS31FL3731 driver; renders three LED views (Grid/Pendulum/BPM). No speaker (audio over USB-MIDI). This is the UI prototype for the eventual Rust `pm-grid` board — see `docs/rust-port.md`.
Firmware device IDs (reported on the SysEx `0x02→0x03` version query, used by the editor's firmware-push to pick the right `.mpy`): `K` Kit, `X` Explorer, `G` Grid.
**`pico-cp/app.py` and `pico-explorer/app.py` must stay pure ASCII** — they are pushed over USB-MIDI as 7-bit data, and a stray non-ASCII char gets mangled to NUL and bricks the device. `build.sh` enforces this with an assert.
## Hardware (PM_K-1 custom board) & Rust port — containerized only
Per the standing rule (see memory), **all EDA and Rust tooling runs in containers via the `run.sh` wrappers — never install these on the host.** Add tools to the respective `Containerfile`. `hardware/eda/` holds the KiCad 9 + ngspice environment; the board design is `hardware/DESIGN.md` + `BOM*.csv` + `kicad/` + the code-defined circuits in `eda/circuits/`. The Rust port is staged inside-out (`docs/rust-port.md`): the pure track-format crate is done and passing; drivers/firmware come only after the engine is proven against the vectors.
## Versioning
`VERSION` holds the formal version. `deploy.sh` stamps the served pages: a clean tree on a commit tagged `v<VERSION>``X.Y.Z`; anything else → `X.Y.Z-dev.<utc-ts>.<sha>[.dirty]`. Source files keep a placeholder `APP_VERSION`; only the deployed copy is stamped.

View file

@ -23,6 +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.
( 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)"
# PM_G-1 "Grid" firmware (Pimoroni Pico Scroll Pack on a plain RP2040 Pico). Same mpy-cross (CircuitPython 10.2.1).
( cd pico-scroll && "$MPYC" app.py -o "$ROOT/dist/scroll-app.mpy" )
echo "precompiled dist/scroll-app.mpy ($(stat -c%s dist/scroll-app.mpy) bytes <- $(stat -c%s pico-scroll/app.py) source)"
python3 - <<'PY'
import os, pathlib, re
@ -42,9 +45,9 @@ def build(name):
out.write_text(src)
return out.stat().st_size
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html",
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html",
"embed.html",
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html"):
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"):
print("built %s (%dKB)" % (name, build(name) // 1024))
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
print("copied embed.js")
@ -65,6 +68,12 @@ 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.mpy").write_bytes(pathlib.Path("dist/explorer-app.mpy").read_bytes())
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)
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",
@ -82,4 +91,11 @@ 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/editor.html", "editor.html")
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.
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

View file

@ -40,9 +40,9 @@ fi
# stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html \
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \
embed.html \
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html; do
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
done
@ -54,6 +54,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/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/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
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
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/player-asbuilt.html" # renamed to teacher.html
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

View file

@ -205,6 +205,7 @@ they **emit**, because ondevice editing differs:
|-------------|----------------------------------------------------|---------------------------------------------|
| **PM_K1** Kit (touchscreen + joystick) | `play` / `stop` / `bpm` / `sel` / `beat` / `lane` (FULL on structural lane edits) | all of the above |
| **PM_X1** Explorer (6 buttons, readonly beats) | `play` / `stop` / `bpm` / `sel` only (no ondevice beat/lane editing) | all of the above |
| **PM_G1** Grid (17×7 LED matrix, 4 buttons, readonly beats) | `play` / `stop` / `bpm` / `sel` only (no ondevice beat/lane editing) | all of the above |
Editors don't need to specialcase the source — both DELTA streams look identical on
the wire, and the **device id is only exposed on the version query** (SysEx `0x02`

View file

@ -13,6 +13,41 @@ scheduler) is host-testable with zero hardware and is gated by the existing gold
once that's proven do we touch drivers, A/B, and the actual firmware. We do **not** rip out
CircuitPython until the Rust engine passes the vectors *and* the drivers are proven on hardware.
## Architecture: one firmware core, modular drivers per form factor
Trying many form factors (Kit, Explorer, **Grid**/Scroll Pack, …) is how we *discover the line
between core and driver*. In Rust that line is enforced by the type system instead of copied by
hand — today each CircuitPython form factor is its own ~1,500-line `app.py` clone; the Rust build
is one core crate plus a thin per-board binary.
**`pm-core` — the core (`no_std`, zero hardware):**
- the track-format codec (`rust/track-format`, Stage 1) and the scheduler/clock (Stage 2, already
`no_std` and building for RP2350),
- playback-flow (rep/end/continue, segment seams), app state, set-list model,
- the **USB-MIDI / live-sync / firmware-update protocol** logic (the SysEx opcode handling, which
is form-factor-independent).
It is host-testable and gated by the golden vectors — the same suite `engine.js` and `app.py`
pass. **This is "core."**
**Driver traits — what the core is generic over (the swappable part):** define small project
traits — `Display` (or render straight to an `embedded-graphics` `DrawTarget`), `Inputs` (yields
button / touch events), `Clicker` (audio out), `Indicator` (RGB) — and write each concrete driver
against **`embedded-hal`** bus traits (`I2c`, `SpiBus`, `OutputPin`, `DelayNs`). The core's UI code
then doesn't care whether the target is a 17×7 mono matrix or a 320×480 colour TFT.
**Per-board binary crates — `pm-kit`, `pm-explorer`, `pm-grid`:** a thin `main.rs` BSP that
instantiates the right concrete drivers and hands them to the generic core:
- **Grid** (Scroll Pack): IS31FL3731 over I²C (a `DrawTarget` for a 17×7 mono frame) + 4 GPIO buttons.
- **Explorer / Kit:** ST7789 via `mipidsi` + `embedded-graphics`; GT911 touch (Kit) over I²C; WS2812
via `ws2812-pio`; I²S to the PCM5102A via PIO.
**The honest caveat (what the Grid prototype is teaching us):** a 17×7 mono grid and a 320×480
touch TFT are too different for *one* pixel-identical UI. So the clean split is **core engine +
protocol + state = fully shared; the *view* = per-display-class.** The Grid is the most extreme,
minimal display in the lineup, which makes it the best forcing-function for finding exactly where
that boundary falls before we commit drivers to Rust. The CircuitPython `pico-scroll/` build exists
to nail that UI down on real hardware first.
## Stages
### Stage 0 — toolchain in a container

View file

@ -1257,7 +1257,8 @@ function _parseDeviceReply(s) {
return i >= 0 ? { id: s.slice(0, i), version: s.slice(i + 1) } : { id: "K", version: s };
}
const FW_PATHS = { K: { py: "/pico-cp-app.py", mpy: "/pico-cp-app.mpy", label: "PM_K-1 Kit" },
X: { py: "/pico-explorer-app.py", mpy: "/pico-explorer-app.mpy", label: "PM_X-1 Explorer" } };
X: { py: "/pico-explorer-app.py", mpy: "/pico-explorer-app.mpy", label: "PM_X-1 Explorer" },
G: { py: "/pico-scroll-app.py", mpy: "/pico-scroll-app.mpy", label: "PM_G-1 Grid" } };
async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy
console.log("[fw] update start");
if (!(await _ensureMidi()) || !_midiOutputs().length) {

View file

@ -1258,7 +1258,8 @@ function _parseDeviceReply(s) {
return i >= 0 ? { id: s.slice(0, i), version: s.slice(i + 1) } : { id: "K", version: s };
}
const FW_PATHS = { K: { py: "/pico-cp-app.py", mpy: "/pico-cp-app.mpy", label: "PM_K-1 Kit" },
X: { py: "/pico-explorer-app.py", mpy: "/pico-explorer-app.mpy", label: "PM_X-1 Explorer" } };
X: { py: "/pico-explorer-app.py", mpy: "/pico-explorer-app.mpy", label: "PM_X-1 Explorer" },
G: { py: "/pico-scroll-app.py", mpy: "/pico-scroll-app.mpy", label: "PM_G-1 Grid" } };
async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy
console.log("[fw] update start");
if (!(await _ensureMidi()) || !_midiOutputs().length) {

View file

@ -109,6 +109,7 @@ const FF = [
{ k:"stage", name:"PM_S1 Stage", file:"stage.html", h:430 },
{ k:"micro", name:"PM_P1 Practice", file:"micro.html", h:240 },
{ k:"showcase", name:"PM_D1 Display", file:"showcase.html",h:540 },
{ k:"grid", name:"PM_G1 Grid", file:"grid.html", h:470 },
{ k:"initial", name:"PM_C1 Concept", file:"player.html", h:440 },
];
function updateFF(k){

View file

@ -15,7 +15,7 @@
*/
(function () {
var PAGES = { editor: "editor.html", kit: "kit.html", initial: "player.html", teacher: "teacher.html",
stage: "stage.html", micro: "micro.html", showcase: "showcase.html" };
stage: "stage.html", micro: "micro.html", showcase: "showcase.html", grid: "grid.html" };
var me = document.currentScript;
var ORIGIN = me ? me.src.replace(/\/embed\.js(\?.*)?$/, "") : location.origin;

326
grid.html Normal file
View file

@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VARASYS PM_G-1 - Grid (Pimoroni Pico Scroll Pack / RP2040)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
/* ?embed=1 -> strip site chrome + auto-size to the host */
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
document.documentElement.dataset.embed="1";
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
})();
</script>
<!--
PM_G-1 "Grid" - the off-the-shelf Pimoroni Pico Scroll Pack (PIM545) on a plain Raspberry Pi
Pico (RP2040) as a button-driven polymeter metronome. Mirrors the firmware
(../pico-scroll/app.py) visually: a 17x7 single-colour white LED matrix + 4 buttons (A/B/X/Y).
The 7-row x 17-column matrix IS the editor's lane x step pad grid in miniature. Three views
(button B-hold or the on-screen toggle cycles): Grid, Pendulum, BPM. Shares src/engine.js.
-->
<script>
(function(){ try{ var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bd:#2a313c; --cyan:#0AB3F7; --panel-bg:#161b22; --field-bg:#0e1116; --field-bd:#2a313c; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bd:#d2dae4; --panel-bg:#ffffff; --field-bg:#f1f4f8; --field-bd:#d2dae4 }
body{ margin:0; min-height:100vh; padding:26px 14px 46px;
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); color:var(--txt);
display:flex; flex-direction:column; align-items:center; gap:16px }
a{ color:var(--link) }
/* the green Raspberry Pi Pico PCB carrying the Scroll Pack */
.device{ width:100%; max-width:380px; position:relative; border-radius:14px; padding:14px 14px 16px;
background:
radial-gradient(rgba(255,255,255,.03) .6px, transparent .7px) 0 0/3px 3px,
linear-gradient(180deg, #0c5a3a, #073f29);
border:1px solid #04301f;
box-shadow:0 26px 52px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.10) }
.pcbtop{ display:flex; align-items:center; justify-content:space-between; margin:0 4px 10px }
.dev-logo{ height:15px; filter:brightness(0) invert(1); opacity:.82 }
.silk{ display:flex; align-items:center; gap:7px; color:#bfe6d3 }
.silk .model{ font-size:8.5px; text-transform:uppercase; letter-spacing:.16em; opacity:.85 }
.pin{ font-size:7.5px; color:#bfe6d3; letter-spacing:.12em; text-transform:uppercase; opacity:.65 }
/* the matrix panel: black solder mask, the 119 LEDs rendered on a canvas */
.screen-wrap{ padding:10px 12px; border-radius:8px;
background:linear-gradient(180deg,#0a0c0f,#040506); border:1px solid #02030a;
box-shadow:inset 0 2px 10px rgba(0,0,0,.85), 0 1px 0 rgba(255,255,255,.05) }
#screen{ display:block; width:100%; height:auto; border-radius:3px; image-rendering:pixelated }
/* 4 buttons in a row below the matrix (Pimoroni Pico-pack layout: A B on the left, X Y on the right) */
.btnrow{ display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin:12px 4px 0 }
.ebtn{ padding:9px 0 6px; border-radius:9px; cursor:pointer; text-align:center;
border:1px solid rgba(0,0,0,.4); background:linear-gradient(180deg,#1b2330,#0e141d); color:#dfe7f1;
font-size:13px; font-weight:800; letter-spacing:.04em;
box-shadow:0 3px 5px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.08) }
.ebtn small{ display:block; margin-top:2px; font-size:7.5px; font-weight:700; opacity:.6; letter-spacing:.04em; text-transform:uppercase }
.ebtn:active{ transform:translateY(2px); box-shadow:0 1px 2px rgba(0,0,0,.3) }
.hint{ max-width:380px; text-align:center; font-size:11px; color:var(--muted); line-height:1.55 }
[data-embed] .hint{ display:none !important }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<h1 class="ff-title">PM_G1 Grid</h1>
<p class="ff-sum">Offtheshelf — the Pimoroni <b>Pico Scroll Pack</b> (a 17×7 white LED matrix + 4 buttons) on a plain Raspberry Pi Pico. The matrix <i>is</i> the editor's lane × step pad grid in miniature: rows are lanes, columns are steps, brightness is accent / normal / ghost. Edit on the web with <b>Live sync</b>; the device mirrors play/stop/tempo/track both ways.</p>
<div class="device">
<div class="pcbtop">
<div class="silk"><img class="dev-logo" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS — Simplifying Complexity" /><span class="model">PM_G1 Grid</span></div>
<span class="pin">RP2040 · 17×7</span>
</div>
<div class="screen-wrap"><canvas id="screen" width="306" height="126" aria-label="17 by 7 LED metronome display"></canvas></div>
<div class="btnrow">
<button class="ebtn" id="btnA" type="button">A<small>play</small></button>
<button class="ebtn" id="btnB" type="button">B<small>track</small></button>
<button class="ebtn" id="btnX" type="button">X<small>bpm</small></button>
<button class="ebtn" id="btnY" type="button">Y<small>+bpm</small></button>
</div>
</div>
<div class="hint">Four buttons: <b>A</b> = play/stop (hold = cycle view: Grid → Pendulum → BPM);
<b>B</b> = next track (hold = next set list); <b>X</b> / <b>Y</b> = tempo /+ (hold to repeat, ±5 after ~1.5 s).
Keyboard: A / B / X / Y, space = play, V = cycle view.</div>
/*@BUILD:include:src/progbox.html@*/
<p class="ff-link pageonly"><a href="/info-grid.html">Wiring, parts &amp; firmware to flash →</a></p>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
/* ========================= ENGINE (shared; synth voices only) ================= */
const SAMPLES = {};
/*@BUILD:include:src/engine.js@*/
/*@BUILD:include:src/setlists.js@*/
const state = { bpm:120, volume:0.85, running:false };
let meters = [], muteWindows = [];
function setBpm(v){ state.bpm = Math.max(5, Math.min(300, Math.round(v))); if(window.progRefresh) progRefresh(); }
function scheduler(){
const ahead = audioCtx.currentTime + SCHEDULE_AHEAD;
for(const m of meters){ while(m.nextTime < ahead){ scheduleMeterTick(m, m.nextTime); m.nextTime += laneStepDur(m, m.tick); m.tick++; } }
}
function buildMeters(lanes){
return (lanes||[]).map(c=>{ const p=parseGroups(c.groupsStr);
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0}; });
}
function startAudio(){
ensureAudio(); audioCtx.resume(); state.running=true;
const t0=audioCtx.currentTime+0.08;
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; }
muteWindows=[];
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler();
}
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; for(const m of meters) m.currentStep=-1; }
function toggle(){ state.running ? stopAudio() : startAudio(); }
/* ========================= TRACKS (seed grooves, with set-list grouping) ========= */
let tracks = SEED_SETLISTS.flatMap(sl => sl.items.map(([n,p]) => ({ name:n, sl: sl.title, ...patchToSetup(p) })));
let trackIdx = 0;
function tracksFromHash(){
const m=(location.hash||"").match(/[#&](p|sl)=([^&]+)/); if(!m) return null;
let payload=m[2]; try{ payload=decodeURIComponent(payload); }catch(e){}
try{
if(m[1]==="sl"){ const sl=codeToSetlist(payload); return sl.items.length ? sl.items.map(it=>({...it, sl: sl.title})) : null; }
const s=patchToSetup(payload); return s.lanes.length ? [{name:"Patch", sl:"Patch", ...s}] : null;
}catch(e){ return null; }
}
function loadTrack(i){
const n=tracks.length; if(!n) return; trackIdx=((i%n)+n)%n;
const t=tracks[trackIdx]; setBpm(t.bpm||120); meters=buildMeters(t.lanes);
const was=state.running; if(was){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
if(was) startAudio();
}
// B-hold jumps to the FIRST item of the next set list (matches the firmware's switch_setlist)
function nextSetlist(){
const cur = tracks[trackIdx].sl;
for(let i=1; i<=tracks.length; i++){
const j = (trackIdx + i) % tracks.length;
if(tracks[j].sl !== cur){ loadTrack(j); return; }
}
}
/* ========================= 17x7 MATRIX (canvas mirrors the firmware's LED views) === */
const NX = 17, NY = 7;
const cv=$("screen"), g=cv.getContext("2d");
const CW = cv.width, CH = cv.height; // logical px before DPR scaling
(function(){ const dpr=Math.max(1,Math.min(3,window.devicePixelRatio||1)); cv.width=CW*dpr; cv.height=CH*dpr; g.scale(dpr,dpr); })();
const cellX = CW / NX, cellY = CH / NY, rad = Math.min(cellX, cellY) * 0.34;
let view = 0; // 0 Grid, 1 Pendulum, 2 BPM
let beatFlash = 0; // decays each frame; 1 on a fresh beat
const VIEW_NAMES = ["Grid","Pendulum","BPM"];
// 3x5 digit glyphs (bit2 = leftmost column) - same shapes the firmware draws
const DIGITS = { '0':[7,5,5,5,7],'1':[2,6,2,2,7],'2':[7,1,7,4,7],'3':[7,1,7,1,7],'4':[5,5,7,1,1],
'5':[7,4,7,1,7],'6':[7,4,7,5,7],'7':[7,1,2,2,2],'8':[7,5,7,5,7],'9':[7,5,7,1,7] };
function lvlBright(lvl){ return lvl===2 ? 1.0 : lvl===1 ? 0.30 : lvl===3 ? 0.09 : 0; }
function drawMatrix(bright){
g.fillStyle = "#06080b"; g.fillRect(0,0,CW,CH);
for(let y=0; y<NY; y++) for(let x=0; x<NX; x++){
const cx = (x+0.5)*cellX, cy = (y+0.5)*cellY;
const v = Math.max(0, Math.min(1, bright[y][x]||0));
// an "off" LED is a faint dark dot so the whole 17x7 grid stays visible
g.beginPath(); g.arc(cx, cy, rad, 0, Math.PI*2);
g.fillStyle = "rgba(255,255,255,0.05)"; g.fill();
if(v > 0.01){
if(v > 0.5){ g.save(); g.shadowColor="rgba(255,255,255,0.7)"; g.shadowBlur=rad*1.6; }
g.beginPath(); g.arc(cx, cy, rad, 0, Math.PI*2);
g.fillStyle = "rgba(255,255,255," + v.toFixed(3) + ")"; g.fill();
if(v > 0.5) g.restore();
}
}
}
function blankBright(){ const b=[]; for(let y=0;y<NY;y++){ b.push(new Array(NX).fill(0)); } return b; }
function renderGrid(){
const b = blankBright();
const n = Math.min(meters.length, NY);
const y0 = (NY - n) >> 1;
for(let li=0; li<n; li++){
const L = meters[li], y = y0 + li;
const steps = (L.beatsOn||[]).length || L.beatsPerBar*L.stepsPerBeat;
const off = steps <= NX ? ((NX - steps) >> 1) : 0;
const lit = state.running ? L.currentStep : -1;
for(let s=0; s<steps; s++){
const col = steps <= NX ? (s + off) : Math.floor(s*NX/steps);
const lvl = L.beatsOn[s]|0;
let v = (s === lit) ? (lvl ? 1.0 : 0.28) : lvlBright(lvl);
if(v > b[y][col]) b[y][col] = v;
}
}
drawMatrix(b);
}
function renderPendulum(){
const b = blankBright();
const M = meters[0];
if(M){
const steps = (M.beatsOn||[]).length || M.beatsPerBar*M.stepsPerBeat;
const beats = Math.max(1, Math.round(M.beatsPerBar));
let frac = 0;
if(state.running && M.currentStep >= 0) frac = (M.currentStep % steps) / steps;
const tri = frac < 0.5 ? frac*2 : 2*(1-frac);
const col = Math.round(tri * (NX-1));
const v = beatFlash > 0.5 ? 1.0 : (beatFlash > 0.05 ? 0.6 : 0.35);
for(let y=0; y<NY; y++) b[y][col] = v;
for(let bi=0; bi<beats; bi++){ const bc = Math.floor(bi*NX/beats); if(b[NY-1][bc] < 0.1) b[NY-1][bc] = 0.1; }
}
drawMatrix(b);
}
function renderBpm(){
const b = blankBright();
const s = String(state.bpm).slice(-3);
const w = s.length*4 - 1, x0 = (NX - w) >> 1, y0 = 1;
const v = state.running ? 1.0 : 0.5;
for(let i=0;i<s.length;i++){
const gph = DIGITS[s[i]]; if(!gph) continue;
const bx = x0 + i*4;
for(let ry=0; ry<5; ry++) for(let rx=0; rx<3; rx++)
if(gph[ry] & (1 << (2-rx))) b[y0+ry][bx+rx] = v;
}
drawMatrix(b);
}
function cycleView(){ view = (view+1) % 3; }
/* the engine queues voice events with timestamps; mirror the firmware's playhead off that queue */
function audioLatency(){ return audioCtx ? (audioCtx.outputLatency || audioCtx.baseLatency || 0) : 0; }
function frame(){
const now = audioCtx ? audioCtx.currentTime - audioLatency() : 0;
if(audioCtx && state.running){
let fired=false;
for(const m of meters){
while(m.vqPtr<m.vq.length && m.vq[m.vqPtr].time<=now){
const e=m.vq[m.vqPtr]; m.currentStep=e.step; m.currentBar=e.bar;
if((m.beatsOn[e.step]|0) > 0) fired=true;
m.vqPtr++;
}
if(m.vqPtr>512){ m.vq=m.vq.slice(m.vqPtr); m.vqPtr=0; }
}
if(fired) beatFlash = 1;
}
beatFlash = Math.max(0, beatFlash - 0.08);
if(view===2) renderBpm(); else if(view===1) renderPendulum(); else renderGrid();
requestAnimationFrame(frame);
}
/* ========================= INPUTS (4 buttons; tap vs hold like the firmware) ====== */
const HOLD_MS = 600, REPEAT_FIRST = 350, REPEAT_NEXT = 120, FAST_AFTER = 1500;
const pressT = { A:0, B:0 }; // press-start (ms) for A/B tap-vs-hold
const held = { X:0, Y:0 }; // press-start (ms) for X/Y repeat; 0 = released
const repNext = { X:0, Y:0 };
function nudge(dir){ const fast = held[dir>0?"Y":"X"] && (performance.now() - held[dir>0?"Y":"X"]) > FAST_AFTER;
setBpm(state.bpm + dir*(fast?5:1)); }
function bindBtn(id, downFn, upFn){
const b = $(id);
b.addEventListener("pointerdown", (e) => { e.preventDefault(); b.setPointerCapture?.(e.pointerId); downFn(); });
b.addEventListener("pointerup", (e) => { e.preventDefault(); upFn(); });
b.addEventListener("pointercancel", () => upFn());
b.addEventListener("pointerleave", () => { if (b.hasPointerCapture?.(0)) upFn(); });
}
// A: tap = play/stop, hold = cycle view. B: tap = next track, hold = next set list.
bindBtn("btnA", () => { pressT.A = performance.now(); },
() => { (performance.now()-pressT.A >= HOLD_MS) ? cycleView() : toggle(); });
bindBtn("btnB", () => { pressT.B = performance.now(); },
() => { (performance.now()-pressT.B >= HOLD_MS) ? nextSetlist() : loadTrack(trackIdx+1); });
bindBtn("btnX", () => { held.X = performance.now(); repNext.X = held.X + REPEAT_FIRST; nudge(-1); }, () => { held.X = 0; });
bindBtn("btnY", () => { held.Y = performance.now(); repNext.Y = held.Y + REPEAT_FIRST; nudge(1); }, () => { held.Y = 0; });
setInterval(() => { // hold-repeat for X / Y
const now = performance.now();
if(held.X && now >= repNext.X){ repNext.X = now + REPEAT_NEXT; nudge(-1); }
if(held.Y && now >= repNext.Y){ repNext.Y = now + REPEAT_NEXT; nudge(1); }
}, 30);
/* ========================= KEYBOARD ============================================ */
addEventListener("keydown", (e) => {
const tag = e.target ? e.target.tagName : ""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
const k = e.key.toLowerCase();
if(e.key === " "){ e.preventDefault(); toggle(); }
else if(k === "a"){ toggle(); }
else if(k === "v"){ cycleView(); }
else if(k === "b"){ loadTrack(trackIdx+1); }
else if(k === "x"){ if(!held.X){ held.X = performance.now(); repNext.X = held.X + REPEAT_FIRST; nudge(-1); } }
else if(k === "y"){ if(!held.Y){ held.Y = performance.now(); repNext.Y = held.Y + REPEAT_FIRST; nudge(1); } }
});
addEventListener("keyup", (e) => {
const k = e.key.toLowerCase();
if(k === "x") held.X = 0; else if(k === "y") held.Y = 0;
});
/* theme toggle + version */
/*@BUILD:include:src/chrome.js@*/
/* ========================= INIT ============================================== */
{ const ht = tracksFromHash(); if(ht) tracks = ht; }
loadTrack(0);
requestAnimationFrame(frame);
window.currentProgramString = function(){ var t = tracks[trackIdx] || {}; return setupToPatch({bpm:state.bpm, volume:state.volume, lanes:t.lanes||[]}); };
window.loadProgramString = function(plain){ var s = patchToSetup(plain); tracks = [{name:"Program", sl:"Program", ...s}]; trackIdx = 0; loadTrack(0); };
/*@BUILD:include:src/progbox.js@*/
</script>
/*@BUILD:include:src/footer.html@*/
</body>
</html>

View file

@ -140,6 +140,7 @@ const VERSIONS = [
{ key:"editor", file:"/editor.html", name:"PM_E1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, perstep accents/ghosts/mutes, swing &amp; polyrhythm, set lists, perlane dB gain." },
{ key:"kit", file:"/kit.html", name:"PM_K1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
{ key:"explorer", file:"/explorer.html", name:"PM_X1 Explorer", chip:"hw", h:500, sum:"Offtheshelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a buttondriven sibling to the Kit. Edit on the web with Live sync; the device mirrors play/stop/tempo/track changes both ways." },
{ key:"grid", file:"/grid.html", name:"PM_G1 Grid", chip:"hw", h:470, sum:"Offtheshelf — a Pimoroni Pico Scroll Pack (17×7 white LED matrix + 4 buttons) on a Raspberry Pi Pico. The matrix IS the editor's lane × step pad grid in miniature; edit on the web with Live sync." },
{ key:"teacher", file:"/teacher.html", name:"PM_T1 Teacher", chip:"hw", h:440, sum:"Studio / lesson desk console — colour TFT of every lane, arcade buttons, instrument passthrough." },
{ key:"stage", file:"/stage.html", name:"PM_S1 Stage", chip:"hw", h:430, sum:"Live foot pedal — two footswitches, expressionpedal tempo, a big floorreadable RGB beat light." },
{ key:"micro", file:"/micro.html", name:"PM_P1 Practice", chip:"hw", h:240, sum:"Inline practice bar — clickable thumbroller, amber 14segment, instrument in/out passthrough." },

174
info-grid.html Normal file
View file

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<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>
<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." />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
/* ?embed=1 -> strip site chrome (base.css [data-embed]) + auto-size to the host iframe */
(function(){ if(!/[?&]embed=1/.test(location.search)) return;
document.documentElement.dataset.embed="1";
function ph(){ try{ parent.postMessage({type:"varasys-h",h:Math.ceil(document.documentElement.getBoundingClientRect().height)},"*"); }catch(e){} }
addEventListener("load",ph); addEventListener("resize",ph); setTimeout(ph,300); setTimeout(ph,1000);
})();
</script>
<script>
(function(){ try{ var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
a{ color:var(--link); }
main{ width:100%; max-width:980px; margin:0 auto; }
.info-hero{ text-align:center; padding:16px 8px 2px; }
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
.steps{ width:100%; max-width:760px; margin:8px auto 0; color:var(--muted); font-size:14px; line-height:1.6; }
.steps li{ margin:5px 0; }
.steps code, .about code, .sub code { background:var(--field-bg); border:1px solid var(--field-bd); border-radius:5px; padding:1px 5px; font-size:12.5px; }
.dl{ display:inline-flex; align-items:center; gap:7px; margin:4px 10px 4px 0; padding:9px 14px; border-radius:10px;
background:linear-gradient(180deg,#34c6ff,var(--cyan)); color:#04121b; font-weight:700; text-decoration:none; font-size:13.5px; }
.dl.alt{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); font-weight:600; }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<main>
<section class="info-hero">
<h1>PM_G-1 Grid</h1>
<p class="sub">The off-the-shelf <b>Pimoroni Pico Scroll Pack</b> (a 17&times;7 white LED matrix + 4 buttons) on a plain <b>Raspberry Pi Pico</b> - sibling to the PM_K-1 Kit and PM_X-1 Explorer, sharing the engine, program-string grammar, and live-sync protocol with the web editor.</p>
</section>
<section class="about">
<h2>What it is</h2>
<div class="ff-tags"><span class="hw">Buildable now</span><span>RP2040 (Raspberry Pi Pico)</span><span>Pimoroni Pico Scroll Pack PIM545</span><span>~$30</span></div>
<p>The <a href="https://shop.pimoroni.com/products/pico-scroll-pack" target="_blank" rel="noopener">Pico Scroll Pack (PIM545)</a>
plugs straight onto a Raspberry Pi Pico's headers: <b>119 white LEDs in a 17&times;7 matrix</b> driven by an
<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
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
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
to the device in real time (HELLO/FULL/DELTA over USB-MIDI) and the device mirrors play/stop/tempo/track back.
<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>
<details class="spec" open>
<summary>Wiring - the Pico Scroll Pack fixed pinout (just press it onto the Pico)</summary>
<div class="spec-body">
<p class="sub">Everything is wired through the header; this is what the firmware reads. Pins verified against Pimoroni's <code>pico_scroll</code> library.</p>
<table class="bom">
<thead><tr><th>Component</th><th>Pico pins</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="2">LED matrix - 17&times;7 white, IS31FL3731 (I&sup2;C @ 0x74)</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><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 class="grp"><td colspan="2">Audio (optional - not on the Scroll Pack)</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>
</table>
</div>
</details>
<details class="spec" open>
<summary>Controls &amp; views</summary>
<div class="spec-body">
<table class="bom">
<thead><tr><th>Button</th><th>Tap</th><th>Hold</th></tr></thead>
<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">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">Y</td><td>tempo +1</td><td>repeat (+5 after ~1.5 s)</td></tr>
</tbody>
</table>
<table class="bom" style="margin-top:10px">
<thead><tr><th>View</th><th>What the 17&times;7 matrix shows</th></tr></thead>
<tbody>
<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">BPM</td><td>the current tempo as three 3&times;5 digits</td></tr>
</tbody>
</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>
</div>
</details>
<details class="spec" open>
<summary>Parts</summary>
<div class="spec-body">
<p class="sub">Two off-the-shelf parts, no soldering - ballpark one-off price (USD).</p>
<table class="bom">
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
<tbody>
<tr><td class="part">Pimoroni Pico Scroll Pack (PIM545) <span class="spec">- 17&times;7 white LED matrix (IS31FL3731) + 4 buttons</span></td><td class="q">1</td><td class="c">22</td></tr>
<tr><td class="part">Raspberry Pi Pico <span class="spec">- RP2040; pre-soldered headers so the pack presses on</span></td><td class="q">1</td><td class="c">5</td></tr>
<tr><td class="part">USB cable <span class="spec">- power + flashing (micro-USB)</span></td><td class="q">1</td><td class="c">2</td></tr>
<tr class="total"><td>Total (one-off)</td><td class="q"></td><td class="c">&asymp; $29</td></tr>
</tbody>
</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>
&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://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">CircuitPython for Raspberry Pi Pico</a>.</p>
</div>
</details>
<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>
<div class="spec-body">
<p class="sub">The firmware turns the Pico + Scroll Pack into a self-contained appliance: it mounts as a
<b>USB drive</b> carrying the (precompiled) firmware, your tracks and an offline copy of this editor;
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
<code>history.json</code>; takes new set lists <b>pushed from the editor over USB-MIDI</b>; and plays
out your <b>computer's speakers over USB-MIDI</b>. By default the firmware owns the drive (read-only to
the computer - so it can log and can't be accidentally erased); hold <b>button A</b> at power-on for
editor mode (drive writable).</p>
<p>
<a class="dl" href="/pm_g1_circuitpy.zip" download>Download CircuitPython bundle &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>
</p>
<ol class="steps">
<li>Flash <b>CircuitPython for Raspberry Pi Pico</b>
(<a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">download</a>)
via BOOTSEL, unzip the bundle onto <code>CIRCUITPY</code>, and power-cycle. It boots into appliance mode.</li>
<li><b>Edit on the web:</b> open the <a href="/editor-beta.html">editor (beta)</a> in Chrome / Edge / Firefox,
click <b>&#x1f517; Live sync</b>, and the matrix mirrors your edits live (beats, tempo, track changes).</li>
<li><b>Save a set list to the device</b> for offline use: set-list <b>&middot;&middot;&middot;</b> menu &rarr;
<b>&#x1f4DF; Save to device</b>. It's pushed over USB-MIDI; the device persists it to
<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>
</div>
</details>
<p class="sub" style="max-width:760px;margin:14px auto 0">Pairs with the touch-driven <a href="/info-kit.html">PM_K-1 Kit</a> and the button-driven <a href="/info-explorer.html">PM_X-1 Explorer</a> - same engine, same programs.json, same web editor.</p>
</main>
/*@BUILD:include:src/footer.html@*/
<script>
const APP_VERSION = "v0.0.1-dev";
/*@BUILD:include:src/chrome.js@*/
</script>
</body>
</html>

67
pico-scroll/README.md Normal file
View file

@ -0,0 +1,67 @@
# PM_G-1 "Grid" — CircuitPython edition (Pimoroni Pico Scroll Pack · RP2040)
The **CircuitPython** firmware for the [Pimoroni Pico Scroll Pack (PIM545)](https://shop.pimoroni.com/products/pico-scroll-pack)
on a plain **Raspberry Pi Pico**, set up as a self-contained appliance. Sibling to the PM_K-1 build
in `../pico-cp/` and PM_X-1 in `../pico-explorer/` — **same engine, same program strings, same
`programs.json`, same web editor, same live-sync protocol.**
This board is a **17 × 7 single-colour (white) LED matrix** (IS31FL3731 over I²C) **+ 4 buttons
(A/B/X/Y)** — *no touchscreen, no joystick, no RGB LED, and no onboard speaker.* It's the smallest,
most minimal form factor: the 7-row × 17-column grid **is** the editor's lane × step pad grid in
miniature. Editing happens in the web editor with **Live sync** on; the device mirrors changes in
real time and emits its own play/stop/bpm/sel deltas back.
**Audio** is over **USB-MIDI** — turn on the editor's **🎹 Device audio** to hear the clicks through
your computer's speakers. The Scroll Pack has no buzzer; if you solder a piezo to a free GPIO, set
`P_BUZZER` near the top of `app.py` to that pin to get an on-device click.
## Views (button **B** cycles)
| View | What the 17 × 7 matrix shows |
| ---- | ---------------------------- |
| **Grid** (default) | Each lane is a **row** (top 7), each step a **column**; brightness = accent (bright) / normal / ghost (dim). A bright **playhead** column tracks the beat. Bars with ≤ 17 steps are centred one-column-per-step; longer bars are scaled to fit (multiple steps may share a column — no steps are dropped). |
| **Pendulum** | A single column **bounces** left↔right across the bar like a metronome arm, with a full-height flash on each beat (accent = brightest). Glanceable from across a room. |
| **BPM** | The current tempo as three 3 × 5 digits. |
## Controls
| Button | Tap | Hold |
| ------ | --- | ---- |
| **A** | play / stop | **≥ 0.6 s:** cycle view (Grid → Pendulum → BPM) |
| **B** | next track | **≥ 0.6 s:** next set list |
| **X** | tempo 1 | auto-repeat (after ~1.5 s the step grows to 5) |
| **Y** | tempo + 1 | auto-repeat (after ~1.5 s the step grows to + 5) |
Tap tempo lives in the web editor; the built-in playlists (Styles / Practice / Song) are baked into
firmware and your own set lists arrive in `/programs.json` over USB-MIDI. The mapping is deliberately
simple (this is a UI prototype) — all four buttons and their pins are at the top of `app.py`, easy to
re-bind.
## Pinout (verified against the Pimoroni `pico_scroll` library)
| Signal | Pico pin |
| ------ | -------- |
| Button A / B / X / Y | GP12 / GP13 / GP14 / GP15 |
| Matrix I²C SDA / SCL | GP4 / GP5 |
| IS31FL3731 address | `0x74` |
| (optional piezo) | any free GPIO → set `P_BUZZER` |
## Flashing
1. Hold **BOOTSEL**, plug the Pico in, and drag on **CircuitPython for Raspberry Pi Pico** (the same
10.2.x build the Kit uses). The drive remounts as `CIRCUITPY`.
2. Download **`/pm_g1_circuitpy.zip`** from <https://metronome.varasys.io> and unzip its contents onto
`CIRCUITPY` (`code.py`, `boot.py`, `app.mpy`, `programs.json`, fonts/icons, `editor.html`).
3. Reset. By default the firmware owns the drive (appliance mode); **hold button A while plugging in**
to make the drive writable again (editor mode).
The application is the precompiled **`app.mpy`** (the ~25 KB `app.py` source is too big to compile
on-device without OOM). Firmware updates are **one click** from the editor (⋯ → Update firmware),
pushed over USB-MIDI as an A/B update with automatic rollback — the device reports id **`G`** so the
editor sends the Grid build. Unbrickable: BOOTSEL + drag a `.uf2` always restores it.
## Status
This is the **CircuitPython prototype** used to nail down the LED-grid UI on real hardware. The
production firmware target is the native-Rust engine (one `pm-core` + per-board drivers — see
`docs/rust-port.md`); the Python builds stay as the simple, no-toolchain option in parallel.

953
pico-scroll/app.py Normal file
View file

@ -0,0 +1,953 @@
# VARASYS PolyMeter - PM_G-1 "Grid" firmware (CircuitPython edition)
# Pimoroni Pico Scroll Pack (PIM545): a plain Raspberry Pi Pico (RP2040) + a 17x7 single-colour
# white LED matrix (IS31FL3731 over I2C @ 0x74) + 4 buttons (A/B/X/Y). No touchscreen, no joystick,
# no speaker. Audio is over USB-MIDI (the editor's "Device audio"); an OPTIONAL piezo on a free GPIO
# can be enabled below.
#
# Sibling to PM_K-1 (../pico-cp/) and PM_X-1 (../pico-explorer/). SAME engine, SAME program-string
# grammar, SAME programs.json, SAME web editor, SAME live-sync protocol. The Grid build is READ-ONLY
# on the device (no on-device beat editing); editing happens in the web editor with Live sync on.
#
# The 7-row x 17-column matrix is the editor's lane x step pad grid in miniature: each lane is a row,
# each step a column, brightness encodes accent / normal / ghost; a moving playhead column tracks the
# beat. Three views (button B cycles): Grid, Pendulum, BPM.
#
# WHY CIRCUITPYTHON: the board mounts as a USB drive (CIRCUITPY) carrying this code + your tracks +
# an offline copy of the editor; edits in the web editor are pushed over USB-MIDI. Pinout in README.md.
import board, busio, digitalio, time, json, gc, os, supervisor
try:
import pwmio # only needed if an optional piezo is wired (P_BUZZER below)
except ImportError:
pwmio = None
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.1" # firmware version (the A/B updater pushes/compares this)
DEVICE_ID = "G" # 'G' = Grid (Scroll Pack); 'K' = 52Pi kit, 'X' = Explorer (see docs/livesync-protocol.md)
try:
import rtc # set from the editor's clock SysEx so the log has real timestamps
except ImportError:
rtc = None
try:
import usb_midi # sends a MIDI note per click to the computer + carries the editor link
except ImportError:
usb_midi = None
try:
from binascii import a2b_base64 # decode the base64-encoded .mpy pushed by the editor's one-click update
except ImportError:
a2b_base64 = None
# ============================== CONFIG (tweak if needed) ==============================
MIDI_ENABLED = True # send a USB-MIDI note per click (play via the web editor's "Device audio")
MIDI_CHANNEL = 10 # 1..16 - GM channel 10 is the drum channel
MIDI_CLOCK_OUT = False # send 24 PPQN MIDI Clock so a DAW can slave its tempo to the metronome
MIDI_CLOCK_OUT_TRANSPORT = True
MIDI_CLOCK_IN = False # follow an external 24 PPQN clock
MIDI_CLOCK_IN_TRANSPORT = True
MUTE_SPEAKER = False # always silence the optional piezo
SPEAKER_AUTO_MUTE = False # auto-mute the piezo when a MIDI host is listening (Live sync heartbeats every 5s)
BRIGHTNESS = 160 # accent brightness 0..255; normal/ghost scale from it (Y/X tune tempo, not this)
# ----- pins (Pimoroni Pico Scroll Pack layout; verified against pimoroni-pico pico_scroll source) -----
P_BTNA, P_BTNB, P_BTNX, P_BTNY = board.GP12, board.GP13, board.GP14, board.GP15 # the 4 switches
P_SDA, P_SCL = board.GP4, board.GP5 # IS31FL3731 I2C bus
MATRIX_ADDR = 0x74 # IS31FL3731 default address on the Scroll Pack
P_BUZZER = None # OPTIONAL: set to e.g. board.GP16 if you solder a piezo to a free GPIO; None = silent (MIDI only)
MIN_LOG_SEC = 5 # don't log plays shorter than this
# ----- BUILT-IN playlists: same defaults as the Kit / Explorer so all firmwares feel identical -----
BUILTIN_SETLISTS = [
("Styles", [
("Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"),
("Purdie half-time shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
("Samba (2/4)", "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
("Nanigo (6/8 bembe)", "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"),
("6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"),
("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"),
("5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"),
]),
("Practice", [
("5 over 4 polyrhythm", "t100;kick:4;claves:5~"),
("3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"),
("2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"),
("Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"),
("Tempo builder 80 up", "t80;woodblock:4;rmp80/4/4"),
("Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"),
]),
("Song (continuous)", [
("Intro - hats & kick", "t88;b4;kick:4=X.x.;hatClosed:4/2=gggggggg"),
("Groove in - backbeat", "t88;b4;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"),
("Half-time shuffle", "t92;b4;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
("Build - ramp 92-120", "t92;b4;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"),
("Four-on-the-floor (909)", "t124;b4;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"),
("Samba break (2/4)", "t116;b4;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
("Peak - 16ths", "t132;b4;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"),
("Outro - ramp down", "t132;b4;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"),
]),
]
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
# ============================== POLYMETER ENGINE (identical to ../pico-explorer/app.py) ==============================
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PRIO = {2: 3, 1: 2, 3: 1}
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
43: "tomLow", 44: "hatClosed", 45: "tomMid", 46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash",
50: "tomHigh", 51: "ride", 53: "ride", 54: "tambourine", 56: "cowbell", 75: "claves", 76: "woodblock", 77: "woodblock"}
def _euclid(k, n, rot): # even distribution: k hits over n steps, rotated (matches web euclid())
n = max(1, n); k = max(0, min(n, k)); rot = ((rot % n) + n) % n
return [1 if ((((i + rot) % n) * k) % n) < k else 0 for i in range(n)]
def parse_program(s):
bpm = 120; lanes = []; bars = 0; ramp = None; trainer = None; rep = None; end = None
for tok in s.strip().split(';'):
tok = tok.strip()
if not tok: continue
if tok[0] == 't' and tok[1:].isdigit():
bpm = int(tok[1:]); continue
if tok[0] == 'b' and tok[1:].isdigit():
bars = int(tok[1:]); continue
if tok.startswith('rmp'):
p = tok[3:].split('/')
if len(p) == 3:
try: ramp = {'start': int(p[0]), 'amt': int(p[1]), 'every': max(1, int(p[2]))}
except ValueError: pass
continue
if tok.startswith('tr') and '/' in tok and ':' not in tok:
p = tok[2:].split('/')
if len(p) == 2:
try: trainer = {'play': max(0, int(p[0])), 'mute': max(0, int(p[1]))}
except ValueError: pass
continue
if tok.startswith('rep='): # rep=<n> cycles before the end-action fires (playback flow)
try: rep = max(1, int(tok[4:]))
except ValueError: pass
continue
if tok.startswith('end='): # end=stop | end=next(+1) | end=<+/-N> relative goto; absent = loop forever
v = tok[4:]
if v == 'stop': end = 'stop'
elif v == 'next': end = 1
else:
try: end = int(v)
except ValueError: pass
continue
if ':' not in tok: continue
lane = _parse_lane(tok)
if lane: lanes.append(lane)
if not lanes: lanes = [_parse_lane("beep:4")]
return max(5, min(300, bpm)), lanes, bars, ramp, trainer, rep, end
def _parse_lane(tok):
poly = '~' in tok; mute = '!' in tok
tok = tok.replace('~', '').replace('!', '')
gain = ''
if '@' in tok: tok, _, g = tok.partition('@'); gain = '@' + g
sound, _, rest = tok.partition(':')
if sound.isdigit(): sound = GM_NUM.get(int(sound), sound) # GM note-number alias (e.g. 36 -> kick)
euc = None # euclidean (k,n,rot) shorthand - pulled before the =/ splits
lp = rest.find('(')
if lp >= 0:
rp = rest.find(')', lp)
if rp > lp:
nums = [int(x) for x in rest[lp + 1:rp].split(',') if x.strip().isdigit()]
rest = rest[:lp] + rest[rp + 1:]
if nums: euc = nums
pattern = None
if '=' in rest: rest, _, pattern = rest.partition('=')
sub = 1; swing = False
if '/' in rest:
rest, _, sd = rest.partition('/')
swing = sd.endswith('s'); sd = sd.rstrip('s')
sub = int(sd) if sd.isdigit() else 1
groups = [int(g) for g in rest.split('+') if g.isdigit()] or [4]
beats = sum(groups); starts = set(); acc = 0
for gp in groups: starts.add(acc); acc += gp
if euc: # euclidean: k hits over n steps, first hit accented
k = euc[0]; n = euc[1] if len(euc) > 1 else beats * sub; rot = euc[2] if len(euc) > 2 else 0
if len(euc) > 1:
if n % beats == 0: sub = n // beats
else: groups = [n]; sub = 1
steps = n; levels = []; first = True
for h in _euclid(k, n, rot):
if h: levels.append(2 if first else 1); first = False
else: levels.append(0)
elif pattern:
steps = beats * sub
levels = [PAT.get(ch, 0) for ch in pattern]
if len(levels) < steps: levels += [0] * (steps - len(levels))
steps = len(levels)
else:
steps = beats * sub
levels = []
for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
else: levels.append(1) # off-beat subdivisions sound at normal
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'}
def lane_to_str(L):
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
s += '=' + ''.join(PAT_CH.get(v, '.') for v in L['levels'])
s += L.get('gain', '')
if L['poly']: s += '~'
if L['mute']: s += '!'
return s
_ALNUM = "abcdefghijklmnopqrstuvwxyz0123456789"
def _slkey(t):
return "".join(c for c in t.lower() if c in _ALNUM)
def load_user_setlists():
try:
with open("/programs.json") as f: d = json.load(f)
except Exception as e:
print("programs.json:", e); return []
def items_of(pl): return [(p.get("name", "?"), p.get("prog", "")) for p in pl if p.get("prog")]
out = []
try:
if isinstance(d.get("setlists"), list):
for sl in d["setlists"]:
it = items_of(sl.get("programs", []))
if it: out.append((sl.get("title", "My set list"), it))
elif isinstance(d.get("programs"), list):
it = items_of(d["programs"])
if it: out.append((d.get("title", "My set list"), it))
except Exception as e:
print("setlists:", e)
return out
# ============================== IS31FL3731 DRIVER (vendored: bulk-framebuffer, one I2C block write per frame) ==============================
# The Scroll Pack wires its 17x7 matrix to the IS31FL3731 with the Scroll pHAT HD pixel map
# (verified from adafruit_is31fl3731.scroll_phat_hd). We keep a 144-byte PWM framebuffer and push
# the whole thing in a single I2C transaction at the colour-register offset (0x24) - per-pixel I2C
# writes are far too slow to animate a metronome.
def _pixel_addr(x, y):
if x <= 8:
x = 8 - x; y = 6 - y
else:
x = x - 8; y = y - 8
return x * 16 + y
class Matrix:
WIDTH = 17; HEIGHT = 7
def __init__(self, i2c, addr=MATRIX_ADDR):
self.i2c = i2c; self.addr = addr
self._zero = bytes(144)
self.fb = bytearray(145); self.fb[0] = 0x24 # fb[0] = COLOR_OFFSET register; fb[1:] = 144 PWM bytes
self._w(bytes([0xFD, 0x0B])) # select the Function (config) bank
self._w(bytes([0x0A, 0x00])) # Shutdown register -> software shutdown (sleep) while we configure
self._w(bytes([0x00]) + bytes(13)) # clear config regs 0x00..0x0C: Picture Mode, frame 0, audiosync off
self._w(bytes([0xFD, 0x00])) # select frame 0
self._w(bytes([0x00]) + b"\xff" * 18) # LED-control regs 0x00..0x11 -> enable every LED
self._w(bytes([0xFD, 0x0B])); self._w(bytes([0x0A, 0x01])) # back to config bank; Shutdown -> normal operation
self._w(bytes([0xFD, 0x00])) # frame 0 selected for all subsequent PWM writes
self.show() # blank it
def _w(self, data):
self.i2c.writeto(self.addr, data)
def clear(self):
self.fb[1:] = self._zero
def get(self, x, y):
if 0 <= x < 17 and 0 <= y < 7: return self.fb[1 + _pixel_addr(x, y)]
return 0
def set(self, x, y, v):
if 0 <= x < 17 and 0 <= y < 7:
self.fb[1 + _pixel_addr(x, y)] = v & 0xFF
def show(self):
try: self.i2c.writeto(self.addr, self.fb)
except Exception: pass
# 3x5 digit glyphs for the BPM view (each value is 5 rows; bit2 = leftmost column)
DIGITS = {
'0': (7, 5, 5, 5, 7), '1': (2, 6, 2, 2, 7), '2': (7, 1, 7, 4, 7), '3': (7, 1, 7, 1, 7),
'4': (5, 5, 7, 1, 1), '5': (7, 4, 7, 1, 7), '6': (7, 4, 7, 5, 7), '7': (7, 1, 2, 2, 2),
'8': (7, 5, 7, 5, 7), '9': (7, 5, 7, 1, 7),
}
# ============================== APP ==============================
class App:
def __init__(self):
self.i2c = busio.I2C(scl=P_SCL, sda=P_SDA, frequency=400_000)
while not self.i2c.try_lock(): pass # the firmware owns the matrix bus for its lifetime
self.mtx = Matrix(self.i2c)
self.midi = usb_midi.ports[1] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 1) else None
self.midi_in = usb_midi.ports[0] if (MIDI_ENABLED and usb_midi and len(usb_midi.ports) > 0) else None
self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0
self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler
self._fw = None; self._fw_n = 0; self._fw_pushing = False # chunked firmware transfer state + bus-quiet flag
# optional piezo (the Scroll Pack has no onboard speaker)
if P_BUZZER is not None and pwmio is not None:
self.spk = pwmio.PWMOut(P_BUZZER, frequency=1600, variable_frequency=True, duty_cycle=0)
else:
self.spk = None
self._buzz_off = 0
# buttons - active-low with internal pull-ups
self.btnA = self._btn(P_BTNA); self.btnB = self._btn(P_BTNB)
self.btnX = self._btn(P_BTNX); self.btnY = self._btn(P_BTNY)
self._prev = {'A': True, 'B': True, 'X': True, 'Y': True}
self._press = {'A': 0, 'B': 0} # press-start time for A/B tap-vs-hold
self._held_t = {'X': 0, 'Y': 0}; self._next_rep = {'X': 0, 'Y': 0} # tempo auto-repeat on held X/Y
self.running = False; self.bpm = 120; self.idx = 0; self.lanes = []; self.bars = 0
self.ramp = None; self.trainer = None; self._lastbar = -1; self._muted = False; self._ramp_base = 120
self.rep = None; self.end = None # per-track playback flow: rep=cycles, end=stop|next|+/-N goto
self.continue_on = False; self._advance = False
self._next_pending = None; self._seam_t = 0
self.view = 0 # 0 = Grid, 1 = Pendulum, 2 = BPM (button B cycles)
self._beatflash = 0; self._beatflash_off = 0
self._beat_ns = 60_000_000_000 // self.bpm
self._note_buf = bytearray([0x90, 0, 0])
self._clock_byte = bytes([0xF8]); self._start_byte = bytes([0xFA]); self._stop_byte = bytes([0xFC])
try:
o = os.urandom(4); self._sync_origin = "d" + "".join("%02x" % b for b in o)
except Exception:
self._sync_origin = "d%08x" % (time.monotonic_ns() & 0xFFFFFFFF)
self._sync_armed = False; self._sync_seq = 0; self._sync_applying = False
self._sync_heartbeat_next = 0.0
self._clock_next = 0; self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False
self.lane_pads = []; self.lane_lit = [] # unused on the LED build; kept so live-sync guards stay valid
self.usb_conn = False; self._m_steps = 0; self._seg_start = 0.0
# practice log + settings
self.can_write = self._probe_write(); self._load_settings()
self.log = self._load_log(); self.play_start = None; self.play_bpm = 0; self.play_name = ""
self.sl = 0; self.rebuild_setlists()
self.dirty = True
self.load(0)
def _btn(self, pin):
d = digitalio.DigitalInOut(pin); d.direction = digitalio.Direction.INPUT; d.pull = digitalio.Pull.UP
return d
# ---------- program / set lists ----------
def rebuild_setlists(self):
self.setlists = [{'title': t, 'items': it, 'builtin': True} for t, it in BUILTIN_SETLISTS]
seen = set(_slkey(t) for t, _ in BUILTIN_SETLISTS)
for t, it in load_user_setlists():
if _slkey(t) in seen: continue
seen.add(_slkey(t)); self.setlists.append({'title': t, 'items': it, 'builtin': False})
if self.sl >= len(self.setlists): self.sl = 0
def switch_setlist(self, delta=1):
if len(self.setlists) < 2: return
if self._sync_applying: return
was = self.running
if was: self.running = False; self._log_play()
self.sl = (self.sl + delta) % len(self.setlists)
self.load(0)
if was: self.running = True; self._reset_clock(); self._start_play()
self.dirty = True
self._sync_broadcast("sel=%d/%d" % (self.sl, self.idx))
def load(self, i):
items = self.setlists[self.sl]['items']
self.idx = i % len(items)
self.name, prog = items[self.idx]
self.bpm, self.lanes, self.bars, self.ramp, self.trainer, self.rep, self.end = parse_program(prog)
self._beat_ns = 60_000_000_000 // max(1, self.bpm); self._rebuild_dur_all()
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._next_pending = None
self._reset_clock(); self.dirty = True
def _prog_str(self):
parts = ['t' + str(self.bpm)]
if self.bars: parts.append('b' + str(self.bars))
if self.ramp: parts.append('rmp%d/%d/%d' % (self.ramp.get('start', self.bpm), self.ramp['amt'], self.ramp['every']))
if self.trainer: parts.append('tr%d/%d' % (self.trainer['play'], self.trainer['mute']))
for L in self.lanes: parts.append(lane_to_str(L))
if self.end is not None:
if self.rep and self.rep > 1: parts.append('rep=' + str(self.rep))
parts.append('end=' + ('stop' if self.end == 'stop' else 'next' if self.end == 1 else ('+%d' % self.end if self.end > 0 else str(self.end))))
return ';'.join(parts)
# ---------- per-lane step durations (cached tuple; no method call in tick) ----------
def _rebuild_dur(self, L):
beat = self._beat_ns
sub = max(1, L['sub']); steps = max(1, L['steps'])
if L.get('poly') and self.lanes:
m = self.lanes[0]; master_bar = beat * (m['steps'] // max(1, m['sub']))
d = master_bar // steps; L['durs'] = tuple(d for _ in range(steps))
elif L.get('swing') and sub % 2 == 0:
pair = beat // max(1, sub // 2); lng = (pair * 2) // 3; sht = pair // 3
L['durs'] = tuple(lng if (s % sub) % 2 == 0 else sht for s in range(steps))
else:
d = beat // sub; L['durs'] = tuple(d for _ in range(steps))
def _rebuild_dur_all(self):
for L in self.lanes: self._rebuild_dur(L)
def _reset_clock(self):
now = time.monotonic_ns()
for L in self.lanes:
L['next'] = now; L['step'] = -1
self._m_steps = 0; self._seg_start = time.monotonic()
def _regen_levels(self, L): # remote lane= deltas recompute default accents
sub = L['sub']; groups = L['groups']; starts = set(); acc = 0
for gp in groups: starts.add(acc); acc += gp
L['steps'] = sum(groups) * sub
L['levels'] = [(2 if (i // sub) in starts else 1) if i % sub == 0 else 0 for i in range(L['steps'])]
def _padbase(self, L, s):
return 0 if L['mute'] else L['levels'][s]
# ---------- audio (optional piezo only) ----------
def click(self, level):
if self.spk is None: return
self.spk.frequency = {2: 2300, 1: 1600, 3: 1050}.get(level, 1600)
self.spk.duty_cycle = {2: 42000, 1: 30000, 3: 14000}.get(level, 30000)
self._buzz_off = time.monotonic_ns() + 22_000_000
# ---------- live sync (HELLO/FULL/DELTA/BYE on SysEx 0x40-0x43; see src/livesync.js) ----------
def _sync_send(self, op, text):
if self.midi is None: return
b = bytearray((0xF0, 0x7D, op))
for c in text:
v = ord(c); b.append(v if v < 0x80 else 0x3F)
b.append(0xF7)
try: self.midi.write(b)
except Exception: pass
def _sync_broadcast(self, evt):
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
text = "%s;%d;%s" % (self._sync_origin, self._sync_seq, evt); self._sync_seq += 1
self._sync_send(0x42, text)
def _sync_broadcast_full(self):
if not self._sync_armed or self.midi is None or self._fw_pushing: return
try: patch = self._prog_str()
except Exception: return
text = "%s;%d;%d;%d;%d;%s" % (self._sync_origin, self._sync_seq,
1 if self.running else 0, self.sl, self.idx, patch)
self._sync_seq += 1
self._sync_send(0x41, text)
self._sync_heartbeat_next = time.monotonic() + 5.0
def _sync_apply_full(self, running, patch):
self._sync_applying = True
try:
try:
gc.collect()
try: cur = self._prog_str()
except Exception: cur = None
if patch and patch != cur:
bpm, lanes, bars, ramp, trainer, rep, end = parse_program(patch)
self.bpm = bpm; self.lanes = lanes; self.bars = bars; self.ramp = ramp
self.trainer = trainer; self.rep = rep; self.end = end
self._beat_ns = 60_000_000_000 // max(1, bpm); self._rebuild_dur_all()
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
self._reset_clock(); self.dirty = True
if running and not self.running: self.toggle()
elif (not running) and self.running: self.toggle()
except Exception as e:
try: print("sync FULL apply:", e)
except Exception: pass
finally:
self._sync_applying = False
def _sync_apply_delta(self, evt):
self._sync_applying = True
try:
eq = evt.find('=')
key = evt if eq < 0 else evt[:eq]
val = '' if eq < 0 else evt[eq+1:]
if key == 'play':
if not self.running: self.toggle()
elif key == 'stop':
if self.running: self.toggle()
elif key == 'bpm':
try: self.set_bpm(int(val))
except Exception: pass
elif key == 'sel':
p = val.split('/')
if len(p) == 2:
try:
sl = int(p[0]); item = int(p[1])
if sl >= 0 and item >= 0:
if sl < len(self.setlists) and sl != self.sl: self.sl = sl
items = self.setlists[self.sl]['items']
if 0 <= item < len(items) and item != self.idx: self.goto(item)
except Exception: pass
elif key == 'beat': # PM_G-1 doesn't EMIT beat= (no on-device editing) but DOES apply
p = val.split('/')
if len(p) == 3:
try:
li = int(p[0]); s = int(p[1]); lvl = int(p[2])
if 0 <= li < len(self.lanes):
L = self.lanes[li]
if 0 <= s < len(L['levels']):
L['levels'][s] = lvl & 3; self.dirty = True
except Exception: pass
elif key == 'lane': # apply but don't emit
p = val.split('/')
if len(p) >= 3:
try:
li = int(p[0]); field = p[1]; v = '/'.join(p[2:])
if 0 <= li < len(self.lanes):
L = self.lanes[li]; structural = False
if field == 'sound': L['sound'] = v
elif field == 'groups':
try: L['groups'] = [int(x) for x in v.split('+')]; structural = True
except Exception: pass
elif field == 'sub':
try: L['sub'] = int(v); structural = True
except Exception: pass
elif field == 'swing': L['swing'] = (v == '1'); structural = True
elif field == 'enabled': L['mute'] = not (v == '1')
elif field == 'gain':
try: L['gain'] = int(v)
except Exception: pass
elif field == 'poly': L['poly'] = (v == '1'); structural = True
if structural: self._regen_levels(L)
if li == 0 and structural: self._rebuild_dur_all()
else: self._rebuild_dur(L)
self.dirty = True
except Exception: pass
finally:
self._sync_applying = False
def midi_send(self, note, vel):
if self.midi is None or self._fw_pushing: return # keep the bus quiet during a firmware push
b = self._note_buf
b[0] = 0x90 | ((MIDI_CHANNEL - 1) & 0x0F)
b[1] = note & 0x7F; b[2] = vel & 0x7F
try: self.midi.write(b)
except Exception: pass
# ---------- transport ----------
def toggle(self):
self.running = not self.running
if self.running:
self._reset_clock(); self._start_play(); self._clock_next = time.monotonic_ns()
if MIDI_CLOCK_OUT and MIDI_CLOCK_OUT_TRANSPORT and self.midi is not None:
try: self.midi.write(self._start_byte)
except Exception: pass
else:
if self.spk: self.spk.duty_cycle = 0
self._log_play()
if MIDI_CLOCK_OUT and MIDI_CLOCK_OUT_TRANSPORT and self.midi is not None:
try: self.midi.write(self._stop_byte)
except Exception: pass
self.dirty = True
self._sync_broadcast("play" if self.running else "stop")
def set_bpm(self, v):
v = max(5, min(300, v))
if v != self.bpm:
self.bpm = v; self._beat_ns = 60_000_000_000 // v
self._rebuild_dur_all(); self.dirty = True
self._sync_broadcast("bpm=%d" % v)
def goto(self, i):
was = self.running
if was: self.running = False; self._log_play()
self.load(i)
if was: self.running = True; self._reset_clock(); self._start_play()
self.dirty = True
self._sync_broadcast("sel=%d/%d" % (self.sl, self.idx))
def tap(self):
now = time.monotonic()
if not hasattr(self, '_taps'): self._taps = []
self._taps = [t for t in self._taps if now - t < 2.4]
self._taps.append(now)
if len(self._taps) >= 2:
span = (self._taps[-1] - self._taps[0]) / (len(self._taps) - 1)
if span > 0: self.set_bpm(round(60 / span))
def cycle_view(self):
self.view = (self.view + 1) % 3; self.dirty = True
# ---------- scheduler (timing identical to the Explorer; display calls replaced by self.dirty) ----------
def tick(self):
now = time.monotonic_ns()
if self._buzz_off and now >= self._buzz_off:
if self.spk: self.spk.duty_cycle = 0
self._buzz_off = 0
if self._slaved and (now - self._clock_in_last_t) > 1_000_000_000: self._slaved = False
if self.running:
fired_best = 0; fired_prio = -1
for li, L in enumerate(self.lanes):
if self._advance: break
adv = False
while now >= L['next']:
L['step'] = (L['step'] + 1) % L['steps']
if li == 0:
self._m_steps += 1
nb = (self._m_steps - 1) // L['steps']
if nb != self._lastbar: self._lastbar = nb; self._on_new_bar(nb)
if self._advance: break
if self.ramp and L['steps'] > 0 and not self._slaved:
mlen = L['steps']; bar_pos = self._m_steps / mlen
seg_bar = (bar_pos % self.bars) if self.bars else bar_pos
new_bpm = max(5, min(300, int(self._ramp_base + seg_bar / self.ramp['every'] * self.ramp['amt'])))
if new_bpm != self.bpm:
self.bpm = new_bpm; self._beat_ns = 60_000_000_000 // new_bpm; self._rebuild_dur_all()
lvl = 0 if L['mute'] else L['levels'][L['step']]
if lvl > 0:
p = PRIO.get(lvl, 0)
if p > fired_prio: fired_prio = p; fired_best = lvl
if not self._muted:
self.midi_send(SOUND_GM.get(L['sound'], GM_DEFAULT), MIDI_VEL.get(lvl, 90))
L['next'] += L['durs'][L['step']]; adv = True
if adv: self.dirty = True
if fired_best and not self._muted:
self._beatflash = fired_best; self._beatflash_off = now + 70_000_000
if not MUTE_SPEAKER and not (SPEAKER_AUTO_MUTE and self.midi_host):
self.click(fired_best)
self.dirty = True
if self._advance:
self._advance = False; self._do_advance()
if self.running and MIDI_CLOCK_OUT and self.midi is not None and not self._slaved and not self._fw_pushing:
clk = self._clock_byte; tick_ns = self._beat_ns // 24
while now >= self._clock_next:
try: self.midi.write(clk)
except Exception: pass
self._clock_next += tick_ns
def _end_plan(self):
end = self.end
if end is None:
if self.continue_on and self.bars: end = 1
else: return None
cyc = self.bars if self.bars else 1
reps = self.rep if self.rep else 1
return (cyc * reps, end)
def _goto_target(self, offset):
items = self.setlists[self.sl]['items']; n = len(items)
t = self.idx + offset
return 0 if t < 0 else (t % n if t >= n else t)
def _end_stop(self):
self.running = False
if self.spk: self.spk.duty_cycle = 0
self._log_play(); self.dirty = True; self._sync_broadcast("stop")
def _on_new_bar(self, bar):
plan = self._end_plan()
if plan is not None and plan[1] != 'stop' and self._next_pending is None and bar == plan[0] - 1:
self._prepare_next(self._goto_target(plan[1]))
if self.bars and bar > 0 and bar % self.bars == 0:
self._seg_start = time.monotonic()
if plan is not None and bar > 0 and bar == plan[0]:
action = plan[1]
if not (self.bars and bar % self.bars == 0): self._seg_start = time.monotonic()
if action == 'stop':
self._end_stop()
else:
if self._next_pending is None: self._prepare_next(self._goto_target(action))
if self._next_pending is not None:
self._seam_t = self.lanes[0]['next']
self._advance = True
t = self.trainer
self._muted = bool(t and (t['play'] + t['mute']) and (bar % (t['play'] + t['mute'])) >= t['play'])
def _prepare_next(self, target=None):
items = self.setlists[self.sl]['items']
nxt = (self.idx + 1) % len(items) if target is None else target
if nxt == self.idx: return
name, prog = items[nxt]
gc.collect()
try:
bpm, lanes, bars, ramp, trainer, rep, end = parse_program(prog)
except MemoryError:
gc.collect(); return
beat = 60_000_000_000 // max(1, bpm)
for L in lanes:
sub = max(1, L['sub']); steps = max(1, L['steps'])
if L.get('poly'):
m = lanes[0]; mbar = beat * (m['steps'] // max(1, m['sub']))
d = mbar // steps; L['durs'] = tuple(d for _ in range(steps))
elif L.get('swing') and sub % 2 == 0:
pair = beat // max(1, sub // 2); lng = (pair * 2) // 3; sht = pair // 3
L['durs'] = tuple(lng if (s % sub) % 2 == 0 else sht for s in range(steps))
else:
d = beat // sub; L['durs'] = tuple(d for _ in range(steps))
self._next_pending = {'lanes': lanes, 'bpm': bpm, 'bars': bars, 'ramp': ramp,
'trainer': trainer, 'name': name, 'idx': nxt, 'rep': rep, 'end': end}
def _do_advance(self):
n = self._next_pending
if n is None: return
self._next_pending = None
self.lanes = n['lanes']; self.bpm = n['bpm']; self.bars = n['bars']
self.ramp = n['ramp']; self.trainer = n['trainer']; self.name = n['name']; self.idx = n['idx']
self.rep = n['rep']; self.end = n['end']
self._beat_ns = 60_000_000_000 // max(1, self.bpm); self._rebuild_dur_all()
self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False; self._m_steps = 0
seam = self._seam_t
for L in self.lanes: L['next'] = seam; L['step'] = -1
self._seg_start = time.monotonic(); self.dirty = True
# ---------- inputs (4 buttons, active-low) ----------
# A: tap = play/stop, hold (>=600ms) = cycle view. B: tap = next track, hold = next set list.
# X / Y: tempo down / up (tap = +-1, auto-repeat while held, +-5 after 1.5s).
def poll(self):
now = time.monotonic_ns()
a = self.btnA.value; b = self.btnB.value; x = self.btnX.value; y = self.btnY.value
if (not a) and self._prev['A']: self._press['A'] = now
if a and (not self._prev['A']):
if now - self._press['A'] >= 600_000_000: self.cycle_view()
else: self.toggle()
if (not b) and self._prev['B']: self._press['B'] = now
if b and (not self._prev['B']):
if now - self._press['B'] >= 600_000_000: self.switch_setlist(1)
else: self.goto(self.idx + 1)
if (not x) and self._prev['X']:
self._held_t['X'] = now; self._next_rep['X'] = now + 350_000_000; self.set_bpm(self.bpm - 1)
elif (not x) and (not self._prev['X']) and now >= self._next_rep['X']:
self._next_rep['X'] = now + 120_000_000
self.set_bpm(self.bpm + (-5 if (now - self._held_t['X']) > 1_500_000_000 else -1))
if (not y) and self._prev['Y']:
self._held_t['Y'] = now; self._next_rep['Y'] = now + 350_000_000; self.set_bpm(self.bpm + 1)
elif (not y) and (not self._prev['Y']) and now >= self._next_rep['Y']:
self._next_rep['Y'] = now + 120_000_000
self.set_bpm(self.bpm + (5 if (now - self._held_t['Y']) > 1_500_000_000 else 1))
self._prev['A'] = a; self._prev['B'] = b; self._prev['X'] = x; self._prev['Y'] = y
# USB-MIDI in: any byte = a host is listening (heartbeat); also assemble SysEx
if self.midi_in is not None:
try: n = self.midi_in.readinto(self._mbuf)
except Exception: n = 0
if n:
self.last_midi_in = time.monotonic(); self._feed_midi(self._mbuf, n)
host = bool(self.last_midi_in) and (time.monotonic() - self.last_midi_in) < 1.0
if host != self.midi_host:
self.midi_host = host
if host and SPEAKER_AUTO_MUTE and self.spk: self.spk.duty_cycle = 0
# ---------- LED rendering ----------
def _lvl_bright(self, lvl):
if lvl == 2: return BRIGHTNESS # accent
if lvl == 1: return max(8, BRIGHTNESS // 4) # normal
if lvl == 3: return max(3, BRIGHTNESS // 16) # ghost
return 0
def render(self):
self.mtx.clear()
if self.view == 2: self._render_bpm()
elif self.view == 1: self._render_pendulum()
else: self._render_grid()
self.mtx.show()
def _render_grid(self):
m = self.mtx; lanes = self.lanes
n = min(len(lanes), 7)
if n == 0: return
y0 = (7 - n) // 2 # centre the lanes vertically
for li in range(n):
L = lanes[li]; steps = max(1, L['steps']); y = y0 + li
lit = L['step'] if self.running else -1
off = (17 - steps) // 2 if steps <= 17 else 0
for s in range(steps):
col = (s + off) if steps <= 17 else (s * 17) // steps
lvl = 0 if L['mute'] else L['levels'][s]
if s == lit:
val = 255 if lvl else 70 # playhead: bright on a hit, a dim travelling dot on a rest
else:
val = self._lvl_bright(lvl)
if val and val > m.get(col, y): m.set(col, y, val)
def _render_pendulum(self):
m = self.mtx
if not self.lanes: return
L = self.lanes[0]; steps = max(1, L['steps'])
sub = max(1, L['sub']); beats = max(1, steps // sub)
frac = (((self._m_steps - 1) % steps) / steps) if self.running else 0.0
tri = frac * 2 if frac < 0.5 else 2 * (1 - frac) # bounce 0..1..0 across the bar (metronome arm)
col = int(tri * 16 + 0.5)
flash = self._beatflash if (self._beatflash and time.monotonic_ns() < self._beatflash_off) else 0
val = 255 if flash == 2 else (150 if flash else 90)
for y in range(7): m.set(col, y, val)
for bi in range(beats): # faint beat ticks along the bottom edge
bc = (bi * 17) // beats
if m.get(bc, 6) < 24: m.set(bc, 6, 24)
def _render_bpm(self):
m = self.mtx; s = str(self.bpm)[-3:]
w = len(s) * 4 - 1; x0 = (17 - w) // 2; y0 = 1
val = BRIGHTNESS if self.running else max(20, BRIGHTNESS // 2)
for i, ch in enumerate(s):
g = DIGITS.get(ch)
if not g: continue
bx = x0 + i * 4
for ry in range(5):
row = g[ry]
for rx in range(3):
if row & (1 << (2 - rx)): m.set(bx + rx, y0 + ry, val)
# ---------- USB-MIDI in: SysEx assembler (clock + editor-pushed programs + live-sync) ----------
def _feed_midi(self, buf, n):
now_ns = time.monotonic_ns() if MIDI_CLOCK_IN else 0
for i in range(n):
b = buf[i]
if b == 0xF0: self._sx = bytearray(); self._sxon = True
elif b == 0xF7:
if self._sxon: self._handle_sysex(self._sx)
self._sxon = False
elif b == 0xF8 and MIDI_CLOCK_IN: self._slave_tick(now_ns)
elif b == 0xFA and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_start()
elif b == 0xFB and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_start()
elif b == 0xFC and MIDI_CLOCK_IN_TRANSPORT and MIDI_CLOCK_IN: self._slave_stop()
elif b >= 0xF8: pass
elif self._sxon:
if len(self._sx) < 60000: self._sx.append(b)
else: self._sxon = False
def _slave_tick(self, now_ns):
if self._clock_in_last_t == 0:
self._clock_in_last_t = now_ns; self._slaved = True; return
interval = now_ns - self._clock_in_last_t
self._clock_in_last_t = now_ns
if interval < 8_300_000 or interval > 500_000_000: return
if self._clock_in_avg == 0: self._clock_in_avg = interval
else: self._clock_in_avg = (self._clock_in_avg * 7 + interval) // 8
new_bpm = max(5, min(300, int(60_000_000_000 // (self._clock_in_avg * 24))))
if new_bpm != self.bpm:
self.bpm = new_bpm; self._beat_ns = 60_000_000_000 // new_bpm; self._rebuild_dur_all()
self._slaved = True
def _slave_start(self):
if not self.running:
self.running = True; self._reset_clock(); self._start_play(); self.dirty = True
self._clock_in_last_t = 0; self._clock_in_avg = 0
def _slave_stop(self):
if self.running:
self.running = False
if self.spk: self.spk.duty_cycle = 0
self._log_play(); self.dirty = True
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False
def _handle_sysex(self, sx):
if len(sx) < 2 or sx[0] != 0x7D: return
cmd = sx[1]
if cmd == 0x01 and len(sx) >= 8 and rtc is not None:
try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1))
except Exception: pass
elif cmd == 0x02: # version query -> reply 0x03 + "G;<APP_VERSION>"
if self.midi:
payload = DEVICE_ID + ";" + APP_VERSION
self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43:
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
except Exception: return
origin = text.split(";", 1)[0] if text else ""
if origin == self._sync_origin: return
self._sync_armed = True
if cmd == 0x40:
self._sync_broadcast_full()
elif cmd == 0x43:
self._sync_armed = False
elif cmd == 0x41:
parts = text.split(";", 5)
if len(parts) >= 6:
try:
running = parts[2] == "1"; patch = parts[5]
self._sync_apply_full(running, patch)
except Exception: pass
elif cmd == 0x42:
parts = text.split(";", 2)
if len(parts) >= 3: self._sync_apply_delta(parts[2])
elif cmd == 0x10:
try:
with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))
self.rebuild_setlists(); self.load(0)
self._ack(True)
except Exception:
self._ack(False)
elif cmd == 0x21:
try:
try: self._fw.close()
except Exception: pass
self._fw = open("/app.new", "wb"); self._fw_n = 0
self._fw_pushing = True
self._ack(True)
except Exception:
self._fw = None; self._fw_pushing = False; self._ack(False)
elif cmd == 0x22:
try:
if self._fw is None or a2b_base64 is None: raise OSError()
self._fw.write(a2b_base64(bytes(sx[2:])))
self._fw.flush(); self._fw_n += 1
gc.collect()
self._ack(True)
except Exception:
try: self._fw.close()
except Exception: pass
self._fw = None; self._fw_pushing = False; self._ack(False)
elif cmd == 0x23:
try:
try: self._fw.close()
except Exception: pass
self._fw = None; gc.collect()
with open("/app.new", "rb") as f: head = f.read(2)
if os.stat("/app.new")[6] < 4000 or len(head) < 2 or head[0] != 0x43 or head[1] != 0x06:
try: os.remove("/app.new")
except OSError: pass
self._fw_pushing = False; self._ack(False); return
try: os.remove("/app.bak")
except OSError: pass
os.rename("/app.mpy", "/app.bak")
os.rename("/app.new", "/app.mpy")
open("/trial", "w").close()
self._fw_pushing = False
self._ack(True); time.sleep(0.4); supervisor.reload()
except Exception:
self._fw_pushing = False; self._ack(False)
def _ack(self, ok):
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F if ok else 0x7E, 0xF7]))
# ---------- practice log (saved to /history.json) ----------
def _probe_write(self):
try:
with open("/.wtest", "w") as f: f.write("1")
try: os.remove("/.wtest")
except Exception: pass
return True
except OSError:
return False
def _load_settings(self):
global MUTE_SPEAKER, SPEAKER_AUTO_MUTE, MIDI_ENABLED, MIDI_CHANNEL, MIDI_CLOCK_OUT, MIDI_CLOCK_IN, BRIGHTNESS
try:
with open("/settings.json") as f: d = json.load(f)
except Exception: return
try:
sm = d.get("speaker", "auto")
MUTE_SPEAKER = (sm == "off"); SPEAKER_AUTO_MUTE = (sm == "auto")
MIDI_ENABLED = bool(d.get("midi_out", MIDI_ENABLED))
MIDI_CHANNEL = max(1, min(16, int(d.get("midi_channel", MIDI_CHANNEL))))
MIDI_CLOCK_OUT = bool(d.get("clock_out", MIDI_CLOCK_OUT))
MIDI_CLOCK_IN = bool(d.get("clock_in", MIDI_CLOCK_IN))
BRIGHTNESS = max(16, min(255, int(d.get("brightness", BRIGHTNESS))))
except Exception as e: print("settings:", e)
def _load_log(self):
try:
with open("/history.json") as f: return json.load(f).get("log", [])
except Exception:
return []
def _save_log(self):
if not self.can_write: return
try:
with open("/history.json", "w") as f: json.dump({"log": self.log[:200]}, f)
except OSError:
self.can_write = False
def _start_play(self):
self.play_start = time.monotonic(); self.play_bpm = self.bpm; self.play_name = self.name
def _log_play(self):
if self.play_start is None: return
dur = int(time.monotonic() - self.play_start); self.play_start = None
if dur < MIN_LOG_SEC: return
mlen = self.lanes[0]['steps'] if self.lanes else 1
t = time.localtime()
self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name})
del self.log[200:]
self._save_log()
def run(self):
boot = time.monotonic()
try: os.stat("/trial"); committed = False
except OSError: committed = True
next_frame = 0.0
while True:
try:
self.tick(); self.poll()
tnow = time.monotonic()
if not committed and tnow - boot > 5:
try: os.remove("/trial")
except Exception: pass
committed = True
if self._sync_armed and tnow >= self._sync_heartbeat_next:
self._sync_broadcast_full()
if self.running and tnow >= next_frame: # keep pendulum/playhead moving even with no input
self.dirty = True; next_frame = tnow + 0.04
if self.dirty:
self.dirty = False; self.render()
time.sleep(0.0005)
except MemoryError:
gc.collect(); time.sleep(0.05)
except Exception as e:
try: print("tick error:", e)
except Exception: pass
time.sleep(0.05)
App().run()

22
pico-scroll/boot.py Normal file
View file

@ -0,0 +1,22 @@
# boot.py - runs once at power-on (before USB connects); decides who owns the filesystem.
#
# DEFAULT = appliance mode: the FIRMWARE owns the drive, so it can save your practice log to
# /history.json and write /programs.json that the editor pushes over USB-MIDI. The drive is then
# READ-ONLY to the computer - which also protects the firmware from accidental deletion.
#
# HOLD BUTTON A (GP12 on the Pico Scroll Pack) WHILE PLUGGING IN = editor mode: the drive is
# writable by the computer, so you can drag programs.json / app.mpy on from any OS or browser
# (the universal fallback). Reset afterwards to return to appliance mode.
#
# Also frees a USB endpoint (disables unused HID) and makes sure USB-MIDI is available.
import board, digitalio, storage, usb_hid, usb_midi
try: usb_hid.disable()
except Exception: pass
usb_midi.enable()
a = digitalio.DigitalInOut(board.GP12)
a.switch_to_input(pull=digitalio.Pull.UP)
appliance = a.value # value True (pull-up, not pressed) -> appliance mode
a.deinit()
if appliance:
try: storage.remount("/", readonly=False) # writable by code, read-only to the computer
except Exception: pass

24
pico-scroll/code.py Normal file
View file

@ -0,0 +1,24 @@
# code.py - PM_G-1 A/B firmware loader (stable; rarely changes).
#
# The real application is the PRECOMPILED app.mpy (CircuitPython compiles a big .py at boot, which
# fragments the heap and OOMs; a .mpy loads without compiling). app.bak holds the previous known-good
# build. The web editor pushes a new app.mpy to a "trial" slot over USB-MIDI; this loader runs it, and
# if it fails to boot it AUTOMATICALLY ROLLS BACK to app.bak. (Unbrickable: BOOTSEL -> drag a .uf2.)
# app.mpy clears the /trial marker once it has run healthily for ~5s.
import supervisor, os
supervisor.runtime.autoreload = False # updates reboot explicitly; never auto-restart on our own writes
def _trial():
try: os.stat("/trial"); return True
except OSError: return False
try:
import app # runs the application (app.mpy; ends with App().run())
except Exception:
if _trial(): # a freshly-pushed build crashed on startup -> roll back
try:
os.remove("/app.mpy"); os.rename("/app.bak", "/app.mpy"); os.remove("/trial")
except Exception: pass
supervisor.reload() # reboot into the restored known-good build
else:
raise # the active build failed unexpectedly (rare) -> on-screen traceback

View file

@ -0,0 +1,3 @@
{
"setlists": []
}