Adds pico-explorer/ as a parallel CircuitPython firmware target alongside the 52Pi
Kit in pico-cp/. Same engine, same program-string grammar, same programs.json, same
live-sync protocol. Read-only on the device (no on-device beat editing); the web
editor's Live sync mirrors all edits in real time and the Explorer emits its own
play/stop/bpm/sel deltas back.
Hardware (Pimoroni Explorer PIM744):
- RP2350B + 2.8" ST7789V 320x240 LCD (8-bit parallel; CircuitPython's official
board definition pre-builds the BusDisplay so we just use board.DISPLAY).
- 6 user buttons - A/B/C on the left of the screen, X/Y/Z on the right.
- Piezo speaker on GP12 (PWM) with amp enable on GP13.
- I2C QwSTEMMA on GP20/21 - reserved, unused by the firmware.
- No touchscreen, no joystick, no RGB LED. Run state shows on a tiny on-screen dot.
Buttons:
- A = play/stop. B = tap tempo. C = menu.
- X = prev track (hold-repeat). Z = next track (hold-repeat).
- Y = tempo -1 (hold-repeat; -5 after 1.5s).
- X+Z chord = tempo +1 (mirrors Y).
- In a menu: X/Z move the row cursor, Y decrements, A cycles/increments/selects,
B = back, C = close.
Files added:
- pico-explorer/{boot.py, code.py, app.py, programs.json, README.md}.
app.py = 1444 lines (~73KB source -> 29.8KB compiled .mpy).
- info-explorer.html.
Files touched:
- pico-cp/app.py: bump to 0.0.23. Version-query (SysEx 0x02 -> 0x03) reply now
includes the device id as "K;<version>" (backward-compat: editor parses
"contains ';'?" - old firmware sent bare version, treated as K).
- editor.html + editor-beta.html: _parseDeviceReply() splits id;version, FW_PATHS
maps id to .py/.mpy URL pair, so Update firmware now pushes the right binary.
- build.sh + deploy.sh: precompile pico-explorer/app.py -> dist/explorer-app.mpy,
zip pm_x1_circuitpy.zip alongside pm_k1_circuitpy.zip, ship
pico-explorer-app.{py,mpy} next to pico-cp-app.{py,mpy}.
- docs/livesync-protocol.md: new section 7 - per-device emit/apply matrix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
PM Live-Sync protocol (beta)
Bidirectional live mirror between the PM_E‑1 editor (web) and a PM_K‑1 device (firmware). When armed, either side can edit a groove, change tempo/volume, start/stop, or select a set‑list item, and the other side reflects it in real time.
It rides the existing USB‑MIDI SysEx channel (manufacturer 0x7D) that the
device link already uses for RTC / version / programs / firmware — no new
transport, no new browser permission.
- Editor side: implemented in
src/livesync.js+ hooks ineditor-beta.html. - Device side: to be implemented in
pico-cp/app.py(this document is the contract). - Browser support: Web MIDI = Chrome / Edge / Firefox (no Safari), same as the existing "Device audio" feature.
1. Frames
Every message is one SysEx frame:
F0 7D <op> <payload ASCII bytes, each 0x00–0x7F> F7
<op> lives in the free 0x40 block (existing ops: 0x01 RTC, 0x02/0x03
version, 0x10 programs, 0x21/22/23 firmware, 0x7E/0x7F NAK/ACK):
| op | name | direction | payload |
|---|---|---|---|
| 0x40 | HELLO | either → either | <origin> |
| 0x41 | FULL | either → either | <origin>;<seq>;<running>;<sl>;<item>;<patch> |
| 0x42 | DELTA | either → either | <origin>;<seq>;<evt> |
| 0x43 | BYE | either → either | <origin> |
- Payload is 7‑bit ASCII — never emit a byte >
0x7F(it corrupts the SysEx stream and, perbuild.sh, would also break the firmware‑update path). All share‑language patch strings are already ASCII. <origin>— a short per‑session id (the editor uses e.g.e1a2b3c). Used to drop your own echoes (see §4).<seq>— a monotonically increasing integer per sender. Informational / duplicate‑drop; ordering is guaranteed by USB‑MIDI so no reordering logic is required.<running>—0or1.<sl>/<item>— set‑list and item index of the loaded program, or-1.<patch>— a share‑language patch string (see §3). It contains;and/, so it is always the tail: parse the first 5;‑fields, then rejoin the rest as the patch.
2. DELTA event grammar (<evt>)
One mutation, no ; inside. Reuses the share‑language tokens (see
src/engine.js / README "Share language").
| evt | meaning |
|---|---|
play |
start transport |
stop |
stop transport |
bpm=<n> |
set tempo (clamped to the firmware's BPM range) |
vol=<pct> |
master volume, 0–100 |
sel=<sl>/<item> |
cue/load a set‑list item |
beat=<lane>/<step>/<level> |
per‑step dynamics; level 0/1/2/3 = mute/normal/accent/ghost |
lane=<lane>/<field>/<value> |
lane field edit (see below) |
<lane> and <step> are 0‑based indices into the current program's lane
list / that lane's step list (same order both sides).
lane= fields and values:
| field | value |
|---|---|
sound |
voice name (kick, snare, hatClosed, …) |
groups |
grouping string, e.g. 2+2+3 |
sub |
subdivision int: 1 / 2 / 3 / 4 / 6 |
swing |
0 or 1 |
gain |
dB int, e.g. -3 |
poly |
0 or 1 |
enabled |
0 or 1 (0 = silenced lane) |
Structural changes that re‑shape the lane list (add lane, remove lane, reorder) are not sent as deltas. Send a fresh
0x41FULL instead — it is simpler and self‑healing. The editor does exactly this (a coalesced full‑state push ~150 ms after the last structural/practice edit).
3. What each side emits vs. applies
The two halves are asymmetric in what they emit but symmetric in what they apply — each must apply every op/evt listed above.
Editor emits:
- fine
0x42deltas forplay/stop,bpm,vol,sel,beat - a coalesced
0x41FULL for any lane‑field / add / remove / practice (trainer, ramp, segment bars, countdown) edit 0x41FULL on connect and in reply to a received0x40
Device should emit (from its on‑device input handlers):
play/stopwhen button A toggles transportbpm=<n>when the joystick / tap changes tempo (throttle to ≤ ~10/s)sel=<sl>/<item>on set‑list navigationbeat=<lane>/<step>/<level>on a touch beat edit (app.py~573–625)- a
0x41FULL after any lane add/remove/reorder or multi‑field lane edit - a periodic
0x41FULL heartbeat (~every 3–5 s) — the device is the convergence authority (see §4) 0x41FULL in reply to a received0x40
The patch in a 0x41 is produced by the device's existing program serializer
(the inverse of parse_program() in app.py). It must round‑trip through the
editor's patchToSetup() — i.e. the same grammar already used for
programs.json prog strings, plus a leading t<bpm> and optional vol<pct>.
4. Echo / loop suppression and conflict policy
Two rules keep the mirror from oscillating:
- Applying a remote change never re‑broadcasts. Wrap every apply in an
"applying remote" flag (the editor uses
_applyingRemote) and have all of your broadcast hooks early‑out while it is set. This is the primary guard. - Drop your own origin. On receive, if
origin == myOrigin, ignore the frame. (Belt‑and‑suspenders; also lets the editor's?loopback=1self‑test work by relabeling echoes as a peer.)
Convergence: the device is authoritative. Its periodic 0x41 heartbeat
is treated as ground truth, so if both sides edited the same field in the same
instant, they reconcile within one heartbeat. To avoid flicker, a receiver
should diff the incoming patch against its current state and skip the
rebuild if they're equal (the editor does this in _applyFull), only
reconciling transport.
This is single‑user‑friendly (last‑writer‑wins per field). True simultaneous multi‑editor use is out of scope for the beta.
5. Handshake & lifecycle
editor "Live sync" ON ─► 0x40 HELLO ─────────────► device
◄──────────── 0x41 FULL ◄── (device's current state)
editor 0x41 FULL ──────────────────────────────► (editor's current state)
… steady state: 0x42 deltas both ways, device 0x41 heartbeat …
editor "Live sync" OFF ─► 0x43 BYE ────────────► device
On connect the editor sends both a 0x40 (asking for the device's state)
and a 0x41 (offering its own), so whichever side the user considers "source of
truth" wins immediately. A device that boots with sync idle should simply answer
0x40 with a 0x41 and start emitting deltas once it has heard from a peer.
6. Firmware checklist (pico-cp/app.py)
- Dispatch
0x40/0x41/0x42in the SysEx handler (~app.py:1361‑1415, alongside0x01/0x02/0x10/0x21‑23). Ignore frames whose origin is your own. - HELLO (
0x40) → reply0x41FULL built from currentAppstate (running, sl/idx, serialized program). - FULL (
0x41) → diff vs. current program; if different, load it (reuseparse_program()/ theprograms.jsonload path); then reconcilerunning(start/stop). Wrap in your remote‑apply flag. - DELTA (
0x42) → applyplay/stop/bpm/vol/sel/beat/lanetoAppstate, wrapped in the remote‑apply flag so the on‑device handlers don't re‑broadcast. - Broadcast a
0x42from each on‑device input handler (button A, joystick tempo, touch beat edit, set‑list nav, lane editor), guarded by the remote‑apply flag. Structural lane changes →0x41FULL. - Heartbeat: emit
0x41FULL every ~3–5 s while a peer is connected. - BYE (
0x43) → mark the peer gone (stop heartbeating/emitting until the next HELLO). - Throttle high‑rate sources (joystick tempo) and keep frames small — the RP2040 USB‑MIDI RX buffer is tiny (the firmware updater already chunks at 64 bytes), and live traffic shares the bus with MIDI clock, note‑out, and the editor's Active‑Sensing heartbeat. Don't let a flood stall a concurrent firmware push.
Built‑in vs. user set lists (must match the editor)
The PM_K‑1's built‑in playlists (Styles / Practice / Song) are baked into
firmware and read‑only; on‑device edits copy‑on‑write into the user
"My edits" list. The editor follows the same rule (userSetlists() excludes the
seeded titles). So a remote edit that targets a built‑in must follow the same
copy‑on‑write semantics on the receiving side, or the two halves will disagree
about where the edit landed. When in doubt, after such an edit send a 0x41
FULL with the resulting (copied) program so both sides converge on the same
target.
Out of scope for the beta
- Streaming the device practice log (
history.json) up to the browser. - Mirroring device
settings.json(LED brightness, MIDI config, etc.). - Multi‑peer / multi‑editor arbitration beyond last‑writer‑wins.
7. Per‑device emit/apply matrix
Both targets implement the full apply path for every verb. They differ in what they emit, because on‑device editing differs:
| Device | Emits | Applies |
|---|---|---|
| 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 |
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
→ 0x03 reply, <id>;<version>; pre‑0.0.23 firmware sends bare version → assume
K).