diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..80ab9c0 --- /dev/null +++ b/CLAUDE.md @@ -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; 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/.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/@*/` 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 (`.html`, what `?embed=1` serves) plus a spec page (`info-.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` → `X.Y.Z`; anything else → `X.Y.Z-dev..[.dirty]`. Source files keep a placeholder `APP_VERSION`; only the deployed copy is stamped. diff --git a/build.sh b/build.sh index 3995ba0..18383e9 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/deploy.sh b/deploy.sh index eb8a629..9f92d6b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/docs/livesync-protocol.md b/docs/livesync-protocol.md index d661479..b4e624f 100644 --- a/docs/livesync-protocol.md +++ b/docs/livesync-protocol.md @@ -205,6 +205,7 @@ they **emit**, because on‑device editing differs: |-------------|----------------------------------------------------|---------------------------------------------| | **PM_K‑1** Kit (touchscreen + joystick) | `play` / `stop` / `bpm` / `sel` / `beat` / `lane` (FULL on structural lane edits) | all of the above | | **PM_X‑1** Explorer (6 buttons, read‑only beats) | `play` / `stop` / `bpm` / `sel` only (no on‑device beat/lane editing) | all of the above | +| **PM_G‑1** Grid (17×7 LED matrix, 4 buttons, read‑only beats) | `play` / `stop` / `bpm` / `sel` only (no on‑device beat/lane editing) | all of the above | Editors don't need to special‑case the source — both DELTA streams look identical on the wire, and the **device id is only exposed on the version query** (SysEx `0x02` diff --git a/docs/rust-port.md b/docs/rust-port.md index 0953e54..89c2368 100644 --- a/docs/rust-port.md +++ b/docs/rust-port.md @@ -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 diff --git a/editor-beta.html b/editor-beta.html index a6eb869..fa96c46 100644 --- a/editor-beta.html +++ b/editor-beta.html @@ -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) { diff --git a/editor.html b/editor.html index cace70d..cfd5bab 100644 --- a/editor.html +++ b/editor.html @@ -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) { diff --git a/embed.html b/embed.html index 04f8c28..6704dd6 100644 --- a/embed.html +++ b/embed.html @@ -109,6 +109,7 @@ const FF = [ { k:"stage", name:"PM_S‑1 Stage", file:"stage.html", h:430 }, { k:"micro", name:"PM_P‑1 Practice", file:"micro.html", h:240 }, { k:"showcase", name:"PM_D‑1 Display", file:"showcase.html",h:540 }, + { k:"grid", name:"PM_G‑1 Grid", file:"grid.html", h:470 }, { k:"initial", name:"PM_C‑1 Concept", file:"player.html", h:440 }, ]; function updateFF(k){ diff --git a/embed.js b/embed.js index 22ec979..15246c7 100644 --- a/embed.js +++ b/embed.js @@ -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; diff --git a/grid.html b/grid.html new file mode 100644 index 0000000..205f0fd --- /dev/null +++ b/grid.html @@ -0,0 +1,326 @@ + + + + + +VARASYS PM_G-1 - Grid (Pimoroni Pico Scroll Pack / RP2040) + + + + + + + + +/*@BUILD:include:src/header.html@*/ + +

PM_G‑1 Grid

+

Off‑the‑shelf — the Pimoroni Pico Scroll Pack (a 17×7 white LED matrix + 4 buttons) on a plain Raspberry Pi Pico. The matrix is 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 Live sync; the device mirrors play/stop/tempo/track both ways.

+ +
+
+
PM_G‑1 Grid
+ RP2040 · 17×7 +
+ +
+ +
+ + + + +
+
+ +
Four buttons: A = play/stop (hold = cycle view: Grid → Pendulum → BPM); + B = next track (hold = next set list); X / Y = tempo −/+ (hold to repeat, ±5 after ~1.5 s). + Keyboard: A / B / X / Y, space = play, V = cycle view.
+ +/*@BUILD:include:src/progbox.html@*/ + + + + + +/*@BUILD:include:src/footer.html@*/ + + diff --git a/index.html b/index.html index 798aa4d..a2d7be6 100644 --- a/index.html +++ b/index.html @@ -140,6 +140,7 @@ const VERSIONS = [ { key:"editor", file:"/editor.html", name:"PM_E‑1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, per‑step accents/ghosts/mutes, swing & polyrhythm, set lists, per‑lane dB gain." }, { key:"kit", file:"/kit.html", name:"PM_K‑1 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_X‑1 Explorer", chip:"hw", h:500, sum:"Off‑the‑shelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a button‑driven 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_G‑1 Grid", chip:"hw", h:470, sum:"Off‑the‑shelf — 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_T‑1 Teacher", chip:"hw", h:440, sum:"Studio / lesson desk console — colour TFT of every lane, arcade buttons, instrument pass‑through." }, { key:"stage", file:"/stage.html", name:"PM_S‑1 Stage", chip:"hw", h:430, sum:"Live foot pedal — two footswitches, expression‑pedal tempo, a big floor‑readable RGB beat light." }, { key:"micro", file:"/micro.html", name:"PM_P‑1 Practice", chip:"hw", h:240, sum:"Inline practice bar — clickable thumb‑roller, amber 14‑segment, instrument in/out pass‑through." }, diff --git a/info-grid.html b/info-grid.html new file mode 100644 index 0000000..d03ae68 --- /dev/null +++ b/info-grid.html @@ -0,0 +1,174 @@ + + + + + +VARASYS PM_G-1 Grid - wiring, parts & firmware (Pimoroni Pico Scroll Pack / RP2040) + + + + + + + + +/*@BUILD:include:src/header.html@*/ + +
+
+

PM_G-1 Grid

+

The off-the-shelf Pimoroni Pico Scroll Pack (a 17×7 white LED matrix + 4 buttons) on a plain Raspberry Pi Pico - 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.

+
+ +
+

What it is

+
Buildable nowRP2040 (Raspberry Pi Pico)Pimoroni Pico Scroll Pack PIM545~$30
+

The Pico Scroll Pack (PIM545) + plugs straight onto a Raspberry Pi Pico's headers: 119 white LEDs in a 17×7 matrix driven by an + IS31FL3731 over I²C (individually brightness-controlled), and 4 buttons (A / B / X / Y). + No soldering, no touchscreen, no joystick, no RGB - and no onboard speaker. About 65 × 25 × 10 mm + on top of the Pico. Power is over VSYS (USB or battery).

+

It runs the same polymeter engine and program strings as the web editor. The 7-row × 17-column + matrix maps directly onto the editor's lane × step 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; Live sync mirrors edits + to the device in real time (HELLO/FULL/DELTA over USB-MIDI) and the device mirrors play/stop/tempo/track back. + Audio is over USB-MIDI - turn on the editor's 🎹 Device audio to hear the clicks through your + computer (or solder a piezo to a free GPIO and set P_BUZZER in app.py).

+
+ +
+ Wiring - the Pico Scroll Pack fixed pinout (just press it onto the Pico) +
+

Everything is wired through the header; this is what the firmware reads. Pins verified against Pimoroni's pico_scroll library.

+ + + + + + + + + + + +
ComponentPico pins
LED matrix - 17×7 white, IS31FL3731 (I²C @ 0x74)
SDA / SCLGP4 / GP5
Buttons (digital, pull-up)
A (play/stop) / B (track)GP12 / GP13
X (-bpm) / Y (+bpm)GP14 / GP15
Audio (optional - not on the Scroll Pack)
Piezo PWM - only if you wire one; set P_BUZZERany free GPIO
+
+
+ +
+ Controls & views +
+ + + + + + + + +
ButtonTapHold
Aplay / stopcycle view (Grid → Pendulum → BPM)
Bnext tracknext set list
Xtempo -1repeat (-5 after ~1.5 s)
Ytempo +1repeat (+5 after ~1.5 s)
+ + + + + + + +
ViewWhat the 17×7 matrix shows
Gridlanes as rows, steps as columns; brightness = accent / normal / ghost; a bright playhead column tracks the beat (bars > 17 steps scale to fit - no steps dropped)
Penduluma column bounces across the bar like a metronome arm, full-height flash on each beat
BPMthe current tempo as three 3×5 digits
+

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 app.py.

+
+
+ +
+ Parts +
+

Two off-the-shelf parts, no soldering - ballpark one-off price (USD).

+ + + + + + + + +
PartQty~$
Pimoroni Pico Scroll Pack (PIM545) - 17×7 white LED matrix (IS31FL3731) + 4 buttons122
Raspberry Pi Pico - RP2040; pre-soldered headers so the pack presses on15
USB cable - power + flashing (micro-USB)12
Total (one-off)≈ $29
+

Reference: Pico Scroll Pack product page + · vendor code (pico_scroll) + · CircuitPython for Raspberry Pi Pico.

+
+
+ +
+ Firmware - self-contained appliance (USB drive · web-driven editing via Live sync · MIDI audio · practice log) +
+

The firmware turns the Pico + Scroll Pack into a self-contained appliance: it mounts as a + USB drive carrying the (precompiled) firmware, your tracks and an offline copy of this editor; + drives the 17×7 matrix with web-driven editing via Live sync; logs your practice to + history.json; takes new set lists pushed from the editor over USB-MIDI; and plays + out your computer's speakers over USB-MIDI. By default the firmware owns the drive (read-only to + the computer - so it can log and can't be accidentally erased); hold button A at power-on for + editor mode (drive writable).

+

+ Download CircuitPython bundle ↓ + Source + README ↗ +

+
    +
  1. Flash CircuitPython for Raspberry Pi Pico + (download) + via BOOTSEL, unzip the bundle onto CIRCUITPY, and power-cycle. It boots into appliance mode.
  2. +
  3. Edit on the web: open the editor (beta) in Chrome / Edge / Firefox, + click 🔗 Live sync, and the matrix mirrors your edits live (beats, tempo, track changes).
  4. +
  5. Save a set list to the device for offline use: set-list ··· menu → + 📟 Save to device. It's pushed over USB-MIDI; the device persists it to + /programs.json.
  6. +
  7. Play through your computer: click 🎹 Device audio, then press A on the device - + the full groove sounds through your speakers over USB-MIDI, in sync (the Scroll Pack has no speaker of its own).
  8. +
  9. Firmware updates: ··· menu → ⬆ Update firmware - the editor reads + the device id (G = Grid), fetches the matching pico-scroll-app.mpy, and pushes it + over USB-MIDI. The device A/B-updates with automatic rollback if a build won't boot.
  10. +
+
+
+ +

Pairs with the touch-driven PM_K-1 Kit and the button-driven PM_X-1 Explorer - same engine, same programs.json, same web editor.

+
+ +/*@BUILD:include:src/footer.html@*/ + + + + diff --git a/pico-scroll/README.md b/pico-scroll/README.md new file mode 100644 index 0000000..674f5ba --- /dev/null +++ b/pico-scroll/README.md @@ -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 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. diff --git a/pico-scroll/app.py b/pico-scroll/app.py new file mode 100644 index 0000000..4b1dc26 --- /dev/null +++ b/pico-scroll/app.py @@ -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= 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;" + 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() diff --git a/pico-scroll/boot.py b/pico-scroll/boot.py new file mode 100644 index 0000000..8971acd --- /dev/null +++ b/pico-scroll/boot.py @@ -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 diff --git a/pico-scroll/code.py b/pico-scroll/code.py new file mode 100644 index 0000000..a82c1a6 --- /dev/null +++ b/pico-scroll/code.py @@ -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 diff --git a/pico-scroll/programs.json b/pico-scroll/programs.json new file mode 100644 index 0000000..2a81802 --- /dev/null +++ b/pico-scroll/programs.json @@ -0,0 +1,3 @@ +{ + "setlists": [] +}