metronome/docs/track-format.md
Me Here da7c94e67f Implement per-track playback flow (rep / end / relative goto)
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>
2026-05-31 00:37:06 -05:00

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:

  • Websrc/engine.js (patchToSetup / laneStrToCfg / setupToPatch / laneCfgToStr)
  • Firmwarepico-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.

{
  "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 ;                       (* 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

  • groups2+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. 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)** — 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

  • 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 (next1); 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:

{
  "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 beep on both.
  • GM note-number aliases36: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.)

Still open — web side only (engine.js, flagged expectFail: ["js"]):

  • Tempo clamp — firmware clamps t to [5,300]; engine.js does not yet.
  • Empty patch — firmware injects a default beep:4; engine.js returns 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.