// ========================================================================= // 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: // 0x44 SLSYNC -> live set-list CONTENT merge (user lists only) payload: ;; (§8) // 0x45 LOGSYNC-> practice-log entry merge (additive, by at+name) payload: ;; (§9) // // 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 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…"; }