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>
97 lines
3.3 KiB
Python
97 lines
3.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Python adapter: extracts the REAL grammar functions out of pico-cp/app.py (no copy) and
|
|
emits the neutral normalized structure from docs/track-format.md §5.
|
|
|
|
app.py can't be imported directly (it does hardware init at module load), so we pull just the
|
|
pure codec nodes by name via `ast` and exec them in isolation. This tests the actual firmware
|
|
code and survives edits as long as the function/constant names persist.
|
|
"""
|
|
import ast
|
|
import json
|
|
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"}
|
|
|
|
with open(APP) as f:
|
|
_src = f.read()
|
|
|
|
_segs = []
|
|
for node in ast.parse(_src).body:
|
|
name = None
|
|
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
name = node.targets[0].id
|
|
elif isinstance(node, ast.FunctionDef):
|
|
name = node.name
|
|
if name in WANT:
|
|
_segs.append(ast.get_source_segment(_src, node))
|
|
|
|
NS = {}
|
|
exec("\n".join(_segs), NS)
|
|
|
|
|
|
def _prog_str(bpm, lanes, bars, ramp, trainer, rep=None, end=None):
|
|
# Mirrors app.py App._prog_str using the real lane_to_str.
|
|
parts = ["t" + str(bpm)]
|
|
if bars:
|
|
parts.append("b" + str(bars))
|
|
if ramp:
|
|
parts.append("rmp%d/%d/%d" % (ramp.get("start", bpm), ramp["amt"], ramp["every"]))
|
|
if trainer:
|
|
parts.append("tr%d/%d" % (trainer["play"], trainer["mute"]))
|
|
for L in lanes:
|
|
parts.append(NS["lane_to_str"](L))
|
|
if end is not None:
|
|
if rep and rep > 1:
|
|
parts.append("rep=" + str(rep))
|
|
parts.append("end=" + ("stop" if end == "stop" else "next" if end == 1 else ("+%d" % end if end > 0 else str(end))))
|
|
return ";".join(parts)
|
|
|
|
|
|
def _gain_db(g):
|
|
if not g:
|
|
return 0
|
|
try:
|
|
return float(str(g).lstrip("@"))
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def normalize(patch):
|
|
bpm, lanes, bars, ramp, trainer, rep, end = NS["parse_program"](patch)
|
|
return {
|
|
"bpm": bpm,
|
|
"bars": bars,
|
|
"volume": None, # device has no master-volume token
|
|
"countMs": 0, # device has no count-in
|
|
"ramp": {"start": ramp.get("start", bpm), "amt": ramp["amt"], "every": ramp["every"]} if ramp else None,
|
|
"trainer": {"play": trainer["play"], "mute": trainer["mute"]} if trainer else None,
|
|
"rep": None if end is None else (rep if rep else 1), # rep only meaningful with end; defaults to 1
|
|
"end": end,
|
|
"lanes": [
|
|
{
|
|
"sound": L["sound"],
|
|
"groups": list(L.get("groups", [4])),
|
|
"sub": L["sub"],
|
|
"swing": bool(L["swing"]),
|
|
"poly": bool(L["poly"]),
|
|
"mute": bool(L["mute"]),
|
|
"gainDb": _gain_db(L.get("gain", "")),
|
|
"levels": [int(v) for v in L["levels"]],
|
|
}
|
|
for L in lanes
|
|
],
|
|
}
|
|
|
|
|
|
def canonical(patch):
|
|
return _prog_str(*NS["parse_program"](patch))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
patch = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
# Re-parse the canonical form too, so the runner can check idempotency in one call.
|
|
c1 = canonical(patch)
|
|
c2 = canonical(c1)
|
|
sys.stdout.write(json.dumps({"norm": normalize(patch), "canonical": c1, "idempotent": c1 == c2}))
|