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.
306 lines
16 KiB
JavaScript
306 lines
16 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>
|
||
// 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 + " |