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:
Me Here 2026-06-02 13:45:26 -05:00
parent 49a4308c4b
commit cb54b4d689
24 changed files with 838 additions and 249 deletions

View file

@ -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 / formfactor gallery; each box embeds the live widget (Open ↗ / Specs & info ⓘ) | | [`/`](https://metronome.varasys.io/) `index.html` | **Concepts** — the landing / formfactor gallery; each box embeds the live widget (Open ↗ / Specs & info ⓘ) |
| `/editor.html` · `/info-editor.html` | **PM_E1 — PolyMeter Editor** (the main app) + its overview | | `/editor.html` · `/info-editor.html` | **PM_E1 — PolyMeter Editor** (the main app) + its overview |
| `/pm_e-2.html` · `/info-pm_e-2.html` | **PM_E2 — PolyMeter Editor (Notation)** — second-gen, engraved drum notation (Bravura/SMuFL): Staff / TUBS / Konnakol views, edit-on-staff |
| `/kit.html` · `/info-kit.html` | **PM_K1 Kit** — buildable Raspberry Pi Pico touchscreen unit (52Pi EP0172); info page has the wiring, parts and firmware | | `/kit.html` · `/info-kit.html` | **PM_K1 Kit** — buildable Raspberry Pi Pico touchscreen unit (52Pi EP0172); info page has the wiring, parts and firmware |
| `/player.html` · `/info-player.html` | **PM_C1 Concept** — idealized concept device (full display + setlist nav, theme, fullscreen "stage" view) | | `/player.html` · `/info-player.html` | **PM_C1 Concept** — idealized concept device (full display + setlist nav, theme, fullscreen "stage" view) |
| `/teacher.html` · `/info-teacher.html` | **PM_T1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument passthrough with analog click injection) | | `/teacher.html` · `/info-teacher.html` | **PM_T1 Teacher** — studio / lesson console (colour TFT, arcade buttons, 1/4″ instrument passthrough 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_E1 editor** app (source, with `@BUILD:*` markers) | | `editor.html` | the **PM_E1 editor** app (source, with `@BUILD:*` markers) |
| `pm_e-2.html` · `src/notation.js` | the **PM_E2 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_K1 Kit, PM_C1 Concept, Teacher, Stage, PM_P1 Practice, PM_D1 Display) | | `kit.html` · `player.html` · `teacher.html` · `stage.html` · `micro.html` · `showcase.html` | the device widget pages (PM_K1 Kit, PM_C1 Concept, Teacher, Stage, PM_P1 Practice, PM_D1 Display) |
| `info-*.html` | performfactor spec pages (embed the live widget + description + dimensions + BOM) | | `info-*.html` | performfactor spec pages (embed the live widget + description + dimensions + BOM) |
| `embed.html` · `embed.js` | embed docs and the dropin widget loader | | `embed.html` · `embed.js` | embed docs and the dropin widget loader |

View file

@ -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")

View file

@ -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

View file

@ -27,11 +27,13 @@ F0 7D <op> <payload ASCII bytes, each 0x000x7F> 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 7bit ASCII** — never emit a byte > `0x7F` (it corrupts the SysEx - **Payload is 7bit ASCII** — never emit a byte > `0x7F` (it corrupts the SysEx
stream and, per `build.sh`, would also break the firmwareupdate path). All stream and, per `build.sh`, would also break the firmwareupdate path). All
@ -96,6 +98,8 @@ apply** — each must apply *every* op/evt listed above.
- a coalesced `0x41` FULL for any lanefield / add / remove / practice (trainer, - a coalesced `0x41` FULL for any lanefield / 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 setlist **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 ondevice input handlers): **Device should emit** (from its ondevice 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 ~35 s while a peer is connected. - [ ] **Heartbeat:** emit `0x41` FULL every ~35 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 (replaceperlist, 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 + resort. Emit a oneentry 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** highrate sources (joystick tempo) and keep frames small — - [ ] **Throttle** highrate sources (joystick tempo) and keep frames small —
the RP2040 USBMIDI RX buffer is tiny (the firmware updater already chunks the RP2040 USBMIDI RX buffer is tiny (the firmware updater already chunks
at 64 bytes), and live traffic shares the bus with MIDI clock, noteout, at 64 bytes), and live traffic shares the bus with MIDI clock, noteout,
@ -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.).
- Multipeer / multieditor arbitration beyond lastwriterwins. - Multipeer / multieditor arbitration beyond lastwriterwins.
> **No longer out of scope** (now specced in §8 / §9): live setlist **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, fulloverwrite path; `0x44` is the *incremental, mergebytitle* live
> mirror that runs automatically while sync is armed.
--- ---
## 7. Perdevice emit/apply matrix ## 7. Perdevice emit/apply matrix
@ -211,3 +228,142 @@ Editors don't need to specialcase 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>`; pre0.0.23 firmware sends bare version → assume `0x03` reply, `<id>;<version>`; pre0.0.23 firmware sends bare version → assume
`K`). `K`).
---
## 8. Setlist content sync (`0x44` SLSYNC)
The `0x41` FULL only carries the *one loaded program* (`<patch>`) plus the
*selection* indices. `0x44` carries **setlist 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 (echodrop + duplicate info).
- `<json>` — a **7bitsafe 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;..."}]}]}
```
NonASCII in titles/names is escaped `\uXXXX` (the editor's existing
`programsJSON()` 7bitsafe 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.** Builtin / seeded lists (firmware `BUILTIN_SETLISTS`;
editor `SEED_SETLISTS` titles) are readonly 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: lowercase, alphanumerics only — the
firmware's `_slkey()`), independent of index. Indices diverge freely between
halves (the device prepends builtins; the web orders differently), so a
positional match is wrong — **title is the key.**
- **Items match by name** within a list (casesensitive, as both UIs key
practice history by exact name).
- Merge is a **perlist 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 **lastwriterwins per list** (consistent with the rest of the
protocol). The receiver applies under its remoteapply guard and does **not**
rebroadcast a `0x44` in response (no echo storm); the next heartbeat / FULL
still reconciles the loaded program.
### Copyonwrite for builtins
A `0x44` never targets a builtin: it only carries user lists, and the receiver
only ever writes user lists. If a user **edits a builtin item** on either half,
that edit must first be **forked into a user list** (the firmware's `_save_edit`
already forks builtin 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
copyonwrite happens **before** the sync, and the wire only ever sees user
content — the builtins on both sides stay pristine and identical.
### When it's emitted
- **Editor:** coalesced ~150 ms after any setlist 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 remoteapply flag, and once in reply to a
`0x40` HELLO. The device's peritem *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
> setlist content is lastwriterwins 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. Practicelog 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 viceversa.
**Frame:** `F0 7D 45 <origin>;<seq>;<json> F7`
`<json>` is a 7bitsafe 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 | setlist 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
lastwriterwins philosophy and avoids a delete echoing into a readd.)
- The receiver **caps** its merged log (web keeps all; device keeps newest 200,
its existing cap) and resorts newestfirst by `at`.
### When it's emitted
- **Editor:** after `logFinalize()` writes a new session (oneentry 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
rebroadcast.
- **Device:** after `_log_play()` appends a session (oneentry batch), guarded by
the remoteapply flag, and a full batch once in reply to a `0x40` HELLO.
> **Batch size caution (firmware):** a fullhistory batch (up to 200 entries) is
> small JSON but still allocates; the device sends its on connect/HELLO only, and
> the editor's onconnect 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. Persession emits are a single entry — negligible.

View file

@ -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

View file

@ -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_E1 Editor</option> <option value="editor">PM_E1 Editor</option>
<option value="pme2">PM_E2 Editor (Notation)</option>
<option value="teacher">PM_T1 Teacher</option> <option value="teacher">PM_T1 Teacher</option>
<option value="stage">PM_S1 Stage</option> <option value="stage">PM_S1 Stage</option>
<option value="micro" selected>PM_P1 Practice</option> <option value="micro" selected>PM_P1 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_E1 Editor", file:"editor.html", h:560 }, { k:"editor", name:"PM_E1 Editor", file:"editor.html", h:560 },
{ k:"pme2", name:"PM_E2 Editor", file:"pm_e-2.html", h:640 },
{ k:"kit", name:"PM_K1 Kit", file:"kit.html", h:560 }, { k:"kit", name:"PM_K1 Kit", file:"kit.html", h:560 },
{ k:"teacher", name:"PM_T1 Teacher", file:"teacher.html", h:440 }, { k:"teacher", name:"PM_T1 Teacher", file:"teacher.html", h:440 },
{ k:"stage", name:"PM_S1 Stage", file:"stage.html", h:430 }, { k:"stage", name:"PM_S1 Stage", file:"stage.html", h:430 },

View file

@ -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_E1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, perstep accents/ghosts/mutes, swing &amp; polyrhythm, set lists, perlane dB gain." }, { key:"editor", file:"/editor.html", name:"PM_E1 Editor", chip:"app", h:620, sum:"Design grooves: stack meter lanes, perstep accents/ghosts/mutes, swing &amp; polyrhythm, set lists, perlane dB gain." },
{ key:"pme2", file:"/pm_e-2.html", name:"PM_E2 Editor", chip:"app", h:640, sum:"Secondgeneration editor built around engraved drum notation — a 5line percussion staff (Bravura/SMuFL) with Staff / TUBS / Konnakol views, editonstaff, plus flams/drags/rolls, odd meters &amp; clave." },
{ key:"kit", file:"/kit.html", name:"PM_K1 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_K1 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_X1 Explorer", chip:"hw", h:500, sum:"Offtheshelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a buttondriven 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_X1 Explorer", chip:"hw", h:500, sum:"Offtheshelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a buttondriven 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_G1 Grid", chip:"hw", h:470, sum:"Offtheshelf — 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_G1 Grid", chip:"hw", h:470, sum:"Offtheshelf — 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." },

View file

@ -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:]))

View file

@ -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:]))

View file

@ -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 += '!'

View file

@ -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):

View file

@ -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

View file

@ -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).

View file

@ -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));
} }

View file

@ -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<_>>(),
}) })
} }

View file

@ -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];

View file

@ -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,

View file

@ -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 ---

View file

@ -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 + "" + logs[k].name] = 1; }
var added = 0;
for (var i = 0; i < incoming.length; i++) {
var w = incoming[i]; if (!w || !w.name) continue;
var at = w.at | 0;
if (at && have[at + "" + w.name]) continue; // dedup (at==0 falls through -> always appended)
logs.push({ at: at || Date.now(), name: w.name, durationSec: w.dur || 0, bpm: w.bpm | 0, lanes: [] });
if (at) have[at + "" + w.name] = 1;
added++;
}
if (!added) return;
logs.sort(function (a, b) { return (b.at | 0) - (a.at | 0); }); // newest first
lsSet(LS.logs, logs);
if (typeof renderLog === "function") renderLog();
}
// ---- broadcast helpers called from the editor's mutation handlers ---------- // ---- broadcast helpers called from the editor's mutation handlers ----------
// Each is a no-op unless mirroring is armed and we're not mid-apply. // Each is a no-op unless mirroring is armed and we're not mid-apply.
function syncTransport() { LiveSync.broadcast(state.running ? "play" : "stop"); } function syncTransport() { LiveSync.broadcast(state.running ? "play" : "stop"); }
@ -165,6 +282,19 @@ function syncPatchSoon() {
clearTimeout(_patchSyncTimer); clearTimeout(_patchSyncTimer);
_patchSyncTimer = setTimeout(function () { LiveSync.broadcastFull(); }, 150); _patchSyncTimer = setTimeout(function () { LiveSync.broadcastFull(); }, 150);
} }
// Set-list CONTENT changes (add/rename/reorder list, add/remove/rename item,
// capture/update) coalesce into one 0x44 manifest push (§8).
var _slSyncTimer = 0;
function syncSetlistsSoon() {
if (!_syncOn || _applyingRemote) return;
clearTimeout(_slSyncTimer);
_slSyncTimer = setTimeout(function () { LiveSync.broadcastSetlists(); }, 200);
}
// A newly logged practice session -> one 0x45 entry (§9).
function syncLog(entry) {
if (!_syncOn || _applyingRemote || !entry) return;
LiveSync.broadcastLog(_logToWire(entry));
}
function toggleSync() { if (_syncOn) LiveSync.disconnect(); else LiveSync.connect(); } function toggleSync() { if (_syncOn) LiveSync.disconnect(); else LiveSync.connect(); }
function updateSyncBtn() { function updateSyncBtn() {

View file

@ -34,4 +34,17 @@ const SEED_SETLISTS = [
["Peak — 16ths", "t132;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"], ["Peak — 16ths", "t132;b16;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"],
["Outro — ramp down", "t132;b8;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"], ["Outro — ramp down", "t132;b8;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"],
] }, ] },
// Showcase for the PM_E-2 engraved notation view — one groove per notation feature; also the
// visual test battery. Exercises the flam/drag/roll grammar (f/F d/D z/Z), ghosts, odd meters,
// clave (Latin), a West-African bell (read it in TUBS), and a polyrhythm.
{ title: "📜 Notation showcase", description: "Open pm_e-2.html (the Notation editor) and step through these — each shows a different notation feature: dynamics, ghosts, flams/drags/rolls, odd-meter beaming, clave, a 12/8 bell, and a polyrhythm.", items: [
["Rock 4/4 — the basics", "t120;kick:4=x.x.;snare:4=.x.x;hatClosed:4/2=xxxxxxxx"],
["Funk ghosts", "t96;kick:4/2=X..x.x..;snare:4/2=..g.X.g.;hatClosed:4/4=xxxxxxxxxxxxxxxx"],
["Flams & rolls", "t90;kick:4=x.x.;snare:4=F.fz;hatClosed:4/2=xxxxxxxx"],
["Drags (ruffs)", "t100;kick:4=x..x;snare:4=d.D.;hatClosed:4/2=xxxxxxxx"],
["7/8 (2+2+3) beaming", "t140;kick:2+2+3=x.x.x..;snare:2+2+3=..x..x.;hatClosed:2+2+3/2"],
["Son clave 2-3", "t96;claves:4/4=..x.x...x..x..x.;cowbell:4/4=x.x.x.x.x.x.x.x.;kick:4=x.x."],
["Afro 12/8 bell (try TUBS)", "t120;cowbell:4/3=x.xx.x.x.x.x;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"],
["3 over 2 polyrhythm", "t90;kick:2;claves:3~"],
] },
]; ];

View file

@ -36,6 +36,7 @@ export function normalize(patch) {
mute: c.enabled === false, mute: c.enabled === false,
gainDb: c.gainDb || 0, gainDb: c.gainDb || 0,
levels: (c.beatsOn || []).map((v) => v | 0), levels: (c.beatsOn || []).map((v) => v | 0),
orns: (c.orns || []).map((v) => v | 0),
})), })),
}; };
} }

View file

@ -12,7 +12,7 @@ import os
import sys import sys
APP = os.path.join(os.path.dirname(__file__), "..", "..", "pico-cp", "app.py") APP = os.path.join(os.path.dirname(__file__), "..", "..", "pico-cp", "app.py")
WANT = {"PAT", "PRIO", "PAT_CH", "SOUND_GM", "GM_NUM", "_euclid", "parse_program", "_parse_lane", "lane_to_str"} WANT = {"PAT", "ORN", "PRIO", "PAT_CH", "ORN_CH", "_cell_ch", "SOUND_GM", "GM_NUM", "_euclid", "parse_program", "_parse_lane", "lane_to_str"}
with open(APP) as f: with open(APP) as f:
_src = f.read() _src = f.read()
@ -79,6 +79,7 @@ def normalize(patch):
"mute": bool(L["mute"]), "mute": bool(L["mute"]),
"gainDb": _gain_db(L.get("gain", "")), "gainDb": _gain_db(L.get("gain", "")),
"levels": [int(v) for v in L["levels"]], "levels": [int(v) for v in L["levels"]],
"orns": [int(v) for v in L.get("orns", [])],
} }
for L in lanes for L in lanes
], ],

View file

@ -648,6 +648,90 @@
} }
] ]
} }
},
{
"id": "ornaments-flam-drag-roll",
"status": "stable",
"in": "t120;snare:4=F.fz",
"norm": {
"bpm": 120,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "snare",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 0, 1, 1],
"orns": [1, 0, 1, 3]
}
]
}
},
{
"id": "ornaments-accented",
"status": "stable",
"in": "t100;snare:4=DdZz",
"norm": {
"bpm": 100,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "snare",
"groups": [4],
"sub": 1,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [2, 1, 2, 1],
"orns": [2, 2, 3, 3]
}
]
}
},
{
"id": "ornaments-subdivision",
"status": "stable",
"in": "t90;snare:4/2=x.f.x.z.",
"norm": {
"bpm": 90,
"bars": 0,
"volume": null,
"countMs": 0,
"ramp": null,
"trainer": null,
"rep": null,
"end": null,
"lanes": [
{
"sound": "snare",
"groups": [4],
"sub": 2,
"swing": false,
"poly": false,
"mute": false,
"gainDb": 0,
"levels": [1, 0, 1, 0, 1, 0, 1, 0],
"orns": [0, 0, 1, 0, 0, 0, 3, 0]
}
]
}
} }
] ]
} }

View file

@ -23,6 +23,14 @@ const stable = (o) => JSON.stringify(o, (k, v) =>
? Object.fromEntries(Object.keys(v).sort().map((kk) => [kk, v[kk]])) ? Object.fromEntries(Object.keys(v).sort().map((kk) => [kk, v[kk]]))
: v); : v);
// `orns` (per-step flam/drag/roll) defaults to all-zeros: an impl MAY emit it always or omit it.
// Drop all-zero `orns` from both sides before comparing so legacy vectors that omit it still match.
const stripZeroOrns = (o) =>
o && typeof o === "object" && Array.isArray(o.lanes)
? { ...o, lanes: o.lanes.map((L) =>
L && Array.isArray(L.orns) && L.orns.every((v) => !v) ? (({ orns, ...rest }) => rest)(L) : L) }
: o;
function runJs(patch) { function runJs(patch) {
try { try {
return { norm: js.normalize(patch), canonical: js.canonical(patch), error: null }; return { norm: js.normalize(patch), canonical: js.canonical(patch), error: null };
@ -41,7 +49,7 @@ function runPy(patch) {
} }
} }
const want = stable; // alias const want = (o) => stable(stripZeroOrns(o)); // compare ignoring all-zero `orns`
let regressions = 0, fixedNowCount = 0, nonIdempotent = 0; let regressions = 0, fixedNowCount = 0, nonIdempotent = 0;
const rows = []; const rows = [];