Adds the per-track end-action model designed in docs/track-format.md §3, end to end across both engines, both firmwares, and the editors. Grammar (parsed + serialized by engine.js and both app.py): rep=<n> cycles before the end-action fires (default 1) end=stop stop after rep cycles end=next advance one track (sugar for end=+1) end=<±N> relative goto after rep cycles (e.g. end=-2 = D.S.) (absent) loop forever — the metronome default Firmware runtime (pico-cp + pico-explorer): _on_new_bar now consults a per-track _end_plan() and fires stop / gapless-advance / relative-goto at the right bar. A cycle = b<bars>, else one master bar; fire bar = rep * cycle. Explicit end= governs; with no end, the global Continue toggle stays a default (=end=next, still needs b<bars>) so existing set-lists and the CONT UI are unchanged. _prepare_next takes a target index; the seam machinery, _do_advance and live-sync all carry rep/end. Editors (editor.html + editor-beta.html): state.rep/state.end thread through applySetup / currentSetup / currentPatch so load -> edit -> save preserves the flow; authoring is via the program-string field (no graphical control yet). Tests: the 3 playback-flow vectors now pass on both engines (39 pass / 3 known). Runtime decision logic (_end_plan / _goto_target) unit-tested for stop, rep, relative goto clamp/wrap, and legacy-Continue precedence. Codec round-trip verified idempotent. Both firmwares compile + mpy-cross clean. Also: untrack stale __pycache__/*.pyc build artifacts and gitignore them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
659 lines
16 KiB
JSON
659 lines
16 KiB
JSON
{
|
|
"_comment": "Golden vectors for the PM track format. See docs/track-format.md. Each case: parse `in`, normalize, compare to `norm`. status: stable|divergence|new. expectFail lists impls (js/py) known to mismatch today; an UNlisted mismatch is a regression, and a listed PASS means the gap was fixed. NOTE: stable cases use EXPLICIT =patterns on every lane, because lanes with no =pattern currently produce DIFFERENT defaults on web vs device (see the 'default-pattern' case + docs §6).",
|
|
"cases": [
|
|
{
|
|
"id": "minimal",
|
|
"status": "stable",
|
|
"in": "t120;kick:4=Xxxx",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "backbeat",
|
|
"status": "stable",
|
|
"in": "t120;kick:4=Xxxx;snare:4=.x.x;hatClosed:4/2=X.x.x.x.",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
},
|
|
{
|
|
"sound": "snare",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [0, 1, 0, 1]
|
|
},
|
|
{
|
|
"sound": "hatClosed",
|
|
"groups": [4],
|
|
"sub": 2,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 0, 1, 0, 1, 0, 1, 0]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "odd-meter-2+2+3",
|
|
"status": "stable",
|
|
"in": "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2=X.x.X.x.X.x.x.",
|
|
"norm": {
|
|
"bpm": 130,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [2, 2, 3],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [1, 0, 0, 1, 0, 0, 1]
|
|
},
|
|
{
|
|
"sound": "hatClosed",
|
|
"groups": [2, 2, 3],
|
|
"sub": 2,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 0, 1, 0, 2, 0, 1, 0, 2, 0, 1, 0, 1, 0]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "swing",
|
|
"status": "stable",
|
|
"in": "t150;ride:4/2s=X.x.x.x.;kick:4=X..x",
|
|
"norm": {
|
|
"bpm": 150,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "ride",
|
|
"groups": [4],
|
|
"sub": 2,
|
|
"swing": true,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 0, 1, 0, 1, 0, 1, 0]
|
|
},
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 0, 0, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "ghost-notes",
|
|
"status": "stable",
|
|
"in": "t92;snare:4/3=..gg.gX.gg.g",
|
|
"norm": {
|
|
"bpm": 92,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "snare",
|
|
"groups": [4],
|
|
"sub": 3,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [0, 0, 3, 3, 0, 3, 2, 0, 3, 3, 0, 3]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "polymeter",
|
|
"status": "stable",
|
|
"in": "t100;kick:4=Xxxx;claves:5=Xxxxx~",
|
|
"norm": {
|
|
"bpm": 100,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
},
|
|
{
|
|
"sound": "claves",
|
|
"groups": [5],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": true,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "disabled-lane",
|
|
"status": "stable",
|
|
"in": "t120;kick:4=Xxxx;hatClosed:4=Xxxx!",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
},
|
|
{
|
|
"sound": "hatClosed",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": true,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "ramp",
|
|
"status": "stable",
|
|
"in": "t80;woodblock:4=Xxxx;rmp80/4/4",
|
|
"norm": {
|
|
"bpm": 80,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": {
|
|
"start": 80,
|
|
"amt": 4,
|
|
"every": 4
|
|
},
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "woodblock",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "gap-trainer",
|
|
"status": "stable",
|
|
"in": "t100;kick:4=Xxxx;tr2/2",
|
|
"norm": {
|
|
"bpm": 100,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": {
|
|
"play": 2,
|
|
"mute": 2
|
|
},
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "segment-bars",
|
|
"status": "stable",
|
|
"in": "t88;b8;kick:4=X.x.",
|
|
"norm": {
|
|
"bpm": 88,
|
|
"bars": 8,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 0, 1, 0]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "gm-note-number-alias",
|
|
"status": "stable",
|
|
"in": "t120;36:4=Xxxx",
|
|
"note": "GM note 36 = kick. Both engines resolve the number to the voice name.",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "default-pattern",
|
|
"status": "stable",
|
|
"note": "No =pattern: every subdivision sounds at normal; accent only on group starts (the grouping is the accent map). One group of 4 ⇒ accent on beat 1 only, off-8ths sound at normal.",
|
|
"in": "t120;hatClosed:4/2",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "hatClosed",
|
|
"groups": [4],
|
|
"sub": 2,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1, 1, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "default-pattern-odd-meter",
|
|
"status": "stable",
|
|
"note": "No =pattern over a 2+2+3 grouping ⇒ accents land on group starts (beats 1,3,5); all 8ths sound.",
|
|
"in": "t130;hatClosed:2+2+3/2",
|
|
"norm": {
|
|
"bpm": 130,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "hatClosed",
|
|
"groups": [2, 2, 3],
|
|
"sub": 2,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "euclid",
|
|
"status": "stable",
|
|
"note": "Euclid (k,n) shorthand: 3 hits spread over 8 steps, first accented. Parsed identically on both engines.",
|
|
"in": "t120;kick:4(3,8)",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 2,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 0, 0, 1, 0, 0, 1, 0]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "vol-and-countin",
|
|
"status": "divergence",
|
|
"expectFail": [
|
|
"py"
|
|
],
|
|
"note": "INTENTIONAL host difference (permanent, not a bug): vol (master volume) and cd (count-in) are web-authoring fields. The device has a hardware volume knob and no count-in, so it omits them. Kept as a vector to document the boundary.",
|
|
"in": "t120;vol80;cd2;kick:4=Xxxx",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": 0.8,
|
|
"countMs": 2000,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "unknown-sound",
|
|
"status": "stable",
|
|
"note": "Unknown sound name falls back to beep on both engines.",
|
|
"in": "t120;blorp:4=Xxxx",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "beep",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "tempo-clamp-high",
|
|
"status": "divergence",
|
|
"expectFail": [
|
|
"js"
|
|
],
|
|
"note": "Firmware clamps t to [5,300]; engine.js does not. Spec = clamp everywhere.",
|
|
"in": "t999;kick:4=Xxxx",
|
|
"norm": {
|
|
"bpm": 300,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "empty-defaults-to-beep",
|
|
"status": "divergence",
|
|
"expectFail": [
|
|
"js"
|
|
],
|
|
"note": "Firmware injects a default beep:4; engine.js returns no lanes (editor guards emptiness). Spec = beep:4.",
|
|
"in": "",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": null,
|
|
"end": null,
|
|
"lanes": [
|
|
{
|
|
"sound": "beep",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "end-stop",
|
|
"status": "stable",
|
|
"note": "Per-track playback flow — not yet implemented anywhere.",
|
|
"in": "t120;kick:4=Xxxx;end=stop",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": 1,
|
|
"end": "stop",
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "rep-then-next",
|
|
"status": "stable",
|
|
"note": "Play 4 cycles then auto-advance. next ⇒ +1.",
|
|
"in": "t120;kick:4=Xxxx;rep=4;end=next",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": 4,
|
|
"end": 1,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"id": "relative-goto-back-two",
|
|
"status": "stable",
|
|
"note": "Relative goto (D.S.): after 1 cycle, jump back two tracks.",
|
|
"in": "t120;kick:4=Xxxx;end=-2",
|
|
"norm": {
|
|
"bpm": 120,
|
|
"bars": 0,
|
|
"volume": null,
|
|
"countMs": 0,
|
|
"ramp": null,
|
|
"trainer": null,
|
|
"rep": 1,
|
|
"end": -2,
|
|
"lanes": [
|
|
{
|
|
"sound": "kick",
|
|
"groups": [4],
|
|
"sub": 1,
|
|
"swing": false,
|
|
"poly": false,
|
|
"mute": false,
|
|
"gainDb": 0,
|
|
"levels": [2, 1, 1, 1]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|