Compare commits
2 commits
09144c9892
...
47fa6d7ce7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47fa6d7ce7 | ||
|
|
eae9057baf |
6 changed files with 2147 additions and 3 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
151
pico-cp/app.py
151
pico-cp/app.py
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
|
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
|
||||||
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
|
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
|
||||||
APP_VERSION = "0.0.20" # firmware version (the A/B updater pushes/compares this)
|
APP_VERSION = "0.0.21" # firmware version (the A/B updater pushes/compares this)
|
||||||
try:
|
try:
|
||||||
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
import rtc # set from the editor's clock SysEx so the log has real timestamps
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -461,6 +461,12 @@ class App:
|
||||||
self._clock_byte = bytes([0xF8]) # singleton MIDI Clock tick (24 PPQN)
|
self._clock_byte = bytes([0xF8]) # singleton MIDI Clock tick (24 PPQN)
|
||||||
self._start_byte = bytes([0xFA]); self._stop_byte = bytes([0xFC])
|
self._start_byte = bytes([0xFA]); self._stop_byte = bytes([0xFC])
|
||||||
self._lastRefresh = 0.0 # for the "force refresh after Xms even if a beat is imminent" guard
|
self._lastRefresh = 0.0 # for the "force refresh after Xms even if a beat is imminent" guard
|
||||||
|
try: # live sync: short random id so peers can drop their own echoes
|
||||||
|
o = os.urandom(4); self._sync_origin = "d" + "".join("%02x" % b for b in o)
|
||||||
|
except Exception:
|
||||||
|
self._sync_origin = "d%08x" % (time.monotonic_ns() & 0xFFFFFFFF)
|
||||||
|
self._sync_armed = False; self._sync_seq = 0; self._sync_applying = False
|
||||||
|
self._sync_heartbeat_next = 0.0 # next periodic FULL broadcast deadline (when armed)
|
||||||
self._displayed_bpm = -1; self._clock_next = 0 # lazy BPM redraw + MIDI Clock Out tick scheduler
|
self._displayed_bpm = -1; self._clock_next = 0 # lazy BPM redraw + MIDI Clock Out tick scheduler
|
||||||
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False # MIDI Clock In: smoothed tracker + slave flag
|
self._clock_in_last_t = 0; self._clock_in_avg = 0; self._slaved = False # MIDI Clock In: smoothed tracker + slave flag
|
||||||
self.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json)
|
self.sl = 0; self.rebuild_setlists() # built-in playlists (baked) + user playlists (programs.json)
|
||||||
|
|
@ -546,12 +552,14 @@ class App:
|
||||||
if self.sl >= len(self.setlists): self.sl = 0
|
if self.sl >= len(self.setlists): self.sl = 0
|
||||||
def switch_setlist(self, delta=1):
|
def switch_setlist(self, delta=1):
|
||||||
if len(self.setlists) < 2: return
|
if len(self.setlists) < 2: return
|
||||||
|
if self._sync_applying: return # the editor sends sel=... directly; don't ping-pong
|
||||||
was = self.running
|
was = self.running
|
||||||
if was: self.running = False; self._log_play()
|
if was: self.running = False; self._log_play()
|
||||||
self.sl = (self.sl + delta) % len(self.setlists)
|
self.sl = (self.sl + delta) % len(self.setlists)
|
||||||
self.load(0)
|
self.load(0)
|
||||||
if was: self.running = True; self._reset_clock(); self._start_play()
|
if was: self.running = True; self._reset_clock(); self._start_play()
|
||||||
self.led_rest(); self.draw_meters()
|
self.led_rest(); self.draw_meters()
|
||||||
|
self._sync_broadcast("sel=%d/%d" % (self.sl, self.idx))
|
||||||
def load(self, i):
|
def load(self, i):
|
||||||
items = self.setlists[self.sl]['items']
|
items = self.setlists[self.sl]['items']
|
||||||
self.idx = i % len(items)
|
self.idx = i % len(items)
|
||||||
|
|
@ -589,6 +597,7 @@ class App:
|
||||||
base = self._padbase(L, s); lit = (self.lane_lit[li] == s)
|
base = self._padbase(L, s); lit = (self.lane_lit[li] == s)
|
||||||
self.lane_pads[li][s].color_index = base + 4 if lit else base
|
self.lane_pads[li][s].color_index = base + 4 if lit else base
|
||||||
self._set_dirty()
|
self._set_dirty()
|
||||||
|
self._sync_broadcast("beat=%d/%d/%d" % (li, s, L['levels'][s]))
|
||||||
def _set_dirty(self):
|
def _set_dirty(self):
|
||||||
if not self._dirty: self._dirty = True; self.draw_status()
|
if not self._dirty: self._dirty = True; self.draw_status()
|
||||||
self.dirty = True
|
self.dirty = True
|
||||||
|
|
@ -726,6 +735,8 @@ class App:
|
||||||
if structural and self._edit_li == 0: self._rebuild_dur_all() # master changed -> polymeter lanes follow
|
if structural and self._edit_li == 0: self._rebuild_dur_all() # master changed -> polymeter lanes follow
|
||||||
else: self._rebuild_dur(self.lanes[self._edit_li])
|
else: self._rebuild_dur(self.lanes[self._edit_li])
|
||||||
self.build_grid()
|
self.build_grid()
|
||||||
|
if not self._sync_applying: # coalesce structural / multi-field lane edits into one FULL
|
||||||
|
self._sync_broadcast_full()
|
||||||
if not self._dirty: self._dirty = True; self.draw_status()
|
if not self._dirty: self._dirty = True; self.draw_status()
|
||||||
self._draw_laneedit() # refresh the modal with the new values
|
self._draw_laneedit() # refresh the modal with the new values
|
||||||
def _edit_sound(self, d):
|
def _edit_sound(self, d):
|
||||||
|
|
@ -994,6 +1005,119 @@ class App:
|
||||||
def led_rest(self): # settle to the resting colour (green idle / red running)
|
def led_rest(self): # settle to the resting colour (green idle / red running)
|
||||||
self.rgb = self._led_base()
|
self.rgb = self._led_base()
|
||||||
self.led.set(*self.rgb)
|
self.led.set(*self.rgb)
|
||||||
|
# ---------- Live sync (HELLO/FULL/DELTA/BYE on SysEx 0x40-0x43; see src/livesync.js for the editor side) ----------
|
||||||
|
def _sync_send(self, op, text):
|
||||||
|
if self.midi is None: return
|
||||||
|
b = bytearray((0xF0, 0x7D, op))
|
||||||
|
for c in text: # ASCII-only payload (the share grammar uses ; / = digits letters)
|
||||||
|
v = ord(c); b.append(v if v < 0x80 else 0x3F)
|
||||||
|
b.append(0xF7)
|
||||||
|
try: self.midi.write(b)
|
||||||
|
except Exception: pass
|
||||||
|
def _sync_broadcast(self, evt): # one DELTA event; suppressed while applying a remote change (echo guard)
|
||||||
|
if not self._sync_armed or self._sync_applying or self.midi is None: return
|
||||||
|
text = "%s;%d;%s" % (self._sync_origin, self._sync_seq, evt); self._sync_seq += 1
|
||||||
|
self._sync_send(0x42, text)
|
||||||
|
def _sync_broadcast_full(self): # FULL snapshot: running + sl + item + patch (coalesces structural edits)
|
||||||
|
if not self._sync_armed or self.midi is None: return
|
||||||
|
try: patch = self._prog_str()
|
||||||
|
except Exception: return
|
||||||
|
text = "%s;%d;%d;%d;%d;%s" % (self._sync_origin, self._sync_seq,
|
||||||
|
1 if self.running else 0, self.sl, self.idx, patch)
|
||||||
|
self._sync_seq += 1
|
||||||
|
self._sync_send(0x41, text)
|
||||||
|
self._sync_heartbeat_next = time.monotonic() + 5.0 # next periodic heartbeat
|
||||||
|
def _sync_apply_full(self, running, patch): # accept the peer's snapshot as ground truth
|
||||||
|
self._sync_applying = True
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
gc.collect()
|
||||||
|
# Diff before rebuilding -> avoid grid flicker / lost focus on a heartbeat that matches local state
|
||||||
|
try: cur = self._prog_str()
|
||||||
|
except Exception: cur = None
|
||||||
|
if patch and patch != cur:
|
||||||
|
bpm, lanes, bars, ramp, trainer = parse_program(patch)
|
||||||
|
self.bpm = bpm; self.lanes = lanes; self.bars = bars; self.ramp = ramp; self.trainer = trainer
|
||||||
|
self._beat_ns = 60_000_000_000 // max(1, bpm); self._rebuild_dur_all()
|
||||||
|
self.master = self.lanes[0]; self._ramp_base = self.bpm; self._lastbar = -1; self._muted = False
|
||||||
|
self._dirty = False; self._overlay = None
|
||||||
|
while len(self.g_overlay): self.g_overlay.pop()
|
||||||
|
self._reset_clock()
|
||||||
|
self.draw_bpm(); self.draw_status(); self.draw_train(); self.draw_meters()
|
||||||
|
self.build_grid(); self.draw_log()
|
||||||
|
if running and not self.running: self.toggle()
|
||||||
|
elif (not running) and self.running: self.toggle()
|
||||||
|
except Exception as e:
|
||||||
|
try: print("sync FULL apply:", e)
|
||||||
|
except Exception: pass
|
||||||
|
finally:
|
||||||
|
self._sync_applying = False
|
||||||
|
def _sync_apply_delta(self, evt): # one mutation
|
||||||
|
self._sync_applying = True
|
||||||
|
try:
|
||||||
|
eq = evt.find('=')
|
||||||
|
key = evt if eq < 0 else evt[:eq]
|
||||||
|
val = '' if eq < 0 else evt[eq+1:]
|
||||||
|
if key == 'play':
|
||||||
|
if not self.running: self.toggle()
|
||||||
|
elif key == 'stop':
|
||||||
|
if self.running: self.toggle()
|
||||||
|
elif key == 'bpm':
|
||||||
|
try: self.set_bpm(int(val))
|
||||||
|
except Exception: pass
|
||||||
|
elif key == 'sel': # sel=-1/-1 = "no selection" sentinel -> ignore
|
||||||
|
p = val.split('/')
|
||||||
|
if len(p) == 2:
|
||||||
|
try:
|
||||||
|
sl = int(p[0]); item = int(p[1])
|
||||||
|
if sl >= 0 and item >= 0:
|
||||||
|
if sl < len(self.setlists) and sl != self.sl: self.sl = sl
|
||||||
|
items = self.setlists[self.sl]['items']
|
||||||
|
if 0 <= item < len(items) and item != self.idx: self.goto(item)
|
||||||
|
except Exception: pass
|
||||||
|
elif key == 'beat': # beat=lane/step/level (0=mute 1=normal 2=accent 3=ghost)
|
||||||
|
p = val.split('/')
|
||||||
|
if len(p) == 3:
|
||||||
|
try:
|
||||||
|
li = int(p[0]); s = int(p[1]); lvl = int(p[2])
|
||||||
|
if 0 <= li < len(self.lanes):
|
||||||
|
L = self.lanes[li]
|
||||||
|
if 0 <= s < len(L['levels']):
|
||||||
|
L['levels'][s] = lvl & 3
|
||||||
|
if li < len(self.lane_pads) and s < len(self.lane_pads[li]):
|
||||||
|
lit = (self.lane_lit[li] == s)
|
||||||
|
self.lane_pads[li][s].color_index = self._padbase(L, s) + (4 if lit else 0)
|
||||||
|
self._set_dirty()
|
||||||
|
except Exception: pass
|
||||||
|
elif key == 'lane': # lane=lane/field/value (field: sound|groups|sub|swing|gain|poly|enabled)
|
||||||
|
p = val.split('/')
|
||||||
|
if len(p) >= 3:
|
||||||
|
try:
|
||||||
|
li = int(p[0]); field = p[1]; v = '/'.join(p[2:])
|
||||||
|
if 0 <= li < len(self.lanes):
|
||||||
|
L = self.lanes[li]; structural = False
|
||||||
|
if field == 'sound': L['sound'] = v
|
||||||
|
elif field == 'groups':
|
||||||
|
try: L['groups'] = [int(x) for x in v.split('+')]; structural = True
|
||||||
|
except Exception: pass
|
||||||
|
elif field == 'sub':
|
||||||
|
try: L['sub'] = int(v); structural = True
|
||||||
|
except Exception: pass
|
||||||
|
elif field == 'swing': L['swing'] = (v == '1'); structural = True # swing changes the dur grid
|
||||||
|
elif field == 'enabled': L['mute'] = not (v == '1')
|
||||||
|
elif field == 'gain':
|
||||||
|
try: L['gain'] = int(v)
|
||||||
|
except Exception: pass
|
||||||
|
elif field == 'poly': L['poly'] = (v == '1'); structural = True
|
||||||
|
if structural: self._regen_levels(L)
|
||||||
|
if li == 0 and structural: self._rebuild_dur_all() # master changed -> poly lanes follow
|
||||||
|
else: self._rebuild_dur(L)
|
||||||
|
if structural: self.build_grid()
|
||||||
|
self._set_dirty()
|
||||||
|
except Exception: pass
|
||||||
|
finally:
|
||||||
|
self._sync_applying = False
|
||||||
|
|
||||||
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
|
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
|
||||||
if self.midi is None: return
|
if self.midi is None: return
|
||||||
b = self._note_buf # reused bytearray -> zero alloc per click (hot path)
|
b = self._note_buf # reused bytearray -> zero alloc per click (hot path)
|
||||||
|
|
@ -1017,18 +1141,21 @@ class App:
|
||||||
try: self.midi.write(self._stop_byte) # Stop (reused singleton)
|
try: self.midi.write(self._stop_byte) # Stop (reused singleton)
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
self.led_rest(); self.draw_meters() # LED shows run state: red running / green stopped
|
self.led_rest(); self.draw_meters() # LED shows run state: red running / green stopped
|
||||||
|
self._sync_broadcast("play" if self.running else "stop")
|
||||||
def set_bpm(self, v):
|
def set_bpm(self, v):
|
||||||
v = max(5, min(300, v))
|
v = max(5, min(300, v))
|
||||||
if v != self.bpm:
|
if v != self.bpm:
|
||||||
self.bpm = v; self._beat_ns = 60_000_000_000 // v
|
self.bpm = v; self._beat_ns = 60_000_000_000 // v
|
||||||
self._rebuild_dur_all() # step grids follow the new beat duration
|
self._rebuild_dur_all() # step grids follow the new beat duration
|
||||||
# Don't draw here -- the 4Hz UI tick redraws bpm/meters; calling per joystick nudge allocated text bitmaps fast enough to trigger GC pauses
|
# Don't draw here -- the 4Hz UI tick redraws bpm/meters; calling per joystick nudge allocated text bitmaps fast enough to trigger GC pauses
|
||||||
|
self._sync_broadcast("bpm=%d" % v)
|
||||||
def goto(self, i):
|
def goto(self, i):
|
||||||
was = self.running
|
was = self.running
|
||||||
if was: self.running = False; self._log_play() # close out the track that was playing
|
if was: self.running = False; self._log_play() # close out the track that was playing
|
||||||
self.load(i)
|
self.load(i)
|
||||||
if was: self.running = True; self._reset_clock(); self._start_play()
|
if was: self.running = True; self._reset_clock(); self._start_play()
|
||||||
self.led_rest(); self.draw_meters()
|
self.led_rest(); self.draw_meters()
|
||||||
|
self._sync_broadcast("sel=%d/%d" % (self.sl, self.idx))
|
||||||
def tap(self):
|
def tap(self):
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if not hasattr(self, '_taps'): self._taps = []
|
if not hasattr(self, '_taps'): self._taps = []
|
||||||
|
|
@ -1426,6 +1553,26 @@ class App:
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
elif cmd == 0x02: # version query -> reply 0x03 + APP_VERSION
|
elif cmd == 0x02: # version query -> reply 0x03 + APP_VERSION
|
||||||
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x03]) + APP_VERSION.encode() + bytes([0xF7]))
|
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x03]) + APP_VERSION.encode() + bytes([0xF7]))
|
||||||
|
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43: # Live sync (see src/livesync.js)
|
||||||
|
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
|
||||||
|
except Exception: return
|
||||||
|
origin = text.split(";", 1)[0] if text else ""
|
||||||
|
if origin == self._sync_origin: return # drop our own echoes (composite USB may loop)
|
||||||
|
self._sync_armed = True
|
||||||
|
if cmd == 0x40: # HELLO -> reply with our current FULL
|
||||||
|
self._sync_broadcast_full()
|
||||||
|
elif cmd == 0x43: # BYE -> peer disconnected; stop heartbeats
|
||||||
|
self._sync_armed = False
|
||||||
|
elif cmd == 0x41: # FULL: origin;seq;running;sl;item;patch...
|
||||||
|
parts = text.split(";", 5)
|
||||||
|
if len(parts) >= 6:
|
||||||
|
try:
|
||||||
|
running = parts[2] == "1"; patch = parts[5]
|
||||||
|
self._sync_apply_full(running, patch)
|
||||||
|
except Exception: pass
|
||||||
|
elif cmd == 0x42: # DELTA: origin;seq;evt
|
||||||
|
parts = text.split(";", 2)
|
||||||
|
if len(parts) >= 3: self._sync_apply_delta(parts[2])
|
||||||
elif cmd == 0x10: # write /programs.json (user playlists) pushed from the editor
|
elif cmd == 0x10: # write /programs.json (user playlists) pushed from the editor
|
||||||
try:
|
try:
|
||||||
with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))
|
with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))
|
||||||
|
|
@ -1497,6 +1644,8 @@ class App:
|
||||||
tnow = time.monotonic()
|
tnow = time.monotonic()
|
||||||
if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter
|
if tnow >= self._uiNext: # ~4x/s: tick the stopwatch + bar counter
|
||||||
self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm() # bpm follows the continuous ramp
|
self._uiNext = tnow + 0.25; self.draw_meters(); self.draw_bpm() # bpm follows the continuous ramp
|
||||||
|
if self._sync_armed and tnow >= self._sync_heartbeat_next:
|
||||||
|
self._sync_broadcast_full() # periodic FULL: device is the convergence authority
|
||||||
if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update
|
if not committed and tnow - boot > 5: # booted & ran fine for 5s -> confirm the update
|
||||||
try: os.remove("/trial")
|
try: os.remove("/trial")
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
|
|
|
||||||
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