diff --git a/README.md b/README.md index 94af3a5..c8bc663 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The site is **one editor + a gallery of form factors**, and each form factor is |-----|------| | [`/`](https://metronome.varasys.io/) `index.html` | **Concepts** — the landing / form‑factor gallery; each box embeds the live widget (Open ↗ / Specs & info ⓘ) | | `/editor.html` · `/info-editor.html` | **PM_E‑1 — PolyMeter Editor** (the main app) + its overview | +| `/pm_e-2.html` · `/info-pm_e-2.html` | **PM_E‑2 — PolyMeter Editor (Notation)** — second-gen, engraved drum notation (Bravura/SMuFL): Staff / TUBS / Konnakol views, edit-on-staff | | `/kit.html` · `/info-kit.html` | **PM_K‑1 Kit** — buildable Raspberry Pi Pico touchscreen unit (52Pi EP‑0172); info page has the wiring, parts and firmware | | `/player.html` · `/info-player.html` | **PM_C‑1 Concept** — idealized concept device (full display + set‑list nav, theme, fullscreen "stage" view) | | `/teacher.html` · `/info-teacher.html` | **PM_T‑1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument pass‑through with analog click injection) | @@ -265,6 +266,7 @@ tags the current commit `v` (requires a clean tree). Push the tag, then |------|---------| | `index.html` | the **Concepts** landing / gallery (embeds each widget live) | | `editor.html` | the **PM_E‑1 editor** app (source, with `@BUILD:*` markers) | +| `pm_e-2.html` · `src/notation.js` | the **PM_E‑2 notation editor** + its Bravura/SMuFL engraving engine (`tools/bravura/` subsets the font → `assets/bravura.woff2.b64`, inlined via `@BUILD:bravura@`) | | `kit.html` · `player.html` · `teacher.html` · `stage.html` · `micro.html` · `showcase.html` | the device widget pages (PM_K‑1 Kit, PM_C‑1 Concept, Teacher, Stage, PM_P‑1 Practice, PM_D‑1 Display) | | `info-*.html` | per‑form‑factor spec pages (embed the live widget + description + dimensions + BOM) | | `embed.html` · `embed.js` | embed docs and the drop‑in widget loader | diff --git a/build.sh b/build.sh index 18383e9..95dc66c 100755 --- a/build.sh +++ b/build.sh @@ -40,14 +40,15 @@ def build(name): src = src.replace("@BUILD:favicon@", (A / "favicon.b64").read_text().strip()) src = src.replace("@BUILD:logo-dark@", (A / "logo-dark.b64").read_text().strip()) src = src.replace("@BUILD:logo-light@", (A / "logo-light.b64").read_text().strip()) + src = src.replace("@BUILD:bravura@", (A / "bravura.woff2.b64").read_text().strip()) # SMuFL music font subset (PM_E-2 notation) assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}" out = pathlib.Path("dist") / name out.write_text(src) return out.stat().st_size -for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html", +for name in ("index.html","editor.html","editor-beta.html","pm_e-2.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.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-explorer.html","info-grid.html"): + "info-editor.html","info-pm_e-2.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"): print("built %s (%dKB)" % (name, build(name) // 1024)) pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is print("copied embed.js") diff --git a/deploy.sh b/deploy.sh index 67d7bcb..d656be0 100755 --- a/deploy.sh +++ b/deploy.sh @@ -40,9 +40,9 @@ fi # stamp the version into the built copy only (source stays clean) echo "deployed v$BUILD -> $DEST_DIR" -for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \ +for f in index.html editor.html editor-beta.html pm_e-2.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.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-explorer.html info-grid.html; do + info-editor.html info-pm_e-2.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f" echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)" done diff --git a/docs/livesync-protocol.md b/docs/livesync-protocol.md index b4e624f..434ceb2 100644 --- a/docs/livesync-protocol.md +++ b/docs/livesync-protocol.md @@ -26,12 +26,14 @@ F0 7D F7 `` 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 | `` | -| 0x41 | FULL | either → either | `;;;;;` | -| 0x42 | DELTA | either → either | `;;` | -| 0x43 | BYE | either → either | `` | +| op | name | direction | payload | +|------|---------|------------------|-------------------------------------------| +| 0x40 | HELLO | either → either | `` | +| 0x41 | FULL | either → either | `;;;;;` | +| 0x42 | DELTA | either → either | `;;` | +| 0x43 | BYE | either → either | `` | +| 0x44 | SLSYNC | either → either | `;;` — live set-list **content** merge (§8) | +| 0x45 | LOGSYNC | either → either | `;;` — practice-log entry merge (§9) | - **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 @@ -96,6 +98,8 @@ apply** — each must apply *every* op/evt listed above. - 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` +- a coalesced `0x44` SLSYNC on any set‑list **content** change (and on connect) — §8 +- a `0x45` LOGSYNC after each logged session (and a full batch on connect) — §9 **Device should emit** (from its on‑device input handlers): - `play`/`stop` when button A toggles transport @@ -172,6 +176,14 @@ truth" wins immediately. A device that boots with sync idle should simply answer - [ ] **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). +- [ ] **SLSYNC (`0x44`)** → merge the JSON manifest of user lists by normalized + title (replace‑per‑list, append unknown, never delete), reusing + `load_user_setlists()`‑shaped parsing; `rebuild_setlists()` + reload. + Emit one after `_persist_user()` and in reply to `0x40`. (§8) +- [ ] **LOGSYNC (`0x45`)** → merge practice entries by (`at`,`name`); append + + cap + re‑sort. Emit a one‑entry batch after `_log_play()` and a full batch + in reply to `0x40`. Record `at` (epoch ms) on each play when the RTC is + set. (§9) - [ ] **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, @@ -190,10 +202,15 @@ 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. +> **No longer out of scope** (now specced in §8 / §9): live set‑list **content** +> sync (`0x44`) and streaming the device practice log up to the browser and back +> (`0x45`). The old `0x10` programs push (Save/Load to device) still exists as the +> explicit, full‑overwrite path; `0x44` is the *incremental, merge‑by‑title* live +> mirror that runs automatically while sync is armed. + --- ## 7. Per‑device emit/apply matrix @@ -211,3 +228,142 @@ Editors don't need to special‑case the source — both DELTA streams look iden the wire, and the **device id is only exposed on the version query** (SysEx `0x02` → `0x03` reply, `;`; pre‑0.0.23 firmware sends bare version → assume `K`). + +--- + +## 8. Set‑list content sync (`0x44` SLSYNC) + +The `0x41` FULL only carries the *one loaded program* (``) plus the +*selection* indices. `0x44` carries **set‑list content** — titles + every item's +name + program string — so the two halves converge on the same library while +sync is armed, without the user pressing "Save to device". + +**Frame:** `F0 7D 44 ;; F7` + +- `` / `` — same as the other ops (echo‑drop + duplicate info). +- `` — a **7‑bit‑safe JSON** manifest of the sender's **user** set lists, + in the *exact same shape `0x10` already uses* so the firmware can reuse its + `programs.json` parser: + + ```json + {"setlists":[{"title":"My set list","programs":[{"name":"Funk","prog":"t120;..."}]}]} + ``` + + Non‑ASCII in titles/names is escaped `\uXXXX` (the editor's existing + `programsJSON()` 7‑bit‑safe path); the firmware stores it verbatim. The whole + manifest rides **one SysEx frame** (same as `0x10` — user libraries are a few + KB and the RX assembler holds 60 000 bytes). It is **never chunked**; if it + ever grew past the buffer, fall back to the explicit `0x10` push. + +### What's included +- **Only user set lists.** Built‑in / seeded lists (firmware `BUILTIN_SETLISTS`; + editor `SEED_SETLISTS` titles) are read‑only on both halves and **never + transmitted** — both sides already have identical copies baked in. (Same filter + as `userSetlists()` / `load_user_setlists()`.) + +### Identity & merge rule +- **Set lists match by title** (normalized: lower‑case, alphanumerics only — the + firmware's `_slkey()`), independent of index. Indices diverge freely between + halves (the device prepends built‑ins; the web orders differently), so a + positional match is wrong — **title is the key.** +- **Items match by name** within a list (case‑sensitive, as both UIs key + practice history by exact name). +- Merge is a **per‑list replace**: a received user list **replaces** the local + user list of the same normalized title wholesale (its items become the + received items, in the received order). Lists present locally but absent from + the message are **left untouched** (additive — sync never deletes a list the + peer simply didn't send). A received list with no matching local title is + **appended** as a new user list. +- This is **last‑writer‑wins per list** (consistent with the rest of the + protocol). The receiver applies under its remote‑apply guard and does **not** + re‑broadcast a `0x44` in response (no echo storm); the next heartbeat / FULL + still reconciles the loaded program. + +### Copy‑on‑write for built‑ins +A `0x44` never targets a built‑in: it only carries user lists, and the receiver +only ever writes user lists. If a user **edits a built‑in item** on either half, +that edit must first be **forked into a user list** (the firmware's `_save_edit` +already forks built‑in edits into the "My edits" user list; the editor keeps a +separate user list). The fork then rides `0x44` as an ordinary user list. So +copy‑on‑write happens **before** the sync, and the wire only ever sees user +content — the built‑ins on both sides stay pristine and identical. + +### When it's emitted +- **Editor:** coalesced ~150 ms after any set‑list structural edit (add/rename/ + reorder list, add/remove/rename item, capture/update an item), and once on + connect right after the first FULL. Reuses `syncPatchSoon()`‑style debouncing. +- **Device:** after `_persist_user()` succeeds (a save that wrote + `programs.json`), guarded by the remote‑apply flag, and once in reply to a + `0x40` HELLO. The device's per‑item *program* edits still ride `0x41` FULL; + `0x44` is specifically for **library shape** (which lists/items exist). + +> The device is the **convergence authority** for the *loaded program* (§4), but +> set‑list content is last‑writer‑wins per list — there is no periodic `0x44` +> heartbeat (it would clobber concurrent edits on the other half). Send it only +> on an actual content change or on connect. + +--- + +## 9. Practice‑log sync (`0x45` LOGSYNC) + +Both halves keep a practice history (web: `localStorage` `metronome.logs`; +device: `/history.json`). `0x45` streams entries between them and **merges by a +stable key**, so a session played on the device shows up in the editor's history +graph and vice‑versa. + +**Frame:** `F0 7D 45 ;; F7` + +`` is a 7‑bit‑safe JSON batch of **normalized** entries: + +```json +{"log":[{"at":1733059200000,"name":"Funk","dur":92,"bpm":120}]} +``` + +| field | type | meaning | +|--------|-------------|------------------------------------------------------------| +| `at` | int (ms) | session start, **Unix epoch milliseconds** — the dedup key | +| `name` | string | set‑list item name the session was logged against | +| `dur` | int (sec) | session duration in **whole seconds** | +| `bpm` | int | tempo at the end of the session | + +This is the **intersection** of the two native schemas (the web's +`{at,name,durationSec,bpm,lanes}` and the device's `{t,bpm,dur,bars,name}`): +`at` ↔ `at`, `dur` ↔ `round(durationSec)` / `dur`, `bpm` ↔ `bpm`, `name` ↔ +`name`. Fields each side keeps privately (web `lanes`; device `t`/`bars`) are +**not** transmitted; the receiver fills them from what it has (`t` from `at` via +the RTC, `bars`/`lanes` left absent). + +### Timestamps & the device clock +The dedup key is `at` (epoch ms). The editor already pushes the RTC over `0x01` +on connect / heartbeat, so the device **can** produce a real epoch. The firmware +therefore records an `at` (epoch **seconds** × 1000 → ms) on each logged play +*in addition to* its existing `t:"HH:MM"` field, computed from `time.time()` +when the RTC is set; if the RTC is unset (no editor ever connected) it falls +back to `at = 0`, which the merge treats as "no stable key" (see dedup). + +### Direction & dedup/merge +- **Bidirectional.** Each half emits its **own** local entries; each applies the + peer's. +- **Dedup key = `at` + `name`.** On receive, an entry whose (`at`,`name`) already + exists locally is dropped. Entries with `at == 0` (device logged before any RTC + sync) are **always appended** (can't be deduped — better a possible duplicate + than a dropped session); these are rare and the user can delete them. +- Merge is **additive only** — `0x45` never deletes history. (Deleting a stale + entry on one half does not propagate; out of scope, matches the + last‑writer‑wins philosophy and avoids a delete echoing into a re‑add.) +- The receiver **caps** its merged log (web keeps all; device keeps newest 200, + its existing cap) and re‑sorts newest‑first by `at`. + +### When it's emitted +- **Editor:** after `logFinalize()` writes a new session (one‑entry batch), and + a **full batch** once on connect (after the first FULL) so the device catches + up on everything it missed. Guarded so an *applied* remote entry doesn't + re‑broadcast. +- **Device:** after `_log_play()` appends a session (one‑entry batch), guarded by + the remote‑apply flag, and a full batch once in reply to a `0x40` HELLO. + +> **Batch size caution (firmware):** a full‑history batch (up to 200 entries) is +> small JSON but still allocates; the device sends its on connect/HELLO only, and +> the editor's on‑connect batch is bounded by the SysEx buffer (60 KB ≈ a few +> thousand minimal entries). If a log ever exceeded that, the editor truncates to +> the newest entries that fit. Per‑session emits are a single entry — negligible. diff --git a/docs/track-format.md b/docs/track-format.md index b1da9ca..f993ed7 100644 --- a/docs/track-format.md +++ b/docs/track-format.md @@ -81,7 +81,10 @@ sound = name | int ; (* int = GM percussion note numb groups = int *( "+" int ) ; (* "4" or "2+2+3" → beats per bar *) sub = int ; (* subdivision; trailing "s" = swing *) euclid = "(" int [ "," int [ "," int ] ] ")" ; (* k [, n [, rot ]] — even distribution *) -pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *) +pattern = *( cell ) ; (* one char per step: dynamics + ornament *) +cell = "X" | "x" | "1" | "g" (* dynamics: accent / normal / normal / ghost *) + | "f" | "F" | "d" | "D" | "z" | "Z" (* ornament hits (see below): flam / drag / roll *) + | "." | "-" | "_" ; (* rest *) ``` ### Lane semantics @@ -92,6 +95,12 @@ pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *) - **swing** — `/2s` lays the off-beat on the last triplet (≈ 2/3). - **pattern** — one char per step: `X`=accent (level 2), `x`/`1`=normal (1), `g`=ghost (3), `.`/`-`/`_`/anything else = rest (0). Short patterns are right-padded with rests to `steps`. +- **ornaments** — three extra hit letters add a per-step *ornament* on top of the dynamic, in a + channel parallel to the dynamic levels (see `orns` in §5): `f`/`F`=flam (one grace note), + `d`/`D`=drag/ruff (two grace notes), `z`/`Z`=roll/buzz. The **case carries the dynamic** so the + two stay orthogonal: **lower-case = normal hit (level 1), UPPER-case = accented hit (level 2)**. + So `snare:4=F.fz` is an accented-flam, rest, normal-flam, normal-roll. Ghosted ornaments aren't + expressible (a `g`-style ghost ornament has no letter); ornament + rest is just a rest. **With no pattern (the default):** every step sounds at normal level and accents fall **only on group starts** — the grouping *is* the accent map. So `4` accents beat 1; `2+2` accents beats 1 & 3; `4/2` is a steady 8th lane with an accent on beat 1. To accent every beat, @@ -192,6 +201,12 @@ expected value in `norm`: `levels` is the resolved per-step dynamics array (0 rest / 1 normal / 2 accent / 3 ghost) — the real audible payload, and the most important thing two implementations must agree on. +`orns` is the resolved per-step **ornament** array, parallel to `levels` +(`0` none / `1` flam / `2` drag / `3` roll). It **defaults to all-zeros**, so a lane with no +ornaments omits it entirely — an implementation MAY always emit it or omit-when-all-zero, and the +conformance runner treats a missing `orns` as all-zeros. Example with ornaments +(`snare:4=F.fz`): `"levels": [2,0,1,1], "orns": [1,0,1,3]`. + --- ## 6. Divergences — status diff --git a/embed.html b/embed.html index 6704dd6..5fecd05 100644 --- a/embed.html +++ b/embed.html @@ -53,6 +53,7 @@