// ========================================================================= // 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…"; }