Compare commits

...

2 commits

Author SHA1 Message Date
Me Here
47fa6d7ce7 PM_K-1 0.0.21: live-sync protocol (HELLO/FULL/DELTA/BYE) - device side
Implements the device half of the docs/livesync-protocol.md contract that the
other (editor) Claude wrote in src/livesync.js. SysEx opcodes 0x40..0x43 on the
existing 0x7D manufacturer id; ASCII payload reusing the share-grammar tokens.

State + helpers (App.__init__):
- self._sync_origin: short random id ("d" + 8-hex from os.urandom(4)) - lets
  peers drop their own echoes (composite USB MIDI may loop a frame back).
- self._sync_seq: monotonic counter.
- self._sync_armed: True after any HELLO/FULL/DELTA seen (until BYE).
- self._sync_applying: echo guard - True while applying a remote change so
  the broadcast hooks early-out and we never ping-pong.
- self._sync_heartbeat_next: deadline for the periodic 5s FULL.

Transport:
- _sync_send(op, text): builds the SysEx frame, ASCII-safe (replaces high bits
  with '?'), writes via self.midi.write.
- _sync_broadcast(evt): one DELTA, guarded.
- _sync_broadcast_full(): one FULL with origin;seq;running;sl;item;patch; also
  resets the heartbeat timer.

Receive (extends _handle_sysex):
- 0x40 HELLO -> _sync_broadcast_full (reply with our state).
- 0x41 FULL  -> _sync_apply_full(running, patch): diff against current
  _prog_str(); only rebuild if different (avoids flicker on a heartbeat that
  matches local state); reconcile transport either way.
- 0x42 DELTA -> _sync_apply_delta(evt) for the seven verbs:
  - play / stop -> toggle if state differs.
  - bpm=<n>      -> set_bpm.
  - sel=<sl>/<item> -> switch + goto; -1/-1 sentinel ignored.
  - beat=<l>/<s>/<lvl> -> set lanes[l]['levels'][s], recolor pad, mark dirty.
  - lane=<l>/<field>/<value> -> sound/groups/sub/swing/gain/poly/enabled; if
    structural, _regen_levels + _rebuild_dur (or _rebuild_dur_all when lane 0
    changed, so poly lanes follow) + build_grid.
- 0x43 BYE -> _sync_armed = False.

Local mutators broadcast:
- toggle()         -> "play" | "stop"
- set_bpm()        -> "bpm=<n>"
- goto() / switch_setlist() -> "sel=<sl>/<item>"
- _cycle_beat()    -> "beat=<l>/<s>/<lvl>"
- _lane_dirty()    -> FULL (coalesced; matches editor's syncPatchSoon idiom)

Heartbeat in the run loop emits a FULL every 5s while armed.

Plus docs/livesync-protocol.md (the spec the other Claude wrote in parallel)
gets committed too. The patch field round-trips through parse_program <->
_prog_str on the device side and setupToPatch <-> patchToSetup on the editor
side, all already share-grammar compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 09:59:19 -05:00
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
6 changed files with 2147 additions and 3 deletions

View file

@ -38,7 +38,7 @@ def build(name):
out.write_text(src)
return out.stat().st_size
for name in ("index.html","editor.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
"embed.html",
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html"):
print("built %s (%dKB)" % (name, build(name) // 1024))

View file

@ -40,7 +40,7 @@ fi
# stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html player.html teacher.html stage.html micro.html showcase.html kit.html \
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html \
embed.html \
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"

195
docs/livesync-protocol.md Normal file
View file

@ -0,0 +1,195 @@
# 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.

1624
editor-beta.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.20" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.21" # firmware version (the A/B updater pushes/compares this)
try:
import rtc # set from the editor's clock SysEx so the log has real timestamps
except ImportError:
@ -461,6 +461,12 @@ class App:
self._clock_byte = bytes([0xF8]) # singleton MIDI Clock tick (24 PPQN)
self._start_byte = bytes([0xFA]); self._stop_byte = bytes([0xFC])
self._lastRefresh = 0.0 # for the "force refresh after Xms even if a beat is imminent" guard
try: # live sync: short random id so peers can drop their own echoes
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 # next periodic FULL broadcast deadline (when armed)
self._displayed_bpm = -1; self._clock_next = 0 # lazy BPM redraw + MIDI Clock Out tick scheduler
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False # MIDI Clock In: smoothed tracker + slave flag
self.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json)
@ -546,12 +552,14 @@ class App:
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 # the editor sends sel=... directly; don't ping-pong
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.led_rest(); self.draw_meters()
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)
@ -589,6 +597,7 @@ class App:
base = self._padbase(L, s); lit = (self.lane_lit[li] == s)
self.lane_pads[li][s].color_index = base + 4 if lit else base
self._set_dirty()
self._sync_broadcast("beat=%d/%d/%d" % (li, s, L['levels'][s]))
def _set_dirty(self):
if not self._dirty: self._dirty = True; self.draw_status()
self.dirty = True
@ -726,6 +735,8 @@ class App:
if structural and self._edit_li == 0: self._rebuild_dur_all() # master changed -> polymeter lanes follow
else: self._rebuild_dur(self.lanes[self._edit_li])
self.build_grid()
if not self._sync_applying: # coalesce structural / multi-field lane edits into one FULL
self._sync_broadcast_full()
if not self._dirty: self._dirty = True; self.draw_status()
self._draw_laneedit() # refresh the modal with the new values
def _edit_sound(self, d):
@ -994,6 +1005,119 @@ class App:
def led_rest(self): # settle to the resting colour (green idle / red running)
self.rgb = self._led_base()
self.led.set(*self.rgb)
# ---------- Live sync (HELLO/FULL/DELTA/BYE on SysEx 0x40-0x43; see src/livesync.js for the editor side) ----------
def _sync_send(self, op, text):
if self.midi is None: return
b = bytearray((0xF0, 0x7D, op))
for c in text: # ASCII-only payload (the share grammar uses ; / = digits letters)
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): # one DELTA event; suppressed while applying a remote change (echo guard)
if not self._sync_armed or self._sync_applying or self.midi is None: 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): # FULL snapshot: running + sl + item + patch (coalesces structural edits)
if not self._sync_armed or self.midi is None: 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 # next periodic heartbeat
def _sync_apply_full(self, running, patch): # accept the peer's snapshot as ground truth
self._sync_applying = True
try:
try:
gc.collect()
# Diff before rebuilding -> avoid grid flicker / lost focus on a heartbeat that matches local state
try: cur = self._prog_str()
except Exception: cur = None
if patch and patch != cur:
bpm, lanes, bars, ramp, trainer = parse_program(patch)
self.bpm = bpm; self.lanes = lanes; self.bars = bars; self.ramp = ramp; self.trainer = trainer
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._dirty = False; self._overlay = None
while len(self.g_overlay): self.g_overlay.pop()
self._reset_clock()
self.draw_bpm(); self.draw_status(); self.draw_train(); self.draw_meters()
self.build_grid(); self.draw_log()
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): # one mutation
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': # sel=-1/-1 = "no selection" sentinel -> ignore
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': # beat=lane/step/level (0=mute 1=normal 2=accent 3=ghost)
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
if li < len(self.lane_pads) and s < len(self.lane_pads[li]):
lit = (self.lane_lit[li] == s)
self.lane_pads[li][s].color_index = self._padbase(L, s) + (4 if lit else 0)
self._set_dirty()
except Exception: pass
elif key == 'lane': # lane=lane/field/value (field: sound|groups|sub|swing|gain|poly|enabled)
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 # swing changes the dur grid
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() # master changed -> poly lanes follow
else: self._rebuild_dur(L)
if structural: self.build_grid()
self._set_dirty()
except Exception: pass
finally:
self._sync_applying = False
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
if self.midi is None: return
b = self._note_buf # reused bytearray -> zero alloc per click (hot path)
@ -1017,18 +1141,21 @@ class App:
try: self.midi.write(self._stop_byte) # Stop (reused singleton)
except Exception: pass
self.led_rest(); self.draw_meters() # LED shows run state: red running / green stopped
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() # step grids follow the new beat duration
# Don't draw here -- the 4Hz UI tick redraws bpm/meters; calling per joystick nudge allocated text bitmaps fast enough to trigger GC pauses
self._sync_broadcast("bpm=%d" % v)
def goto(self, i):
was = self.running
if was: self.running = False; self._log_play() # close out the track that was playing
self.load(i)
if was: self.running = True; self._reset_clock(); self._start_play()
self.led_rest(); self.draw_meters()
self._sync_broadcast("sel=%d/%d" % (self.sl, self.idx))
def tap(self):
now = time.monotonic()
if not hasattr(self, '_taps'): self._taps = []
@ -1426,6 +1553,26 @@ class App:
except Exception: pass
elif cmd == 0x02: # version query -> reply 0x03 + APP_VERSION
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x03]) + APP_VERSION.encode() + bytes([0xF7]))
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43: # Live sync (see src/livesync.js)
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 # drop our own echoes (composite USB may loop)
self._sync_armed = True
if cmd == 0x40: # HELLO -> reply with our current FULL
self._sync_broadcast_full()
elif cmd == 0x43: # BYE -> peer disconnected; stop heartbeats
self._sync_armed = False
elif cmd == 0x41: # FULL: origin;seq;running;sl;item;patch...
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: # DELTA: origin;seq;evt
parts = text.split(";", 2)
if len(parts) >= 3: self._sync_apply_delta(parts[2])
elif cmd == 0x10: # write /programs.json (user playlists) pushed from the editor
try:
with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))
@ -1497,6 +1644,8 @@ class App:
tnow = time.monotonic()
if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter
self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm() # bpm follows the continuous ramp
if self._sync_armed and tnow >= self._sync_heartbeat_next:
self._sync_broadcast_full() # periodic FULL: device is the convergence authority
if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update
try: os.remove("/trial")
except Exception: pass

176
src/livesync.js Normal file
View file

@ -0,0 +1,176 @@
// =========================================================================
// Live sync (PM_E-1 beta) — bidirectional mirror with a connected PM_K-1.
//
// Rides the SAME Web-MIDI SysEx channel as the rest of the device link
// (manufacturer 0x7D); see editor's _ensureMidi / _send / onDeviceMidi.
// Opcodes (free 0x40 block, alongside 0x01 RTC / 0x02-03 version /
// 0x10 programs / 0x21-23 firmware / 0x7E-7F NAK-ACK):
//
// 0x40 HELLO -> "begin mirroring; reply with your full state" payload: <origin>
// 0x41 FULL -> full snapshot (resync / heartbeat / on connect) payload: <origin>;<seq>;<running>;<sl>;<item>;<patch>
// 0x42 DELTA -> one mutation event payload: <origin>;<seq>;<evt>
// 0x43 BYE -> mirroring off payload: <origin>
//
// DELTA <evt> grammar (reuses the share-language tokens — see engine.js):
// play | stop | bpm=<n> | vol=<pct> | sel=<sl>/<item>
// beat=<lane>/<step>/<level> level 0/1/2/3 = mute/normal/accent/ghost
// lane=<lane>/<field>/<value> field: sound|groups|sub|swing|gain|poly|enabled
//
// Echo/loop guard: applying a remote change is wrapped in _applyingRemote so
// it never re-broadcasts, and any message whose origin == our own is dropped.
// The DEVICE is the convergence authority — its periodic 0x41 is ground truth,
// so simultaneous edits reconcile within one heartbeat. The editor emits fine
// deltas for transport/tempo/volume/selection/beat-taps and a coalesced 0x41
// for structural lane/practice edits; it APPLIES every delta type either way.
// Full protocol + firmware checklist: docs/livesync-protocol.md.
// =========================================================================
var _applyingRemote = false; // true while applying a remote change (suppresses re-broadcast)
var _syncOn = false; // mirroring armed
var _loopback = /[?&]loopback=1/.test(location.search); // no-hardware self-echo test mode
var LiveSync = {
origin: "e" + Math.floor(Math.random() * 1e9).toString(36), // this editor's id (drop our own echoes)
seq: 0,
peerSeq: 0, // loopback only: stand-in counter for the simulated peer
peerOrigin: null, // last origin we heard from (for the status line)
connected: false, // a peer has answered (HELLO/FULL/DELTA seen)
async connect() {
var hasMidi = await _ensureMidi();
if (!hasMidi && !_loopback) { alert("Live sync needs the Web MIDI API — use Chrome, Edge, or Firefox.\nConnect the PM_K-1 (CircuitPython firmware) and try again."); return false; }
_syncOn = true;
this.send(0x40, ""); // HELLO — ask the device for its full state
this.broadcastFull(); // and push ours so the device mirrors us immediately
if (_loopback) { this.connected = true; this.peerOrigin = "loopback"; }
updateSyncBtn();
return true;
},
disconnect() {
if (_syncOn) this.send(0x43, ""); // BYE
_syncOn = false; this.connected = false; this.peerOrigin = null;
updateSyncBtn();
},
// ---- transmit ----------------------------------------------------------
send(op, evt) {
var text = (op === 0x40 || op === 0x43) ? this.origin
: this.origin + ";" + (this.seq++) + ";" + evt;
var bytes = [0xF0, 0x7D, op];
for (var i = 0; i < text.length; i++) { var c = text.charCodeAt(i); bytes.push(c > 0x7F ? 0x3F : c); }
bytes.push(0xF7);
try { _send(bytes); } catch (e) {}
if (_loopback) { // simulate a perfect mirror: echo back as a "peer"
var self = this, peer = (op === 0x40 || op === 0x43) ? "peer" : "peer;" + (this.peerSeq++) + ";" + evt;
setTimeout(function () { self.applyRemote(op, peer); }, 0);
}
},
broadcast(evt) { if (_syncOn && !_applyingRemote) this.send(0x42, evt); },
broadcastFull() {
if (!_syncOn) return;
var patch; try { patch = currentPatch(); } catch (e) { return; }
this.send(0x41, (state.running ? 1 : 0) + ";" + loadedSL + ";" + activeItem + ";" + patch);
},
// ---- receive -----------------------------------------------------------
applyRemote(op, text) {
var origin = text.split(";", 1)[0];
if (origin === this.origin) return; // our own echo — ignore
this.peerOrigin = origin; this.connected = true;
if (op === 0x40) { this.broadcastFull(); updateSyncBtn(); return; } // peer said HELLO -> send our state
if (op === 0x43) { this.connected = false; updateSyncBtn(); return; } // peer said BYE
var parts = text.split(";");
if (op === 0x41) { // FULL: origin;seq;running;sl;item;patch...
var running = parts[2] === "1", patch = parts.slice(5).join(";");
_applyRemote(function () { _applyFull(running, patch); });
} else if (op === 0x42) { // DELTA: origin;seq;evt
var evt = parts.slice(2).join(";");
_applyRemote(function () { _applyDelta(evt); });
}
updateSyncBtn();
},
};
// Run fn with re-broadcast suppressed, then refresh the live patch-string field.
function _applyRemote(fn) {
var prev = _applyingRemote; _applyingRemote = true;
try { fn(); } catch (e) { console.warn("[sync] apply failed", e); }
finally { _applyingRemote = prev; }
if (typeof refreshPatchField === "function") refreshPatchField();
}
function _applyDelta(evt) {
var eq = evt.indexOf("="), key = eq < 0 ? evt : evt.slice(0, eq), val = eq < 0 ? "" : evt.slice(eq + 1);
if (key === "play") { if (!state.running) toggleTransport(); return; }
if (key === "stop") { if (state.running) toggleTransport(); return; }
if (key === "bpm") { setBpm(+val); return; }
if (key === "vol") { setVolume(+val); return; }
if (key === "sel") { var a = val.split("/"); loadItem(+a[1], +a[0]); return; }
if (key === "beat") {
var p = val.split("/"), m = meters[+p[0]];
if (m) { m.beatsOn[+p[1]] = +p[2] | 0; renderLaneStrip(m); }
return;
}
if (key === "lane") {
var q = val.split("/"), L = meters[+q[0]], field = q[1], v = q.slice(2).join("/");
if (!L) return;
if (field === "sound") L.sound = v;
else if (field === "groups") { L.groupsStr = v; }
else if (field === "sub") { L.stepsPerBeat = parseInt(v, 10) || 1; }
else if (field === "swing") { L.swing = (v === "1"); }
else if (field === "gain") { L.gainDb = +v || 0; }
else if (field === "poly") { L.poly = (v === "1"); }
else if (field === "enabled"){ setLaneEnabled(L, v === "1"); }
if (field === "groups" || field === "sub" || field === "swing") recomputeLane(L);
_syncLaneControls(L);
return;
}
}
// Full-state mirror: only rebuild if the groove actually differs (avoids
// flicker / lost focus when a heartbeat arrives and we're already in sync),
// then reconcile transport.
function _applyFull(running, patch) {
var cur = null; try { cur = currentPatch(); } catch (e) {}
if (patch && patch !== cur) applyPatch(patch);
if (running && !state.running) toggleTransport();
else if (!running && state.running) toggleTransport();
}
// Push a lane's model values back into its card's controls (for incoming
// fine-grained lane= deltas from the device; local edits drive these already).
function _syncLaneControls(m) {
if (!m || !m.el) return;
var q = function (s) { return m.el.querySelector(s); };
var g = q("#m" + m.id + "_group"); if (g) g.value = m.groupsStr;
var sub = q("#m" + m.id + "_sub"); if (sub) sub.value = m.swing ? (m.stepsPerBeat + "s") : String(m.stepsPerBeat);
var snd = q("#m" + m.id + "_sound"); if (snd) snd.value = m.sound;
var poly = q("#m" + m.id + "_poly"); if (poly) poly.checked = !!m.poly;
var en = q("#m" + m.id + "_enable"); if (en) en.checked = !!m.enabled;
var gain = q("#m" + m.id + "_gain");
if (gain) { var d = m.gainDb || 0; gain.textContent = (d > 0 ? "+" : "") + d + " dB"; gain.classList.toggle("boost", d > 0); gain.classList.toggle("cut", d < 0); }
}
// ---- broadcast helpers called from the editor's mutation handlers ----------
// Each is a no-op unless mirroring is armed and we're not mid-apply.
function syncTransport() { LiveSync.broadcast(state.running ? "play" : "stop"); }
function syncBpm() { LiveSync.broadcast("bpm=" + state.bpm); }
function syncVol() { LiveSync.broadcast("vol=" + Math.round(state.volume * 100)); }
function syncSel(sl, i) { LiveSync.broadcast("sel=" + sl + "/" + i); }
function syncBeat(m, i) { var idx = meters.indexOf(m); if (idx >= 0) LiveSync.broadcast("beat=" + idx + "/" + i + "/" + (m.beatsOn[i] | 0)); }
// Structural / multi-field edits coalesce into one full-state push (small Pico
// RX buffer — don't flood the bus with a delta per keystroke).
var _patchSyncTimer = 0;
function syncPatchSoon() {
if (!_syncOn || _applyingRemote) return;
clearTimeout(_patchSyncTimer);
_patchSyncTimer = setTimeout(function () { LiveSync.broadcastFull(); }, 150);
}
function toggleSync() { if (_syncOn) LiveSync.disconnect(); else LiveSync.connect(); }
function updateSyncBtn() {
var b = document.getElementById("syncBtn"); if (!b) return;
if (!_syncOn) { b.textContent = "🔗 Live sync"; b.classList.remove("primary"); b.title = "Mirror a connected PM_K-1 live (Web MIDI · Chrome/Edge/Firefox)"; return; }
b.classList.add("primary");
b.textContent = LiveSync.connected ? "🔗 Synced" + (_loopback ? " (loop)" : "") : "🔗 Linking…";
b.title = LiveSync.connected ? "Live-mirroring with " + (LiveSync.peerOrigin || "device") + " — click to stop" : "Waiting for the device to answer…";
}