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 ⓘ) |
|
||||
| `/editor.html` · `/info-editor.html` | **PM_E‑1 — PolyMeter Editor** (the main app) + its overview |
|
||||
| `/pm_e-2.html` · `/info-pm_e-2.html` | **PM_E‑2 — PolyMeter Editor (Notation)** — second-gen, engraved drum notation (Bravura/SMuFL): Staff / TUBS / Konnakol views, edit-on-staff |
|
||||
| `/kit.html` · `/info-kit.html` | **PM_K‑1 Kit** — buildable Raspberry Pi Pico touchscreen unit (52Pi EP‑0172); info page has the wiring, parts and firmware |
|
||||
| `/player.html` · `/info-player.html` | **PM_C‑1 Concept** — idealized concept device (full display + set‑list nav, theme, fullscreen "stage" view) |
|
||||
| `/teacher.html` · `/info-teacher.html` | **PM_T‑1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument pass‑through with analog click injection) |
|
||||
|
|
@ -265,6 +266,7 @@ tags the current commit `v<VERSION>` (requires a clean tree). Push the tag, then
|
|||
|------|---------|
|
||||
| `index.html` | the **Concepts** landing / gallery (embeds each widget live) |
|
||||
| `editor.html` | the **PM_E‑1 editor** app (source, with `@BUILD:*` markers) |
|
||||
| `pm_e-2.html` · `src/notation.js` | the **PM_E‑2 notation editor** + its Bravura/SMuFL engraving engine (`tools/bravura/` subsets the font → `assets/bravura.woff2.b64`, inlined via `@BUILD:bravura@`) |
|
||||
| `kit.html` · `player.html` · `teacher.html` · `stage.html` · `micro.html` · `showcase.html` | the device widget pages (PM_K‑1 Kit, PM_C‑1 Concept, Teacher, Stage, PM_P‑1 Practice, PM_D‑1 Display) |
|
||||
| `info-*.html` | per‑form‑factor spec pages (embed the live widget + description + dimensions + BOM) |
|
||||
| `embed.html` · `embed.js` | embed docs and the drop‑in widget loader |
|
||||
|
|
|
|||
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:logo-dark@", (A / "logo-dark.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:logo-light@", (A / "logo-light.b64").read_text().strip())
|
||||
src = src.replace("@BUILD:bravura@", (A / "bravura.woff2.b64").read_text().strip()) # SMuFL music font subset (PM_E-2 notation)
|
||||
assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
|
||||
out = pathlib.Path("dist") / name
|
||||
out.write_text(src)
|
||||
return out.stat().st_size
|
||||
|
||||
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html",
|
||||
for name in ("index.html","editor.html","editor-beta.html","pm_e-2.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html",
|
||||
"embed.html",
|
||||
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"):
|
||||
"info-editor.html","info-pm_e-2.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"):
|
||||
print("built %s (%dKB)" % (name, build(name) // 1024))
|
||||
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
|
||||
print("copied embed.js")
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ fi
|
|||
|
||||
# stamp the version into the built copy only (source stays clean)
|
||||
echo "deployed v$BUILD -> $DEST_DIR"
|
||||
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \
|
||||
for f in index.html editor.html editor-beta.html pm_e-2.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \
|
||||
embed.html \
|
||||
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do
|
||||
info-editor.html info-pm_e-2.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do
|
||||
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
|
||||
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
||||
done
|
||||
|
|
|
|||
|
|
@ -26,12 +26,14 @@ F0 7D <op> <payload ASCII bytes, each 0x00–0x7F> F7
|
|||
`<op>` lives in the free `0x40` block (existing ops: `0x01` RTC, `0x02/0x03`
|
||||
version, `0x10` programs, `0x21/22/23` firmware, `0x7E/0x7F` NAK/ACK):
|
||||
|
||||
| op | name | direction | payload |
|
||||
|------|-------|------------------|-------------------------------------------|
|
||||
| 0x40 | HELLO | either → either | `<origin>` |
|
||||
| 0x41 | FULL | either → either | `<origin>;<seq>;<running>;<sl>;<item>;<patch>` |
|
||||
| 0x42 | DELTA | either → either | `<origin>;<seq>;<evt>` |
|
||||
| 0x43 | BYE | either → either | `<origin>` |
|
||||
| op | name | direction | payload |
|
||||
|------|---------|------------------|-------------------------------------------|
|
||||
| 0x40 | HELLO | either → either | `<origin>` |
|
||||
| 0x41 | FULL | either → either | `<origin>;<seq>;<running>;<sl>;<item>;<patch>` |
|
||||
| 0x42 | DELTA | either → either | `<origin>;<seq>;<evt>` |
|
||||
| 0x43 | BYE | either → either | `<origin>` |
|
||||
| 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
|
||||
stream and, per `build.sh`, would also break the firmware‑update path). All
|
||||
|
|
@ -96,6 +98,8 @@ apply** — each must apply *every* op/evt listed above.
|
|||
- a coalesced `0x41` FULL for any lane‑field / add / remove / practice (trainer,
|
||||
ramp, segment bars, countdown) edit
|
||||
- `0x41` FULL on connect and in reply to a received `0x40`
|
||||
- a coalesced `0x44` SLSYNC on any set‑list **content** change (and on connect) — §8
|
||||
- a `0x45` LOGSYNC after each logged session (and a full batch on connect) — §9
|
||||
|
||||
**Device should emit** (from its on‑device input handlers):
|
||||
- `play`/`stop` when button A toggles transport
|
||||
|
|
@ -172,6 +176,14 @@ truth" wins immediately. A device that boots with sync idle should simply answer
|
|||
- [ ] **Heartbeat:** emit `0x41` FULL every ~3–5 s while a peer is connected.
|
||||
- [ ] **BYE (`0x43`)** → mark the peer gone (stop heartbeating/emitting until
|
||||
the next HELLO).
|
||||
- [ ] **SLSYNC (`0x44`)** → merge the JSON manifest of user lists by normalized
|
||||
title (replace‑per‑list, append unknown, never delete), reusing
|
||||
`load_user_setlists()`‑shaped parsing; `rebuild_setlists()` + reload.
|
||||
Emit one after `_persist_user()` and in reply to `0x40`. (§8)
|
||||
- [ ] **LOGSYNC (`0x45`)** → merge practice entries by (`at`,`name`); append +
|
||||
cap + re‑sort. Emit a one‑entry batch after `_log_play()` and a full batch
|
||||
in reply to `0x40`. Record `at` (epoch ms) on each play when the RTC is
|
||||
set. (§9)
|
||||
- [ ] **Throttle** high‑rate sources (joystick tempo) and keep frames small —
|
||||
the RP2040 USB‑MIDI RX buffer is tiny (the firmware updater already chunks
|
||||
at 64 bytes), and live traffic shares the bus with MIDI clock, note‑out,
|
||||
|
|
@ -190,10 +202,15 @@ FULL with the resulting (copied) program so both sides converge on the same
|
|||
target.
|
||||
|
||||
### Out of scope for the beta
|
||||
- Streaming the device practice log (`history.json`) up to the browser.
|
||||
- Mirroring device `settings.json` (LED brightness, MIDI config, etc.).
|
||||
- Multi‑peer / multi‑editor arbitration beyond last‑writer‑wins.
|
||||
|
||||
> **No longer out of scope** (now specced in §8 / §9): live set‑list **content**
|
||||
> sync (`0x44`) and streaming the device practice log up to the browser and back
|
||||
> (`0x45`). The old `0x10` programs push (Save/Load to device) still exists as the
|
||||
> explicit, full‑overwrite path; `0x44` is the *incremental, merge‑by‑title* live
|
||||
> mirror that runs automatically while sync is armed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Per‑device emit/apply matrix
|
||||
|
|
@ -211,3 +228,142 @@ Editors don't need to special‑case the source — both DELTA streams look iden
|
|||
the wire, and the **device id is only exposed on the version query** (SysEx `0x02`
|
||||
→ `0x03` reply, `<id>;<version>`; pre‑0.0.23 firmware sends bare version → assume
|
||||
`K`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Set‑list content sync (`0x44` SLSYNC)
|
||||
|
||||
The `0x41` FULL only carries the *one loaded program* (`<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 *)
|
||||
sub = int ; (* subdivision; trailing "s" = swing *)
|
||||
euclid = "(" int [ "," int [ "," int ] ] ")" ; (* k [, n [, rot ]] — even distribution *)
|
||||
pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *)
|
||||
pattern = *( cell ) ; (* one char per step: dynamics + ornament *)
|
||||
cell = "X" | "x" | "1" | "g" (* dynamics: accent / normal / normal / ghost *)
|
||||
| "f" | "F" | "d" | "D" | "z" | "Z" (* ornament hits (see below): flam / drag / roll *)
|
||||
| "." | "-" | "_" ; (* rest *)
|
||||
```
|
||||
|
||||
### Lane semantics
|
||||
|
|
@ -92,6 +95,12 @@ pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *)
|
|||
- **swing** — `/2s` lays the off-beat on the last triplet (≈ 2/3).
|
||||
- **pattern** — one char per step: `X`=accent (level 2), `x`/`1`=normal (1), `g`=ghost (3),
|
||||
`.`/`-`/`_`/anything else = rest (0). Short patterns are right-padded with rests to `steps`.
|
||||
- **ornaments** — three extra hit letters add a per-step *ornament* on top of the dynamic, in a
|
||||
channel parallel to the dynamic levels (see `orns` in §5): `f`/`F`=flam (one grace note),
|
||||
`d`/`D`=drag/ruff (two grace notes), `z`/`Z`=roll/buzz. The **case carries the dynamic** so the
|
||||
two stay orthogonal: **lower-case = normal hit (level 1), UPPER-case = accented hit (level 2)**.
|
||||
So `snare:4=F.fz` is an accented-flam, rest, normal-flam, normal-roll. Ghosted ornaments aren't
|
||||
expressible (a `g`-style ghost ornament has no letter); ornament + rest is just a rest.
|
||||
**With no pattern (the default):** every step sounds at normal level and accents fall **only
|
||||
on group starts** — the grouping *is* the accent map. So `4` accents beat 1; `2+2` accents
|
||||
beats 1 & 3; `4/2` is a steady 8th lane with an accent on beat 1. To accent every beat,
|
||||
|
|
@ -192,6 +201,12 @@ expected value in `norm`:
|
|||
`levels` is the resolved per-step dynamics array (0 rest / 1 normal / 2 accent / 3 ghost) —
|
||||
the real audible payload, and the most important thing two implementations must agree on.
|
||||
|
||||
`orns` is the resolved per-step **ornament** array, parallel to `levels`
|
||||
(`0` none / `1` flam / `2` drag / `3` roll). It **defaults to all-zeros**, so a lane with no
|
||||
ornaments omits it entirely — an implementation MAY always emit it or omit-when-all-zero, and the
|
||||
conformance runner treats a missing `orns` as all-zeros. Example with ornaments
|
||||
(`snare:4=F.fz`): `"levels": [2,0,1,1], "orns": [1,0,1,3]`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Divergences — status
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
<p class="pick"><label for="ffSel">Show snippets for:</label>
|
||||
<select id="ffSel">
|
||||
<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="stage">PM_S‑1 Stage</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 FF = [
|
||||
{ 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:"teacher", name:"PM_T‑1 Teacher", file:"teacher.html", h:440 },
|
||||
{ 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 = [
|
||||
{ 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:"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." },
|
||||
|
|
|
|||
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()
|
||||
|
||||
# ============================== 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}
|
||||
# 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",
|
||||
|
|
@ -345,10 +347,13 @@ def _parse_lane(tok):
|
|||
for h in _euclid(k, n, rot):
|
||||
if h: levels.append(2 if first else 1); first = False
|
||||
else: levels.append(0)
|
||||
orns = [0] * len(levels) # euclid hits carry no ornament
|
||||
elif pattern:
|
||||
steps = beats * sub
|
||||
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)
|
||||
else:
|
||||
steps = beats * sub
|
||||
|
|
@ -356,15 +361,21 @@ def _parse_lane(tok):
|
|||
for i in range(steps):
|
||||
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)
|
||||
orns = [0] * steps
|
||||
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}
|
||||
|
||||
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)
|
||||
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 '')
|
||||
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', '')
|
||||
if L['poly']: 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]}
|
||||
try:
|
||||
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
|
||||
except OSError:
|
||||
return False # editor mode: the drive is read-only to us
|
||||
|
|
@ -1153,6 +1165,96 @@ class App:
|
|||
finally:
|
||||
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
|
||||
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)
|
||||
|
|
@ -1531,10 +1633,13 @@ class App:
|
|||
if dur < MIN_LOG_SEC: return # skip plays under 5 seconds
|
||||
mlen = self.lanes[0]['steps'] if self.lanes else 1
|
||||
t = time.localtime()
|
||||
self.log.insert(0, {"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})
|
||||
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,
|
||||
"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
|
||||
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):
|
||||
g = self.g_log
|
||||
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
|
||||
payload = DEVICE_ID + ";" + APP_VERSION
|
||||
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:])
|
||||
except Exception: return
|
||||
origin = text.split(";", 1)[0] if text else ""
|
||||
if origin == self._sync_origin: return # drop our own echoes (composite USB may loop)
|
||||
self._sync_armed = True
|
||||
if cmd == 0x40: # HELLO -> reply with our current FULL
|
||||
self._sync_broadcast_full()
|
||||
if cmd == 0x40: # HELLO -> reply with our FULL + set-list library + practice log
|
||||
self._sync_broadcast_full(); self._sync_send_setlists(); self._sync_send_log_batch()
|
||||
elif cmd == 0x43: # BYE -> peer disconnected; stop heartbeats
|
||||
self._sync_armed = False
|
||||
elif cmd == 0x41: # FULL: origin;seq;running;sl;item;patch...
|
||||
|
|
@ -1633,6 +1738,12 @@ class App:
|
|||
elif cmd == 0x42: # DELTA: origin;seq;evt
|
||||
parts = text.split(";", 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
|
||||
try:
|
||||
with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))
|
||||
|
|
|
|||
|
|
@ -230,7 +230,9 @@ ICON_USB = load_alpha("/usb.bin")
|
|||
gc.collect()
|
||||
|
||||
# ============================== 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}
|
||||
# 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",
|
||||
|
|
@ -314,10 +316,13 @@ def _parse_lane(tok):
|
|||
for h in _euclid(k, n, rot):
|
||||
if h: levels.append(2 if first else 1); first = False
|
||||
else: levels.append(0)
|
||||
orns = [0] * len(levels) # euclid hits carry no ornament
|
||||
elif pattern:
|
||||
steps = beats * sub
|
||||
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)
|
||||
else:
|
||||
steps = beats * sub
|
||||
|
|
@ -325,15 +330,21 @@ def _parse_lane(tok):
|
|||
for i in range(steps):
|
||||
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)
|
||||
orns = [0] * steps
|
||||
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}
|
||||
|
||||
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):
|
||||
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 '')
|
||||
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', '')
|
||||
if L['poly']: s += '~'
|
||||
if L['mute']: s += '!'
|
||||
|
|
@ -881,6 +892,107 @@ class App:
|
|||
except Exception: pass
|
||||
finally:
|
||||
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
|
||||
sub = L['sub']; groups = L['groups']; starts = set(); acc = 0
|
||||
for gp in groups: starts.add(acc); acc += gp
|
||||
|
|
@ -1352,10 +1464,13 @@ class App:
|
|||
if dur < MIN_LOG_SEC: return
|
||||
mlen = self.lanes[0]['steps'] if self.lanes else 1
|
||||
t = time.localtime()
|
||||
self.log.insert(0, {"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})
|
||||
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,
|
||||
"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._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
|
||||
g = self.g_log
|
||||
while len(g): g.pop()
|
||||
|
|
@ -1427,14 +1542,14 @@ class App:
|
|||
if self.midi:
|
||||
payload = DEVICE_ID + ";" + APP_VERSION
|
||||
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:])
|
||||
except Exception: return
|
||||
origin = text.split(";", 1)[0] if text else ""
|
||||
if origin == self._sync_origin: return
|
||||
self._sync_armed = True
|
||||
if cmd == 0x40:
|
||||
self._sync_broadcast_full()
|
||||
self._sync_broadcast_full(); self._sync_send_setlists(); self._sync_send_log_batch()
|
||||
elif cmd == 0x43:
|
||||
self._sync_armed = False
|
||||
elif cmd == 0x41:
|
||||
|
|
@ -1447,6 +1562,12 @@ class App:
|
|||
elif cmd == 0x42:
|
||||
parts = text.split(";", 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:
|
||||
try:
|
||||
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
|
||||
|
||||
# ============================== 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}
|
||||
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",
|
||||
|
|
@ -179,10 +181,13 @@ def _parse_lane(tok):
|
|||
for h in _euclid(k, n, rot):
|
||||
if h: levels.append(2 if first else 1); first = False
|
||||
else: levels.append(0)
|
||||
orns = [0] * len(levels) # euclid hits carry no ornament
|
||||
elif pattern:
|
||||
steps = beats * sub
|
||||
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)
|
||||
else:
|
||||
steps = beats * sub
|
||||
|
|
@ -190,15 +195,21 @@ def _parse_lane(tok):
|
|||
for i in range(steps):
|
||||
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
|
||||
orns = [0] * steps
|
||||
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}
|
||||
|
||||
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):
|
||||
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 '')
|
||||
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', '')
|
||||
if L['poly']: 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)
|
||||
# lane = <sound>:<grouping>[/<sub>[s]][=pattern][@db][~][!]
|
||||
# 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
|
||||
|
||||
def parse_program(s):
|
||||
|
|
|
|||
|
|
@ -309,7 +309,15 @@ fn main() -> ! {
|
|||
info!("parsed groove 0: bpm={} lanes={}, {} free", track.bpm, track.lanes.len(), HEAP.free());
|
||||
let mut tempo: i64 = track.bpm;
|
||||
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
|
||||
// changed vs the last frame. Full-width windows (CASET 0..319) behave exactly like the proven
|
||||
|
|
@ -345,7 +353,12 @@ fn main() -> ! {
|
|||
dirty = true;
|
||||
}
|
||||
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;
|
||||
force_full = true; // whole screen changes — use the clean full blit
|
||||
}
|
||||
|
|
@ -421,17 +434,20 @@ fn main() -> ! {
|
|||
.map(|l| pm_ui::LaneView {
|
||||
name: &l.sound,
|
||||
levels: &l.levels,
|
||||
orns: &l.orns,
|
||||
groups: &l.groups,
|
||||
beats: l.groups.iter().map(|&g| g as u32).sum::<u32>().min(255) as u8,
|
||||
poly: l.poly,
|
||||
muted: l.mute,
|
||||
})
|
||||
.collect();
|
||||
let screen = pm_ui::Screen { name: NAMES[idx], bpm: tempo, playing, phase, lanes: &lanes };
|
||||
if notation {
|
||||
pm_ui::draw_notation(&mut fb, &screen).ok();
|
||||
} else {
|
||||
pm_ui::draw_metronome(&mut fb, &screen).ok();
|
||||
}
|
||||
match view {
|
||||
View::Grid => pm_ui::draw_metronome(&mut fb, &screen).ok(),
|
||||
View::Staff => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Staff).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)
|
||||
fn tile_fnv(px: &[Rgb565], tx: usize, ty: usize) -> u64 {
|
||||
let mut h = 0xcbf29ce484222325u64; // FNV-1a offset basis
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
|
||||
#![no_std]
|
||||
|
||||
pub mod notation;
|
||||
pub use notation::ViewMode;
|
||||
|
||||
use embedded_graphics::{
|
||||
mono_font::{ascii::{FONT_10X20, FONT_6X10, FONT_9X18_BOLD}, MonoTextStyle},
|
||||
pixelcolor::Rgb565,
|
||||
|
|
@ -35,7 +38,12 @@ pub struct LaneView<'a> {
|
|||
pub name: &'a str,
|
||||
/// per-step dynamics: 0 rest, 1 normal, 2 accent, 3 ghost
|
||||
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 poly: bool,
|
||||
pub muted: bool,
|
||||
|
|
@ -263,179 +271,14 @@ where
|
|||
}
|
||||
|
||||
// ---- 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
|
||||
/// stems (hands up / feet down) and beamed eighths/sixteenths. First pass — refine freely.
|
||||
/// Render one bar of the groove as drum notation (Staff view). Thin wrapper over the ported
|
||||
/// 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>
|
||||
where
|
||||
D: DrawTarget<Color = Rgb565>,
|
||||
{
|
||||
let bb = d.bounding_box();
|
||||
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(())
|
||||
notation::draw(d, s, ViewMode::Staff)
|
||||
}
|
||||
|
||||
/// Bring-up diagnostic pattern (kept for hardware bring-up / fallback).
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ pub struct Lane {
|
|||
pub mute: bool,
|
||||
pub gain_db: f64,
|
||||
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)]
|
||||
|
|
@ -108,12 +110,35 @@ fn euclid(k: i64, n: i64, rot: i64) -> Vec<u8> {
|
|||
.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 {
|
||||
'X' => 2,
|
||||
'x' | '1' => 1,
|
||||
'g' => 3,
|
||||
_ => 0,
|
||||
'X' => (2, 0),
|
||||
'x' | '1' => (1, 0),
|
||||
'g' => (3, 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;
|
||||
}
|
||||
|
||||
let levels: Vec<u8> = if let Some(e) = euc {
|
||||
// euclidean: k hits over n steps, first hit accented
|
||||
let (levels, orns): (Vec<u8>, Vec<u8>) = if let Some(e) = euc {
|
||||
// euclidean: k hits over n steps, first hit accented (no ornaments)
|
||||
let k = e[0];
|
||||
let n = if e.len() > 1 { e[1] } else { (beats * sub) as i64 };
|
||||
let rot = if e.len() > 2 { e[2] } else { 0 };
|
||||
|
|
@ -200,7 +225,7 @@ fn parse_lane(tok: &str) -> Lane {
|
|||
}
|
||||
}
|
||||
let mut first = true;
|
||||
euclid(k, n, rot)
|
||||
let lv: Vec<u8> = euclid(k, n, rot)
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
if h != 0 {
|
||||
|
|
@ -211,18 +236,23 @@ fn parse_lane(tok: &str) -> Lane {
|
|||
0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
let orn = vec![0u8; lv.len()];
|
||||
(lv, orn)
|
||||
} else if let Some(p) = pattern {
|
||||
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 {
|
||||
lv.resize(steps, 0);
|
||||
orn.resize(steps, 0);
|
||||
}
|
||||
lv
|
||||
(lv, orn)
|
||||
} else {
|
||||
// default: every subdivision sounds at normal, accent only on group starts
|
||||
let steps = beats * sub;
|
||||
(0..steps)
|
||||
let lv: Vec<u8> = (0..steps)
|
||||
.map(|i| {
|
||||
if i % sub == 0 {
|
||||
if starts.contains(&(i / sub)) { 2 } else { 1 }
|
||||
|
|
@ -230,13 +260,15 @@ fn parse_lane(tok: &str) -> Lane {
|
|||
1
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
let orn = vec![0u8; lv.len()];
|
||||
(lv, orn)
|
||||
};
|
||||
|
||||
if !known_sound(&sound) {
|
||||
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 {
|
||||
|
|
@ -337,12 +369,7 @@ fn lane_to_str(l: &Lane) -> String {
|
|||
s.push_str(&format!("/{}{}", l.sub, if l.swing { "s" } else { "" }));
|
||||
}
|
||||
s.push('=');
|
||||
s.extend(l.levels.iter().map(|&v| match v {
|
||||
2 => 'X',
|
||||
1 => 'x',
|
||||
3 => 'g',
|
||||
_ => '.',
|
||||
}));
|
||||
s.extend(l.levels.iter().enumerate().map(|(i, &v)| cell_ch(v, l.orns.get(i).copied().unwrap_or(0))));
|
||||
if l.gain_db != 0.0 {
|
||||
s.push_str(&format!("@{}", l.gain_db));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,17 @@ fn norm(t: &Track) -> Value {
|
|||
Some(End::Stop) => json!("stop"),
|
||||
Some(End::Goto(n)) => json!(n),
|
||||
},
|
||||
"lanes": t.lanes.iter().map(|l| json!({
|
||||
"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
|
||||
})).collect::<Vec<_>>(),
|
||||
"lanes": t.lanes.iter().map(|l| {
|
||||
let mut o = json!({
|
||||
"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
|
||||
});
|
||||
// `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() {
|
||||
// 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 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 lanes: Vec<LaneView> = track
|
||||
.lanes
|
||||
.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();
|
||||
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] };
|
||||
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 c = fb.px[(y * W + x) as usize];
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ fn main() {
|
|||
.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,
|
||||
|
|
|
|||
|
|
@ -172,15 +172,39 @@ function laneStepDur(m, tick) {
|
|||
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 ---
|
||||
function laneCfgToStr(c) {
|
||||
let s = c.sound + ":" + c.groupsStr;
|
||||
const spb = c.stepsPerBeat || 1;
|
||||
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 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 isDefault = 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) => (v === 3 ? "g" : v >= 2 ? "X" : v >= 1 ? "x" : ".")).join("");
|
||||
const anyOrn = orn.some((v) => (v | 0) !== 0); // any ornament → not the implicit default; must write the pattern
|
||||
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.poly) s += "~";
|
||||
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("/");
|
||||
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 beatsOn;
|
||||
let beatsOn, orns;
|
||||
if (eucK != null) { // k hits spread evenly; first hit accented
|
||||
let n = eucN || (bpb * sub);
|
||||
if (eucN) { if (n % bpb === 0) sub = n / bpb; else { bpb = n; sub = 1; groupsStr = String(n); } }
|
||||
let first = true;
|
||||
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 {
|
||||
// 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)
|
||||
beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0)
|
||||
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
|
||||
beatsOn = Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
|
||||
orns = beatsOn.map(() => 0);
|
||||
}
|
||||
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 ---
|
||||
|
|
|
|||
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>
|
||||
// 0x42 DELTA -> one mutation event payload: <origin>;<seq>;<evt>
|
||||
// 0x43 BYE -> mirroring off payload: <origin>
|
||||
// 0x44 SLSYNC -> live set-list CONTENT merge (user lists only) payload: <origin>;<seq>;<json> (§8)
|
||||
// 0x45 LOGSYNC-> practice-log entry merge (additive, by at+name) payload: <origin>;<seq>;<json> (§9)
|
||||
//
|
||||
// DELTA <evt> grammar (reuses the share-language tokens — see engine.js):
|
||||
// play | stop | bpm=<n> | vol=<pct> | sel=<sl>/<item>
|
||||
|
|
@ -41,6 +43,8 @@ var LiveSync = {
|
|||
_syncOn = true;
|
||||
this.send(0x40, ""); // HELLO — ask the device for its full state
|
||||
this.broadcastFull(); // and push ours so the device mirrors us immediately
|
||||
this.broadcastSetlists(); // offer our user set-list library (content merge — §8)
|
||||
this.broadcastLogBatch(); // and our whole practice history (additive merge — §9)
|
||||
if (_loopback) { this.connected = true; this.peerOrigin = "loopback"; }
|
||||
updateSyncBtn();
|
||||
return true;
|
||||
|
|
@ -70,6 +74,25 @@ var LiveSync = {
|
|||
var patch; try { patch = currentPatch(); } catch (e) { return; }
|
||||
this.send(0x41, (state.running ? 1 : 0) + ";" + loadedSL + ";" + activeItem + ";" + patch);
|
||||
},
|
||||
// 0x44 SLSYNC — full manifest of OUR user set lists (titles + items + progs),
|
||||
// in the same JSON shape the 0x10 programs push uses. Built-ins/seeded lists
|
||||
// are excluded (both halves bake identical copies); merge is by title (§8).
|
||||
broadcastSetlists() {
|
||||
if (!_syncOn || _applyingRemote) return;
|
||||
var json; try { json = _userSetlistsJSON(); } catch (e) { return; }
|
||||
this.send(0x44, _ascii7(json));
|
||||
},
|
||||
// 0x45 LOGSYNC — practice-log entries, normalized to {at,name,dur,bpm} (§9).
|
||||
// entries omitted => whole log (on connect); else a single new session.
|
||||
broadcastLog(entry) {
|
||||
if (!_syncOn || _applyingRemote) return;
|
||||
this.send(0x45, _ascii7(JSON.stringify({ log: [entry] })));
|
||||
},
|
||||
broadcastLogBatch() {
|
||||
if (!_syncOn || _applyingRemote) return;
|
||||
var json; try { json = _logBatchJSON(); } catch (e) { return; }
|
||||
this.send(0x45, _ascii7(json));
|
||||
},
|
||||
|
||||
// ---- receive -----------------------------------------------------------
|
||||
applyRemote(op, text) {
|
||||
|
|
@ -80,11 +103,19 @@ var LiveSync = {
|
|||
if (op === 0x43) { this.connected = false; updateSyncBtn(); return; } // peer said BYE
|
||||
var parts = text.split(";");
|
||||
if (op === 0x41) { // FULL: origin;seq;running;sl;item;patch...
|
||||
var running = parts[2] === "1", patch = parts.slice(5).join(";");
|
||||
_applyRemote(function () { _applyFull(running, patch); });
|
||||
var running = parts[2] === "1";
|
||||
var sl = parseInt(parts[3], 10), item = parseInt(parts[4], 10);
|
||||
var patch = parts.slice(5).join(";"); // patch is the tail (it contains ; and /)
|
||||
_applyRemote(function () { _applyFull(running, patch, sl, item); });
|
||||
} else if (op === 0x42) { // DELTA: origin;seq;evt
|
||||
var evt = parts.slice(2).join(";");
|
||||
_applyRemote(function () { _applyDelta(evt); });
|
||||
} else if (op === 0x44) { // SLSYNC: origin;seq;json (set-list content)
|
||||
var sj = parts.slice(2).join(";");
|
||||
_applyRemote(function () { _applySetlists(sj); });
|
||||
} else if (op === 0x45) { // LOGSYNC: origin;seq;json (practice entries)
|
||||
var lj = parts.slice(2).join(";");
|
||||
_applyRemote(function () { _applyLog(lj); });
|
||||
}
|
||||
updateSyncBtn();
|
||||
},
|
||||
|
|
@ -128,10 +159,24 @@ function _applyDelta(evt) {
|
|||
|
||||
// Full-state mirror: only rebuild if the groove actually differs (avoids
|
||||
// flicker / lost focus when a heartbeat arrives and we're already in sync),
|
||||
// then reconcile transport.
|
||||
function _applyFull(running, patch) {
|
||||
// then reconcile selection + transport.
|
||||
// sl/item — the peer's loaded set-list item (or -1 / NaN = free play). We mirror
|
||||
// the SELECTION HIGHLIGHT only (state-only setLoaded), never re-loading the item:
|
||||
// the groove itself already arrived in `patch`, so re-loading would double-apply
|
||||
// and could glitch audio. This keeps the "now loaded" row + history in sync.
|
||||
function _applyFull(running, patch, sl, item) {
|
||||
var cur = null; try { cur = currentPatch(); } catch (e) {}
|
||||
if (patch && patch !== cur) applyPatch(patch);
|
||||
// Mirror which set-list item the peer has loaded, if it maps onto a list we have.
|
||||
if (typeof sl === "number" && sl >= 0 && typeof item === "number" && item >= 0 &&
|
||||
typeof setlists !== "undefined" && setlists[sl] && setlists[sl].items[item]) {
|
||||
if (loadedSL !== sl || activeItem !== item) {
|
||||
if (typeof setLoaded === "function") setLoaded(sl, item);
|
||||
if (activeSL !== loadedSL) { activeSL = loadedSL; if (typeof renderSetlists === "function") renderSetlists(); }
|
||||
else if (typeof renderItems === "function") renderItems();
|
||||
if (typeof renderLog === "function") renderLog();
|
||||
}
|
||||
}
|
||||
if (running && !state.running) toggleTransport();
|
||||
else if (!running && state.running) toggleTransport();
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
||||
// ---- 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 + " | ||||