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>
176 lines
9.4 KiB
JavaScript
176 lines
9.4 KiB
JavaScript
// =========================================================================
|
|
// 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…";
|
|
}
|