Close three real parser divergences the conformance suite flagged on the device side (pico-cp + pico-explorer) — cases where the firmware produced a different groove/sound than the web for the same patch: - Euclidean (k,n,rot) shorthand (e.g. kick:4(3,8)) — was silently dropped to a plain bar; now expands to the same hits as engine.js (added _euclid + parsing). - GM note-number lane sounds (e.g. 36:4) — now resolve to the voice name (GM_NUM). - Unknown sound names fall back to beep, matching the web. vol/cd are NOT carried by the firmware by design: they are web-authoring fields (the device has a hardware volume knob and no count-in). Documented as an intentional, permanent host difference rather than a bug; the vol-and-countin vector stays as expectFail[py] to mark the boundary. tests/adapters/py_adapter.py: extract the new SOUND_GM/GM_NUM/_euclid nodes. fixtures: euclid/unknown-sound/gm-note-number now pass on both engines. docs §6 updated. node tests/run.mjs: 33 pass / 9 known, round-trips stable. pico-explorer parser spot-checked identical to pico-cp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
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.htmlinlinesengine.jsat 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.
{
"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).
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 ; (* NEW: 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 = *( "X" | "x" | "g" | "." | "-" | "_" ) ; (* per-step dynamics *)
Lane semantics
- groups —
2+2+3means 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 (
/2eighths,/3triplets,/4sixteenths).steps = beats*sub. - swing —
/2slays 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 tosteps. With no pattern (the default): every step sounds at normal level and accents fall only on group starts — the grouping is the accent map. So4accents beat 1;2+2accents beats 1 & 3;4/2is 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)** —khits spread as evenly as possible overnsteps (rotated byrot), 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
ttempo, clamped to[5, 300].b<n>the segment's cycle length in bars — used for Continue/repaccounting 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,cdweb-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 (NEW)
Replaces the old global Continue toggle with per-track behavior.
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_tseam). Count-in (cd) fires only on manual/initial start. Tempo jumps to the destination track'stat the seam. - Goto past the last item (or
end=nexton the last item) triggers the set-list'sonEndpolicy:stop|nextList|loop. - Goto before the first item clamps to the first item.
defaultEndon a set-list is inherited by any item whose patch has noend=.
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:
{
"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.
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
beepon both. - GM note-number aliases —
36:4resolves 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@dbgain, which the device does round-trip as a per-lane field even though it doesn't apply it.)
Still open — web side only (engine.js, flagged expectFail: ["js"]):
- Tempo clamp — firmware clamps
tto[5,300];engine.jsdoes not yet. - Empty patch — firmware injects a default
beep:4;engine.jsreturns zero lanes (the editor guards emptiness separately, so this may stay as-is).
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.