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:
parent
c1601d9e46
commit
400d896518
17 changed files with 1704 additions and 7 deletions
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal 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.
|
||||||
20
build.sh
20
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.
|
# bundles each ship their own precompiled binary; the served URLs follow the same one-target-per-file rule.
|
||||||
( cd pico-explorer && "$MPYC" app.py -o "$ROOT/dist/explorer-app.mpy" )
|
( cd pico-explorer && "$MPYC" app.py -o "$ROOT/dist/explorer-app.mpy" )
|
||||||
echo "precompiled dist/explorer-app.mpy ($(stat -c%s dist/explorer-app.mpy) bytes <- $(stat -c%s pico-explorer/app.py) source)"
|
echo "precompiled dist/explorer-app.mpy ($(stat -c%s dist/explorer-app.mpy) bytes <- $(stat -c%s pico-explorer/app.py) source)"
|
||||||
|
# PM_G-1 "Grid" firmware (Pimoroni Pico Scroll Pack on a plain RP2040 Pico). Same mpy-cross (CircuitPython 10.2.1).
|
||||||
|
( 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'
|
python3 - <<'PY'
|
||||||
import os, pathlib, re
|
import os, pathlib, re
|
||||||
|
|
@ -42,9 +45,9 @@ def build(name):
|
||||||
out.write_text(src)
|
out.write_text(src)
|
||||||
return out.stat().st_size
|
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",
|
"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))
|
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
|
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
|
||||||
print("copied embed.js")
|
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.py").write_text(_xsrc) # editor reads APP_VERSION from here
|
||||||
pathlib.Path("dist/pico-explorer-app.mpy").write_bytes(pathlib.Path("dist/explorer-app.mpy").read_bytes())
|
pathlib.Path("dist/pico-explorer-app.mpy").write_bytes(pathlib.Path("dist/explorer-app.mpy").read_bytes())
|
||||||
print("copied pico-explorer-app.py + pico-explorer-app.mpy")
|
print("copied pico-explorer-app.py + pico-explorer-app.mpy")
|
||||||
|
_gsrc = pathlib.Path("pico-scroll/app.py").read_text() # PM_G-1 Grid firmware (Pico Scroll Pack)
|
||||||
|
_gbad = [(i, c) for i, c in enumerate(_gsrc) if ord(c) > 0x7F]
|
||||||
|
assert not _gbad, "pico-scroll/app.py has non-ASCII at %r -- keep it ASCII (version regex + clean source)" % (_gbad[:5],)
|
||||||
|
pathlib.Path("dist/pico-scroll-app.py").write_text(_gsrc) # editor reads APP_VERSION from here
|
||||||
|
pathlib.Path("dist/pico-scroll-app.mpy").write_bytes(pathlib.Path("dist/scroll-app.mpy").read_bytes())
|
||||||
|
print("copied pico-scroll-app.py + pico-scroll-app.mpy")
|
||||||
import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY)
|
import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY)
|
||||||
with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin",
|
for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin",
|
||||||
|
|
@ -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/explorer-app.mpy", "app.mpy")
|
||||||
z.write("dist/editor.html", "editor.html")
|
z.write("dist/editor.html", "editor.html")
|
||||||
print("zipped pm_x1_circuitpy.zip")
|
print("zipped pm_x1_circuitpy.zip")
|
||||||
|
# PM_G-1 Grid drive bundle (Pico Scroll Pack on a plain Pico). No font/icon blobs - the LED firmware draws directly.
|
||||||
|
with zipfile.ZipFile("dist/pm_g1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for f in ("code.py", "boot.py", "programs.json", "README.md"):
|
||||||
|
z.write("pico-scroll/" + f, f)
|
||||||
|
z.write("dist/scroll-app.mpy", "app.mpy")
|
||||||
|
z.write("dist/editor.html", "editor.html")
|
||||||
|
print("zipped pm_g1_circuitpy.zip")
|
||||||
PY
|
PY
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ fi
|
||||||
|
|
||||||
# stamp the version into the built copy only (source stays clean)
|
# stamp the version into the built copy only (source stays clean)
|
||||||
echo "deployed v$BUILD -> $DEST_DIR"
|
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 \
|
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"
|
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
|
||||||
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
||||||
done
|
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/pm_x1_circuitpy.zip" "$DEST_DIR/pm_x1_circuitpy.zip"; echo " pm_x1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_x1_circuitpy.zip") bytes)" # PM_X-1 Explorer CircuitPython bundle
|
||||||
cp "$DIST_DIR/pico-explorer-app.py" "$DEST_DIR/pico-explorer-app.py"; echo " pico-explorer-app.py ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.py") bytes)" # served for version reading
|
cp "$DIST_DIR/pico-explorer-app.py" "$DEST_DIR/pico-explorer-app.py"; echo " pico-explorer-app.py ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.py") bytes)" # served for version reading
|
||||||
cp "$DIST_DIR/pico-explorer-app.mpy" "$DEST_DIR/pico-explorer-app.mpy"; echo " pico-explorer-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.mpy") bytes)" # PM_X-1 firmware (the editor pushes this when device id = X)
|
cp "$DIST_DIR/pico-explorer-app.mpy" "$DEST_DIR/pico-explorer-app.mpy"; echo " pico-explorer-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.mpy") bytes)" # PM_X-1 firmware (the editor pushes this when device id = X)
|
||||||
|
cp "$DIST_DIR/pm_g1_circuitpy.zip" "$DEST_DIR/pm_g1_circuitpy.zip"; echo " pm_g1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_g1_circuitpy.zip") bytes)" # PM_G-1 Grid CircuitPython bundle
|
||||||
|
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/player-asbuilt.html" # renamed to teacher.html
|
||||||
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
|
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
|
||||||
# info-*.html are first-class pages again: each form factor has a lean widget page
|
# info-*.html are first-class pages again: each form factor has a lean widget page
|
||||||
|
|
|
||||||
|
|
@ -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_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_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
|
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`
|
the wire, and the **device id is only exposed on the version query** (SysEx `0x02`
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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
|
## Stages
|
||||||
|
|
||||||
### Stage 0 — toolchain in a container
|
### Stage 0 — toolchain in a container
|
||||||
|
|
|
||||||
|
|
@ -1257,7 +1257,8 @@ function _parseDeviceReply(s) {
|
||||||
return i >= 0 ? { id: s.slice(0, i), version: s.slice(i + 1) } : { id: "K", version: 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" },
|
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
|
async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy
|
||||||
console.log("[fw] update start");
|
console.log("[fw] update start");
|
||||||
if (!(await _ensureMidi()) || !_midiOutputs().length) {
|
if (!(await _ensureMidi()) || !_midiOutputs().length) {
|
||||||
|
|
|
||||||
|
|
@ -1258,7 +1258,8 @@ function _parseDeviceReply(s) {
|
||||||
return i >= 0 ? { id: s.slice(0, i), version: s.slice(i + 1) } : { id: "K", version: 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" },
|
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
|
async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy
|
||||||
console.log("[fw] update start");
|
console.log("[fw] update start");
|
||||||
if (!(await _ensureMidi()) || !_midiOutputs().length) {
|
if (!(await _ensureMidi()) || !_midiOutputs().length) {
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ const FF = [
|
||||||
{ k:"stage", name:"PM_S‑1 Stage", file:"stage.html", h:430 },
|
{ 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:"micro", name:"PM_P‑1 Practice", file:"micro.html", h:240 },
|
||||||
{ k:"showcase", name:"PM_D‑1 Display", file:"showcase.html",h:540 },
|
{ 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 },
|
{ k:"initial", name:"PM_C‑1 Concept", file:"player.html", h:440 },
|
||||||
];
|
];
|
||||||
function updateFF(k){
|
function updateFF(k){
|
||||||
|
|
|
||||||
2
embed.js
2
embed.js
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
var PAGES = { editor: "editor.html", kit: "kit.html", initial: "player.html", teacher: "teacher.html",
|
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 me = document.currentScript;
|
||||||
var ORIGIN = me ? me.src.replace(/\/embed\.js(\?.*)?$/, "") : location.origin;
|
var ORIGIN = me ? me.src.replace(/\/embed\.js(\?.*)?$/, "") : location.origin;
|
||||||
|
|
||||||
|
|
|
||||||
326
grid.html
Normal file
326
grid.html
Normal 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_G‑1 Grid</h1>
|
||||||
|
<p class="ff-sum">Off‑the‑shelf — 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_G‑1 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 & 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>
|
||||||
|
|
@ -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:"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:"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:"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:"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:"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." },
|
{ 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." },
|
||||||
|
|
|
||||||
174
info-grid.html
Normal file
174
info-grid.html
Normal 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 & 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×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×7 matrix</b> driven by an
|
||||||
|
<b>IS31FL3731</b> over I²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 × 25 × 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 × 17-column
|
||||||
|
matrix maps directly onto the editor's <b>lane × 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>🎹 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×7 white, IS31FL3731 (I²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 & 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 → Pendulum → 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×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 > 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×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×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">≈ $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>
|
||||||
|
· <a href="https://github.com/pimoroni/pimoroni-pico/tree/main/libraries/pico_scroll" target="_blank" rel="noopener">vendor code (pico_scroll)</a>
|
||||||
|
· <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 · web-driven editing via Live sync · MIDI audio · 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×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 ↓</a>
|
||||||
|
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-scroll" target="_blank" rel="noopener">Source + README ↗</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>🔗 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>···</b> menu →
|
||||||
|
<b>📟 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>🎹 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> ··· menu → <b>⬆ 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
67
pico-scroll/README.md
Normal 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
953
pico-scroll/app.py
Normal 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
22
pico-scroll/boot.py
Normal 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
24
pico-scroll/code.py
Normal 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
|
||||||
3
pico-scroll/programs.json
Normal file
3
pico-scroll/programs.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"setlists": []
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue