metronome/docs/livesync-protocol.md
Me Here eae9057baf PM_E-1 beta: live-sync editor (editor-beta.html) mirroring a connected PM_K-1
New editor-beta.html: a bidirectional live mirror over the existing USB-MIDI
SysEx channel (0x7D). Either the website or the device can edit grooves, change
tempo/volume, start/stop, or select set-list items, and the other reflects it.

- src/livesync.js: LiveSync layer (opcodes 0x40 HELLO / 0x41 FULL / 0x42 DELTA /
  0x43 BYE) riding the existing _ensureMidi/_send/onDeviceMidi plumbing. Fine
  deltas for transport/bpm/vol/sel/beat, coalesced full-state for structural
  edits; echo suppression via origin + _applyingRemote guard; device-authoritative
  heartbeat reconciles drift. ?loopback=1 self-test mode (no hardware needed).
- editor-beta.html: copy of editor.html + "Live sync" toggle, SysEx routing,
  and broadcast hooks at each mutation choke point (guarded by _applyingRemote).
- docs/livesync-protocol.md: wire spec + firmware checklist for pico-cp/app.py
  (firmware half owned by the other instance — editor side + spec only here).
- build.sh / deploy.sh: add editor-beta.html to the build + version-stamp loops.

Editor side only; pico-cp/app.py untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:09:37 -05:00

195 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PM Live-Sync protocol (beta)
Bidirectional live mirror between the **PM_E1 editor** (web) and a **PM_K1 device**
(firmware). When armed, either side can edit a groove, change tempo/volume,
start/stop, or select a setlist item, and the other side reflects it in real
time.
It rides the **existing USBMIDI 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 0x000x7F> 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 7bit ASCII** — never emit a byte > `0x7F` (it corrupts the SysEx
stream and, per `build.sh`, would also break the firmwareupdate path). All
sharelanguage patch strings are already ASCII.
- `<origin>` — a short persession id (the editor uses e.g. `e1a2b3c`). Used to
drop your own echoes (see §4).
- `<seq>` — a monotonically increasing integer per sender. Informational /
duplicatedrop; ordering is guaranteed by USBMIDI so no reordering logic is
required.
- `<running>``0` or `1`.
- `<sl>` / `<item>` — setlist and item index of the loaded program, or `-1`.
- `<patch>` — a sharelanguage 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 sharelanguage 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, 0100 |
| `sel=<sl>/<item>` | cue/load a setlist item |
| `beat=<lane>/<step>/<level>` | perstep dynamics; level `0/1/2/3` = mute/normal/accent/ghost |
| `lane=<lane>/<field>/<value>`| lane field edit (see below) |
`<lane>` and `<step>` are **0based** 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 reshape the lane list (add lane, remove lane,
> reorder) are **not** sent as deltas. Send a fresh **`0x41` FULL** instead — it
> is simpler and selfhealing. The editor does exactly this (a coalesced
> fullstate 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 lanefield / 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 ondevice 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 setlist navigation
- `beat=<lane>/<step>/<level>` on a touch beat edit (`app.py` ~573625)
- a `0x41` FULL after any lane add/remove/reorder or multifield lane edit
- a periodic `0x41` FULL **heartbeat** (~every 35 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 roundtrip 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 rebroadcasts.** Wrap every apply in an
"applying remote" flag (the editor uses `_applyingRemote`) and have all of
your broadcast hooks earlyout while it is set. This is the primary guard.
2. **Drop your own origin.** On receive, if `origin == myOrigin`, ignore the
frame. (Beltandsuspenders; also lets the editor's `?loopback=1` selftest
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 singleuserfriendly (lastwriterwins per field). True simultaneous
multieditor 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:13611415`,
alongside `0x01/0x02/0x10/0x2123`). 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 remoteapply flag.
- [ ] **DELTA (`0x42`)** → apply `play/stop/bpm/vol/sel/beat/lane` to `App`
state, wrapped in the remoteapply flag so the ondevice handlers don't
rebroadcast.
- [ ] **Broadcast** a `0x42` from each ondevice input handler (button A,
joystick tempo, touch beat edit, setlist nav, lane editor), guarded by
the remoteapply flag. Structural lane changes → `0x41` FULL.
- [ ] **Heartbeat:** emit `0x41` FULL every ~35 s while a peer is connected.
- [ ] **BYE (`0x43`)** → mark the peer gone (stop heartbeating/emitting until
the next HELLO).
- [ ] **Throttle** highrate sources (joystick tempo) and keep frames small —
the RP2040 USBMIDI RX buffer is tiny (the firmware updater already chunks
at 64 bytes), and live traffic shares the bus with MIDI clock, noteout,
and the editor's ActiveSensing heartbeat. Don't let a flood stall a
concurrent firmware push.
### Builtin vs. user set lists (must match the editor)
The PM_K1's builtin playlists (Styles / Practice / Song) are **baked into
firmware and readonly**; ondevice edits **copyonwrite** into the user
"My edits" list. The editor follows the same rule (`userSetlists()` excludes the
seeded titles). So a **remote edit that targets a builtin must follow the same
copyonwrite 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.).
- Multipeer / multieditor arbitration beyond lastwriterwins.