PM_E-1 beta: live-sync editor (editor-beta.html) mirroring a connected PM_K-1
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>
This commit is contained in:
parent
09144c9892
commit
eae9057baf
5 changed files with 1997 additions and 2 deletions
2
build.sh
2
build.sh
|
|
@ -38,7 +38,7 @@ def build(name):
|
||||||
out.write_text(src)
|
out.write_text(src)
|
||||||
return out.stat().st_size
|
return out.stat().st_size
|
||||||
|
|
||||||
for name in ("index.html","editor.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
|
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
|
||||||
"embed.html",
|
"embed.html",
|
||||||
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html"):
|
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html"):
|
||||||
print("built %s (%dKB)" % (name, build(name) // 1024))
|
print("built %s (%dKB)" % (name, build(name) // 1024))
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ fi
|
||||||
|
|
||||||
# stamp the version into the built copy only (source stays clean)
|
# stamp the version into the built copy only (source stays clean)
|
||||||
echo "deployed v$BUILD -> $DEST_DIR"
|
echo "deployed v$BUILD -> $DEST_DIR"
|
||||||
for f in index.html editor.html player.html teacher.html stage.html micro.html showcase.html kit.html \
|
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html \
|
||||||
embed.html \
|
embed.html \
|
||||||
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html; do
|
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html; do
|
||||||
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
|
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
|
||||||
|
|
|
||||||
195
docs/livesync-protocol.md
Normal file
195
docs/livesync-protocol.md
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
# PM Live-Sync protocol (beta)
|
||||||
|
|
||||||
|
Bidirectional live mirror between the **PM_E‑1 editor** (web) and a **PM_K‑1 device**
|
||||||
|
(firmware). When armed, either side can edit a groove, change tempo/volume,
|
||||||
|
start/stop, or select a set‑list item, and the other side reflects it in real
|
||||||
|
time.
|
||||||
|
|
||||||
|
It rides the **existing USB‑MIDI SysEx channel** (manufacturer `0x7D`) that the
|
||||||
|
device link already uses for RTC / version / programs / firmware — no new
|
||||||
|
transport, no new browser permission.
|
||||||
|
|
||||||
|
- **Editor side:** implemented in `src/livesync.js` + hooks in `editor-beta.html`.
|
||||||
|
- **Device side:** to be implemented in `pico-cp/app.py` (this document is the contract).
|
||||||
|
- **Browser support:** Web MIDI = Chrome / Edge / Firefox (no Safari), same as the existing "Device audio" feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Frames
|
||||||
|
|
||||||
|
Every message is one SysEx frame:
|
||||||
|
|
||||||
|
```
|
||||||
|
F0 7D <op> <payload ASCII bytes, each 0x00–0x7F> F7
|
||||||
|
```
|
||||||
|
|
||||||
|
`<op>` lives in the free `0x40` block (existing ops: `0x01` RTC, `0x02/0x03`
|
||||||
|
version, `0x10` programs, `0x21/22/23` firmware, `0x7E/0x7F` NAK/ACK):
|
||||||
|
|
||||||
|
| op | name | direction | payload |
|
||||||
|
|------|-------|------------------|-------------------------------------------|
|
||||||
|
| 0x40 | HELLO | either → either | `<origin>` |
|
||||||
|
| 0x41 | FULL | either → either | `<origin>;<seq>;<running>;<sl>;<item>;<patch>` |
|
||||||
|
| 0x42 | DELTA | either → either | `<origin>;<seq>;<evt>` |
|
||||||
|
| 0x43 | BYE | either → either | `<origin>` |
|
||||||
|
|
||||||
|
- **Payload is 7‑bit ASCII** — never emit a byte > `0x7F` (it corrupts the SysEx
|
||||||
|
stream and, per `build.sh`, would also break the firmware‑update path). All
|
||||||
|
share‑language patch strings are already ASCII.
|
||||||
|
- `<origin>` — a short per‑session id (the editor uses e.g. `e1a2b3c`). Used to
|
||||||
|
drop your own echoes (see §4).
|
||||||
|
- `<seq>` — a monotonically increasing integer per sender. Informational /
|
||||||
|
duplicate‑drop; ordering is guaranteed by USB‑MIDI so no reordering logic is
|
||||||
|
required.
|
||||||
|
- `<running>` — `0` or `1`.
|
||||||
|
- `<sl>` / `<item>` — set‑list and item index of the loaded program, or `-1`.
|
||||||
|
- `<patch>` — a share‑language patch string (see §3). It contains `;` and `/`,
|
||||||
|
so it is **always the tail**: parse the first 5 `;`‑fields, then rejoin the
|
||||||
|
rest as the patch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DELTA event grammar (`<evt>`)
|
||||||
|
|
||||||
|
One mutation, no `;` inside. Reuses the share‑language tokens (see
|
||||||
|
`src/engine.js` / README "Share language").
|
||||||
|
|
||||||
|
| evt | meaning |
|
||||||
|
|------------------------------|----------------------------------------------------|
|
||||||
|
| `play` | start transport |
|
||||||
|
| `stop` | stop transport |
|
||||||
|
| `bpm=<n>` | set tempo (clamped to the firmware's BPM range) |
|
||||||
|
| `vol=<pct>` | master volume, 0–100 |
|
||||||
|
| `sel=<sl>/<item>` | cue/load a set‑list item |
|
||||||
|
| `beat=<lane>/<step>/<level>` | per‑step dynamics; level `0/1/2/3` = mute/normal/accent/ghost |
|
||||||
|
| `lane=<lane>/<field>/<value>`| lane field edit (see below) |
|
||||||
|
|
||||||
|
`<lane>` and `<step>` are **0‑based** indices into the current program's lane
|
||||||
|
list / that lane's step list (same order both sides).
|
||||||
|
|
||||||
|
`lane=` fields and values:
|
||||||
|
|
||||||
|
| field | value |
|
||||||
|
|-----------|------------------------------------------------|
|
||||||
|
| `sound` | voice name (`kick`, `snare`, `hatClosed`, …) |
|
||||||
|
| `groups` | grouping string, e.g. `2+2+3` |
|
||||||
|
| `sub` | subdivision int: 1 / 2 / 3 / 4 / 6 |
|
||||||
|
| `swing` | `0` or `1` |
|
||||||
|
| `gain` | dB int, e.g. `-3` |
|
||||||
|
| `poly` | `0` or `1` |
|
||||||
|
| `enabled` | `0` or `1` (0 = silenced lane) |
|
||||||
|
|
||||||
|
> Structural changes that re‑shape the lane list (add lane, remove lane,
|
||||||
|
> reorder) are **not** sent as deltas. Send a fresh **`0x41` FULL** instead — it
|
||||||
|
> is simpler and self‑healing. The editor does exactly this (a coalesced
|
||||||
|
> full‑state push ~150 ms after the last structural/practice edit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What each side emits vs. applies
|
||||||
|
|
||||||
|
The two halves are **asymmetric in what they emit but symmetric in what they
|
||||||
|
apply** — each must apply *every* op/evt listed above.
|
||||||
|
|
||||||
|
**Editor emits:**
|
||||||
|
- fine `0x42` deltas for `play`/`stop`, `bpm`, `vol`, `sel`, `beat`
|
||||||
|
- a coalesced `0x41` FULL for any lane‑field / add / remove / practice (trainer,
|
||||||
|
ramp, segment bars, countdown) edit
|
||||||
|
- `0x41` FULL on connect and in reply to a received `0x40`
|
||||||
|
|
||||||
|
**Device should emit** (from its on‑device input handlers):
|
||||||
|
- `play`/`stop` when button A toggles transport
|
||||||
|
- `bpm=<n>` when the joystick / tap changes tempo (throttle to ≤ ~10/s)
|
||||||
|
- `sel=<sl>/<item>` on set‑list navigation
|
||||||
|
- `beat=<lane>/<step>/<level>` on a touch beat edit (`app.py` ~573–625)
|
||||||
|
- a `0x41` FULL after any lane add/remove/reorder or multi‑field lane edit
|
||||||
|
- a periodic `0x41` FULL **heartbeat** (~every 3–5 s) — the device is the
|
||||||
|
convergence authority (see §4)
|
||||||
|
- `0x41` FULL in reply to a received `0x40`
|
||||||
|
|
||||||
|
The `patch` in a `0x41` is produced by the device's existing program serializer
|
||||||
|
(the inverse of `parse_program()` in `app.py`). It must round‑trip through the
|
||||||
|
editor's `patchToSetup()` — i.e. the same grammar already used for
|
||||||
|
`programs.json` `prog` strings, plus a leading `t<bpm>` and optional `vol<pct>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Echo / loop suppression and conflict policy
|
||||||
|
|
||||||
|
Two rules keep the mirror from oscillating:
|
||||||
|
|
||||||
|
1. **Applying a remote change never re‑broadcasts.** Wrap every apply in an
|
||||||
|
"applying remote" flag (the editor uses `_applyingRemote`) and have all of
|
||||||
|
your broadcast hooks early‑out while it is set. This is the primary guard.
|
||||||
|
2. **Drop your own origin.** On receive, if `origin == myOrigin`, ignore the
|
||||||
|
frame. (Belt‑and‑suspenders; also lets the editor's `?loopback=1` self‑test
|
||||||
|
work by relabeling echoes as a peer.)
|
||||||
|
|
||||||
|
**Convergence:** the **device is authoritative**. Its periodic `0x41` heartbeat
|
||||||
|
is treated as ground truth, so if both sides edited the same field in the same
|
||||||
|
instant, they reconcile within one heartbeat. To avoid flicker, a receiver
|
||||||
|
should **diff the incoming `patch` against its current state and skip the
|
||||||
|
rebuild if they're equal** (the editor does this in `_applyFull`), only
|
||||||
|
reconciling transport.
|
||||||
|
|
||||||
|
This is single‑user‑friendly (last‑writer‑wins per field). True simultaneous
|
||||||
|
multi‑editor use is out of scope for the beta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Handshake & lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
editor "Live sync" ON ─► 0x40 HELLO ─────────────► device
|
||||||
|
◄──────────── 0x41 FULL ◄── (device's current state)
|
||||||
|
editor 0x41 FULL ──────────────────────────────► (editor's current state)
|
||||||
|
… steady state: 0x42 deltas both ways, device 0x41 heartbeat …
|
||||||
|
editor "Live sync" OFF ─► 0x43 BYE ────────────► device
|
||||||
|
```
|
||||||
|
|
||||||
|
On connect the editor sends **both** a `0x40` (asking for the device's state)
|
||||||
|
and a `0x41` (offering its own), so whichever side the user considers "source of
|
||||||
|
truth" wins immediately. A device that boots with sync idle should simply answer
|
||||||
|
`0x40` with a `0x41` and start emitting deltas once it has heard from a peer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Firmware checklist (`pico-cp/app.py`)
|
||||||
|
|
||||||
|
- [ ] **Dispatch** `0x40/0x41/0x42` in the SysEx handler (~`app.py:1361‑1415`,
|
||||||
|
alongside `0x01/0x02/0x10/0x21‑23`). Ignore frames whose origin is your own.
|
||||||
|
- [ ] **HELLO (`0x40`)** → reply `0x41` FULL built from current `App` state
|
||||||
|
(running, sl/idx, serialized program).
|
||||||
|
- [ ] **FULL (`0x41`)** → diff vs. current program; if different, load it
|
||||||
|
(reuse `parse_program()` / the `programs.json` load path); then reconcile
|
||||||
|
`running` (start/stop). Wrap in your remote‑apply flag.
|
||||||
|
- [ ] **DELTA (`0x42`)** → apply `play/stop/bpm/vol/sel/beat/lane` to `App`
|
||||||
|
state, wrapped in the remote‑apply flag so the on‑device handlers don't
|
||||||
|
re‑broadcast.
|
||||||
|
- [ ] **Broadcast** a `0x42` from each on‑device input handler (button A,
|
||||||
|
joystick tempo, touch beat edit, set‑list nav, lane editor), guarded by
|
||||||
|
the remote‑apply flag. Structural lane changes → `0x41` FULL.
|
||||||
|
- [ ] **Heartbeat:** emit `0x41` FULL every ~3–5 s while a peer is connected.
|
||||||
|
- [ ] **BYE (`0x43`)** → mark the peer gone (stop heartbeating/emitting until
|
||||||
|
the next HELLO).
|
||||||
|
- [ ] **Throttle** high‑rate sources (joystick tempo) and keep frames small —
|
||||||
|
the RP2040 USB‑MIDI RX buffer is tiny (the firmware updater already chunks
|
||||||
|
at 64 bytes), and live traffic shares the bus with MIDI clock, note‑out,
|
||||||
|
and the editor's Active‑Sensing heartbeat. Don't let a flood stall a
|
||||||
|
concurrent firmware push.
|
||||||
|
|
||||||
|
### Built‑in vs. user set lists (must match the editor)
|
||||||
|
|
||||||
|
The PM_K‑1's built‑in playlists (Styles / Practice / Song) are **baked into
|
||||||
|
firmware and read‑only**; on‑device edits **copy‑on‑write** into the user
|
||||||
|
"My edits" list. The editor follows the same rule (`userSetlists()` excludes the
|
||||||
|
seeded titles). So a **remote edit that targets a built‑in must follow the same
|
||||||
|
copy‑on‑write semantics** on the receiving side, or the two halves will disagree
|
||||||
|
about where the edit landed. When in doubt, after such an edit send a `0x41`
|
||||||
|
FULL with the resulting (copied) program so both sides converge on the same
|
||||||
|
target.
|
||||||
|
|
||||||
|
### Out of scope for the beta
|
||||||
|
- Streaming the device practice log (`history.json`) up to the browser.
|
||||||
|
- Mirroring device `settings.json` (LED brightness, MIDI config, etc.).
|
||||||
|
- Multi‑peer / multi‑editor arbitration beyond last‑writer‑wins.
|
||||||
1624
editor-beta.html
Normal file
1624
editor-beta.html
Normal file
File diff suppressed because it is too large
Load diff
176
src/livesync.js
Normal file
176
src/livesync.js
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
// =========================================================================
|
||||||
|
// 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…";
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue