metronome/src/livesync.js
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

306 lines
16 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// =========================================================================
// 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>
// 0x44 SLSYNC -> live set-list CONTENT merge (user lists only) payload: <origin>;<seq>;<json> (§8)
// 0x45 LOGSYNC-> practice-log entry merge (additive, by at+name) payload: <origin>;<seq>;<json> (§9)
//
// 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
this.broadcastSetlists(); // offer our user set-list library (content merge — §8)
this.broadcastLogBatch(); // and our whole practice history (additive merge — §9)
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);
},
// 0x44 SLSYNC — full manifest of OUR user set lists (titles + items + progs),
// in the same JSON shape the 0x10 programs push uses. Built-ins/seeded lists
// are excluded (both halves bake identical copies); merge is by title (§8).
broadcastSetlists() {
if (!_syncOn || _applyingRemote) return;
var json; try { json = _userSetlistsJSON(); } catch (e) { return; }
this.send(0x44, _ascii7(json));
},
// 0x45 LOGSYNC — practice-log entries, normalized to {at,name,dur,bpm} (§9).
// entries omitted => whole log (on connect); else a single new session.
broadcastLog(entry) {
if (!_syncOn || _applyingRemote) return;
this.send(0x45, _ascii7(JSON.stringify({ log: [entry] })));
},
broadcastLogBatch() {
if (!_syncOn || _applyingRemote) return;
var json; try { json = _logBatchJSON(); } catch (e) { return; }
this.send(0x45, _ascii7(json));
},
// ---- 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";
var sl = parseInt(parts[3], 10), item = parseInt(parts[4], 10);
var patch = parts.slice(5).join(";"); // patch is the tail (it contains ; and /)
_applyRemote(function () { _applyFull(running, patch, sl, item); });
} else if (op === 0x42) { // DELTA: origin;seq;evt
var evt = parts.slice(2).join(";");
_applyRemote(function () { _applyDelta(evt); });
} else if (op === 0x44) { // SLSYNC: origin;seq;json (set-list content)
var sj = parts.slice(2).join(";");
_applyRemote(function () { _applySetlists(sj); });
} else if (op === 0x45) { // LOGSYNC: origin;seq;json (practice entries)
var lj = parts.slice(2).join(";");
_applyRemote(function () { _applyLog(lj); });
}
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 selection + transport.
// sl/item — the peer's loaded set-list item (or -1 / NaN = free play). We mirror
// the SELECTION HIGHLIGHT only (state-only setLoaded), never re-loading the item:
// the groove itself already arrived in `patch`, so re-loading would double-apply
// and could glitch audio. This keeps the "now loaded" row + history in sync.
function _applyFull(running, patch, sl, item) {
var cur = null; try { cur = currentPatch(); } catch (e) {}
if (patch && patch !== cur) applyPatch(patch);
// Mirror which set-list item the peer has loaded, if it maps onto a list we have.
if (typeof sl === "number" && sl >= 0 && typeof item === "number" && item >= 0 &&
typeof setlists !== "undefined" && setlists[sl] && setlists[sl].items[item]) {
if (loadedSL !== sl || activeItem !== item) {
if (typeof setLoaded === "function") setLoaded(sl, item);
if (activeSL !== loadedSL) { activeSL = loadedSL; if (typeof renderSetlists === "function") renderSetlists(); }
else if (typeof renderItems === "function") renderItems();
if (typeof renderLog === "function") renderLog();
}
}
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); }
}
// ---- set-list content + practice-log sync (0x44 / 0x45) --------------------
// 7-bit-safe encode: escape any non-ASCII to \uXXXX (matches the editor's 0x10
// programsJSON path) so the SysEx stream stays clean and the firmware never sees
// a byte > 0x7F. (regex covers U+0080..U+FFFF, same class the editor's 0x10 uses)
function _ascii7(s) { return String(s).replace(/[\u0080-\uFFFF]/g, function (c) { return "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"); }); }
// Normalize a set-list title the same way the firmware's _slkey() does
// (lower-case, alphanumerics only) — the cross-half identity key for merge (§8).
function _slKey(t) { return String(t == null ? "" : t).toLowerCase().replace(/[^a-z0-9]/g, ""); }
// JSON manifest of OUR user set lists, in the 0x10 / programs.json shape.
function _userSetlistsJSON() {
return JSON.stringify({ setlists: userSetlists().map(function (sl) {
return { title: sl.title || "My set list",
programs: sl.items.map(function (it) { return { name: it.name, prog: setupToPatch(it) }; }) };
}) });
}
// Whole practice log, normalized to the wire schema {at,name,dur,bpm} (§9).
function _logBatchJSON() {
var logs = lsGet(LS.logs, []);
return JSON.stringify({ log: logs.map(_logToWire) });
}
function _logToWire(e) {
return { at: e.at | 0, name: e.name, dur: Math.round(e.durationSec || 0), bpm: e.bpm | 0 };
}
// Apply a received 0x44 manifest: merge user lists by normalized title
// (replace-per-list / append unknown / never delete). Built-ins/seeded are
// untouched. Honors copy-on-write implicitly — the wire only carries user lists.
function _applySetlists(json) {
var d; try { d = JSON.parse(json); } catch (e) { return; }
var lists = (d && Array.isArray(d.setlists)) ? d.setlists : null;
if (!lists) return;
var seed = (typeof SEED_SETLISTS !== "undefined") ? new Set(SEED_SETLISTS.map(function (s) { return _slKey(s.title); })) : new Set();
var changed = false;
for (var i = 0; i < lists.length; i++) {
var rl = lists[i], key = _slKey(rl.title);
if (!key || seed.has(key)) continue; // never overwrite a built-in/seeded list
var items = (rl.programs || []).map(function (p) {
var su = patchToSetup(p.prog || ""); su.name = p.name || "Item"; return su;
});
var idx = -1;
for (var j = 0; j < setlists.length; j++) { if (_slKey(setlists[j].title) === key && !seed.has(_slKey(setlists[j].title))) { idx = j; break; } }
if (idx >= 0) { setlists[idx].items = items; } // replace this list's contents wholesale
else { setlists.push({ title: rl.title || "Device", description: "", items: items }); }
changed = true;
}
if (!changed) return;
if (typeof saveSetlists === "function") saveSetlists();
if (typeof activeSL !== "undefined" && activeSL >= setlists.length) activeSL = Math.max(0, setlists.length - 1);
if (typeof renderSetlists === "function") renderSetlists();
}
// Apply a received 0x45 batch: additive merge by (at,name); at==0 always appended.
function _applyLog(json) {
var d; try { d = JSON.parse(json); } catch (e) { return; }
var incoming = (d && Array.isArray(d.log)) ? d.log : null;
if (!incoming) return;
var logs = lsGet(LS.logs, []);
var have = {};
for (var k = 0; k < logs.length; k++) { if (logs[k].at) have[logs[k].at + "" + logs[k].name] = 1; }
var added = 0;
for (var i = 0; i < incoming.length; i++) {
var w = incoming[i]; if (!w || !w.name) continue;
var at = w.at | 0;
if (at && have[at + "" + w.name]) continue; // dedup (at==0 falls through -> always appended)
logs.push({ at: at || Date.now(), name: w.name, durationSec: w.dur || 0, bpm: w.bpm | 0, lanes: [] });
if (at) have[at + "" + w.name] = 1;
added++;
}
if (!added) return;
logs.sort(function (a, b) { return (b.at | 0) - (a.at | 0); }); // newest first
lsSet(LS.logs, logs);
if (typeof renderLog === "function") renderLog();
}
// ---- 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);
}
// Set-list CONTENT changes (add/rename/reorder list, add/remove/rename item,
// capture/update) coalesce into one 0x44 manifest push (§8).
var _slSyncTimer = 0;
function syncSetlistsSoon() {
if (!_syncOn || _applyingRemote) return;
clearTimeout(_slSyncTimer);
_slSyncTimer = setTimeout(function () { LiveSync.broadcastSetlists(); }, 200);
}
// A newly logged practice session -> one 0x45 entry (§9).
function syncLog(entry) {
if (!_syncOn || _applyingRemote || !entry) return;
LiveSync.broadcastLog(_logToWire(entry));
}
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…";
}