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 ⓘ) |
| `/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 |
| `/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) |
@ -265,6 +266,7 @@ tags the current commit `v<VERSION>` (requires a clean tree). Push the tag, then
|------|---------|
| `index.html` | the **Concepts** landing / gallery (embeds each widget live) |
| `editor.html` | the **PM_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) |
| `info-*.html` | performfactor spec pages (embed the live widget + description + dimensions + BOM) |
| `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:logo-dark@", (A / "logo-dark.b64").read_text().strip())
src = src.replace("@BUILD:logo-light@", (A / "logo-light.b64").read_text().strip())
src = src.replace("@BUILD:bravura@", (A / "bravura.woff2.b64").read_text().strip()) # SMuFL music font subset (PM_E-2 notation)
assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
out = pathlib.Path("dist") / name
out.write_text(src)
return out.stat().st_size
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html",
for name in ("index.html","editor.html","editor-beta.html","pm_e-2.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html",
"embed.html",
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"):
"info-editor.html","info-pm_e-2.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"):
print("built %s (%dKB)" % (name, build(name) // 1024))
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
print("copied embed.js")

View file

@ -40,9 +40,9 @@ fi
# stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \
for f in index.html editor.html editor-beta.html pm_e-2.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \
embed.html \
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do
info-editor.html info-pm_e-2.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
done

View file

@ -26,12 +26,14 @@ F0 7D <op> <payload ASCII bytes, each 0x000x7F> F7
`<op>` lives in the free `0x40` block (existing ops: `0x01` RTC, `0x02/0x03`
version, `0x10` programs, `0x21/22/23` firmware, `0x7E/0x7F` NAK/ACK):
| op | name | direction | payload |
|------|-------|------------------|-------------------------------------------|
| 0x40 | HELLO | either → either | `<origin>` |
| 0x41 | FULL | either → either | `<origin>;<seq>;<running>;<sl>;<item>;<patch>` |
| 0x42 | DELTA | either → either | `<origin>;<seq>;<evt>` |
| 0x43 | BYE | either → either | `<origin>` |
| op | name | direction | payload |
|------|---------|------------------|-------------------------------------------|
| 0x40 | HELLO | either → either | `<origin>` |
| 0x41 | FULL | either → either | `<origin>;<seq>;<running>;<sl>;<item>;<patch>` |
| 0x42 | DELTA | either → either | `<origin>;<seq>;<evt>` |
| 0x43 | BYE | either → either | `<origin>` |
| 0x44 | SLSYNC | either → either | `<origin>;<seq>;<json>` — live set-list **content** merge (§8) |
| 0x45 | LOGSYNC | either → either | `<origin>;<seq>;<json>` — practice-log entry merge (§9) |
- **Payload is 7bit ASCII** — never emit a byte > `0x7F` (it corrupts the SysEx
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,
ramp, segment bars, countdown) edit
- `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):
- `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.
- [ ] **BYE (`0x43`)** → mark the peer gone (stop heartbeating/emitting until
the next HELLO).
- [ ] **SLSYNC (`0x44`)** → merge the JSON manifest of user lists by normalized
title (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 —
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,
@ -190,10 +202,15 @@ FULL with the resulting (copied) program so both sides converge on the same
target.
### Out of scope for the beta
- Streaming the device practice log (`history.json`) up to the browser.
- Mirroring device `settings.json` (LED brightness, MIDI config, etc.).
- 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
@ -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`
`0x03` reply, `<id>;<version>`; pre0.0.23 firmware sends bare version → assume
`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 *)
sub = int ; (* subdivision; trailing "s" = swing *)
euclid = "(" int [ "," int [ "," int ] ] ")" ; (* k [, n [, rot ]] — even distribution *)
pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *)
pattern = *( cell ) ; (* one char per step: dynamics + ornament *)
cell = "X" | "x" | "1" | "g" (* dynamics: accent / normal / normal / ghost *)
| "f" | "F" | "d" | "D" | "z" | "Z" (* ornament hits (see below): flam / drag / roll *)
| "." | "-" | "_" ; (* rest *)
```
### Lane semantics
@ -92,6 +95,12 @@ pattern = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *)
- **swing**`/2s` lays the off-beat on the last triplet (≈ 2/3).
- **pattern** — one char per step: `X`=accent (level 2), `x`/`1`=normal (1), `g`=ghost (3),
`.`/`-`/`_`/anything else = rest (0). Short patterns are right-padded with rests to `steps`.
- **ornaments** — three extra hit letters add a per-step *ornament* on top of the dynamic, in a
channel parallel to the dynamic levels (see `orns` in §5): `f`/`F`=flam (one grace note),
`d`/`D`=drag/ruff (two grace notes), `z`/`Z`=roll/buzz. The **case carries the dynamic** so the
two stay orthogonal: **lower-case = normal hit (level 1), UPPER-case = accented hit (level 2)**.
So `snare:4=F.fz` is an accented-flam, rest, normal-flam, normal-roll. Ghosted ornaments aren't
expressible (a `g`-style ghost ornament has no letter); ornament + rest is just a rest.
**With no pattern (the default):** every step sounds at normal level and accents fall **only
on group starts** — the grouping *is* the accent map. So `4` accents beat 1; `2+2` accents
beats 1 & 3; `4/2` is a steady 8th lane with an accent on beat 1. To accent every beat,
@ -192,6 +201,12 @@ expected value in `norm`:
`levels` is the resolved per-step dynamics array (0 rest / 1 normal / 2 accent / 3 ghost) —
the real audible payload, and the most important thing two implementations must agree on.
`orns` is the resolved per-step **ornament** array, parallel to `levels`
(`0` none / `1` flam / `2` drag / `3` roll). It **defaults to all-zeros**, so a lane with no
ornaments omits it entirely — an implementation MAY always emit it or omit-when-all-zero, and the
conformance runner treats a missing `orns` as all-zeros. Example with ornaments
(`snare:4=F.fz`): `"levels": [2,0,1,1], "orns": [1,0,1,3]`.
---
## 6. Divergences — status

View file

@ -53,6 +53,7 @@
<p class="pick"><label for="ffSel">Show snippets for:</label>
<select id="ffSel">
<option value="editor">PM_E1 Editor</option>
<option value="pme2">PM_E2 Editor (Notation)</option>
<option value="teacher">PM_T1 Teacher</option>
<option value="stage">PM_S1 Stage</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 FF = [
{ 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:"teacher", name:"PM_T1 Teacher", file:"teacher.html", h:440 },
{ 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 = [
{ 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:"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." },

View file

@ -261,7 +261,9 @@ ICON_USB = load_alpha("/usb.bin") # trident: lit when USB-conne
gc.collect()
# ============================== POLYMETER ENGINE (same semantics as the web/MicroPython) ==============================
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2} # ornament hits: UPPER = accented, lower = normal
ORN = {'f': 1, 'F': 1, 'd': 2, 'D': 2, 'z': 3, 'Z': 3} # ornament type: 0 none / 1 flam / 2 drag / 3 roll
PRIO = {2: 3, 1: 2, 3: 1}
# General-MIDI percussion note numbers -> voice names (so a lane can be typed as "36:4"); matches the web GM_NUM
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
@ -345,10 +347,13 @@ def _parse_lane(tok):
for h in _euclid(k, n, rot):
if h: levels.append(2 if first else 1); first = False
else: levels.append(0)
orns = [0] * len(levels) # euclid hits carry no ornament
elif pattern:
steps = beats * sub
levels = [PAT.get(ch, 0) for ch in pattern]
if len(levels) < steps: levels += [0] * (steps - len(levels))
orns = [ORN.get(ch, 0) for ch in pattern] # per-step flam/drag/roll, parallel to levels
if len(levels) < steps:
levels += [0] * (steps - len(levels)); orns += [0] * (steps - len(orns))
steps = len(levels)
else:
steps = beats * sub
@ -356,15 +361,21 @@ def _parse_lane(tok):
for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
else: levels.append(1) # off-beat subdivisions sound at normal (grouping IS the accent map)
orns = [0] * steps
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'orns': orns,
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'} # level -> pattern char (inverse of PAT)
ORN_CH = {1: ('f', 'F'), 2: ('d', 'D'), 3: ('z', 'Z')} # ornament -> (normal, accented) pattern char
def _cell_ch(v, o): # (level, ornament) -> one pattern char
if o in ORN_CH: return ORN_CH[o][1 if v >= 2 else 0]
return PAT_CH.get(v, '.')
def lane_to_str(L): # serialize a lane back to the share grammar (round-trips)
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
s += '=' + ''.join(PAT_CH.get(v, '.') for v in L['levels'])
orns = L.get('orns') or [0] * len(L['levels'])
s += '=' + ''.join(_cell_ch(v, orns[i] if i < len(orns) else 0) for i, v in enumerate(L['levels']))
s += L.get('gain', '')
if L['poly']: s += '~'
if L['mute']: s += '!'
@ -658,6 +669,7 @@ class App:
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]}
try:
with open("/programs.json", "w") as f: json.dump(data, f)
if not self._sync_applying: self._sync_send_setlists() # mirror our user library to the editor (sec 8)
return True
except OSError:
return False # editor mode: the drive is read-only to us
@ -1153,6 +1165,96 @@ class App:
finally:
self._sync_applying = False
# ---------- set-list content sync (0x44) + practice-log sync (0x45); see docs/livesync-protocol.md ----------
def _sync_send_setlists(self): # 0x44: manifest of OUR user lists (programs.json shape), merge by title
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
user = [s for s in self.setlists if not s['builtin']]
sls = [{"title": s['title'],
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]
try: body = json.dumps({"setlists": sls})
except Exception: return
self._sync_send(0x44, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
def _log_to_wire(self, e): # device entry -> wire schema {at,name,dur,bpm} (sec 9)
return {"at": e.get("at", 0), "name": e.get("name", ""), "dur": e.get("dur", 0), "bpm": e.get("bpm", 0)}
def _sync_send_log_batch(self): # 0x45: whole practice log (on connect / HELLO)
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
try: body = json.dumps({"log": [self._log_to_wire(e) for e in self.log]})
except Exception: return
self._sync_send(0x45, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
def _sync_send_log_one(self, e): # 0x45: a single freshly-logged session
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
try: body = json.dumps({"log": [self._log_to_wire(e)]})
except Exception: return
self._sync_send(0x45, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
def _sync_apply_setlists(self, body): # merge user lists by normalized title (replace / append; never delete built-ins)
self._sync_applying = True
try:
try:
d = json.loads(body); lists = d.get("setlists")
if not isinstance(lists, list): return
builtin_keys = set(_slkey(t) for t, _ in BUILTIN_SETLISTS)
changed = False
for rl in lists:
title = rl.get("title", ""); key = _slkey(title)
if not key or key in builtin_keys: continue # never overwrite a baked-in list
items = [(p.get("name", "Item"), p.get("prog", "")) for p in rl.get("programs", []) if p.get("prog")]
tgt = None
for s in self.setlists:
if not s['builtin'] and _slkey(s['title']) == key: tgt = s; break
if tgt is not None: tgt['items'] = items
else: self.setlists.append({'title': title or "Device", 'items': items, 'builtin': False})
changed = True
if changed:
self._persist_user() # write back to programs.json (no-op if read-only)
if self.sl >= len(self.setlists): self.sl = 0
self.draw_meters()
except Exception as e:
try: print("sync SLSYNC:", e)
except Exception: pass
finally:
self._sync_applying = False
def _sync_apply_log(self, body): # additive merge by (at,name); at==0 always appended
self._sync_applying = True
try:
try:
d = json.loads(body); incoming = d.get("log")
if not isinstance(incoming, list): return
have = set()
for e in self.log:
a = e.get("at", 0)
if a: have.add((a, e.get("name", "")))
added = 0
for w in incoming:
nm = w.get("name", "")
if not nm: continue
at = w.get("at", 0) or 0
if at and (at, nm) in have: continue
self.log.append({"at": at, "name": nm, "dur": w.get("dur", 0), "bpm": w.get("bpm", 0),
"t": self._hhmm(at), "bars": 0})
if at: have.add((at, nm))
added += 1
if added:
self.log.sort(key=lambda e: e.get("at", 0), reverse=True) # newest first
del self.log[200:]
self._save_log(); self.draw_log()
except Exception as e:
try: print("sync LOGSYNC:", e)
except Exception: pass
finally:
self._sync_applying = False
def _hhmm(self, at_ms): # epoch-ms -> "HH:MM" for the on-device log row (0 -> unknown)
if not at_ms: return "--:--"
try:
t = time.localtime(at_ms // 1000); return "%02d:%02d" % (t.tm_hour, t.tm_min)
except Exception:
return "--:--"
def _epoch_ms(self): # real epoch ms once the editor has set the RTC, else 0 (unset)
try:
secs = time.time()
return int(secs) * 1000 if secs > 1_000_000_000 else 0 # < 2001 -> RTC unset, no stable key
except Exception:
return 0
def midi_send(self, note, vel): # device-as-conductor: a note per click to the computer
if self.midi is None or self._fw_pushing: return # keep the bus quiet during a firmware push so ACKs aren't interleaved
b = self._note_buf # reused bytearray -> zero alloc per click (hot path)
@ -1531,10 +1633,13 @@ class App:
if dur < MIN_LOG_SEC: return # skip plays under 5 seconds
mlen = self.lanes[0]['steps'] if self.lanes else 1
t = time.localtime()
self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name})
e = {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name,
"at": self._epoch_ms()} # epoch ms (when RTC set) = cross-half dedup key (sec 9)
self.log.insert(0, e)
del self.log[200:]; self._armed = None
self._save_log(); self.draw_log()
self._sync_send_log_one(e) # mirror this session to the editor (no-op if not armed)
def draw_log(self):
g = self.g_log
while len(g): g.pop()
@ -1613,14 +1718,14 @@ class App:
if self.midi: # old firmware sent bare APP_VERSION; editor parses "contains ';'?" for back-compat
payload = DEVICE_ID + ";" + APP_VERSION
self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43: # Live sync (see src/livesync.js)
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43 or cmd == 0x44 or cmd == 0x45: # Live sync (see src/livesync.js)
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
except Exception: return
origin = text.split(";", 1)[0] if text else ""
if origin == self._sync_origin: return # drop our own echoes (composite USB may loop)
self._sync_armed = True
if cmd == 0x40: # HELLO -> reply with our current FULL
self._sync_broadcast_full()
if cmd == 0x40: # HELLO -> reply with our FULL + set-list library + practice log
self._sync_broadcast_full(); self._sync_send_setlists(); self._sync_send_log_batch()
elif cmd == 0x43: # BYE -> peer disconnected; stop heartbeats
self._sync_armed = False
elif cmd == 0x41: # FULL: origin;seq;running;sl;item;patch...
@ -1633,6 +1738,12 @@ class App:
elif cmd == 0x42: # DELTA: origin;seq;evt
parts = text.split(";", 2)
if len(parts) >= 3: self._sync_apply_delta(parts[2])
elif cmd == 0x44: # SLSYNC: origin;seq;json (set-list content)
parts = text.split(";", 2)
if len(parts) >= 3: self._sync_apply_setlists(parts[2])
elif cmd == 0x45: # LOGSYNC: origin;seq;json (practice entries)
parts = text.split(";", 2)
if len(parts) >= 3: self._sync_apply_log(parts[2])
elif cmd == 0x10: # write /programs.json (user playlists) pushed from the editor
try:
with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))

View file

@ -230,7 +230,9 @@ ICON_USB = load_alpha("/usb.bin")
gc.collect()
# ============================== POLYMETER ENGINE ==============================
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2} # ornament hits: UPPER = accented, lower = normal
ORN = {'f': 1, 'F': 1, 'd': 2, 'D': 2, 'z': 3, 'Z': 3} # ornament type: 0 none / 1 flam / 2 drag / 3 roll
PRIO = {2: 3, 1: 2, 3: 1}
# General-MIDI percussion note numbers -> voice names (so a lane can be typed as "36:4"); matches the web GM_NUM
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
@ -314,10 +316,13 @@ def _parse_lane(tok):
for h in _euclid(k, n, rot):
if h: levels.append(2 if first else 1); first = False
else: levels.append(0)
orns = [0] * len(levels) # euclid hits carry no ornament
elif pattern:
steps = beats * sub
levels = [PAT.get(ch, 0) for ch in pattern]
if len(levels) < steps: levels += [0] * (steps - len(levels))
orns = [ORN.get(ch, 0) for ch in pattern] # per-step flam/drag/roll, parallel to levels
if len(levels) < steps:
levels += [0] * (steps - len(levels)); orns += [0] * (steps - len(orns))
steps = len(levels)
else:
steps = beats * sub
@ -325,15 +330,21 @@ def _parse_lane(tok):
for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
else: levels.append(1) # off-beat subdivisions sound at normal (grouping IS the accent map)
orns = [0] * steps
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'orns': orns,
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'}
ORN_CH = {1: ('f', 'F'), 2: ('d', 'D'), 3: ('z', 'Z')} # ornament -> (normal, accented) pattern char
def _cell_ch(v, o): # (level, ornament) -> one pattern char
if o in ORN_CH: return ORN_CH[o][1 if v >= 2 else 0]
return PAT_CH.get(v, '.')
def lane_to_str(L):
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
s += '=' + ''.join(PAT_CH.get(v, '.') for v in L['levels'])
orns = L.get('orns') or [0] * len(L['levels'])
s += '=' + ''.join(_cell_ch(v, orns[i] if i < len(orns) else 0) for i, v in enumerate(L['levels']))
s += L.get('gain', '')
if L['poly']: s += '~'
if L['mute']: s += '!'
@ -881,6 +892,107 @@ class App:
except Exception: pass
finally:
self._sync_applying = False
# ---------- set-list content sync (0x44) + practice-log sync (0x45); see docs/livesync-protocol.md ----------
# PM_X-1 has no on-device set-list editing, so it APPLIES 0x44 but never EMITS one.
# It does emit 0x45 (logged sessions), like the Kit.
def _persist_user(self): # write all user playlists back to /programs.json (no-op if read-only)
user = [s for s in self.setlists if not s['builtin']]
data = {"setlists": [{"title": s['title'],
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]}
try:
with open("/programs.json", "w") as f: json.dump(data, f)
return True
except OSError:
return False
def _log_to_wire(self, e):
return {"at": e.get("at", 0), "name": e.get("name", ""), "dur": e.get("dur", 0), "bpm": e.get("bpm", 0)}
def _sync_send_setlists(self): # 0x44: our user library (HELLO reply only -- X can't edit lists)
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
user = [s for s in self.setlists if not s['builtin']]
sls = [{"title": s['title'],
"programs": [{"name": n, "prog": p} for n, p in s['items']]} for s in user]
try: body = json.dumps({"setlists": sls})
except Exception: return
self._sync_send(0x44, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
def _sync_send_log_batch(self): # 0x45: whole practice log (on connect / HELLO)
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
try: body = json.dumps({"log": [self._log_to_wire(e) for e in self.log]})
except Exception: return
self._sync_send(0x45, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
def _sync_send_log_one(self, e): # 0x45: a single freshly-logged session
if not self._sync_armed or self._sync_applying or self.midi is None or self._fw_pushing: return
try: body = json.dumps({"log": [self._log_to_wire(e)]})
except Exception: return
self._sync_send(0x45, "%s;%d;%s" % (self._sync_origin, self._sync_seq, body)); self._sync_seq += 1
def _sync_apply_setlists(self, body): # merge user lists by normalized title (replace / append; never built-ins)
self._sync_applying = True
try:
try:
d = json.loads(body); lists = d.get("setlists")
if not isinstance(lists, list): return
builtin_keys = set(_slkey(t) for t, _ in BUILTIN_SETLISTS)
changed = False
for rl in lists:
title = rl.get("title", ""); key = _slkey(title)
if not key or key in builtin_keys: continue
items = [(p.get("name", "Item"), p.get("prog", "")) for p in rl.get("programs", []) if p.get("prog")]
tgt = None
for s in self.setlists:
if not s['builtin'] and _slkey(s['title']) == key: tgt = s; break
if tgt is not None: tgt['items'] = items
else: self.setlists.append({'title': title or "Device", 'items': items, 'builtin': False})
changed = True
if changed:
self._persist_user()
if self.sl >= len(self.setlists): self.sl = 0
self.draw_meters()
except Exception as e:
try: print("sync SLSYNC:", e)
except Exception: pass
finally:
self._sync_applying = False
def _sync_apply_log(self, body): # additive merge by (at,name); at==0 always appended
self._sync_applying = True
try:
try:
d = json.loads(body); incoming = d.get("log")
if not isinstance(incoming, list): return
have = set()
for e in self.log:
a = e.get("at", 0)
if a: have.add((a, e.get("name", "")))
added = 0
for w in incoming:
nm = w.get("name", "")
if not nm: continue
at = w.get("at", 0) or 0
if at and (at, nm) in have: continue
self.log.append({"at": at, "name": nm, "dur": w.get("dur", 0), "bpm": w.get("bpm", 0),
"t": self._hhmm(at), "bars": 0})
if at: have.add((at, nm))
added += 1
if added:
self.log.sort(key=lambda e: e.get("at", 0), reverse=True)
del self.log[200:]
self._save_log(); self.draw_log()
except Exception as e:
try: print("sync LOGSYNC:", e)
except Exception: pass
finally:
self._sync_applying = False
def _hhmm(self, at_ms):
if not at_ms: return "--:--"
try:
t = time.localtime(at_ms // 1000); return "%02d:%02d" % (t.tm_hour, t.tm_min)
except Exception:
return "--:--"
def _epoch_ms(self):
try:
secs = time.time()
return int(secs) * 1000 if secs > 1_000_000_000 else 0
except Exception:
return 0
def _regen_levels(self, L): # called on remote lane= deltas to recompute default accents
sub = L['sub']; groups = L['groups']; starts = set(); acc = 0
for gp in groups: starts.add(acc); acc += gp
@ -1352,10 +1464,13 @@ class App:
if dur < MIN_LOG_SEC: return
mlen = self.lanes[0]['steps'] if self.lanes else 1
t = time.localtime()
self.log.insert(0, {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name})
e = {"t": "%02d:%02d" % (t.tm_hour, t.tm_min), "bpm": self.play_bpm,
"dur": dur, "bars": self._m_steps // max(1, mlen), "name": self.play_name,
"at": self._epoch_ms()} # epoch ms (when RTC set) = cross-half dedup key (sec 9)
self.log.insert(0, e)
del self.log[200:]
self._save_log(); self.draw_log()
self._sync_send_log_one(e) # mirror this session to the editor (no-op if not armed)
def draw_log(self): # footer practice log (this track only), Kit-style
g = self.g_log
while len(g): g.pop()
@ -1427,14 +1542,14 @@ class App:
if self.midi:
payload = DEVICE_ID + ";" + APP_VERSION
self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43:
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43 or cmd == 0x44 or cmd == 0x45:
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
except Exception: return
origin = text.split(";", 1)[0] if text else ""
if origin == self._sync_origin: return
self._sync_armed = True
if cmd == 0x40:
self._sync_broadcast_full()
self._sync_broadcast_full(); self._sync_send_setlists(); self._sync_send_log_batch()
elif cmd == 0x43:
self._sync_armed = False
elif cmd == 0x41:
@ -1447,6 +1562,12 @@ class App:
elif cmd == 0x42:
parts = text.split(";", 2)
if len(parts) >= 3: self._sync_apply_delta(parts[2])
elif cmd == 0x44: # SLSYNC: origin;seq;json (set-list content)
parts = text.split(";", 2)
if len(parts) >= 3: self._sync_apply_setlists(parts[2])
elif cmd == 0x45: # LOGSYNC: origin;seq;json (practice entries)
parts = text.split(";", 2)
if len(parts) >= 3: self._sync_apply_log(parts[2])
elif cmd == 0x10:
try:
with open("/programs.json", "wb") as f: f.write(bytes(sx[2:]))

View file

@ -96,7 +96,9 @@ GM_DEFAULT = 37
MIDI_VEL = {2: 120, 1: 90, 3: 45} # accent / normal / ghost
# ============================== POLYMETER ENGINE (identical to ../pico-explorer/app.py) ==============================
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2} # ornament hits: UPPER = accented, lower = normal
ORN = {'f': 1, 'F': 1, 'd': 2, 'D': 2, 'z': 3, 'Z': 3} # ornament type: 0 none / 1 flam / 2 drag / 3 roll
PRIO = {2: 3, 1: 2, 3: 1}
GM_NUM = {35: "kick", 36: "kick", 37: "rim", 38: "snare", 39: "clap", 40: "snare", 41: "tomLow", 42: "hatClosed",
43: "tomLow", 44: "hatClosed", 45: "tomMid", 46: "hatOpen", 47: "tomMid", 48: "tomHigh", 49: "crash",
@ -179,10 +181,13 @@ def _parse_lane(tok):
for h in _euclid(k, n, rot):
if h: levels.append(2 if first else 1); first = False
else: levels.append(0)
orns = [0] * len(levels) # euclid hits carry no ornament
elif pattern:
steps = beats * sub
levels = [PAT.get(ch, 0) for ch in pattern]
if len(levels) < steps: levels += [0] * (steps - len(levels))
orns = [ORN.get(ch, 0) for ch in pattern] # per-step flam/drag/roll, parallel to levels
if len(levels) < steps:
levels += [0] * (steps - len(levels)); orns += [0] * (steps - len(orns))
steps = len(levels)
else:
steps = beats * sub
@ -190,15 +195,21 @@ def _parse_lane(tok):
for i in range(steps):
if i % sub == 0: levels.append(2 if (i // sub) in starts else 1) # beat: accent on group starts
else: levels.append(1) # off-beat subdivisions sound at normal
orns = [0] * steps
if sound not in SOUND_GM: sound = "beep" # unknown sound -> beep (match web)
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels,
return {'sound': sound, 'sub': sub, 'swing': swing, 'steps': steps, 'levels': levels, 'orns': orns,
'poly': poly, 'mute': mute, 'groups': groups, 'gain': gain}
PAT_CH = {2: 'X', 1: 'x', 3: 'g', 0: '.'}
ORN_CH = {1: ('f', 'F'), 2: ('d', 'D'), 3: ('z', 'Z')} # ornament -> (normal, accented) pattern char
def _cell_ch(v, o): # (level, ornament) -> one pattern char
if o in ORN_CH: return ORN_CH[o][1 if v >= 2 else 0]
return PAT_CH.get(v, '.')
def lane_to_str(L):
s = L['sound'] + ':' + '+'.join(str(g) for g in L.get('groups', [4]))
if L['sub'] != 1 or L['swing']: s += '/' + str(L['sub']) + ('s' if L['swing'] else '')
s += '=' + ''.join(PAT_CH.get(v, '.') for v in L['levels'])
orns = L.get('orns') or [0] * len(L['levels'])
s += '=' + ''.join(_cell_ch(v, orns[i] if i < len(orns) else 0) for i, v in enumerate(L['levels']))
s += L.get('gain', '')
if L['poly']: s += '~'
if L['mute']: s += '!'

View file

@ -254,7 +254,8 @@ class GT911:
# program string: [v1;]t<bpm>;<lane>;<lane>;... (globals like b/rmp/tr are ignored on-device)
# lane = <sound>:<grouping>[/<sub>[s]][=pattern][@db][~][!]
# pattern chars: X=accent(2) x=normal(1) g=ghost(3) . - _ =mute(0)
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0}
PAT = {'X': 2, 'x': 1, 'g': 3, '.': 0, '-': 0, '_': 0,
'f': 1, 'F': 2, 'd': 1, 'D': 2, 'z': 1, 'Z': 2} # ornament hits play as their dynamic (no grace-note render here)
PRIO = {2: 3, 1: 2, 3: 1} # click priority when lanes coincide: accent > normal > ghost
def parse_program(s):

View file

@ -309,7 +309,15 @@ fn main() -> ! {
info!("parsed groove 0: bpm={} lanes={}, {} free", track.bpm, track.lanes.len(), HEAP.free());
let mut tempo: i64 = track.bpm;
let mut playing = true;
let mut notation = false;
// View cycle on button-B: Grid → Staff → TUBS → Konnakol → Grid …
#[derive(Clone, Copy, PartialEq)]
enum View {
Grid,
Staff,
Tubs,
Konnakol,
}
let mut view = View::Grid;
// double-buffer: draw into the RAM framebuffer, then push only the full-width ROW BANDS that
// changed vs the last frame. Full-width windows (CASET 0..319) behave exactly like the proven
@ -345,7 +353,12 @@ fn main() -> ! {
dirty = true;
}
if b && !pb {
notation = !notation;
view = match view {
View::Grid => View::Staff,
View::Staff => View::Tubs,
View::Tubs => View::Konnakol,
View::Konnakol => View::Grid,
};
dirty = true;
force_full = true; // whole screen changes — use the clean full blit
}
@ -421,17 +434,20 @@ fn main() -> ! {
.map(|l| pm_ui::LaneView {
name: &l.sound,
levels: &l.levels,
orns: &l.orns,
groups: &l.groups,
beats: l.groups.iter().map(|&g| g as u32).sum::<u32>().min(255) as u8,
poly: l.poly,
muted: l.mute,
})
.collect();
let screen = pm_ui::Screen { name: NAMES[idx], bpm: tempo, playing, phase, lanes: &lanes };
if notation {
pm_ui::draw_notation(&mut fb, &screen).ok();
} else {
pm_ui::draw_metronome(&mut fb, &screen).ok();
}
match view {
View::Grid => pm_ui::draw_metronome(&mut fb, &screen).ok(),
View::Staff => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Staff).ok(),
View::Tubs => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Tubs).ok(),
View::Konnakol => pm_ui::notation::draw(&mut fb, &screen, pm_ui::ViewMode::Konnakol).ok(),
};
// FNV-1a of one TILE×TILE block at grid cell (tx, ty)
fn tile_fnv(px: &[Rgb565], tx: usize, ty: usize) -> u64 {
let mut h = 0xcbf29ce484222325u64; // FNV-1a offset basis

View file

@ -7,6 +7,9 @@
#![no_std]
pub mod notation;
pub use notation::ViewMode;
use embedded_graphics::{
mono_font::{ascii::{FONT_10X20, FONT_6X10, FONT_9X18_BOLD}, MonoTextStyle},
pixelcolor::Rgb565,
@ -35,7 +38,12 @@ pub struct LaneView<'a> {
pub name: &'a str,
/// per-step dynamics: 0 rest, 1 normal, 2 accent, 3 ghost
pub levels: &'a [u8],
/// beats per bar (for the group/beat gridlines); 0 = none
/// per-step ornaments: 0 none, 1 flam, 2 drag, 3 roll (parallel to `levels`; may be shorter/empty)
pub orns: &'a [u8],
/// group structure (e.g. `[2,2,3]` for 7/8) — drives time-signature + group-aware beaming.
/// `beats` = sum(groups). Empty → notation defaults to [4].
pub groups: &'a [u32],
/// beats per bar (for the group/beat gridlines); 0 = none. Usually `groups.iter().sum()`.
pub beats: u8,
pub poly: bool,
pub muted: bool,
@ -263,179 +271,14 @@ where
}
// ---- drum notation ----
#[derive(Clone, Copy)]
enum Head {
Oval,
Cross,
}
/// Map a voice name to (vertical offset from the staff top line, notehead, stem-up?).
/// Offsets are multiples of 6 (half a 12px line-space) so heads land on lines/spaces.
fn map_voice(name: &str) -> (i32, Head, bool) {
if name.starts_with("kick") {
(42, Head::Oval, false) // bass drum: low, stem DOWN (foot)
} else if name.starts_with("snare") || name.starts_with("clap") || name.starts_with("rim") {
(18, Head::Oval, true)
} else if name.starts_with("hat") || name.starts_with("openHat") {
(-12, Head::Cross, true) // hi-hat: first ledger line above the staff
} else if name.starts_with("ride") {
(0, Head::Cross, true)
} else if name.starts_with("crash") {
(-24, Head::Cross, true) // crash: high above, more ledgers
} else if name.starts_with("tom") {
(12, Head::Oval, true)
} else if name.starts_with("cowbell") || name.starts_with("woodblock") || name.starts_with("claves") || name.starts_with("tambourine") {
(6, Head::Cross, true)
} else {
(24, Head::Oval, true)
}
}
/// Render one bar of the groove as drum notation: 5-line staff, time signature, noteheads with
/// stems (hands up / feet down) and beamed eighths/sixteenths. First pass — refine freely.
/// Render one bar of the groove as drum notation (Staff view). Thin wrapper over the ported
/// engine in `notation` — kept for back-compat with existing call sites that defaulted to Staff.
pub fn draw_notation<D>(d: &mut D, s: &Screen) -> Result<(), D::Error>
where
D: DrawTarget<Color = Rgb565>,
{
let bb = d.bounding_box();
let w = bb.size.width as i32;
d.clear(BG)?;
// header
Text::new(s.name, Point::new(12, 22), MonoTextStyle::new(&FONT_10X20, TXT)).draw(d)?;
let mut nb = [0u8; 12];
Text::with_alignment(fmt_u32(s.bpm.max(0) as u32, &mut nb), Point::new(w - 12, 18), MonoTextStyle::new(&FONT_9X18_BOLD, CYAN), Alignment::Right).draw(d)?;
let beats = s.lanes.first().map(|l| l.beats.max(1)).unwrap_or(4) as i32;
let staff_top = 80;
let line_gap = 12;
let m = 14;
let clef_w = 34;
let x0 = m + clef_w;
let x1 = w - m;
let bw = (x1 - x0).max(1);
let ink = TXT;
// staff (5 lines)
for i in 0..5 {
let y = staff_top + i * line_gap;
Line::new(Point::new(m, y), Point::new(x1, y)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
}
// bar lines (start + end)
Line::new(Point::new(m, staff_top), Point::new(m, staff_top + 4 * line_gap)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
Line::new(Point::new(x1, staff_top), Point::new(x1, staff_top + 4 * line_gap)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
// time signature (beats / 4)
let ts = MonoTextStyle::new(&FONT_10X20, ink);
let mut tb = [0u8; 12];
Text::with_alignment(fmt_u32(beats as u32, &mut tb), Point::new(m + clef_w / 2, staff_top + 16), ts, Alignment::Center).draw(d)?;
Text::with_alignment("4", Point::new(m + clef_w / 2, staff_top + 40), ts, Alignment::Center).draw(d)?;
// Time resolution = finest lane (so off-beats land on columns); lanes whose step count divides
// `res` align to it (polymeter that doesn't divide falls back to its own grid implicitly).
let res = s.lanes.iter().filter(|l| !l.muted).map(|l| l.levels.len() as i32).max().unwrap_or(4).max(1);
let up_end = staff_top - 22; // fixed stem-end levels → horizontal beams
let dn_end = staff_top + 4 * line_gap + 22;
let bot = staff_top + 4 * line_gap;
let mut up_prev: Option<(i32, i32)> = None; // (stem_x, beat) for beaming
let mut dn_prev: Option<(i32, i32)> = None;
for c in 0..res {
let cx = x0 + c * bw / res + bw / (2 * res);
// gather notes at this column, per voice
let (mut up_lo, mut up_hi, mut up_any, mut up_sub2) = (i32::MIN, i32::MAX, false, false);
let (mut dn_lo, mut dn_hi, mut dn_any, mut dn_sub2) = (i32::MIN, i32::MAX, false, false);
for lane in s.lanes.iter() {
if lane.muted {
continue;
}
let steps = lane.levels.len() as i32;
if steps == 0 || (c * steps) % res != 0 {
continue; // this lane has no note position at this column
}
let lvl = lane.levels[(c * steps / res) as usize];
if lvl == 0 {
continue;
}
let (off, head, up) = map_voice(lane.name);
let hy = staff_top + off;
let col = if lvl == 2 { AMBER } else { ink };
// notehead
match head {
Head::Oval => embedded_graphics::primitives::Ellipse::new(Point::new(cx - 6, hy - 4), Size::new(12, 8))
.into_styled(PrimitiveStyle::with_fill(col))
.draw(d)?,
Head::Cross => {
Line::new(Point::new(cx - 5, hy - 5), Point::new(cx + 5, hy + 5)).into_styled(PrimitiveStyle::with_stroke(col, 2)).draw(d)?;
Line::new(Point::new(cx - 5, hy + 5), Point::new(cx + 5, hy - 5)).into_styled(PrimitiveStyle::with_stroke(col, 2)).draw(d)?;
}
}
// ledger lines for notes a full line+ above or below the staff
let mut ly = staff_top - 12;
while ly >= hy {
Line::new(Point::new(cx - 9, ly), Point::new(cx + 9, ly)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
ly -= 12;
}
let mut ly = bot + 12;
while ly <= hy {
Line::new(Point::new(cx - 9, ly), Point::new(cx + 9, ly)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
ly += 12;
}
let lsub = steps / beats.max(1);
if up {
up_any = true;
up_lo = up_lo.max(hy);
up_hi = up_hi.min(hy);
up_sub2 |= lsub >= 2;
} else {
dn_any = true;
dn_lo = dn_lo.max(hy);
dn_hi = dn_hi.min(hy);
dn_sub2 |= lsub >= 2;
}
}
let beat = c * beats / res;
// shared up-stem (hands): right side, from lowest head up past the highest head
if up_any {
let sx = cx + 6;
let top = up_end.min(up_hi - 12); // always clear the highest notehead
Line::new(Point::new(sx, up_lo), Point::new(sx, top)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
let up_end = top;
if up_sub2 {
if let Some((px, pb)) = up_prev {
if pb == beat {
Rectangle::new(Point::new(px.min(sx), up_end), Size::new((px - sx).unsigned_abs(), 4)).into_styled(PrimitiveStyle::with_fill(ink)).draw(d)?;
}
}
up_prev = Some((sx, beat));
} else {
up_prev = None;
}
}
// shared down-stem (feet): left side, from highest head down past the lowest head
if dn_any {
let sx = cx - 6;
let bottom = dn_end.max(dn_lo + 12);
Line::new(Point::new(sx, dn_hi), Point::new(sx, bottom)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
let dn_end = bottom;
if dn_sub2 {
if let Some((px, pb)) = dn_prev {
if pb == beat {
Rectangle::new(Point::new(px.min(sx), dn_end - 3), Size::new((px - sx).unsigned_abs(), 4)).into_styled(PrimitiveStyle::with_fill(ink)).draw(d)?;
}
}
dn_prev = Some((sx, beat));
} else {
dn_prev = None;
}
}
}
Text::new("drum notation", Point::new(12, bot + 40), MonoTextStyle::new(&FONT_6X10, MUTE)).draw(d)?;
Ok(())
notation::draw(d, s, ViewMode::Staff)
}
/// Bring-up diagnostic pattern (kept for hardware bring-up / fallback).

View file

@ -27,6 +27,8 @@ pub struct Lane {
pub mute: bool,
pub gain_db: f64,
pub levels: Vec<u8>,
/// per-step ornament parallel to `levels`: 0 none / 1 flam / 2 drag / 3 roll.
pub orns: Vec<u8>,
}
#[derive(Debug, Clone)]
@ -108,12 +110,35 @@ fn euclid(k: i64, n: i64, rot: i64) -> Vec<u8> {
.collect()
}
fn pat(c: char) -> u8 {
/// Pattern char -> (level, ornament). Ornament letters: UPPER = accented, lower = normal
/// (the case carries the dynamic, so dynamics stay orthogonal): f/F flam, d/D drag, z/Z roll.
fn cell(c: char) -> (u8, u8) {
match c {
'X' => 2,
'x' | '1' => 1,
'g' => 3,
_ => 0,
'X' => (2, 0),
'x' | '1' => (1, 0),
'g' => (3, 0),
'f' => (1, 1),
'F' => (2, 1),
'd' => (1, 2),
'D' => (2, 2),
'z' => (1, 3),
'Z' => (2, 3),
_ => (0, 0),
}
}
/// (level, ornament) -> pattern char (inverse of `cell`).
fn cell_ch(v: u8, o: u8) -> char {
match o {
1 => if v >= 2 { 'F' } else { 'f' },
2 => if v >= 2 { 'D' } else { 'd' },
3 => if v >= 2 { 'Z' } else { 'z' },
_ => match v {
2 => 'X',
1 => 'x',
3 => 'g',
_ => '.',
},
}
}
@ -186,8 +211,8 @@ fn parse_lane(tok: &str) -> Lane {
acc += g;
}
let levels: Vec<u8> = if let Some(e) = euc {
// euclidean: k hits over n steps, first hit accented
let (levels, orns): (Vec<u8>, Vec<u8>) = if let Some(e) = euc {
// euclidean: k hits over n steps, first hit accented (no ornaments)
let k = e[0];
let n = if e.len() > 1 { e[1] } else { (beats * sub) as i64 };
let rot = if e.len() > 2 { e[2] } else { 0 };
@ -200,7 +225,7 @@ fn parse_lane(tok: &str) -> Lane {
}
}
let mut first = true;
euclid(k, n, rot)
let lv: Vec<u8> = euclid(k, n, rot)
.into_iter()
.map(|h| {
if h != 0 {
@ -211,18 +236,23 @@ fn parse_lane(tok: &str) -> Lane {
0
}
})
.collect()
.collect();
let orn = vec![0u8; lv.len()];
(lv, orn)
} else if let Some(p) = pattern {
let steps = (beats * sub) as usize;
let mut lv: Vec<u8> = p.chars().map(pat).collect();
let cells: Vec<(u8, u8)> = p.chars().map(cell).collect();
let mut lv: Vec<u8> = cells.iter().map(|c| c.0).collect();
let mut orn: Vec<u8> = cells.iter().map(|c| c.1).collect();
if lv.len() < steps {
lv.resize(steps, 0);
orn.resize(steps, 0);
}
lv
(lv, orn)
} else {
// default: every subdivision sounds at normal, accent only on group starts
let steps = beats * sub;
(0..steps)
let lv: Vec<u8> = (0..steps)
.map(|i| {
if i % sub == 0 {
if starts.contains(&(i / sub)) { 2 } else { 1 }
@ -230,13 +260,15 @@ fn parse_lane(tok: &str) -> Lane {
1
}
})
.collect()
.collect();
let orn = vec![0u8; lv.len()];
(lv, orn)
};
if !known_sound(&sound) {
sound = "beep".to_string();
}
Lane { sound, groups, sub, swing, poly, mute, gain_db, levels }
Lane { sound, groups, sub, swing, poly, mute, gain_db, levels, orns }
}
pub fn parse(s: &str) -> Track {
@ -337,12 +369,7 @@ fn lane_to_str(l: &Lane) -> String {
s.push_str(&format!("/{}{}", l.sub, if l.swing { "s" } else { "" }));
}
s.push('=');
s.extend(l.levels.iter().map(|&v| match v {
2 => 'X',
1 => 'x',
3 => 'g',
_ => '.',
}));
s.extend(l.levels.iter().enumerate().map(|(i, &v)| cell_ch(v, l.orns.get(i).copied().unwrap_or(0))));
if l.gain_db != 0.0 {
s.push_str(&format!("@{}", l.gain_db));
}

View file

@ -20,10 +20,17 @@ fn norm(t: &Track) -> Value {
Some(End::Stop) => json!("stop"),
Some(End::Goto(n)) => json!(n),
},
"lanes": t.lanes.iter().map(|l| json!({
"sound": l.sound, "groups": l.groups, "sub": l.sub, "swing": l.swing,
"poly": l.poly, "mute": l.mute, "gainDb": l.gain_db, "levels": l.levels
})).collect::<Vec<_>>(),
"lanes": t.lanes.iter().map(|l| {
let mut o = json!({
"sound": l.sound, "groups": l.groups, "sub": l.sub, "swing": l.swing,
"poly": l.poly, "mute": l.mute, "gainDb": l.gain_db, "levels": l.levels
});
// `orns` defaults to all-zeros and is omitted then, so legacy vectors (no `orns`) still match.
if l.orns.iter().any(|&v| v != 0) {
o.as_object_mut().unwrap().insert("orns".into(), json!(l.orns));
}
o
}).collect::<Vec<_>>(),
})
}

View file

@ -28,17 +28,23 @@ impl OriginDimensions for Fb {
}
fn main() {
// args: [prog] [view] where view ∈ staff|tubs|konnakol (default staff)
let prog = std::env::args().nth(1).unwrap_or_else(|| "t96;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2=xxxxxxxx".into());
let view = match std::env::args().nth(2).as_deref() {
Some("tubs") => pm_ui::ViewMode::Tubs,
Some("konnakol") | Some("kon") => pm_ui::ViewMode::Konnakol,
_ => pm_ui::ViewMode::Staff,
};
let track = track_format::parse(&prog);
let lanes: Vec<LaneView> = track
.lanes
.iter()
.map(|l| LaneView { name: &l.sound, levels: &l.levels, beats: l.groups.iter().sum::<u32>().min(255) as u8, poly: l.poly, muted: l.mute })
.map(|l| LaneView { name: &l.sound, levels: &l.levels, orns: &l.orns, groups: &l.groups, beats: l.groups.iter().sum::<u32>().min(255) as u8, poly: l.poly, muted: l.mute })
.collect();
let screen = Screen { name: "Rock beat", bpm: track.bpm, playing: false, phase: 0.0, lanes: &lanes };
let mut fb = Fb { px: vec![Rgb565::BLACK; (W * H) as usize] };
pm_ui::draw_notation(&mut fb, &screen).unwrap();
pm_ui::notation::draw(&mut fb, &screen, view).unwrap();
let img = image::RgbImage::from_fn(W, H, |x, y| {
let c = fb.px[(y * W + x) as usize];

View file

@ -50,6 +50,8 @@ fn main() {
.map(|l| LaneView {
name: &l.sound,
levels: &l.levels,
orns: &l.orns,
groups: &l.groups,
beats: l.groups.iter().sum::<u32>().min(255) as u8,
poly: l.poly,
muted: l.mute,

View file

@ -172,15 +172,39 @@ function laneStepDur(m, tick) {
return beat / m.stepsPerBeat; // straight: shared even grid
}
// --- pattern cell codec: char ⇄ (level, ornament) ---
// level: 0 rest / 1 normal / 2 accent / 3 ghost. ornament: 0 none / 1 flam / 2 drag / 3 roll.
// Ornaments use new letters, UPPER-case = accented hit, lower-case = normal hit (case carries the
// dynamic so it stays orthogonal): f/F flam · d/D drag · z/Z roll. Ghosted ornaments aren't expressible.
function patCell(ch) {
switch (ch) {
case "X": return [2, 0];
case "x": case "1": return [1, 0];
case "g": return [3, 0];
case "f": return [1, 1]; case "F": return [2, 1];
case "d": return [1, 2]; case "D": return [2, 2];
case "z": return [1, 3]; case "Z": return [2, 3];
default: return [0, 0]; // . - _ / anything else = rest
}
}
function cellCh(lvl, orn) {
if (orn === 1) return lvl >= 2 ? "F" : "f";
if (orn === 2) return lvl >= 2 ? "D" : "d";
if (orn === 3) return lvl >= 2 ? "Z" : "z";
return lvl === 3 ? "g" : lvl >= 2 ? "X" : lvl >= 1 ? "x" : ".";
}
// --- share-language codec: config ⇄ lane token ---
function laneCfgToStr(c) {
let s = c.sound + ":" + c.groupsStr;
const spb = c.stepsPerBeat || 1;
if (spb !== 1 || c.swing) s += "/" + spb + (c.swing ? "s" : ""); // "/2s" = swung eighths
const on = c.beatsOn || []; // per-step dynamics: one char per pad (X accent / x normal / g ghost / . mute)
const orn = c.orns || []; // per-step ornament (flam/drag/roll), parallel to beatsOn
const gs = parseGroups(c.groupsStr).groupStarts; // default = accent group starts only; everything else sounds at normal
const isDefault = on.length && on.every((v, i) => (v | 0) === (((i % spb) === 0 && gs.has(i / spb)) ? 2 : 1));
if (on.length && !isDefault) s += "=" + on.map((v) => (v === 3 ? "g" : v >= 2 ? "X" : v >= 1 ? "x" : ".")).join("");
const anyOrn = orn.some((v) => (v | 0) !== 0); // any ornament → not the implicit default; must write the pattern
const isDefault = !anyOrn && on.length && on.every((v, i) => (v | 0) === (((i % spb) === 0 && gs.has(i / spb)) ? 2 : 1));
if (on.length && !isDefault) s += "=" + on.map((v, i) => cellCh(v | 0, orn[i] | 0)).join("");
if (c.gainDb) s += "@" + c.gainDb; // per-lane gain in dB (e.g. @-3, @2)
if (c.poly) s += "~";
if (c.enabled === false) s += "!"; // "!" = silenced / disabled
@ -201,20 +225,26 @@ function laneStrToCfg(tok) {
let groupsStr = rest, sub = 1, swing = false; const sl = rest.indexOf("/");
if (sl >= 0) { groupsStr = rest.slice(0, sl); const sp = rest.slice(sl + 1); swing = /s$/i.test(sp); sub = parseInt(sp, 10) || 1; }
let { beatsPerBar: bpb, groupStarts } = parseGroups(groupsStr);
let beatsOn;
let beatsOn, orns;
if (eucK != null) { // k hits spread evenly; first hit accented
let n = eucN || (bpb * sub);
if (eucN) { if (n % bpb === 0) sub = n / bpb; else { bpb = n; sub = 1; groupsStr = String(n); } }
let first = true;
beatsOn = euclid(eucK, n, eucRot).map((h) => h ? (first ? (first = false, 2) : 1) : 0);
orns = beatsOn.map(() => 0); // euclid hits carry no ornament
} else if (pattern != null) {
// pattern cells: per-step (level, ornament) — X accent, x/1 normal, g ghost, f/F flam, d/D drag,
// z/Z roll, . - _ / anything else = rest. See patCell().
const cells = pattern.split("").map(patCell);
beatsOn = cells.map((c) => c[0]);
orns = cells.map((c) => c[1]);
} else {
// pattern levels: X=accent(2), g=ghost(3), x/1=normal(1), . - _ / anything else = mute(0);
// no pattern → every subdivision sounds at normal, accent on group starts (the grouping IS the accent map)
beatsOn = pattern ? pattern.split("").map((ch) => ch === "X" ? 2 : ch === "g" ? 3 : (ch === "x" || ch === "1") ? 1 : 0)
: Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
beatsOn = Array.from({ length: bpb * sub }, (_, i) => ((i % sub) === 0 && groupStarts.has(i / sub)) ? 2 : 1);
orns = beatsOn.map(() => 0);
}
if (!DRUMS[sound]) sound = "beep";
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, poly, swing, enabled: !disabled, gainDb };
return { groupsStr, stepsPerBeat: sub, sound, beatsOn, orns, poly, swing, enabled: !disabled, gainDb };
}
// --- share-language codec: patch ⇄ setup ---

View file

@ -10,6 +10,8 @@
// 0x41 FULL -> full snapshot (resync / heartbeat / on connect) payload: <origin>;<seq>;<running>;<sl>;<item>;<patch>
// 0x42 DELTA -> one mutation event payload: <origin>;<seq>;<evt>
// 0x43 BYE -> mirroring off payload: <origin>
// 0x44 SLSYNC -> live set-list CONTENT merge (user lists only) payload: <origin>;<seq>;<json> (§8)
// 0x45 LOGSYNC-> practice-log entry merge (additive, by at+name) payload: <origin>;<seq>;<json> (§9)
//
// DELTA <evt> grammar (reuses the share-language tokens — see engine.js):
// play | stop | bpm=<n> | vol=<pct> | sel=<sl>/<item>
@ -41,6 +43,8 @@ var LiveSync = {
_syncOn = true;
this.send(0x40, ""); // HELLO — ask the device for its full state
this.broadcastFull(); // and push ours so the device mirrors us immediately
this.broadcastSetlists(); // offer our user set-list library (content merge — §8)
this.broadcastLogBatch(); // and our whole practice history (additive merge — §9)
if (_loopback) { this.connected = true; this.peerOrigin = "loopback"; }
updateSyncBtn();
return true;
@ -70,6 +74,25 @@ var LiveSync = {
var patch; try { patch = currentPatch(); } catch (e) { return; }
this.send(0x41, (state.running ? 1 : 0) + ";" + loadedSL + ";" + activeItem + ";" + patch);
},
// 0x44 SLSYNC — full manifest of OUR user set lists (titles + items + progs),
// in the same JSON shape the 0x10 programs push uses. Built-ins/seeded lists
// are excluded (both halves bake identical copies); merge is by title (§8).
broadcastSetlists() {
if (!_syncOn || _applyingRemote) return;
var json; try { json = _userSetlistsJSON(); } catch (e) { return; }
this.send(0x44, _ascii7(json));
},
// 0x45 LOGSYNC — practice-log entries, normalized to {at,name,dur,bpm} (§9).
// entries omitted => whole log (on connect); else a single new session.
broadcastLog(entry) {
if (!_syncOn || _applyingRemote) return;
this.send(0x45, _ascii7(JSON.stringify({ log: [entry] })));
},
broadcastLogBatch() {
if (!_syncOn || _applyingRemote) return;
var json; try { json = _logBatchJSON(); } catch (e) { return; }
this.send(0x45, _ascii7(json));
},
// ---- receive -----------------------------------------------------------
applyRemote(op, text) {
@ -80,11 +103,19 @@ var LiveSync = {
if (op === 0x43) { this.connected = false; updateSyncBtn(); return; } // peer said BYE
var parts = text.split(";");
if (op === 0x41) { // FULL: origin;seq;running;sl;item;patch...
var running = parts[2] === "1", patch = parts.slice(5).join(";");
_applyRemote(function () { _applyFull(running, patch); });
var running = parts[2] === "1";
var sl = parseInt(parts[3], 10), item = parseInt(parts[4], 10);
var patch = parts.slice(5).join(";"); // patch is the tail (it contains ; and /)
_applyRemote(function () { _applyFull(running, patch, sl, item); });
} else if (op === 0x42) { // DELTA: origin;seq;evt
var evt = parts.slice(2).join(";");
_applyRemote(function () { _applyDelta(evt); });
} else if (op === 0x44) { // SLSYNC: origin;seq;json (set-list content)
var sj = parts.slice(2).join(";");
_applyRemote(function () { _applySetlists(sj); });
} else if (op === 0x45) { // LOGSYNC: origin;seq;json (practice entries)
var lj = parts.slice(2).join(";");
_applyRemote(function () { _applyLog(lj); });
}
updateSyncBtn();
},
@ -128,10 +159,24 @@ function _applyDelta(evt) {
// Full-state mirror: only rebuild if the groove actually differs (avoids
// flicker / lost focus when a heartbeat arrives and we're already in sync),
// then reconcile transport.
function _applyFull(running, patch) {
// then reconcile selection + transport.
// sl/item — the peer's loaded set-list item (or -1 / NaN = free play). We mirror
// the SELECTION HIGHLIGHT only (state-only setLoaded), never re-loading the item:
// the groove itself already arrived in `patch`, so re-loading would double-apply
// and could glitch audio. This keeps the "now loaded" row + history in sync.
function _applyFull(running, patch, sl, item) {
var cur = null; try { cur = currentPatch(); } catch (e) {}
if (patch && patch !== cur) applyPatch(patch);
// Mirror which set-list item the peer has loaded, if it maps onto a list we have.
if (typeof sl === "number" && sl >= 0 && typeof item === "number" && item >= 0 &&
typeof setlists !== "undefined" && setlists[sl] && setlists[sl].items[item]) {
if (loadedSL !== sl || activeItem !== item) {
if (typeof setLoaded === "function") setLoaded(sl, item);
if (activeSL !== loadedSL) { activeSL = loadedSL; if (typeof renderSetlists === "function") renderSetlists(); }
else if (typeof renderItems === "function") renderItems();
if (typeof renderLog === "function") renderLog();
}
}
if (running && !state.running) toggleTransport();
else if (!running && state.running) toggleTransport();
}
@ -150,6 +195,78 @@ function _syncLaneControls(m) {
if (gain) { var d = m.gainDb || 0; gain.textContent = (d > 0 ? "+" : "") + d + " dB"; gain.classList.toggle("boost", d > 0); gain.classList.toggle("cut", d < 0); }
}
// ---- set-list content + practice-log sync (0x44 / 0x45) --------------------
// 7-bit-safe encode: escape any non-ASCII to \uXXXX (matches the editor's 0x10
// programsJSON path) so the SysEx stream stays clean and the firmware never sees
// a byte > 0x7F. (regex covers U+0080..U+FFFF, same class the editor's 0x10 uses)
function _ascii7(s) { return String(s).replace(/[\u0080-\uFFFF]/g, function (c) { return "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"); }); }
// Normalize a set-list title the same way the firmware's _slkey() does
// (lower-case, alphanumerics only) — the cross-half identity key for merge (§8).
function _slKey(t) { return String(t == null ? "" : t).toLowerCase().replace(/[^a-z0-9]/g, ""); }
// JSON manifest of OUR user set lists, in the 0x10 / programs.json shape.
function _userSetlistsJSON() {
return JSON.stringify({ setlists: userSetlists().map(function (sl) {
return { title: sl.title || "My set list",
programs: sl.items.map(function (it) { return { name: it.name, prog: setupToPatch(it) }; }) };
}) });
}
// Whole practice log, normalized to the wire schema {at,name,dur,bpm} (§9).
function _logBatchJSON() {
var logs = lsGet(LS.logs, []);
return JSON.stringify({ log: logs.map(_logToWire) });
}
function _logToWire(e) {
return { at: e.at | 0, name: e.name, dur: Math.round(e.durationSec || 0), bpm: e.bpm | 0 };
}
// Apply a received 0x44 manifest: merge user lists by normalized title
// (replace-per-list / append unknown / never delete). Built-ins/seeded are
// untouched. Honors copy-on-write implicitly — the wire only carries user lists.
function _applySetlists(json) {
var d; try { d = JSON.parse(json); } catch (e) { return; }
var lists = (d && Array.isArray(d.setlists)) ? d.setlists : null;
if (!lists) return;
var seed = (typeof SEED_SETLISTS !== "undefined") ? new Set(SEED_SETLISTS.map(function (s) { return _slKey(s.title); })) : new Set();
var changed = false;
for (var i = 0; i < lists.length; i++) {
var rl = lists[i], key = _slKey(rl.title);
if (!key || seed.has(key)) continue; // never overwrite a built-in/seeded list
var items = (rl.programs || []).map(function (p) {
var su = patchToSetup(p.prog || ""); su.name = p.name || "Item"; return su;
});
var idx = -1;
for (var j = 0; j < setlists.length; j++) { if (_slKey(setlists[j].title) === key && !seed.has(_slKey(setlists[j].title))) { idx = j; break; } }
if (idx >= 0) { setlists[idx].items = items; } // replace this list's contents wholesale
else { setlists.push({ title: rl.title || "Device", description: "", items: items }); }
changed = true;
}
if (!changed) return;
if (typeof saveSetlists === "function") saveSetlists();
if (typeof activeSL !== "undefined" && activeSL >= setlists.length) activeSL = Math.max(0, setlists.length - 1);
if (typeof renderSetlists === "function") renderSetlists();
}
// Apply a received 0x45 batch: additive merge by (at,name); at==0 always appended.
function _applyLog(json) {
var d; try { d = JSON.parse(json); } catch (e) { return; }
var incoming = (d && Array.isArray(d.log)) ? d.log : null;
if (!incoming) return;
var logs = lsGet(LS.logs, []);
var have = {};
for (var k = 0; k < logs.length; k++) { if (logs[k].at) have[logs[k].at + "" + 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 ----------
// Each is a no-op unless mirroring is armed and we're not mid-apply.
function syncTransport() { LiveSync.broadcast(state.running ? "play" : "stop"); }
@ -165,6 +282,19 @@ function syncPatchSoon() {
clearTimeout(_patchSyncTimer);
_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 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"],
["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,
gainDb: c.gainDb || 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
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:
_src = f.read()
@ -79,6 +79,7 @@ def normalize(patch):
"mute": bool(L["mute"]),
"gainDb": _gain_db(L.get("gain", "")),
"levels": [int(v) for v in L["levels"]],
"orns": [int(v) for v in L.get("orns", [])],
}
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]]))
: 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) {
try {
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;
const rows = [];