#!/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}))