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>
213 lines
11 KiB
Markdown
213 lines
11 KiB
Markdown
# 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 in `editor-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, per `build.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>` — `0` or `1`.
|
||
- `<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 **`0x41` FULL** 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 `0x42` deltas for `play`/`stop`, `bpm`, `vol`, `sel`, `beat`
|
||
- a coalesced `0x41` FULL for any lane‑field / add / remove / practice (trainer,
|
||
ramp, segment bars, countdown) edit
|
||
- `0x41` FULL on connect and in reply to a received `0x40`
|
||
|
||
**Device should emit** (from its on‑device input handlers):
|
||
- `play`/`stop` when button A toggles transport
|
||
- `bpm=<n>` when the joystick / tap changes tempo (throttle to ≤ ~10/s)
|
||
- `sel=<sl>/<item>` on set‑list navigation
|
||
- `beat=<lane>/<step>/<level>` on a touch beat edit (`app.py` ~573–625)
|
||
- a `0x41` FULL after any lane add/remove/reorder or multi‑field lane edit
|
||
- a periodic `0x41` FULL **heartbeat** (~every 3–5 s) — the device is the
|
||
convergence authority (see §4)
|
||
- `0x41` FULL in reply to a received `0x40`
|
||
|
||
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:
|
||
|
||
1. **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.
|
||
2. **Drop your own origin.** On receive, if `origin == myOrigin`, ignore the
|
||
frame. (Belt‑and‑suspenders; also lets the editor's `?loopback=1` self‑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/0x42` in the SysEx handler (~`app.py:1361‑1415`,
|
||
alongside `0x01/0x02/0x10/0x21‑23`). Ignore frames whose origin is your own.
|
||
- [ ] **HELLO (`0x40`)** → reply `0x41` FULL built from current `App` state
|
||
(running, sl/idx, serialized program).
|
||
- [ ] **FULL (`0x41`)** → diff vs. current program; if different, load it
|
||
(reuse `parse_program()` / the `programs.json` load path); then reconcile
|
||
`running` (start/stop). Wrap in your remote‑apply flag.
|
||
- [ ] **DELTA (`0x42`)** → apply `play/stop/bpm/vol/sel/beat/lane` to `App`
|
||
state, wrapped in the remote‑apply flag so the on‑device handlers don't
|
||
re‑broadcast.
|
||
- [ ] **Broadcast** a `0x42` from 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 → `0x41` FULL.
|
||
- [ ] **Heartbeat:** emit `0x41` FULL 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 |
|
||
| **PM_G‑1** Grid (17×7 LED matrix, 4 buttons, read‑only beats) | `play` / `stop` / `bpm` / `sel` only (no on‑device beat/lane editing) | all of the above |
|
||
|
||
Editors don't need to special‑case the source — both DELTA streams look identical on
|
||
the wire, and the **device id is only exposed on the version query** (SysEx `0x02`
|
||
→ `0x03` reply, `<id>;<version>`; pre‑0.0.23 firmware sends bare version → assume
|
||
`K`).
|