From eae9057baf25164e71594af93888102040185ae3 Mon Sep 17 00:00:00 2001 From: Me Here Date: Sat, 30 May 2026 09:09:37 -0500 Subject: [PATCH] PM_E-1 beta: live-sync editor (editor-beta.html) mirroring a connected PM_K-1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- build.sh | 2 +- deploy.sh | 2 +- docs/livesync-protocol.md | 195 +++++ editor-beta.html | 1624 +++++++++++++++++++++++++++++++++++++ src/livesync.js | 176 ++++ 5 files changed, 1997 insertions(+), 2 deletions(-) create mode 100644 docs/livesync-protocol.md create mode 100644 editor-beta.html create mode 100644 src/livesync.js diff --git a/build.sh b/build.sh index a0633a7..b3b6edc 100755 --- a/build.sh +++ b/build.sh @@ -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)) diff --git a/deploy.sh b/deploy.sh index eeade18..f6485df 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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" diff --git a/docs/livesync-protocol.md b/docs/livesync-protocol.md new file mode 100644 index 0000000..6820da1 --- /dev/null +++ b/docs/livesync-protocol.md @@ -0,0 +1,195 @@ +# 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 F7 +``` + +`` 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 | `` | +| 0x41 | FULL | either → either | `;;;;;` | +| 0x42 | DELTA | either → either | `;;` | +| 0x43 | BYE | either → either | `` | + +- **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. +- `` — a short per‑session id (the editor uses e.g. `e1a2b3c`). Used to + drop your own echoes (see §4). +- `` — a monotonically increasing integer per sender. Informational / + duplicate‑drop; ordering is guaranteed by USB‑MIDI so no reordering logic is + required. +- `` — `0` or `1`. +- `` / `` — set‑list and item index of the loaded program, or `-1`. +- `` — 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 (``) + +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=` | set tempo (clamped to the firmware's BPM range) | +| `vol=` | master volume, 0–100 | +| `sel=/` | cue/load a set‑list item | +| `beat=//` | per‑step dynamics; level `0/1/2/3` = mute/normal/accent/ghost | +| `lane=//`| lane field edit (see below) | + +`` and `` 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=` when the joystick / tap changes tempo (throttle to ≤ ~10/s) +- `sel=/` on set‑list navigation +- `beat=//` 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` and optional `vol`. + +--- + +## 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. diff --git a/editor-beta.html b/editor-beta.html new file mode 100644 index 0000000..c0e1ea6 --- /dev/null +++ b/editor-beta.html @@ -0,0 +1,1624 @@ + + + + + +PM_E‑1 — PolyMeter Editor · Live Sync (Beta) + + + + + + + + + +/*@BUILD:include:src/header.html@*/ + +
+
+
+

PM_E‑1 PolyMeter Editor LIVE SYNC β

+
+ +
+
+
Space play · T tap · ←→ tempo · ↑↓ cue · ⏎ commit · N/P step · A add · ? help
+ + +
+
+
+
+
+
120
+
+ 0:00 + + +
+
 
+
+
+
+ +
+
+
Now loaded
+
Free play
+
+
+
+
+
+
+ +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ +
+
+ + +
+
+ + +
+
+ + 0 = manual +
+
+
+
+
+
+
+ +
+

Meter lanes

+ Each beat splits into subdivision pads — click a pad to cycle accent → normal → ghost → mute. Pick a swing subdivision for a triplet feel. +
+
+
+ + +
+ + + +
+ +/*@BUILD:include:src/footer.html@*/ + + +
+ + + + +
+ + + + + + + + + diff --git a/src/livesync.js b/src/livesync.js new file mode 100644 index 0000000..c546d7a --- /dev/null +++ b/src/livesync.js @@ -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: +// 0x41 FULL -> full snapshot (resync / heartbeat / on connect) payload: ;;;;; +// 0x42 DELTA -> one mutation event payload: ;; +// 0x43 BYE -> mirroring off payload: +// +// DELTA grammar (reuses the share-language tokens — see engine.js): +// play | stop | bpm= | vol= | sel=/ +// beat=// level 0/1/2/3 = mute/normal/accent/ghost +// lane=// 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…"; +}