Preserve notation + grammar feature work (verified complete + green)
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.
This commit is contained in:
parent
49a4308c4b
commit
cb54b4d689
24 changed files with 838 additions and 249 deletions
|
|
@ -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 ⓘ) |
|
| [`/`](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 |
|
| `/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 |
|
| `/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) |
|
| `/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) |
|
| `/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<VERSION>` (requires a clean tree). Push the tag, then
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `index.html` | the **Concepts** landing / gallery (embeds each widget live) |
|
| `index.html` | the **Concepts** landing / gallery (embeds each widget live) |
|
||||||
| `editor.html` | the **PM_E‑1 editor** app (source, with `@BUILD:*` markers) |
|
| `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) |
|
| `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) |
|
| `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 |
|
| `embed.html` · `embed.js` | embed docs and the drop‑in widget loader |
|
||||||
|
|
|
||||||
5
build.sh
5
build.sh
|
|
@ -40,14 +40,15 @@ def build(name):
|
||||||
src = src.replace("@BUILD:favicon@", (A / "favicon.b64").read_text().strip())
|
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-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: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}"
|
assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
|
||||||
out = pathlib.Path("dist") / name
|
out = pathlib.Path("dist") / 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","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",
|
"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))
|
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
|
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
|
||||||
print("copied embed.js")
|
print("copied embed.js")
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ 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 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 \
|
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"
|
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
|
||||||
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,13 @@ F0 7D <op> <payload ASCII bytes, each 0x00–0x7F> F7
|
||||||
version, `0x10` programs, `0x21/22/23` firmware, `0x7E/0x7F` NAK/ACK):
|
version, `0x10` programs, `0x21/22/23` firmware, `0x7E/0x7F` NAK/ACK):
|
||||||
|
|
||||||
| op | name | direction | payload |
|
| op | name | direction | payload |
|
||||||
|------|-------|------------------|-------------------------------------------|
|
|------|---------|------------------|-------------------------------------------|
|
||||||
| 0x40 | HELLO | either → either | `<origin>` |
|
| 0x40 | HELLO | either → either | `<origin>` |
|
||||||
| 0x41 | FULL | either → either | `<origin>;<seq>;<running>;<sl>;<item>;<patch>` |
|
| 0x41 | FULL | either → either | `<origin>;<seq>;<running>;<sl>;<item>;<patch>` |
|
||||||
| 0x42 | DELTA | either → either | `<origin>;<seq>;<evt>` |
|
| 0x42 | DELTA | either → either | `<origin>;<seq>;<evt>` |
|
||||||
| 0x43 | BYE | either → either | `<origin>` |
|
| 0x43 | BYE | either → either | `<origin>` |
|
||||||
|
| 0x44 | SLSYNC | either → either | `<origin>;<seq>;<json>` — live set-list **content** merge (§8) |
|
||||||
|
| 0x45 | LOGSYNC | either → either | `<origin>;<seq>;<json>` — practice-log entry merge (§9) |
|
||||||
|
|
||||||
- **Payload is 7‑bit ASCII** — never emit a byte > `0x7F` (it corrupts the SysEx
|
- **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
|
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,
|
- a coalesced `0x41` FULL for any lane‑field / add / remove / practice (trainer,
|
||||||
ramp, segment bars, countdown) edit
|
ramp, segment bars, countdown) edit
|
||||||
- `0x41` FULL on connect and in reply to a received `0x40`
|
- `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):
|
**Device should emit** (from its on‑device input handlers):
|
||||||
- `play`/`stop` when button A toggles transport
|
- `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.
|
- [ ] **Heartbeat:** emit `0x41` FULL every ~3–5 s while a peer is connected.
|
||||||
- [ ] **BYE (`0x43`)** → mark the peer gone (stop heartbeating/emitting until
|
- [ ] **BYE (`0x43`)** → mark the peer gone (stop heartbeating/emitting until
|
||||||
the next HELLO).
|
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 —
|
- [ ] **Throttle** high‑rate sources (joystick tempo) and keep frames small —
|
||||||
the RP2040 USB‑MIDI RX buffer is tiny (the firmware updater already chunks
|
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,
|
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.
|
target.
|
||||||
|
|
||||||
### Out of scope for the beta
|
### 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.).
|
- Mirroring device `settings.json` (LED brightness, MIDI config, etc.).
|
||||||
- Multi‑peer / multi‑editor arbitration beyond last‑writer‑wins.
|
- 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
|
## 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`
|
the wire, and the **device id is only exposed on the version query** (SysEx `0x02`
|
||||||
→ `0x03` reply, `<id>;<version>`; pre‑0.0.23 firmware sends bare version → assume
|
→ `0x03` reply, `<id>;<version>`; pre‑0.0.23 firmware sends bare version → assume
|
||||||
`K`).
|
`K`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Set‑list content sync (`0x44` SLSYNC)
|
||||||
|
|
||||||
|
The `0x41` FULL only carries the *one loaded program* (`<patch>`) 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 <origin>;<seq>;<json> F7`
|
||||||
|
|
||||||
|
- `<origin>` / `<seq>` — same as the other ops (echo‑drop + duplicate info).
|
||||||
|
- `<json>` — 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 <origin>;<seq>;<json> F7`
|
||||||
|
|
||||||
|
`<json>` 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.
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,10 @@ sound = name | int ; (* int = GM percussion note numb
|
||||||
groups = int *( "+" int ) ; (* "4" or "2+2+3" → beats per bar *)
|
groups = int *( "+" int ) ; (* "4" or "2+2+3" → beats per bar *)
|
||||||
sub = int ; (* subdivision; trailing "s" = swing *)
|
sub = int ; (* subdivision; trailing "s" = swing *)
|
||||||
euclid = "(" int [ "," int [ "," int ] ] ")" ; (* k [, n [, rot ]] — even distribution *)
|
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
|
### 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).
|
- **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),
|
- **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`.
|
`.`/`-`/`_`/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
|
**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
|
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,
|
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) —
|
`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.
|
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
|
## 6. Divergences — status
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
<p class="pick"><label for="ffSel">Show snippets for:</label>
|
<p class="pick"><label for="ffSel">Show snippets for:</label>
|
||||||
<select id="ffSel">
|
<select id="ffSel">
|
||||||
<option value="editor">PM_E‑1 Editor</option>
|
<option value="editor">PM_E‑1 Editor</option>
|
||||||
|
<option value="pme2">PM_E‑2 Editor (Notation)</option>
|
||||||
<option value="teacher">PM_T‑1 Teacher</option>
|
<option value="teacher">PM_T‑1 Teacher</option>
|
||||||
<option value="stage">PM_S‑1 Stage</option>
|
<option value="stage">PM_S‑1 Stage</option>
|
||||||
<option value="micro" selected>PM_P‑1 Practice</option>
|
<option value="micro" selected>PM_P‑1 Practice</option>
|
||||||
|
|
@ -104,6 +105,7 @@ const ORIGIN = "https://metronome.varasys.io";
|
||||||
const DEMO_PATCH = "v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2";
|
const DEMO_PATCH = "v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2";
|
||||||
const FF = [
|
const FF = [
|
||||||
{ k:"editor", name:"PM_E‑1 Editor", file:"editor.html", h:560 },
|
{ k:"editor", name:"PM_E‑1 Editor", file:"editor.html", h:560 },
|
||||||
|
{ k:"pme2", name:"PM_E‑2 Editor", file:"pm_e-2.html", h:640 },
|
||||||
{ k:"kit", name:"PM_K‑1 Kit", file:"kit.html", h:560 },
|
{ k:"kit", name:"PM_K‑1 Kit", file:"kit.html", h:560 },
|
||||||
{ k:"teacher", name:"PM_T‑1 Teacher", file:"teacher.html", h:440 },
|
{ k:"teacher", name:"PM_T‑1 Teacher", file:"teacher.html", h:440 },
|
||||||
{ k:"stage", name:"PM_S‑1 Stage", file:"stage.html", h:430 },
|
{ k:"stage", name:"PM_S‑1 Stage", file:"stage.html", h:430 },
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ const SAMPLES = {}; let state = { bpm:120, volume:0.85 }, meters = [], muteWindo
|
||||||
|
|
||||||
const VERSIONS = [
|
const VERSIONS = [
|
||||||
{ key:"editor", file:"/editor.html", name:"PM_E‑1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, per‑step accents/ghosts/mutes, swing & polyrhythm, set lists, per‑lane dB gain." },
|
{ key:"editor", file:"/editor.html", name:"PM_E‑1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, per‑step accents/ghosts/mutes, swing & polyrhythm, set lists, per‑lane dB gain." },
|
||||||
|
{ key:"pme2", file:"/pm_e-2.html", name:"PM_E‑2 Editor", chip:"app", h:640, sum:"Second‑generation editor built around engraved drum notation — a 5‑line percussion staff (Bravura/SMuFL) with Staff / TUBS / Konnakol views, edit‑on‑staff, plus flams/drags/rolls, odd meters & clave." },
|
||||||
{ key:"kit", file:"/kit.html", name:"PM_K‑1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
|
{ key:"kit", file:"/kit.html", name:"PM_K‑1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
|
||||||
{ key:"explorer", file:"/explorer.html", name:"PM_X‑1 Explorer", chip:"hw", h:500, sum:"Off‑the‑shelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a button‑driven sibling to the Kit. Edit on the web with Live sync; the device mirrors play/stop/tempo/track changes both ways." },
|
{ key:"explorer", file:"/explorer.html", name:"PM_X‑1 Explorer", chip:"hw", h:500, sum:"Off‑the‑shelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a button‑driven sibling to the Kit. Edit on the web with Live sync; the device mirrors play/stop/tempo/track changes both ways." },
|
||||||
{ key:"grid", file:"/grid.html", name:"PM_G‑1 Grid", chip:"hw", h:470, sum:"Off‑the‑shelf — a Pimoroni Pico Scroll Pack (17×7 white LED matrix + 4 buttons) on a Raspberry Pi Pico. The matrix IS the editor's lane × step pad grid in miniature; edit on the web with Live sync." },
|
{ key:"grid", file:"/grid.html", name:"PM_G‑1 Grid", chip:"hw", h:470, sum:"Off‑the‑shelf — a Pimoroni Pico Scroll Pack (17×7 white LED matrix + 4 buttons) on a Raspberry Pi Pico. The matrix IS the editor's lane × step pad grid in miniature; edit on the web with Live sync." },
|
||||||
|
|
|
||||||
129
pico-cp/app.py
129
pico-cp/app.py
|
|
@ -261,7 +261,9 @@ ICON_USB = load_alpha("/usb.bin") # trident: lit when USB-conne
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ==============================
|
# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ==============================
|
||||||
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
|
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
|
||||||
|
'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2} # ornament hits: UPPER = accented, lower = normal
|
||||||
|
ORN = {'f': 1, 'F': 1, 'd': 2, 'D': 2, 'z': 3, 'Z': 3} # ornament type: 0 none / 1 flam / 2 drag / 3 roll
|
||||||
PRIO = {2: 3, 1: 2, 3: 1}
|
PRIO = {2: 3, 1: 2, 3: 1}
|
||||||
# General-MIDI percussion note numbers -> voice names (so a lane can be typed as "36:4"); matches the web GM_NUM
|
# General-MIDI percussion note numbers -> voice names (so a lane can be typed as "36:4"); matches the web GM_NUM
|
||||||
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
|
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
|
||||||
|
|
@ -345,10 +347,13 @@ def _parse_lane(tok):
|
||||||
for h in _euclid(k, n, rot):
|
for h in _euclid(k, n, rot):
|
||||||
if h: levels.append(2 if first else 1); first = False
|
if h: levels.append(2 if first else 1); first = False
|
||||||
else: levels.append(0)
|
else: levels.append(0)
|
||||||
|
orns = [0] * len(levels) # euclid hits carry no ornament
|
||||||
elif pattern:
|
elif pattern:
|
||||||
steps = beats * sub
|
steps = beats * sub
|
||||||
levels = [PAT.get(ch, 0) for ch in pattern]
|
levels = [PAT.get(ch, 0) for ch in pattern]
|
||||||
if len(levels) < steps: levels += [0] * (steps - len(levels))
|
orns = [ORN.get(ch, 0) for ch in pattern] # per-step flam/drag/roll, parallel to levels
|
||||||
|
if len(levels) < steps:
|
||||||
|
levels += [0] * (steps - len(levels)); orns += [0] * (steps - len(orns))
|
||||||
steps = len(levels)
|
steps = len(levels)
|
||||||
else:
|
else:
|
||||||
steps = beats * sub
|
steps = beats * sub
|
||||||
|
|
@ -356,15 +361,21 @@ def _parse_lane(tok):
|
||||||
for i in range(steps):
|
for i in range(steps):
|
||||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
|
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
|
||||||
else: levels.append(1) # off-beat subdivisions sound at normal (grouping IS the accent map)
|
else: levels.append(1) # off-beat subdivisions sound at normal (grouping IS the accent map)
|
||||||
|
orns = [0] * steps
|
||||||
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
|
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
|
||||||
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
|
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'orns': orns,
|
||||||
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
|
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
|
||||||
|
|
||||||
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'} # level -> pattern char (inverse of PAT)
|
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'} # level -> pattern char (inverse of PAT)
|
||||||
|
ORN_CH = {1: ('f', 'F'), 2: ('d', 'D'), 3: ('z', 'Z')} # ornament -> (normal, accented) pattern char
|
||||||
|
def _cell_ch(v, o): # (level, ornament) -> one pattern char
|
||||||
|
if o in ORN_CH: return ORN_CH[o][1 if v >= 2 else 0]
|
||||||
|
return PAT_CH.get(v, '.')
|
||||||
def lane_to_str(L): # serialize a lane back to the share grammar (round-trips)
|
def lane_to_str(L): # serialize a lane back to the share grammar (round-trips)
|
||||||
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
|
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
|
||||||
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
|
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
|
||||||
s += '=' + ''.join(PAT_CH.get(v, '.') for v in L['levels'])
|
orns = L.get('orns') or [0] * len(L['levels'])
|
||||||
|
s += '=' + ''.join(_cell_ch(v, orns[i] if i < len(orns) else 0) for i, v in enumerate(L['levels']))
|
||||||
s += L.get('gain', '')
|
s += L.get('gain', '')
|
||||||
if L['poly']: s += '~'
|
if L['poly']: s += '~'
|
||||||
if L['mute']: s += '!'
|
if L['mute']: s += '!'
|
||||||
|
|
@ -658,6 +669,7 @@ class App:
|
||||||
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]}
|
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]}
|
||||||
try:
|
try:
|
||||||
with open("/programs.json", "w") as f: json.dump(data, f)
|
with open("/programs.json", "w") as f: json.dump(data, f)
|
||||||
|
if not self._sync_applying: self._sync_send_setlists() # mirror our user library to the editor (sec 8)
|
||||||
return True
|
return True
|
||||||
except OSError:
|
except OSError:
|
||||||
return False # editor mode: the drive is read-only to us
|
return False # editor mode: the drive is read-only to us
|
||||||
|
|
@ -1153,6 +1165,96 @@ class App:
|
||||||
finally:
|
finally:
|
||||||
self._sync_applying = False
|
self._sync_applying = False
|
||||||
|
|
||||||
|
# ---------- set-list content sync (0x44) + practice-log sync (0x45); see docs/livesync-protocol.md ----------
|
||||||
|
def _sync_send_setlists(self): # 0x44: manifest of OUR user lists (programs.json shape), merge by title
|
||||||
|
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
|
||||||
|
user = [s for s in self.setlists if not s['builtin']]
|
||||||
|
sls = [{"title": s['title'],
|
||||||
|
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]
|
||||||
|
try: body = json.dumps({"setlists": sls})
|
||||||
|
except Exception: return
|
||||||
|
self._sync_send(0x44, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
|
||||||
|
def _log_to_wire(self, e): # device entry -> wire schema {at,name,dur,bpm} (sec 9)
|
||||||
|
return {"at": e.get("at", 0), "name": e.get("name", ""), "dur": e.get("dur", 0), "bpm": e.get("bpm", 0)}
|
||||||
|
def _sync_send_log_batch(self): # 0x45: whole practice log (on connect / HELLO)
|
||||||
|
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
|
||||||
|
try: body = json.dumps({"log": [self._log_to_wire(e) for e in self.log]})
|
||||||
|
except Exception: return
|
||||||
|
self._sync_send(0x45, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
|
||||||
|
def _sync_send_log_one(self, e): # 0x45: a single freshly-logged session
|
||||||
|
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
|
||||||
|
try: body = json.dumps({"log": [self._log_to_wire(e)]})
|
||||||
|
except Exception: return
|
||||||
|
self._sync_send(0x45, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
|
||||||
|
def _sync_apply_setlists(self, body): # merge user lists by normalized title (replace / append; never delete built-ins)
|
||||||
|
self._sync_applying = True
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
d = json.loads(body); lists = d.get("setlists")
|
||||||
|
if not isinstance(lists, list): return
|
||||||
|
builtin_keys = set(_slkey(t) for t, _ in BUILTIN_SETLISTS)
|
||||||
|
changed = False
|
||||||
|
for rl in lists:
|
||||||
|
title = rl.get("title", ""); key = _slkey(title)
|
||||||
|
if not key or key in builtin_keys: continue # never overwrite a baked-in list
|
||||||
|
items = [(p.get("name", "Item"), p.get("prog", "")) for p in rl.get("programs", []) if p.get("prog")]
|
||||||
|
tgt = None
|
||||||
|
for s in self.setlists:
|
||||||
|
if not s['builtin'] and _slkey(s['title']) == key: tgt = s; break
|
||||||
|
if tgt is not None: tgt['items'] = items
|
||||||
|
else: self.setlists.append({'title': title or "Device", 'items': items, 'builtin': False})
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self._persist_user() # write back to programs.json (no-op if read-only)
|
||||||
|
if self.sl >= len(self.setlists): self.sl = 0
|
||||||
|
self.draw_meters()
|
||||||
|
except Exception as e:
|
||||||
|
try: print("sync SLSYNC:", e)
|
||||||
|
except Exception: pass
|
||||||
|
finally:
|
||||||
|
self._sync_applying = False
|
||||||
|
def _sync_apply_log(self, body): # additive merge by (at,name); at==0 always appended
|
||||||
|
self._sync_applying = True
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
d = json.loads(body); incoming = d.get("log")
|
||||||
|
if not isinstance(incoming, list): return
|
||||||
|
have = set()
|
||||||
|
for e in self.log:
|
||||||
|
a = e.get("at", 0)
|
||||||
|
if a: have.add((a, e.get("name", "")))
|
||||||
|
added = 0
|
||||||
|
for w in incoming:
|
||||||
|
nm = w.get("name", "")
|
||||||
|
if not nm: continue
|
||||||
|
at = w.get("at", 0) or 0
|
||||||
|
if at and (at, nm) in have: continue
|
||||||
|
self.log.append({"at": at, "name": nm, "dur": w.get("dur", 0), "bpm": w.get("bpm", 0),
|
||||||
|
"t": self._hhmm(at), "bars": 0})
|
||||||
|
if at: have.add((at, nm))
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
self.log.sort(key=lambda e: e.get("at", 0), reverse=True) # newest first
|
||||||
|
del self.log[200:]
|
||||||
|
self._save_log(); self.draw_log()
|
||||||
|
except Exception as e:
|
||||||
|
try: print("sync LOGSYNC:", e)
|
||||||
|
except Exception: pass
|
||||||
|
finally:
|
||||||
|
self._sync_applying = False
|
||||||
|
def _hhmm(self, at_ms): # epoch-ms -> "HH:MM" for the on-device log row (0 -> unknown)
|
||||||
|
if not at_ms: return "--:--"
|
||||||
|
try:
|
||||||
|
t = time.localtime(at_ms // 1000); return "%02d:%02d" % (t.tm_hour, t.tm_min)
|
||||||
|
except Exception:
|
||||||
|
return "--:--"
|
||||||
|
def _epoch_ms(self): # real epoch ms once the editor has set the RTC, else 0 (unset)
|
||||||
|
try:
|
||||||
|
secs = time.time()
|
||||||
|
return int(secs) * 1000 if secs > 1_000_000_000 else 0 # < 2001 -> RTC unset, no stable key
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
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 or self._fw_pushing: return # keep the bus quiet during a firmware push so ACKs aren't interleaved
|
if self.midi is None or self._fw_pushing: return # keep the bus quiet during a firmware push so ACKs aren't interleaved
|
||||||
b = self._note_buf # reused bytearray -> zero alloc per click (hot path)
|
b = self._note_buf # reused bytearray -> zero alloc per click (hot path)
|
||||||
|
|
@ -1531,10 +1633,13 @@ class App:
|
||||||
if dur < MIN_LOG_SEC: return # skip plays under 5 seconds
|
if dur < MIN_LOG_SEC: return # skip plays under 5 seconds
|
||||||
mlen = self.lanes[0]['steps'] if self.lanes else 1
|
mlen = self.lanes[0]['steps'] if self.lanes else 1
|
||||||
t = time.localtime()
|
t = time.localtime()
|
||||||
self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
|
e = {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
|
||||||
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name})
|
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name,
|
||||||
|
"at": self._epoch_ms()} # epoch ms (when RTC set) = cross-half dedup key (sec 9)
|
||||||
|
self.log.insert(0, e)
|
||||||
del self.log[200:]; self._armed = None
|
del self.log[200:]; self._armed = None
|
||||||
self._save_log(); self.draw_log()
|
self._save_log(); self.draw_log()
|
||||||
|
self._sync_send_log_one(e) # mirror this session to the editor (no-op if not armed)
|
||||||
def draw_log(self):
|
def draw_log(self):
|
||||||
g = self.g_log
|
g = self.g_log
|
||||||
while len(g): g.pop()
|
while len(g): g.pop()
|
||||||
|
|
@ -1613,14 +1718,14 @@ class App:
|
||||||
if self.midi: # old firmware sent bare APP_VERSION; editor parses "contains ';'?" for back-compat
|
if self.midi: # old firmware sent bare APP_VERSION; editor parses "contains ';'?" for back-compat
|
||||||
payload = DEVICE_ID + ";" + APP_VERSION
|
payload = DEVICE_ID + ";" + APP_VERSION
|
||||||
self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
|
self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
|
||||||
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43: # Live sync (see src/livesync.js)
|
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43 or cmd == 0x44 or cmd == 0x45: # Live sync (see src/livesync.js)
|
||||||
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
|
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
|
||||||
except Exception: return
|
except Exception: return
|
||||||
origin = text.split(";", 1)[0] if text else ""
|
origin = text.split(";", 1)[0] if text else ""
|
||||||
if origin == self._sync_origin: return # drop our own echoes (composite USB may loop)
|
if origin == self._sync_origin: return # drop our own echoes (composite USB may loop)
|
||||||
self._sync_armed = True
|
self._sync_armed = True
|
||||||
if cmd == 0x40: # HELLO -> reply with our current FULL
|
if cmd == 0x40: # HELLO -> reply with our FULL + set-list library + practice log
|
||||||
self._sync_broadcast_full()
|
self._sync_broadcast_full(); self._sync_send_setlists(); self._sync_send_log_batch()
|
||||||
elif cmd == 0x43: # BYE -> peer disconnected; stop heartbeats
|
elif cmd == 0x43: # BYE -> peer disconnected; stop heartbeats
|
||||||
self._sync_armed = False
|
self._sync_armed = False
|
||||||
elif cmd == 0x41: # FULL: origin;seq;running;sl;item;patch...
|
elif cmd == 0x41: # FULL: origin;seq;running;sl;item;patch...
|
||||||
|
|
@ -1633,6 +1738,12 @@ class App:
|
||||||
elif cmd == 0x42: # DELTA: origin;seq;evt
|
elif cmd == 0x42: # DELTA: origin;seq;evt
|
||||||
parts = text.split(";", 2)
|
parts = text.split(";", 2)
|
||||||
if len(parts) >= 3: self._sync_apply_delta(parts[2])
|
if len(parts) >= 3: self._sync_apply_delta(parts[2])
|
||||||
|
elif cmd == 0x44: # SLSYNC: origin;seq;json (set-list content)
|
||||||
|
parts = text.split(";", 2)
|
||||||
|
if len(parts) >= 3: self._sync_apply_setlists(parts[2])
|
||||||
|
elif cmd == 0x45: # LOGSYNC: origin;seq;json (practice entries)
|
||||||
|
parts = text.split(";", 2)
|
||||||
|
if len(parts) >= 3: self._sync_apply_log(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:]))
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,9 @@ ICON_USB = load_alpha("/usb.bin")
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
# ============================== POLYMETER ENGINE ==============================
|
# ============================== POLYMETER ENGINE ==============================
|
||||||
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
|
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
|
||||||
|
'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2} # ornament hits: UPPER = accented, lower = normal
|
||||||
|
ORN = {'f': 1, 'F': 1, 'd': 2, 'D': 2, 'z': 3, 'Z': 3} # ornament type: 0 none / 1 flam / 2 drag / 3 roll
|
||||||
PRIO = {2: 3, 1: 2, 3: 1}
|
PRIO = {2: 3, 1: 2, 3: 1}
|
||||||
# General-MIDI percussion note numbers -> voice names (so a lane can be typed as "36:4"); matches the web GM_NUM
|
# General-MIDI percussion note numbers -> voice names (so a lane can be typed as "36:4"); matches the web GM_NUM
|
||||||
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
|
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
|
||||||
|
|
@ -314,10 +316,13 @@ def _parse_lane(tok):
|
||||||
for h in _euclid(k, n, rot):
|
for h in _euclid(k, n, rot):
|
||||||
if h: levels.append(2 if first else 1); first = False
|
if h: levels.append(2 if first else 1); first = False
|
||||||
else: levels.append(0)
|
else: levels.append(0)
|
||||||
|
orns = [0] * len(levels) # euclid hits carry no ornament
|
||||||
elif pattern:
|
elif pattern:
|
||||||
steps = beats * sub
|
steps = beats * sub
|
||||||
levels = [PAT.get(ch, 0) for ch in pattern]
|
levels = [PAT.get(ch, 0) for ch in pattern]
|
||||||
if len(levels) < steps: levels += [0] * (steps - len(levels))
|
orns = [ORN.get(ch, 0) for ch in pattern] # per-step flam/drag/roll, parallel to levels
|
||||||
|
if len(levels) < steps:
|
||||||
|
levels += [0] * (steps - len(levels)); orns += [0] * (steps - len(orns))
|
||||||
steps = len(levels)
|
steps = len(levels)
|
||||||
else:
|
else:
|
||||||
steps = beats * sub
|
steps = beats * sub
|
||||||
|
|
@ -325,15 +330,21 @@ def _parse_lane(tok):
|
||||||
for i in range(steps):
|
for i in range(steps):
|
||||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
|
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
|
||||||
else: levels.append(1) # off-beat subdivisions sound at normal (grouping IS the accent map)
|
else: levels.append(1) # off-beat subdivisions sound at normal (grouping IS the accent map)
|
||||||
|
orns = [0] * steps
|
||||||
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
|
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
|
||||||
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
|
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'orns': orns,
|
||||||
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
|
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
|
||||||
|
|
||||||
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'}
|
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'}
|
||||||
|
ORN_CH = {1: ('f', 'F'), 2: ('d', 'D'), 3: ('z', 'Z')} # ornament -> (normal, accented) pattern char
|
||||||
|
def _cell_ch(v, o): # (level, ornament) -> one pattern char
|
||||||
|
if o in ORN_CH: return ORN_CH[o][1 if v >= 2 else 0]
|
||||||
|
return PAT_CH.get(v, '.')
|
||||||
def lane_to_str(L):
|
def lane_to_str(L):
|
||||||
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
|
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
|
||||||
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
|
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
|
||||||
s += '=' + ''.join(PAT_CH.get(v, '.') for v in L['levels'])
|
orns = L.get('orns') or [0] * len(L['levels'])
|
||||||
|
s += '=' + ''.join(_cell_ch(v, orns[i] if i < len(orns) else 0) for i, v in enumerate(L['levels']))
|
||||||
s += L.get('gain', '')
|
s += L.get('gain', '')
|
||||||
if L['poly']: s += '~'
|
if L['poly']: s += '~'
|
||||||
if L['mute']: s += '!'
|
if L['mute']: s += '!'
|
||||||
|
|
@ -881,6 +892,107 @@ class App:
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
finally:
|
finally:
|
||||||
self._sync_applying = False
|
self._sync_applying = False
|
||||||
|
|
||||||
|
# ---------- set-list content sync (0x44) + practice-log sync (0x45); see docs/livesync-protocol.md ----------
|
||||||
|
# PM_X-1 has no on-device set-list editing, so it APPLIES 0x44 but never EMITS one.
|
||||||
|
# It does emit 0x45 (logged sessions), like the Kit.
|
||||||
|
def _persist_user(self): # write all user playlists back to /programs.json (no-op if read-only)
|
||||||
|
user = [s for s in self.setlists if not s['builtin']]
|
||||||
|
data = {"setlists": [{"title": s['title'],
|
||||||
|
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]}
|
||||||
|
try:
|
||||||
|
with open("/programs.json", "w") as f: json.dump(data, f)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
def _log_to_wire(self, e):
|
||||||
|
return {"at": e.get("at", 0), "name": e.get("name", ""), "dur": e.get("dur", 0), "bpm": e.get("bpm", 0)}
|
||||||
|
def _sync_send_setlists(self): # 0x44: our user library (HELLO reply only -- X can't edit lists)
|
||||||
|
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
|
||||||
|
user = [s for s in self.setlists if not s['builtin']]
|
||||||
|
sls = [{"title": s['title'],
|
||||||
|
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]
|
||||||
|
try: body = json.dumps({"setlists": sls})
|
||||||
|
except Exception: return
|
||||||
|
self._sync_send(0x44, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
|
||||||
|
def _sync_send_log_batch(self): # 0x45: whole practice log (on connect / HELLO)
|
||||||
|
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
|
||||||
|
try: body = json.dumps({"log": [self._log_to_wire(e) for e in self.log]})
|
||||||
|
except Exception: return
|
||||||
|
self._sync_send(0x45, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
|
||||||
|
def _sync_send_log_one(self, e): # 0x45: a single freshly-logged session
|
||||||
|
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
|
||||||
|
try: body = json.dumps({"log": [self._log_to_wire(e)]})
|
||||||
|
except Exception: return
|
||||||
|
self._sync_send(0x45, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
|
||||||
|
def _sync_apply_setlists(self, body): # merge user lists by normalized title (replace / append; never built-ins)
|
||||||
|
self._sync_applying = True
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
d = json.loads(body); lists = d.get("setlists")
|
||||||
|
if not isinstance(lists, list): return
|
||||||
|
builtin_keys = set(_slkey(t) for t, _ in BUILTIN_SETLISTS)
|
||||||
|
changed = False
|
||||||
|
for rl in lists:
|
||||||
|
title = rl.get("title", ""); key = _slkey(title)
|
||||||
|
if not key or key in builtin_keys: continue
|
||||||
|
items = [(p.get("name", "Item"), p.get("prog", "")) for p in rl.get("programs", []) if p.get("prog")]
|
||||||
|
tgt = None
|
||||||
|
for s in self.setlists:
|
||||||
|
if not s['builtin'] and _slkey(s['title']) == key: tgt = s; break
|
||||||
|
if tgt is not None: tgt['items'] = items
|
||||||
|
else: self.setlists.append({'title': title or "Device", 'items': items, 'builtin': False})
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self._persist_user()
|
||||||
|
if self.sl >= len(self.setlists): self.sl = 0
|
||||||
|
self.draw_meters()
|
||||||
|
except Exception as e:
|
||||||
|
try: print("sync SLSYNC:", e)
|
||||||
|
except Exception: pass
|
||||||
|
finally:
|
||||||
|
self._sync_applying = False
|
||||||
|
def _sync_apply_log(self, body): # additive merge by (at,name); at==0 always appended
|
||||||
|
self._sync_applying = True
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
d = json.loads(body); incoming = d.get("log")
|
||||||
|
if not isinstance(incoming, list): return
|
||||||
|
have = set()
|
||||||
|
for e in self.log:
|
||||||
|
a = e.get("at", 0)
|
||||||
|
if a: have.add((a, e.get("name", "")))
|
||||||
|
added = 0
|
||||||
|
for w in incoming:
|
||||||
|
nm = w.get("name", "")
|
||||||
|
if not nm: continue
|
||||||
|
at = w.get("at", 0) or 0
|
||||||
|
if at and (at, nm) in have: continue
|
||||||
|
self.log.append({"at": at, "name": nm, "dur": w.get("dur", 0), "bpm": w.get("bpm", 0),
|
||||||
|
"t": self._hhmm(at), "bars": 0})
|
||||||
|
if at: have.add((at, nm))
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
self.log.sort(key=lambda e: e.get("at", 0), reverse=True)
|
||||||
|
del self.log[200:]
|
||||||
|
self._save_log(); self.draw_log()
|
||||||
|
except Exception as e:
|
||||||
|
try: print("sync LOGSYNC:", e)
|
||||||
|
except Exception: pass
|
||||||
|
finally:
|
||||||
|
self._sync_applying = False
|
||||||
|
def _hhmm(self, at_ms):
|
||||||
|
if not at_ms: return "--:--"
|
||||||
|
try:
|
||||||
|
t = time.localtime(at_ms // 1000); return "%02d:%02d" % (t.tm_hour, t.tm_min)
|
||||||
|
except Exception:
|
||||||
|
return "--:--"
|
||||||
|
def _epoch_ms(self):
|
||||||
|
try:
|
||||||
|
secs = time.time()
|
||||||
|
return int(secs) * 1000 if secs > 1_000_000_000 else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
def _regen_levels(self, L): # called on remote lane= deltas to recompute default accents
|
def _regen_levels(self, L): # called on remote lane= deltas to recompute default accents
|
||||||
sub = L['sub']; groups = L['groups']; starts = set(); acc = 0
|
sub = L['sub']; groups = L['groups']; starts = set(); acc = 0
|
||||||
for gp in groups: starts.add(acc); acc += gp
|
for gp in groups: starts.add(acc); acc += gp
|
||||||
|
|
@ -1352,10 +1464,13 @@ class App:
|
||||||
if dur < MIN_LOG_SEC: return
|
if dur < MIN_LOG_SEC: return
|
||||||
mlen = self.lanes[0]['steps'] if self.lanes else 1
|
mlen = self.lanes[0]['steps'] if self.lanes else 1
|
||||||
t = time.localtime()
|
t = time.localtime()
|
||||||
self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
|
e = {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
|
||||||
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name})
|
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name,
|
||||||
|
"at": self._epoch_ms()} # epoch ms (when RTC set) = cross-half dedup key (sec 9)
|
||||||
|
self.log.insert(0, e)
|
||||||
del self.log[200:]
|
del self.log[200:]
|
||||||
self._save_log(); self.draw_log()
|
self._save_log(); self.draw_log()
|
||||||
|
self._sync_send_log_one(e) # mirror this session to the editor (no-op if not armed)
|
||||||
def draw_log(self): # footer practice log (this track only), Kit-style
|
def draw_log(self): # footer practice log (this track only), Kit-style
|
||||||
g = self.g_log
|
g = self.g_log
|
||||||
while len(g): g.pop()
|
while len(g): g.pop()
|
||||||
|
|
@ -1427,14 +1542,14 @@ class App:
|
||||||
if self.midi:
|
if self.midi:
|
||||||
payload = DEVICE_ID + ";" + APP_VERSION
|
payload = DEVICE_ID + ";" + APP_VERSION
|
||||||
self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
|
self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
|
||||||
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43:
|
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43 or cmd == 0x44 or cmd == 0x45:
|
||||||
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
|
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
|
||||||
except Exception: return
|
except Exception: return
|
||||||
origin = text.split(";", 1)[0] if text else ""
|
origin = text.split(";", 1)[0] if text else ""
|
||||||
if origin == self._sync_origin: return
|
if origin == self._sync_origin: return
|
||||||
self._sync_armed = True
|
self._sync_armed = True
|
||||||
if cmd == 0x40:
|
if cmd == 0x40:
|
||||||
self._sync_broadcast_full()
|
self._sync_broadcast_full(); self._sync_send_setlists(); self._sync_send_log_batch()
|
||||||
elif cmd == 0x43:
|
elif cmd == 0x43:
|
||||||
self._sync_armed = False
|
self._sync_armed = False
|
||||||
elif cmd == 0x41:
|
elif cmd == 0x41:
|
||||||
|
|
@ -1447,6 +1562,12 @@ class App:
|
||||||
elif cmd == 0x42:
|
elif cmd == 0x42:
|
||||||
parts = text.split(";", 2)
|
parts = text.split(";", 2)
|
||||||
if len(parts) >= 3: self._sync_apply_delta(parts[2])
|
if len(parts) >= 3: self._sync_apply_delta(parts[2])
|
||||||
|
elif cmd == 0x44: # SLSYNC: origin;seq;json (set-list content)
|
||||||
|
parts = text.split(";", 2)
|
||||||
|
if len(parts) >= 3: self._sync_apply_setlists(parts[2])
|
||||||
|
elif cmd == 0x45: # LOGSYNC: origin;seq;json (practice entries)
|
||||||
|
parts = text.split(";", 2)
|
||||||
|
if len(parts) >= 3: self._sync_apply_log(parts[2])
|
||||||
elif cmd == 0x10:
|
elif cmd == 0x10:
|
||||||
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:]))
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,9 @@ GM_DEFAULT = 37
|
||||||
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
|
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
|
||||||
|
|
||||||
# ============================== POLYMETER ENGINE (identical to ../pico-explorer/app.py) ==============================
|
# ============================== POLYMETER ENGINE (identical to ../pico-explorer/app.py) ==============================
|
||||||
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
|
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
|
||||||
|
'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2} # ornament hits: UPPER = accented, lower = normal
|
||||||
|
ORN = {'f': 1, 'F': 1, 'd': 2, 'D': 2, 'z': 3, 'Z': 3} # ornament type: 0 none / 1 flam / 2 drag / 3 roll
|
||||||
PRIO = {2: 3, 1: 2, 3: 1}
|
PRIO = {2: 3, 1: 2, 3: 1}
|
||||||
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
|
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
|
||||||
43: "tomLow", 44: "hatClosed", 45: "tomMid", 46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash",
|
43: "tomLow", 44: "hatClosed", 45: "tomMid", 46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash",
|
||||||
|
|
@ -179,10 +181,13 @@ def _parse_lane(tok):
|
||||||
for h in _euclid(k, n, rot):
|
for h in _euclid(k, n, rot):
|
||||||
if h: levels.append(2 if first else 1); first = False
|
if h: levels.append(2 if first else 1); first = False
|
||||||
else: levels.append(0)
|
else: levels.append(0)
|
||||||
|
orns = [0] * len(levels) # euclid hits carry no ornament
|
||||||
elif pattern:
|
elif pattern:
|
||||||
steps = beats * sub
|
steps = beats * sub
|
||||||
levels = [PAT.get(ch, 0) for ch in pattern]
|
levels = [PAT.get(ch, 0) for ch in pattern]
|
||||||
if len(levels) < steps: levels += [0] * (steps - len(levels))
|
orns = [ORN.get(ch, 0) for ch in pattern] # per-step flam/drag/roll, parallel to levels
|
||||||
|
if len(levels) < steps:
|
||||||
|
levels += [0] * (steps - len(levels)); orns += [0] * (steps - len(orns))
|
||||||
steps = len(levels)
|
steps = len(levels)
|
||||||
else:
|
else:
|
||||||
steps = beats * sub
|
steps = beats * sub
|
||||||
|
|
@ -190,15 +195,21 @@ def _parse_lane(tok):
|
||||||
for i in range(steps):
|
for i in range(steps):
|
||||||
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
|
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
|
||||||
else: levels.append(1) # off-beat subdivisions sound at normal
|
else: levels.append(1) # off-beat subdivisions sound at normal
|
||||||
|
orns = [0] * steps
|
||||||
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
|
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
|
||||||
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
|
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'orns': orns,
|
||||||
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
|
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
|
||||||
|
|
||||||
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'}
|
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'}
|
||||||
|
ORN_CH = {1: ('f', 'F'), 2: ('d', 'D'), 3: ('z', 'Z')} # ornament -> (normal, accented) pattern char
|
||||||
|
def _cell_ch(v, o): # (level, ornament) -> one pattern char
|
||||||
|
if o in ORN_CH: return ORN_CH[o][1 if v >= 2 else 0]
|
||||||
|
return PAT_CH.get(v, '.')
|
||||||
def lane_to_str(L):
|
def lane_to_str(L):
|
||||||
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
|
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
|
||||||
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
|
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
|
||||||
s += '=' + ''.join(PAT_CH.get(v, '.') for v in L['levels'])
|
orns = L.get('orns') or [0] * len(L['levels'])
|
||||||
|
s += '=' + ''.join(_cell_ch(v, orns[i] if i < len(orns) else 0) for i, v in enumerate(L['levels']))
|
||||||
s += L.get('gain', '')
|
s += L.get('gain', '')
|
||||||
if L['poly']: s += '~'
|
if L['poly']: s += '~'
|
||||||
if L['mute']: s += '!'
|
if L['mute']: s += '!'
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,8 @@ class GT911:
|
||||||
# program string: [v1;]t<bpm>;<lane>;<lane>;... (globals like b/rmp/tr are ignored on-device)
|
# program string: [v1;]t<bpm>;<lane>;<lane>;... (globals like b/rmp/tr are ignored on-device)
|
||||||
# lane = <sound>:<grouping>[/<sub>[s]][=pattern][@db][~][!]
|
# lane = <sound>:<grouping>[/<sub>[s]][=pattern][@db][~][!]
|
||||||
# pattern chars: X=accent(2) x=normal(1) g=ghost(3) . - _ =mute(0)
|
# pattern chars: X=accent(2) x=normal(1) g=ghost(3) . - _ =mute(0)
|
||||||
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
|
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
|
||||||
|
'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2} # ornament hits play as their dynamic (no grace-note render here)
|
||||||
PRIO = {2: 3, 1: 2, 3: 1} # click priority when lanes coincide: accent > normal > ghost
|
PRIO = {2: 3, 1: 2, 3: 1} # click priority when lanes coincide: accent > normal > ghost
|
||||||
|
|
||||||
def parse_program(s):
|
def parse_program(s):
|
||||||
|
|
|
||||||
|
|
@ -309,7 +309,15 @@ fn main() -> ! {
|
||||||
info!("parsed groove 0: bpm={} lanes={}, {} free", track.bpm, track.lanes.len(), HEAP.free());
|
info!("parsed groove 0: bpm={} lanes={}, {} free", track.bpm, track.lanes.len(), HEAP.free());
|
||||||
let mut tempo: i64 = track.bpm;
|
let mut tempo: i64 = track.bpm;
|
||||||
let mut playing = true;
|
let mut playing = true;
|
||||||
let mut notation = false;
|
// View cycle on button-B: Grid → Staff → TUBS → Konnakol → Grid …
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum View {
|
||||||
|
Grid,
|
||||||
|
Staff,
|
||||||
|
Tubs,
|
||||||
|
Konnakol,
|
||||||
|
}
|
||||||
|
let mut view = View::Grid;
|
||||||
|
|
||||||
// double-buffer: draw into the RAM framebuffer, then push only the full-width ROW BANDS that
|
// double-buffer: draw into the RAM framebuffer, then push only the full-width ROW BANDS that
|
||||||
// changed vs the last frame. Full-width windows (CASET 0..319) behave exactly like the proven
|
// changed vs the last frame. Full-width windows (CASET 0..319) behave exactly like the proven
|
||||||
|
|
@ -345,7 +353,12 @@ fn main() -> ! {
|
||||||
dirty = true;
|
dirty = true;
|
||||||
}
|
}
|
||||||
if b && !pb {
|
if b && !pb {
|
||||||
notation = !notation;
|
view = match view {
|
||||||
|
View::Grid => View::Staff,
|
||||||
|
View::Staff => View::Tubs,
|
||||||
|
View::Tubs => View::Konnakol,
|
||||||
|
View::Konnakol => View::Grid,
|
||||||
|
};
|
||||||
dirty = true;
|
dirty = true;
|
||||||
force_full = true; // whole screen changes — use the clean full blit
|
force_full = true; // whole screen changes — use the clean full blit
|
||||||
}
|
}
|
||||||
|
|
@ -421,17 +434,20 @@ fn main() -> ! {
|
||||||
.map(|l| pm_ui::LaneView {
|
.map(|l| pm_ui::LaneView {
|
||||||
name: &l.sound,
|
name: &l.sound,
|
||||||
levels: &l.levels,
|
levels: &l.levels,
|
||||||
|
orns: &l.orns,
|
||||||
|
groups: &l.groups,
|
||||||
beats: l.groups.iter().map(|&g| g as u32).sum::<u32>().min(255) as u8,
|
beats: l.groups.iter().map(|&g| g as u32).sum::<u32>().min(255) as u8,
|
||||||
poly: l.poly,
|
poly: l.poly,
|
||||||
muted: l.mute,
|
muted: l.mute,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let screen = pm_ui::Screen { name: NAMES[idx], bpm: tempo, playing, phase, lanes: &lanes };
|
let screen = pm_ui::Screen { name: NAMES[idx], bpm: tempo, playing, phase, lanes: &lanes };
|
||||||
if notation {
|
match view {
|
||||||
pm_ui::draw_notation(&mut fb, &screen).ok();
|
View::Grid => pm_ui::draw_metronome(&mut fb, &screen).ok(),
|
||||||
} else {
|
View::Staff => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Staff).ok(),
|
||||||
pm_ui::draw_metronome(&mut fb, &screen).ok();
|
View::Tubs => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Tubs).ok(),
|
||||||
}
|
View::Konnakol => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Konnakol).ok(),
|
||||||
|
};
|
||||||
// FNV-1a of one TILE×TILE block at grid cell (tx, ty)
|
// FNV-1a of one TILE×TILE block at grid cell (tx, ty)
|
||||||
fn tile_fnv(px: &[Rgb565], tx: usize, ty: usize) -> u64 {
|
fn tile_fnv(px: &[Rgb565], tx: usize, ty: usize) -> u64 {
|
||||||
let mut h = 0xcbf29ce484222325u64; // FNV-1a offset basis
|
let mut h = 0xcbf29ce484222325u64; // FNV-1a offset basis
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
|
pub mod notation;
|
||||||
|
pub use notation::ViewMode;
|
||||||
|
|
||||||
use embedded_graphics::{
|
use embedded_graphics::{
|
||||||
mono_font::{ascii::{FONT_10X20, FONT_6X10, FONT_9X18_BOLD}, MonoTextStyle},
|
mono_font::{ascii::{FONT_10X20, FONT_6X10, FONT_9X18_BOLD}, MonoTextStyle},
|
||||||
pixelcolor::Rgb565,
|
pixelcolor::Rgb565,
|
||||||
|
|
@ -35,7 +38,12 @@ pub struct LaneView<'a> {
|
||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
/// per-step dynamics: 0 rest, 1 normal, 2 accent, 3 ghost
|
/// per-step dynamics: 0 rest, 1 normal, 2 accent, 3 ghost
|
||||||
pub levels: &'a [u8],
|
pub levels: &'a [u8],
|
||||||
/// beats per bar (for the group/beat gridlines); 0 = none
|
/// per-step ornaments: 0 none, 1 flam, 2 drag, 3 roll (parallel to `levels`; may be shorter/empty)
|
||||||
|
pub orns: &'a [u8],
|
||||||
|
/// group structure (e.g. `[2,2,3]` for 7/8) — drives time-signature + group-aware beaming.
|
||||||
|
/// `beats` = sum(groups). Empty → notation defaults to [4].
|
||||||
|
pub groups: &'a [u32],
|
||||||
|
/// beats per bar (for the group/beat gridlines); 0 = none. Usually `groups.iter().sum()`.
|
||||||
pub beats: u8,
|
pub beats: u8,
|
||||||
pub poly: bool,
|
pub poly: bool,
|
||||||
pub muted: bool,
|
pub muted: bool,
|
||||||
|
|
@ -263,179 +271,14 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- drum notation ----
|
// ---- drum notation ----
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum Head {
|
|
||||||
Oval,
|
|
||||||
Cross,
|
|
||||||
}
|
|
||||||
/// Map a voice name to (vertical offset from the staff top line, notehead, stem-up?).
|
|
||||||
/// Offsets are multiples of 6 (half a 12px line-space) so heads land on lines/spaces.
|
|
||||||
fn map_voice(name: &str) -> (i32, Head, bool) {
|
|
||||||
if name.starts_with("kick") {
|
|
||||||
(42, Head::Oval, false) // bass drum: low, stem DOWN (foot)
|
|
||||||
} else if name.starts_with("snare") || name.starts_with("clap") || name.starts_with("rim") {
|
|
||||||
(18, Head::Oval, true)
|
|
||||||
} else if name.starts_with("hat") || name.starts_with("openHat") {
|
|
||||||
(-12, Head::Cross, true) // hi-hat: first ledger line above the staff
|
|
||||||
} else if name.starts_with("ride") {
|
|
||||||
(0, Head::Cross, true)
|
|
||||||
} else if name.starts_with("crash") {
|
|
||||||
(-24, Head::Cross, true) // crash: high above, more ledgers
|
|
||||||
} else if name.starts_with("tom") {
|
|
||||||
(12, Head::Oval, true)
|
|
||||||
} else if name.starts_with("cowbell") || name.starts_with("woodblock") || name.starts_with("claves") || name.starts_with("tambourine") {
|
|
||||||
(6, Head::Cross, true)
|
|
||||||
} else {
|
|
||||||
(24, Head::Oval, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render one bar of the groove as drum notation: 5-line staff, time signature, noteheads with
|
/// Render one bar of the groove as drum notation (Staff view). Thin wrapper over the ported
|
||||||
/// stems (hands up / feet down) and beamed eighths/sixteenths. First pass — refine freely.
|
/// engine in `notation` — kept for back-compat with existing call sites that defaulted to Staff.
|
||||||
pub fn draw_notation<D>(d: &mut D, s: &Screen) -> Result<(), D::Error>
|
pub fn draw_notation<D>(d: &mut D, s: &Screen) -> Result<(), D::Error>
|
||||||
where
|
where
|
||||||
D: DrawTarget<Color = Rgb565>,
|
D: DrawTarget<Color = Rgb565>,
|
||||||
{
|
{
|
||||||
let bb = d.bounding_box();
|
notation::draw(d, s, ViewMode::Staff)
|
||||||
let w = bb.size.width as i32;
|
|
||||||
d.clear(BG)?;
|
|
||||||
|
|
||||||
// header
|
|
||||||
Text::new(s.name, Point::new(12, 22), MonoTextStyle::new(&FONT_10X20, TXT)).draw(d)?;
|
|
||||||
let mut nb = [0u8; 12];
|
|
||||||
Text::with_alignment(fmt_u32(s.bpm.max(0) as u32, &mut nb), Point::new(w - 12, 18), MonoTextStyle::new(&FONT_9X18_BOLD, CYAN), Alignment::Right).draw(d)?;
|
|
||||||
|
|
||||||
let beats = s.lanes.first().map(|l| l.beats.max(1)).unwrap_or(4) as i32;
|
|
||||||
let staff_top = 80;
|
|
||||||
let line_gap = 12;
|
|
||||||
let m = 14;
|
|
||||||
let clef_w = 34;
|
|
||||||
let x0 = m + clef_w;
|
|
||||||
let x1 = w - m;
|
|
||||||
let bw = (x1 - x0).max(1);
|
|
||||||
let ink = TXT;
|
|
||||||
|
|
||||||
// staff (5 lines)
|
|
||||||
for i in 0..5 {
|
|
||||||
let y = staff_top + i * line_gap;
|
|
||||||
Line::new(Point::new(m, y), Point::new(x1, y)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
|
|
||||||
}
|
|
||||||
// bar lines (start + end)
|
|
||||||
Line::new(Point::new(m, staff_top), Point::new(m, staff_top + 4 * line_gap)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
|
|
||||||
Line::new(Point::new(x1, staff_top), Point::new(x1, staff_top + 4 * line_gap)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
|
|
||||||
// time signature (beats / 4)
|
|
||||||
let ts = MonoTextStyle::new(&FONT_10X20, ink);
|
|
||||||
let mut tb = [0u8; 12];
|
|
||||||
Text::with_alignment(fmt_u32(beats as u32, &mut tb), Point::new(m + clef_w / 2, staff_top + 16), ts, Alignment::Center).draw(d)?;
|
|
||||||
Text::with_alignment("4", Point::new(m + clef_w / 2, staff_top + 40), ts, Alignment::Center).draw(d)?;
|
|
||||||
|
|
||||||
// Time resolution = finest lane (so off-beats land on columns); lanes whose step count divides
|
|
||||||
// `res` align to it (polymeter that doesn't divide falls back to its own grid implicitly).
|
|
||||||
let res = s.lanes.iter().filter(|l| !l.muted).map(|l| l.levels.len() as i32).max().unwrap_or(4).max(1);
|
|
||||||
let up_end = staff_top - 22; // fixed stem-end levels → horizontal beams
|
|
||||||
let dn_end = staff_top + 4 * line_gap + 22;
|
|
||||||
let bot = staff_top + 4 * line_gap;
|
|
||||||
|
|
||||||
let mut up_prev: Option<(i32, i32)> = None; // (stem_x, beat) for beaming
|
|
||||||
let mut dn_prev: Option<(i32, i32)> = None;
|
|
||||||
|
|
||||||
for c in 0..res {
|
|
||||||
let cx = x0 + c * bw / res + bw / (2 * res);
|
|
||||||
// gather notes at this column, per voice
|
|
||||||
let (mut up_lo, mut up_hi, mut up_any, mut up_sub2) = (i32::MIN, i32::MAX, false, false);
|
|
||||||
let (mut dn_lo, mut dn_hi, mut dn_any, mut dn_sub2) = (i32::MIN, i32::MAX, false, false);
|
|
||||||
|
|
||||||
for lane in s.lanes.iter() {
|
|
||||||
if lane.muted {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let steps = lane.levels.len() as i32;
|
|
||||||
if steps == 0 || (c * steps) % res != 0 {
|
|
||||||
continue; // this lane has no note position at this column
|
|
||||||
}
|
|
||||||
let lvl = lane.levels[(c * steps / res) as usize];
|
|
||||||
if lvl == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (off, head, up) = map_voice(lane.name);
|
|
||||||
let hy = staff_top + off;
|
|
||||||
let col = if lvl == 2 { AMBER } else { ink };
|
|
||||||
|
|
||||||
// notehead
|
|
||||||
match head {
|
|
||||||
Head::Oval => embedded_graphics::primitives::Ellipse::new(Point::new(cx - 6, hy - 4), Size::new(12, 8))
|
|
||||||
.into_styled(PrimitiveStyle::with_fill(col))
|
|
||||||
.draw(d)?,
|
|
||||||
Head::Cross => {
|
|
||||||
Line::new(Point::new(cx - 5, hy - 5), Point::new(cx + 5, hy + 5)).into_styled(PrimitiveStyle::with_stroke(col, 2)).draw(d)?;
|
|
||||||
Line::new(Point::new(cx - 5, hy + 5), Point::new(cx + 5, hy - 5)).into_styled(PrimitiveStyle::with_stroke(col, 2)).draw(d)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ledger lines for notes a full line+ above or below the staff
|
|
||||||
let mut ly = staff_top - 12;
|
|
||||||
while ly >= hy {
|
|
||||||
Line::new(Point::new(cx - 9, ly), Point::new(cx + 9, ly)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
|
|
||||||
ly -= 12;
|
|
||||||
}
|
|
||||||
let mut ly = bot + 12;
|
|
||||||
while ly <= hy {
|
|
||||||
Line::new(Point::new(cx - 9, ly), Point::new(cx + 9, ly)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
|
|
||||||
ly += 12;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lsub = steps / beats.max(1);
|
|
||||||
if up {
|
|
||||||
up_any = true;
|
|
||||||
up_lo = up_lo.max(hy);
|
|
||||||
up_hi = up_hi.min(hy);
|
|
||||||
up_sub2 |= lsub >= 2;
|
|
||||||
} else {
|
|
||||||
dn_any = true;
|
|
||||||
dn_lo = dn_lo.max(hy);
|
|
||||||
dn_hi = dn_hi.min(hy);
|
|
||||||
dn_sub2 |= lsub >= 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let beat = c * beats / res;
|
|
||||||
// shared up-stem (hands): right side, from lowest head up past the highest head
|
|
||||||
if up_any {
|
|
||||||
let sx = cx + 6;
|
|
||||||
let top = up_end.min(up_hi - 12); // always clear the highest notehead
|
|
||||||
Line::new(Point::new(sx, up_lo), Point::new(sx, top)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
|
|
||||||
let up_end = top;
|
|
||||||
if up_sub2 {
|
|
||||||
if let Some((px, pb)) = up_prev {
|
|
||||||
if pb == beat {
|
|
||||||
Rectangle::new(Point::new(px.min(sx), up_end), Size::new((px - sx).unsigned_abs(), 4)).into_styled(PrimitiveStyle::with_fill(ink)).draw(d)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
up_prev = Some((sx, beat));
|
|
||||||
} else {
|
|
||||||
up_prev = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// shared down-stem (feet): left side, from highest head down past the lowest head
|
|
||||||
if dn_any {
|
|
||||||
let sx = cx - 6;
|
|
||||||
let bottom = dn_end.max(dn_lo + 12);
|
|
||||||
Line::new(Point::new(sx, dn_hi), Point::new(sx, bottom)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
|
|
||||||
let dn_end = bottom;
|
|
||||||
if dn_sub2 {
|
|
||||||
if let Some((px, pb)) = dn_prev {
|
|
||||||
if pb == beat {
|
|
||||||
Rectangle::new(Point::new(px.min(sx), dn_end - 3), Size::new((px - sx).unsigned_abs(), 4)).into_styled(PrimitiveStyle::with_fill(ink)).draw(d)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dn_prev = Some((sx, beat));
|
|
||||||
} else {
|
|
||||||
dn_prev = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text::new("drum notation", Point::new(12, bot + 40), MonoTextStyle::new(&FONT_6X10, MUTE)).draw(d)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring-up diagnostic pattern (kept for hardware bring-up / fallback).
|
/// Bring-up diagnostic pattern (kept for hardware bring-up / fallback).
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ pub struct Lane {
|
||||||
pub mute: bool,
|
pub mute: bool,
|
||||||
pub gain_db: f64,
|
pub gain_db: f64,
|
||||||
pub levels: Vec<u8>,
|
pub levels: Vec<u8>,
|
||||||
|
/// per-step ornament parallel to `levels`: 0 none / 1 flam / 2 drag / 3 roll.
|
||||||
|
pub orns: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -108,12 +110,35 @@ fn euclid(k: i64, n: i64, rot: i64) -> Vec<u8> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pat(c: char) -> u8 {
|
/// Pattern char -> (level, ornament). Ornament letters: UPPER = accented, lower = normal
|
||||||
|
/// (the case carries the dynamic, so dynamics stay orthogonal): f/F flam, d/D drag, z/Z roll.
|
||||||
|
fn cell(c: char) -> (u8, u8) {
|
||||||
match c {
|
match c {
|
||||||
'X' => 2,
|
'X' => (2, 0),
|
||||||
'x' | '1' => 1,
|
'x' | '1' => (1, 0),
|
||||||
'g' => 3,
|
'g' => (3, 0),
|
||||||
_ => 0,
|
'f' => (1, 1),
|
||||||
|
'F' => (2, 1),
|
||||||
|
'd' => (1, 2),
|
||||||
|
'D' => (2, 2),
|
||||||
|
'z' => (1, 3),
|
||||||
|
'Z' => (2, 3),
|
||||||
|
_ => (0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (level, ornament) -> pattern char (inverse of `cell`).
|
||||||
|
fn cell_ch(v: u8, o: u8) -> char {
|
||||||
|
match o {
|
||||||
|
1 => if v >= 2 { 'F' } else { 'f' },
|
||||||
|
2 => if v >= 2 { 'D' } else { 'd' },
|
||||||
|
3 => if v >= 2 { 'Z' } else { 'z' },
|
||||||
|
_ => match v {
|
||||||
|
2 => 'X',
|
||||||
|
1 => 'x',
|
||||||
|
3 => 'g',
|
||||||
|
_ => '.',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,8 +211,8 @@ fn parse_lane(tok: &str) -> Lane {
|
||||||
acc += g;
|
acc += g;
|
||||||
}
|
}
|
||||||
|
|
||||||
let levels: Vec<u8> = if let Some(e) = euc {
|
let (levels, orns): (Vec<u8>, Vec<u8>) = if let Some(e) = euc {
|
||||||
// euclidean: k hits over n steps, first hit accented
|
// euclidean: k hits over n steps, first hit accented (no ornaments)
|
||||||
let k = e[0];
|
let k = e[0];
|
||||||
let n = if e.len() > 1 { e[1] } else { (beats * sub) as i64 };
|
let n = if e.len() > 1 { e[1] } else { (beats * sub) as i64 };
|
||||||
let rot = if e.len() > 2 { e[2] } else { 0 };
|
let rot = if e.len() > 2 { e[2] } else { 0 };
|
||||||
|
|
@ -200,7 +225,7 @@ fn parse_lane(tok: &str) -> Lane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
euclid(k, n, rot)
|
let lv: Vec<u8> = euclid(k, n, rot)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|h| {
|
.map(|h| {
|
||||||
if h != 0 {
|
if h != 0 {
|
||||||
|
|
@ -211,18 +236,23 @@ fn parse_lane(tok: &str) -> Lane {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
|
let orn = vec![0u8; lv.len()];
|
||||||
|
(lv, orn)
|
||||||
} else if let Some(p) = pattern {
|
} else if let Some(p) = pattern {
|
||||||
let steps = (beats * sub) as usize;
|
let steps = (beats * sub) as usize;
|
||||||
let mut lv: Vec<u8> = p.chars().map(pat).collect();
|
let cells: Vec<(u8, u8)> = p.chars().map(cell).collect();
|
||||||
|
let mut lv: Vec<u8> = cells.iter().map(|c| c.0).collect();
|
||||||
|
let mut orn: Vec<u8> = cells.iter().map(|c| c.1).collect();
|
||||||
if lv.len() < steps {
|
if lv.len() < steps {
|
||||||
lv.resize(steps, 0);
|
lv.resize(steps, 0);
|
||||||
|
orn.resize(steps, 0);
|
||||||
}
|
}
|
||||||
lv
|
(lv, orn)
|
||||||
} else {
|
} else {
|
||||||
// default: every subdivision sounds at normal, accent only on group starts
|
// default: every subdivision sounds at normal, accent only on group starts
|
||||||
let steps = beats * sub;
|
let steps = beats * sub;
|
||||||
(0..steps)
|
let lv: Vec<u8> = (0..steps)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
if i % sub == 0 {
|
if i % sub == 0 {
|
||||||
if starts.contains(&(i / sub)) { 2 } else { 1 }
|
if starts.contains(&(i / sub)) { 2 } else { 1 }
|
||||||
|
|
@ -230,13 +260,15 @@ fn parse_lane(tok: &str) -> Lane {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect();
|
||||||
|
let orn = vec![0u8; lv.len()];
|
||||||
|
(lv, orn)
|
||||||
};
|
};
|
||||||
|
|
||||||
if !known_sound(&sound) {
|
if !known_sound(&sound) {
|
||||||
sound = "beep".to_string();
|
sound = "beep".to_string();
|
||||||
}
|
}
|
||||||
Lane { sound, groups, sub, swing, poly, mute, gain_db, levels }
|
Lane { sound, groups, sub, swing, poly, mute, gain_db, levels, orns }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse(s: &str) -> Track {
|
pub fn parse(s: &str) -> Track {
|
||||||
|
|
@ -337,12 +369,7 @@ fn lane_to_str(l: &Lane) -> String {
|
||||||
s.push_str(&format!("/{}{}", l.sub, if l.swing { "s" } else { "" }));
|
s.push_str(&format!("/{}{}", l.sub, if l.swing { "s" } else { "" }));
|
||||||
}
|
}
|
||||||
s.push('=');
|
s.push('=');
|
||||||
s.extend(l.levels.iter().map(|&v| match v {
|
s.extend(l.levels.iter().enumerate().map(|(i, &v)| cell_ch(v, l.orns.get(i).copied().unwrap_or(0))));
|
||||||
2 => 'X',
|
|
||||||
1 => 'x',
|
|
||||||
3 => 'g',
|
|
||||||
_ => '.',
|
|
||||||
}));
|
|
||||||
if l.gain_db != 0.0 {
|
if l.gain_db != 0.0 {
|
||||||
s.push_str(&format!("@{}", l.gain_db));
|
s.push_str(&format!("@{}", l.gain_db));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,17 @@ fn norm(t: &Track) -> Value {
|
||||||
Some(End::Stop) => json!("stop"),
|
Some(End::Stop) => json!("stop"),
|
||||||
Some(End::Goto(n)) => json!(n),
|
Some(End::Goto(n)) => json!(n),
|
||||||
},
|
},
|
||||||
"lanes": t.lanes.iter().map(|l| json!({
|
"lanes": t.lanes.iter().map(|l| {
|
||||||
|
let mut o = json!({
|
||||||
"sound": l.sound, "groups": l.groups, "sub": l.sub, "swing": l.swing,
|
"sound": l.sound, "groups": l.groups, "sub": l.sub, "swing": l.swing,
|
||||||
"poly": l.poly, "mute": l.mute, "gainDb": l.gain_db, "levels": l.levels
|
"poly": l.poly, "mute": l.mute, "gainDb": l.gain_db, "levels": l.levels
|
||||||
})).collect::<Vec<_>>(),
|
});
|
||||||
|
// `orns` defaults to all-zeros and is omitted then, so legacy vectors (no `orns`) still match.
|
||||||
|
if l.orns.iter().any(|&v| v != 0) {
|
||||||
|
o.as_object_mut().unwrap().insert("orns".into(), json!(l.orns));
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,23 @@ impl OriginDimensions for Fb {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
// args: [prog] [view] where view ∈ staff|tubs|konnakol (default staff)
|
||||||
let prog = std::env::args().nth(1).unwrap_or_else(|| "t96;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2=xxxxxxxx".into());
|
let prog = std::env::args().nth(1).unwrap_or_else(|| "t96;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2=xxxxxxxx".into());
|
||||||
|
let view = match std::env::args().nth(2).as_deref() {
|
||||||
|
Some("tubs") => pm_ui::ViewMode::Tubs,
|
||||||
|
Some("konnakol") | Some("kon") => pm_ui::ViewMode::Konnakol,
|
||||||
|
_ => pm_ui::ViewMode::Staff,
|
||||||
|
};
|
||||||
let track = track_format::parse(&prog);
|
let track = track_format::parse(&prog);
|
||||||
let lanes: Vec<LaneView> = track
|
let lanes: Vec<LaneView> = track
|
||||||
.lanes
|
.lanes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| LaneView { name: &l.sound, levels: &l.levels, beats: l.groups.iter().sum::<u32>().min(255) as u8, poly: l.poly, muted: l.mute })
|
.map(|l| LaneView { name: &l.sound, levels: &l.levels, orns: &l.orns, groups: &l.groups, beats: l.groups.iter().sum::<u32>().min(255) as u8, poly: l.poly, muted: l.mute })
|
||||||
.collect();
|
.collect();
|
||||||
let screen = Screen { name: "Rock beat", bpm: track.bpm, playing: false, phase: 0.0, lanes: &lanes };
|
let screen = Screen { name: "Rock beat", bpm: track.bpm, playing: false, phase: 0.0, lanes: &lanes };
|
||||||
|
|
||||||
let mut fb = Fb { px: vec![Rgb565::BLACK; (W * H) as usize] };
|
let mut fb = Fb { px: vec![Rgb565::BLACK; (W * H) as usize] };
|
||||||
pm_ui::draw_notation(&mut fb, &screen).unwrap();
|
pm_ui::notation::draw(&mut fb, &screen, view).unwrap();
|
||||||
|
|
||||||
let img = image::RgbImage::from_fn(W, H, |x, y| {
|
let img = image::RgbImage::from_fn(W, H, |x, y| {
|
||||||
let c = fb.px[(y * W + x) as usize];
|
let c = fb.px[(y * W + x) as usize];
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ fn main() {
|
||||||
.map(|l| LaneView {
|
.map(|l| LaneView {
|
||||||
name: &l.sound,
|
name: &l.sound,
|
||||||
levels: &l.levels,
|
levels: &l.levels,
|
||||||
|
orns: &l.orns,
|
||||||
|
groups: &l.groups,
|
||||||
beats: l.groups.iter().sum::<u32>().min(255) as u8,
|
beats: l.groups.iter().sum::<u32>().min(255) as u8,
|
||||||
poly: l.poly,
|
poly: l.poly,
|
||||||
muted: l.mute,
|
muted: l.mute,
|
||||||
|
|
|
||||||
|
|
@ -172,15 +172,39 @@ function laneStepDur(m, tick) {
|
||||||
return beat / m.stepsPerBeat; // straight: shared even grid
|
return beat / m.stepsPerBeat; // straight: shared even grid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- pattern cell codec: char ⇄ (level, ornament) ---
|
||||||
|
// level: 0 rest / 1 normal / 2 accent / 3 ghost. ornament: 0 none / 1 flam / 2 drag / 3 roll.
|
||||||
|
// Ornaments use new letters, UPPER-case = accented hit, lower-case = normal hit (case carries the
|
||||||
|
// dynamic so it stays orthogonal): f/F flam · d/D drag · z/Z roll. Ghosted ornaments aren't expressible.
|
||||||
|
function patCell(ch) {
|
||||||
|
switch (ch) {
|
||||||
|
case "X": return [2, 0];
|
||||||
|
case "x": case "1": return [1, 0];
|
||||||
|
case "g": return [3, 0];
|
||||||
|
case "f": return [1, 1]; case "F": return [2, 1];
|
||||||
|
case "d": return [1, 2]; case "D": return [2, 2];
|
||||||
|
case "z": return [1, 3]; case "Z": return [2, 3];
|
||||||
|
default: return [0, 0]; // . - _ / anything else = rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cellCh(lvl, orn) {
|
||||||
|
if (orn === 1) return lvl >= 2 ? "F" : "f";
|
||||||
|
if (orn === 2) return lvl >= 2 ? "D" : "d";
|
||||||
|
if (orn === 3) return lvl >= 2 ? "Z" : "z";
|
||||||
|
return lvl === 3 ? "g" : lvl >= 2 ? "X" : lvl >= 1 ? "x" : ".";
|
||||||
|
}
|
||||||
|
|
||||||
// --- share-language codec: config ⇄ lane token ---
|
// --- share-language codec: config ⇄ lane token ---
|
||||||
function laneCfgToStr(c) {
|
function laneCfgToStr(c) {
|
||||||
let s = c.sound + ":" + c.groupsStr;
|
let s = c.sound + ":" + c.groupsStr;
|
||||||
const spb = c.stepsPerBeat || 1;
|
const spb = c.stepsPerBeat || 1;
|
||||||
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
|
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
|
||||||
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / g ghost / . mute)
|
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / g ghost / . mute)
|
||||||
|
const orn = c.orns || []; // per-step ornament (flam/drag/roll), parallel to beatsOn
|
||||||
const gs = parseGroups(c.groupsStr).groupStarts; // default = accent group starts only; everything else sounds at normal
|
const gs = parseGroups(c.groupsStr).groupStarts; // default = accent group starts only; everything else sounds at normal
|
||||||
const isDefault = on.length && on.every((v, i) => (v | 0) === (((i % spb) === 0 && gs.has(i / spb)) ? 2 : 1));
|
const anyOrn = orn.some((v) => (v | 0) !== 0); // any ornament → not the implicit default; must write the pattern
|
||||||
if (on.length && !isDefault) s += "=" + on.map((v) => (v === 3 ? "g" : v >= 2 ? "X" : v >= 1 ? "x" : ".")).join("");
|
const isDefault = !anyOrn && on.length && on.every((v, i) => (v | 0) === (((i % spb) === 0 && gs.has(i / spb)) ? 2 : 1));
|
||||||
|
if (on.length && !isDefault) s += "=" + on.map((v, i) => cellCh(v | 0, orn[i] | 0)).join("");
|
||||||
if (c.gainDb) s += "@" + c.gainDb; // per-lane gain in dB (e.g. @-3, @2)
|
if (c.gainDb) s += "@" + c.gainDb; // per-lane gain in dB (e.g. @-3, @2)
|
||||||
if (c.poly) s += "~";
|
if (c.poly) s += "~";
|
||||||
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
|
||||||
|
|
@ -201,20 +225,26 @@ function laneStrToCfg(tok) {
|
||||||
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
|
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
|
||||||
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
|
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
|
||||||
let { beatsPerBar: bpb, groupStarts } = parseGroups(groupsStr);
|
let { beatsPerBar: bpb, groupStarts } = parseGroups(groupsStr);
|
||||||
let beatsOn;
|
let beatsOn, orns;
|
||||||
if (eucK != null) { // k hits spread evenly; first hit accented
|
if (eucK != null) { // k hits spread evenly; first hit accented
|
||||||
let n = eucN || (bpb * sub);
|
let n = eucN || (bpb * sub);
|
||||||
if (eucN) { if (n % bpb === 0) sub = n / bpb; else { bpb = n; sub = 1; groupsStr = String(n); } }
|
if (eucN) { if (n % bpb === 0) sub = n / bpb; else { bpb = n; sub = 1; groupsStr = String(n); } }
|
||||||
let first = true;
|
let first = true;
|
||||||
beatsOn = euclid(eucK, n, eucRot).map((h) => h ? (first ? (first = false, 2) : 1) : 0);
|
beatsOn = euclid(eucK, n, eucRot).map((h) => h ? (first ? (first = false, 2) : 1) : 0);
|
||||||
|
orns = beatsOn.map(() => 0); // euclid hits carry no ornament
|
||||||
|
} else if (pattern != null) {
|
||||||
|
// pattern cells: per-step (level, ornament) — X accent, x/1 normal, g ghost, f/F flam, d/D drag,
|
||||||
|
// z/Z roll, . - _ / anything else = rest. See patCell().
|
||||||
|
const cells = pattern.split("").map(patCell);
|
||||||
|
beatsOn = cells.map((c) => c[0]);
|
||||||
|
orns = cells.map((c) => c[1]);
|
||||||
} else {
|
} else {
|
||||||
// pattern levels: X=accent(2), g=ghost(3), x/1=normal(1), . - _ / anything else = mute(0);
|
|
||||||
// no pattern → every subdivision sounds at normal, accent on group starts (the grouping IS the accent map)
|
// no pattern → every subdivision sounds at normal, accent on group starts (the grouping IS the accent map)
|
||||||
beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0)
|
beatsOn = Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
|
||||||
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
|
orns = beatsOn.map(() => 0);
|
||||||
}
|
}
|
||||||
if (!DRUMS[sound]) sound = "beep";
|
if (!DRUMS[sound]) sound = "beep";
|
||||||
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, swing, enabled: !disabled, gainDb };
|
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, orns, poly, swing, enabled: !disabled, gainDb };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- share-language codec: patch ⇄ setup ---
|
// --- share-language codec: patch ⇄ setup ---
|
||||||
|
|
|
||||||
138
src/livesync.js
138
src/livesync.js
|
|
@ -10,6 +10,8 @@
|
||||||
// 0x41 FULL -> full snapshot (resync / heartbeat / on connect) payload: <origin>;<seq>;<running>;<sl>;<item>;<patch>
|
// 0x41 FULL -> full snapshot (resync / heartbeat / on connect) payload: <origin>;<seq>;<running>;<sl>;<item>;<patch>
|
||||||
// 0x42 DELTA -> one mutation event payload: <origin>;<seq>;<evt>
|
// 0x42 DELTA -> one mutation event payload: <origin>;<seq>;<evt>
|
||||||
// 0x43 BYE -> mirroring off payload: <origin>
|
// 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):
|
// DELTA <evt> grammar (reuses the share-language tokens — see engine.js):
|
||||||
// play | stop | bpm=<n> | vol=<pct> | sel=<sl>/<item>
|
// play | stop | bpm=<n> | vol=<pct> | sel=<sl>/<item>
|
||||||
|
|
@ -41,6 +43,8 @@ var LiveSync = {
|
||||||
_syncOn = true;
|
_syncOn = true;
|
||||||
this.send(0x40, ""); // HELLO — ask the device for its full state
|
this.send(0x40, ""); // HELLO — ask the device for its full state
|
||||||
this.broadcastFull(); // and push ours so the device mirrors us immediately
|
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"; }
|
if (_loopback) { this.connected = true; this.peerOrigin = "loopback"; }
|
||||||
updateSyncBtn();
|
updateSyncBtn();
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -70,6 +74,25 @@ var LiveSync = {
|
||||||
var patch; try { patch = currentPatch(); } catch (e) { return; }
|
var patch; try { patch = currentPatch(); } catch (e) { return; }
|
||||||
this.send(0x41, (state.running ? 1 : 0) + ";" + loadedSL + ";" + activeItem + ";" + patch);
|
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 -----------------------------------------------------------
|
// ---- receive -----------------------------------------------------------
|
||||||
applyRemote(op, text) {
|
applyRemote(op, text) {
|
||||||
|
|
@ -80,11 +103,19 @@ var LiveSync = {
|
||||||
if (op === 0x43) { this.connected = false; updateSyncBtn(); return; } // peer said BYE
|
if (op === 0x43) { this.connected = false; updateSyncBtn(); return; } // peer said BYE
|
||||||
var parts = text.split(";");
|
var parts = text.split(";");
|
||||||
if (op === 0x41) { // FULL: origin;seq;running;sl;item;patch...
|
if (op === 0x41) { // FULL: origin;seq;running;sl;item;patch...
|
||||||
var running = parts[2] === "1", patch = parts.slice(5).join(";");
|
var running = parts[2] === "1";
|
||||||
_applyRemote(function () { _applyFull(running, patch); });
|
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
|
} else if (op === 0x42) { // DELTA: origin;seq;evt
|
||||||
var evt = parts.slice(2).join(";");
|
var evt = parts.slice(2).join(";");
|
||||||
_applyRemote(function () { _applyDelta(evt); });
|
_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();
|
updateSyncBtn();
|
||||||
},
|
},
|
||||||
|
|
@ -128,10 +159,24 @@ function _applyDelta(evt) {
|
||||||
|
|
||||||
// Full-state mirror: only rebuild if the groove actually differs (avoids
|
// Full-state mirror: only rebuild if the groove actually differs (avoids
|
||||||
// flicker / lost focus when a heartbeat arrives and we're already in sync),
|
// flicker / lost focus when a heartbeat arrives and we're already in sync),
|
||||||
// then reconcile transport.
|
// then reconcile selection + transport.
|
||||||
function _applyFull(running, patch) {
|
// 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) {}
|
var cur = null; try { cur = currentPatch(); } catch (e) {}
|
||||||
if (patch && patch !== cur) applyPatch(patch);
|
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();
|
if (running && !state.running) toggleTransport();
|
||||||
else if (!running && state.running) toggleTransport();
|
else if (!running && state.running) toggleTransport();
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +195,78 @@ function _syncLaneControls(m) {
|
||||||
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); }
|
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 + " | ||||||