metronome/tests/adapters/py_adapter.py
Me Here cb54b4d689 Preserve notation + grammar feature work (verified complete + green)
The parallel agent's full session, committed now that it's solo:
- Grammar: flam/drag/roll ornaments (f/F d/D z/Z, per-lane orns channel) across
  src/engine.js, pico-cp/pico-explorer/pico-scroll app.py, pico/main.py, rust/track-format,
  + golden vectors / conformance (tests/, rust/track-format/tests).
- Live-sync deep-sync: SysEx 0x44 SLSYNC + 0x45 LOGSYNC (docs/livesync-protocol.md, src/livesync.js).
- PM_E-2 notation: web engine (pm_e-2.html, build/deploy/index/embed wiring) + Rust device port
  (pm-ui draw_notation rewrite + LaneView.groups, pm-kit ViewMode, uisim notesim).

Verified: node tests/run.mjs 47 pass / 1 known; ./rust/run.sh green; pm-kit firmware + uisim compile.
2026-06-02 13:45:26 -05:00

98 lines
3.4 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", "ORN", "PRIO", "PAT_CH", "ORN_CH", "_cell_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"]],
"orns": [int(v) for v in L.get("orns", [])],
}
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}))