metronome/docs/livesync-protocol.md
Me Here cb54b4d689 Preserve notation + grammar feature work (verified complete + green)
The parallel agent's full session, committed now that it's solo:
- Grammar: flam/drag/roll ornaments (f/F d/D z/Z, per-lane orns channel) across
  src/engine.js, pico-cp/pico-explorer/pico-scroll app.py, pico/main.py, rust/track-format,
  + golden vectors / conformance (tests/, rust/track-format/tests).
- Live-sync deep-sync: SysEx 0x44 SLSYNC + 0x45 LOGSYNC (docs/livesync-protocol.md, src/livesync.js).
- PM_E-2 notation: web engine (pm_e-2.html, build/deploy/index/embed wiring) + Rust device port
  (pm-ui draw_notation rewrite + LaneView.groups, pm-kit ViewMode, uisim notesim).

Verified: node tests/run.mjs 47 pass / 1 known; ./rust/run.sh green; pm-kit firmware + uisim compile.
2026-06-02 13:45:26 -05:00

20 KiB
Raw Permalink Blame History

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>
0x44 SLSYNC either → either <origin>;<seq>;<json> — live set-list content merge (§8)
0x45 LOGSYNC either → either <origin>;<seq>;<json> — practice-log entry merge (§9)
  • 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
  • a coalesced 0x44 SLSYNC on any setlist content change (and on connect) — §8
  • a 0x45 LOGSYNC after each logged session (and a full batch on connect) — §9

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).
  • SLSYNC (0x44) → merge the JSON manifest of user lists by normalized title (replaceperlist, append unknown, never delete), reusing load_user_setlists()shaped parsing; rebuild_setlists() + reload. Emit one after _persist_user() and in reply to 0x40. (§8)
  • LOGSYNC (0x45) → merge practice entries by (at,name); append + cap + resort. Emit a oneentry batch after _log_play() and a full batch in reply to 0x40. Record at (epoch ms) on each play when the RTC is set. (§9)
  • 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

  • Mirroring device settings.json (LED brightness, MIDI config, etc.).
  • Multipeer / multieditor arbitration beyond lastwriterwins.

No longer out of scope (now specced in §8 / §9): live setlist content sync (0x44) and streaming the device practice log up to the browser and back (0x45). The old 0x10 programs push (Save/Load to device) still exists as the explicit, fulloverwrite path; 0x44 is the incremental, mergebytitle live mirror that runs automatically while sync is armed.


7. Perdevice emit/apply matrix

Both targets implement the full apply path for every verb. They differ in what they emit, because ondevice editing differs:

Device Emits Applies
PM_K1 Kit (touchscreen + joystick) play / stop / bpm / sel / beat / lane (FULL on structural lane edits) all of the above
PM_X1 Explorer (6 buttons, readonly beats) play / stop / bpm / sel only (no ondevice beat/lane editing) all of the above
PM_G1 Grid (17×7 LED matrix, 4 buttons, readonly beats) play / stop / bpm / sel only (no ondevice beat/lane editing) all of the above

Editors don't need to specialcase the source — both DELTA streams look identical on the wire, and the device id is only exposed on the version query (SysEx 0x020x03 reply, <id>;<version>; pre0.0.23 firmware sends bare version → assume K).


8. Setlist content sync (0x44 SLSYNC)

The 0x41 FULL only carries the one loaded program (<patch>) plus the selection indices. 0x44 carries setlist content — titles + every item's name + program string — so the two halves converge on the same library while sync is armed, without the user pressing "Save to device".

Frame: F0 7D 44 <origin>;<seq>;<json> F7

  • <origin> / <seq> — same as the other ops (echodrop + duplicate info).

  • <json> — a 7bitsafe JSON manifest of the sender's user set lists, in the exact same shape 0x10 already uses so the firmware can reuse its programs.json parser:

    {"setlists":[{"title":"My set list","programs":[{"name":"Funk","prog":"t120;..."}]}]}
    

    NonASCII in titles/names is escaped \uXXXX (the editor's existing programsJSON() 7bitsafe path); the firmware stores it verbatim. The whole manifest rides one SysEx frame (same as 0x10 — user libraries are a few KB and the RX assembler holds 60 000 bytes). It is never chunked; if it ever grew past the buffer, fall back to the explicit 0x10 push.

What's included

  • Only user set lists. Builtin / seeded lists (firmware BUILTIN_SETLISTS; editor SEED_SETLISTS titles) are readonly on both halves and never transmitted — both sides already have identical copies baked in. (Same filter as userSetlists() / load_user_setlists().)

Identity & merge rule

  • Set lists match by title (normalized: lowercase, alphanumerics only — the firmware's _slkey()), independent of index. Indices diverge freely between halves (the device prepends builtins; the web orders differently), so a positional match is wrong — title is the key.
  • Items match by name within a list (casesensitive, as both UIs key practice history by exact name).
  • Merge is a perlist replace: a received user list replaces the local user list of the same normalized title wholesale (its items become the received items, in the received order). Lists present locally but absent from the message are left untouched (additive — sync never deletes a list the peer simply didn't send). A received list with no matching local title is appended as a new user list.
  • This is lastwriterwins per list (consistent with the rest of the protocol). The receiver applies under its remoteapply guard and does not rebroadcast a 0x44 in response (no echo storm); the next heartbeat / FULL still reconciles the loaded program.

Copyonwrite for builtins

A 0x44 never targets a builtin: it only carries user lists, and the receiver only ever writes user lists. If a user edits a builtin item on either half, that edit must first be forked into a user list (the firmware's _save_edit already forks builtin edits into the "My edits" user list; the editor keeps a separate user list). The fork then rides 0x44 as an ordinary user list. So copyonwrite happens before the sync, and the wire only ever sees user content — the builtins on both sides stay pristine and identical.

When it's emitted

  • Editor: coalesced ~150 ms after any setlist structural edit (add/rename/ reorder list, add/remove/rename item, capture/update an item), and once on connect right after the first FULL. Reuses syncPatchSoon()style debouncing.
  • Device: after _persist_user() succeeds (a save that wrote programs.json), guarded by the remoteapply flag, and once in reply to a 0x40 HELLO. The device's peritem program edits still ride 0x41 FULL; 0x44 is specifically for library shape (which lists/items exist).

The device is the convergence authority for the loaded program (§4), but setlist content is lastwriterwins per list — there is no periodic 0x44 heartbeat (it would clobber concurrent edits on the other half). Send it only on an actual content change or on connect.


9. Practicelog sync (0x45 LOGSYNC)

Both halves keep a practice history (web: localStorage metronome.logs; device: /history.json). 0x45 streams entries between them and merges by a stable key, so a session played on the device shows up in the editor's history graph and viceversa.

Frame: F0 7D 45 <origin>;<seq>;<json> F7

<json> is a 7bitsafe JSON batch of normalized entries:

{"log":[{"at":1733059200000,"name":"Funk","dur":92,"bpm":120}]}
field type meaning
at int (ms) session start, Unix epoch milliseconds — the dedup key
name string setlist item name the session was logged against
dur int (sec) session duration in whole seconds
bpm int tempo at the end of the session

This is the intersection of the two native schemas (the web's {at,name,durationSec,bpm,lanes} and the device's {t,bpm,dur,bars,name}): atat, durround(durationSec) / dur, bpmbpm, namename. Fields each side keeps privately (web lanes; device t/bars) are not transmitted; the receiver fills them from what it has (t from at via the RTC, bars/lanes left absent).

Timestamps & the device clock

The dedup key is at (epoch ms). The editor already pushes the RTC over 0x01 on connect / heartbeat, so the device can produce a real epoch. The firmware therefore records an at (epoch seconds × 1000 → ms) on each logged play in addition to its existing t:"HH:MM" field, computed from time.time() when the RTC is set; if the RTC is unset (no editor ever connected) it falls back to at = 0, which the merge treats as "no stable key" (see dedup).

Direction & dedup/merge

  • Bidirectional. Each half emits its own local entries; each applies the peer's.
  • Dedup key = at + name. On receive, an entry whose (at,name) already exists locally is dropped. Entries with at == 0 (device logged before any RTC sync) are always appended (can't be deduped — better a possible duplicate than a dropped session); these are rare and the user can delete them.
  • Merge is additive only0x45 never deletes history. (Deleting a stale entry on one half does not propagate; out of scope, matches the lastwriterwins philosophy and avoids a delete echoing into a readd.)
  • The receiver caps its merged log (web keeps all; device keeps newest 200, its existing cap) and resorts newestfirst by at.

When it's emitted

  • Editor: after logFinalize() writes a new session (oneentry batch), and a full batch once on connect (after the first FULL) so the device catches up on everything it missed. Guarded so an applied remote entry doesn't rebroadcast.
  • Device: after _log_play() appends a session (oneentry batch), guarded by the remoteapply flag, and a full batch once in reply to a 0x40 HELLO.

Batch size caution (firmware): a fullhistory batch (up to 200 entries) is small JSON but still allocates; the device sends its on connect/HELLO only, and the editor's onconnect batch is bounded by the SysEx buffer (60 KB ≈ a few thousand minimal entries). If a log ever exceeded that, the editor truncates to the newest entries that fit. Persession emits are a single entry — negligible.