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.
235 lines
12 KiB
Markdown
235 lines
12 KiB
Markdown
# PM track format — specification
|
|
|
|
This is the **single source of truth** for the metronome's track ("program" / "patch")
|
|
format. The grammar is currently implemented by hand in three places that have quietly
|
|
drifted:
|
|
|
|
- **Web** — `src/engine.js` (`patchToSetup` / `laneStrToCfg` / `setupToPatch` / `laneCfgToStr`)
|
|
- **Firmware** — `pico-cp/app.py` (`parse_program` / `_parse_lane` / `lane_to_str` / `_prog_str`)
|
|
- (`editor.html` inlines `engine.js` at build time)
|
|
|
|
`tests/fixtures/track-format.json` holds golden vectors that pin every feature to a single
|
|
expected meaning. `tests/run.mjs` runs both the JS and Python implementations against them
|
|
and reports divergences. **Any new implementation (e.g. a Rust engine) must pass the same
|
|
vectors** — that is what keeps "the same groove on the device and in the browser" true.
|
|
|
|
Status legend used below and in the fixtures:
|
|
|
|
- **stable** — implemented and (intended to be) identical on web + firmware.
|
|
- **divergence** — a real cross-implementation disagreement that exists *today*; the vector
|
|
encodes the spec's intended behavior and the runner flags the side that is wrong.
|
|
- **new** — defined here but not yet implemented anywhere (the playback-flow model below);
|
|
the vector is the acceptance test for building it.
|
|
|
|
---
|
|
|
|
## 1. Container: `programs.json`
|
|
|
|
The device reads set-lists from `/programs.json` (pushed by the editor over USB-MIDI SysEx,
|
|
or dragged onto the CIRCUITPY drive). Built-in set-lists are baked into firmware and are *not*
|
|
in this file.
|
|
|
|
```jsonc
|
|
{
|
|
"format": 2, // NEW. absent ⇒ treat as format 1 (legacy)
|
|
"setlists": [
|
|
{
|
|
"title": "My set",
|
|
"description": "optional",
|
|
"onEnd": "stop", // NEW: stop | nextList | loop (default: stop)
|
|
"defaultEnd": "next", // NEW: items without their own end= inherit this
|
|
"programs": [
|
|
{ "name": "Intro", "prog": "t88;b8;kick:4=X.x.;end=next" },
|
|
{ "name": "Groove", "prog": "t88;kick:4;snare:4=.X.X" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Legacy (format 1):** `{ "setlists":[...] }` with no `format`, or the older flat
|
|
`{ "programs":[...] }` (a single list). Loaders MUST still accept both. Migration to format 2:
|
|
set `format: 2`; a previously "continuous" set-list gets `onEnd` per the author's intent and
|
|
`defaultEnd: "next"` (or `end=next` on each item).
|
|
|
|
---
|
|
|
|
## 2. Patch string grammar
|
|
|
|
A patch is `;`-separated tokens. **Rule that prevents token collisions:** a token containing
|
|
`:` is a **lane**; every other token is a **keyword directive** matched against the reserved
|
|
set `{ v1, t, vol, cd, b, tr, rmp, rep, end }`. Unknown tokens are ignored (see §6).
|
|
|
|
```ebnf
|
|
patch = [ "v1" ";" ] directive *( ";" directive ) ;
|
|
|
|
directive = tempo | volume | countin | bars | trainer | ramp | rep | end | lane ;
|
|
|
|
tempo = "t" int ; (* beats per minute, clamped to 5..300 *)
|
|
volume = "vol" int ; (* 0..100; web-authoring only — device has a hardware volume knob *)
|
|
countin = "cd" int ; (* count-in seconds; web-authoring only — device has no count-in *)
|
|
bars = "b" int ; (* cycle length in bars; drives Continue/rep *)
|
|
trainer = "tr" int "/" int ; (* playBars "/" muteBars (gap trainer) *)
|
|
ramp = "rmp" int "/" signed "/" int ; (* startBpm "/" amount "/" everyBars *)
|
|
|
|
rep = "rep=" int ; (* cycles before end fires; default 1 *)
|
|
end = "end" "=" ( "stop" | "next" | signed ) ; (* NEW: see §3 *)
|
|
|
|
lane = sound ":" groups [ "/" sub [ "s" ] ] [ euclid ] [ "=" pattern ]
|
|
[ "@" signed ] [ "~" ] [ "!" ] ;
|
|
sound = name | int ; (* int = GM percussion note number alias *)
|
|
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 = *( 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
|
|
|
|
- **groups** — `2+2+3` means a 7-beat bar grouped 2+2+3; the first step of each group is
|
|
accented by default. `beatsPerBar = sum(groups)`.
|
|
- **sub** — steps per beat (`/2` eighths, `/3` triplets, `/4` sixteenths). `steps = beats*sub`.
|
|
- **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,
|
|
write the grouping (`1+1+1+1`) rather than relying on the default.
|
|
- **euclid** `(k,n,rot)** — `k` hits spread as evenly as possible over `n` steps (rotated by
|
|
`rot`), first hit accented. Replaces an explicit `=pattern`.
|
|
- **`@<db>`** — per-lane gain in dB. (Host-optional: applied in the browser; see §6.)
|
|
- **`~`** — polymeter: this lane keeps its own bar length independent of the master lane.
|
|
- **`!`** — lane present but silenced/disabled.
|
|
|
|
### Top-level directives
|
|
|
|
- **`t`** tempo, clamped to `[5, 300]`.
|
|
- **`b<n>`** the segment's **cycle length** in bars — used for Continue/`rep` accounting and
|
|
song auto-advance. Absent ⇒ the cycle is one bar of the **master lane** (the first lane).
|
|
- **`rmp<start>/<amt>/<every>`** tempo ramp: every `<every>` bars, change tempo by `<amt>`
|
|
(may be negative), starting from `<start>`.
|
|
- **`tr<play>/<mute>`** gap trainer: play `<play>` bars, silence `<mute>` bars, repeat.
|
|
- **`vol`, `cd`** web-authoring only — the device has a hardware volume knob and no count-in, so
|
|
it parses past these and does not carry them (see §6).
|
|
|
|
---
|
|
|
|
## 3. Playback flow
|
|
|
|
Per-track playback behavior (parsed + serialized by both engines; firmware runtime implemented).
|
|
**Implementation note:** the device keeps the global `Continue` toggle as a *default* — a track
|
|
with an explicit `end=` governs itself; a track without one falls back to `end=next` while
|
|
Continue is on (and still needs `b<bars>`), else it loops. So per-track `end=` overrides the
|
|
global toggle rather than replacing the UI.
|
|
|
|
**Default (no `end=` token) — loop forever**, exactly like a metronome. Manual advance
|
|
(joystick / footswitch) always moves to the next track. "Vamp until cue" is therefore the
|
|
default and needs no special token.
|
|
|
|
`end=` exists only to make flow **automatic** after a finite number of cycles:
|
|
|
|
| Token | Meaning |
|
|
|------------------|---------------------------------------------------------------------|
|
|
| *(absent)* | Loop forever. Manual advance → next track. |
|
|
| `end=stop` | After `rep` cycles, stop. |
|
|
| `end=next` | After `rep` cycles, auto-advance one track. (Sugar for `end=+1`.) |
|
|
| `end=<±N>` | **Relative goto**: after `rep` cycles, jump `N` tracks (e.g. `end=-2` repeats a section, D.S.). |
|
|
| `rep=<N>` | Cycles to play before `end` fires. Default `1`. Only meaningful with `end`. |
|
|
|
|
A **cycle** = `b<bars>` bars if `b` is present, else one bar of the master lane.
|
|
|
|
**Normalization** (what the golden vectors compare): `end` normalizes to `"stop"`, or an
|
|
integer offset (`next` ⇒ `1`); absent ⇒ `null`. `rep` ⇒ the integer (defaults to `1` when
|
|
`end` is present), else `null`.
|
|
|
|
### Resolution & boundaries
|
|
|
|
- **Manual override always wins.** `end=` is only the hands-off behavior.
|
|
- **Seam is gapless.** An automatic `next`/goto does **not** re-trigger count-in and inserts
|
|
no gap (honors the existing `_seam_t` seam). Count-in (`cd`) fires only on manual/initial
|
|
start. Tempo jumps to the destination track's `t` at the seam.
|
|
- **Goto past the last item** (or `end=next` on the last item) triggers the set-list's
|
|
`onEnd` policy: `stop` | `nextList` | `loop`.
|
|
- **Goto before the first item** clamps to the first item.
|
|
- **`defaultEnd`** on a set-list is inherited by any item whose patch has no `end=`.
|
|
|
|
---
|
|
|
|
## 4. Canonical form & round-tripping
|
|
|
|
Serializing a parsed patch MUST be **idempotent**: `serialize(parse(serialize(parse(x))))`
|
|
equals `serialize(parse(x))`. Host-optional fields that a host does not act on
|
|
(`vol`, `cd`, `@db`) MUST still survive the round-trip rather than being dropped — silent
|
|
data loss is the failure mode this spec exists to prevent.
|
|
|
|
The runner checks **semantic equality** (the normalized structure — what actually plays)
|
|
across implementations, plus per-implementation idempotency. It does **not** require the two
|
|
serializers to emit byte-identical strings (they legitimately differ on the optional `v1`
|
|
prefix and on whether a default pattern is written out).
|
|
|
|
---
|
|
|
|
## 5. Normalized structure (the comparison target)
|
|
|
|
Each implementation's adapter parses a patch and emits this neutral shape; vectors store the
|
|
expected value in `norm`:
|
|
|
|
```jsonc
|
|
{
|
|
"bpm": 120, "bars": 0, "volume": null, "countMs": 0,
|
|
"ramp": null, // or { "start": 80, "amt": 4, "every": 4 }
|
|
"trainer": null, // or { "play": 2, "mute": 2 }
|
|
"rep": null, // NEW
|
|
"end": null, // NEW: "stop" | <int> | null
|
|
"lanes": [
|
|
{ "sound": "kick", "groups": [4], "sub": 1, "swing": false,
|
|
"poly": false, "mute": false, "gainDb": 0, "levels": [2,1,1,1] }
|
|
]
|
|
}
|
|
```
|
|
|
|
`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
|
|
|
|
Surfaced by the runner. **Resolved** (now identical on web + firmware, verified by the suite):
|
|
|
|
- **Default (no-pattern) groove** — every subdivision sounds; accent only on group starts.
|
|
- **Euclid `(k,n,rot)`** — now parsed by both engines (`kick:4(3,8)` etc.).
|
|
- **Unknown sound name** — falls back to `beep` on both.
|
|
- **GM note-number aliases** — `36:4` resolves to the voice name on both.
|
|
|
|
**Intentional / permanent host differences** (not bugs — the device is a host that lacks these):
|
|
|
|
- **`vol` (master volume) / `cd` (count-in)** — web-authoring fields. The device has a hardware
|
|
volume knob and no count-in, so it parses past them and does not carry them. (Contrast `@db`
|
|
gain, which the device *does* round-trip as a per-lane field even though it doesn't apply it.)
|
|
|
|
**Resolved on the web side** (`engine.js` now matches the firmware):
|
|
|
|
- **Tempo clamp** — `patchToSetup` clamps `t` to `[5,300]`.
|
|
- **Empty patch** — `patchToSetup` defaults to a `beep:4` lane when no lanes are given. (The
|
|
editor still shows its "no lanes" hint by checking the *raw* input for a `:` token.)
|
|
|
|
The capability/version handshake (the device already replies with its firmware version over
|
|
SysEx) should gate features so the editor can warn when a track uses something the connected
|
|
firmware is too old to play, instead of letting it degrade silently.
|