Commit ab368180 by PLN (Algolia)

feat(pv-at): --track proves every control on a REAL track moves the sound (#74)

PLN's ask, verbatim: "all controls have impact on sound... even the crushes should
change noticeably". The existing suite proves the RIG works against a fixture;
this proves a TRACK is wired, which is the thing that bites at 160 BPM when a knob
turns out to do nothing.

    python3 tools/at --track live/.../gimme_acid.tidal --dry-run   # no audio
    python3 tools/at --track live/.../gimme_acid.tidal [--json]

Per control: park at REST, take TWO baselines, probe, restore, judge. Four
outcomes, not two — MOVED / NO_IMPACT / INCONCLUSIVE / UNMEASURABLE — because a
swing that does not clearly beat the pattern's own drift is genuinely unknown, and
calling that a pass is how a green suite comes to mean nothing. NO_SIGNAL is kept
separate from NO_IMPACT: "nothing was playing" and "the control is dead" have
different fixes, and conflating them once cost a whole evening chasing SuperDirt.

THE BUG THE FIRST DRY RUN FOUND, which is the real content of this commit

The plan for gimme_acid came back "0 bipolar, no CC 49/50/51 at all" — on a track
that applies gF1/gF2/gF3 to SIX orbits. gF1/gF2/gF3 are defined in BootTidal.hs,
not in the track, so lens.py's scan of the .tidal file alone was structurally
blind to the three DJ FILTERS and to EVERY MUTE in the rig. The controls PLN
reaches for most were the ones the acceptance test could not see.

Fixed by PARSING BootTidal.hs (parsers-over-copy — it is the source of truth and
it moves: gF1 was rewired onto gDJF, midiGGlobal was retired hours ago, and a
hardcoded table would rot into a confidently wrong report). boot_helpers() now
resolves:
  * `gF1 = gDJF "^49"` -> CC49, and BORROWS its lens from gDJF's body, since
    "gDJF" is not a keyword any table knows and the lpf/hpf evidence sits on two
    separate lines
  * `gM1 = gMask . gMute1` -> composition, inheriting CC41 AND CC73
  * a track's own `let gMute = ...` SHADOWS the boot one, as Tidal does
Result on gimme_acid: 18 controls -> 22, with 49/50/51 present, bipolar, resting
at 64 and probing downward. Across the OPAL set: 275 controls, 15-29 per track,
all three filters resolved in all 13, zero Ardour-owned CCs.

Two parse bugs found and fixed on the way, both by reading the output instead of
trusting it:
  * `mask "f*16"` was classified as DENSITY. An all-false mask is a MUTE — same
    keyword, opposite lens (rms vs onsets) — and my first all-false test asked
    "does it contain a 1", which the *16 repeat count satisfies. So gMute1/2/3,
    three textually IDENTICAL helpers, came back mute/density/density: rms would
    have been the wrong lens on two of PLN's three mutes, and two working mutes
    would have reported NO_IMPACT.
  * `gM3 = gMask . gMute3` swallowed the Launchpad block that follows it and
    reported CC 7 and CC 9 as mutes, because continuation lines were appended to
    whatever was defined last. A continuation now has to LOOK like one.

SAFETY
CC93 is added to a new lens.NEVER_PROBE. It arms panic — gPanic gates on "^93", so
sending it high silences every gPanic'd stream. "Probe every control" plus "one
control is a kill switch" is how a self-test mutes a rehearsal and gets blamed on
the rig. It is reported and never swept. CC77-84 stay excluded by construction in
lens AND re-asserted at the probe; CC77 down is total silence, so belt and braces
is proportionate there.
Controls are restored per control, not at exit, so Ctrl-C mid-run is safe. Restore
goes to Control.rest — 64 for a DJ filter, because djf 0.05 is a ~26 Hz low-pass,
i.e. silence, and parking one at 0 to get a "baseline" measures silence and then
calls the track broken (#48).

--dry-run exists deliberately: it prints the plan with no audio and no MIDI, so
the half that can be checked tonight — which controls were found, which lens each
gets, what will be sent, what will be restored — is checkable at all. "Built but
never run" is exactly how the LED work shipped a colour ramp that was never
called.

TESTS: +79 (22 in test_lens.py, 57 in the new test_track_runner.py), suite
345 -> 424, all green. Every boot-helper regression above is pinned, including a
parametrised all-false-mask table with `f*16` in it; each lens is asserted to have
a floor (2 * max(drift, 0) is zero, so without one any tiny swing looks
significant on a quiet capture); the probe is asserted to REFUSE all of CC77-84;
and the dry-run plan is built for all 13 OPAL tracks with the DJ filters asserted
bipolar-and-resting-at-64 in each.

STATUS: the judgement half is validated. The AUDIO half is built but unrun — it
needs monitors, and the house is quiet. First thing to run with sound up.
parent 3ab9ae03
......@@ -277,6 +277,248 @@ def control_case(seconds: float) -> Result:
# --------------------------------------------------------------------------
# --track: prove EVERY mapped control on a real track moves the sound (#74)
# --------------------------------------------------------------------------
# PLN's ask, verbatim: "all controls have impact on sound... even the crushes
# should change noticeably". The fixture suite above proves the RIG works; this
# proves a TRACK is wired, which is the thing that actually bites at 160 BPM when
# a knob turns out to do nothing.
#
# Everything hard-won about measuring this rig is encoded here rather than in the
# operator's head:
# * the LENS comes from lens.py, per control. rms is blind to a filter or a
# crusher — they rearrange the spectrum and leave the level alone — so an
# rms assertion on gF1 reports "no change" on a filter that works perfectly.
# * REST is 64 for a DJ filter, 0 for everything else. Parking a DJF at 0 to get
# a "baseline" measures a ~26 Hz low-pass, i.e. silence, and then calls the
# track broken.
# * BASELINE TWICE. A pattern varies per cycle on its own; one baseline cannot
# tell a real effect from ordinary drift.
# * a swing that does not clearly beat that drift is INCONCLUSIVE, not a pass and
# not a failure. Three outcomes, because two would force a lie.
# * measure only the orbits the control is actually applied to. A global average
# hides a control that moves one orbit out of eight.
# * CC 77-84 are excluded BY CONSTRUCTION (lens.FORBIDDEN_CC), not by care.
TRACK_SETTLE = 8.0
CONTROL_SETTLE = 2.0
@dataclass
class ControlVerdict:
cc: int
kind: str
lens: str
orbits: list[int]
outcome: str # MOVED | NO_IMPACT | INCONCLUSIVE | NO_SIGNAL | UNMEASURABLE
detail: str = ""
base: float = 0.0
probed: float = 0.0
drift: float = 0.0
probe_value: int = 0
@property
def passed(self) -> bool:
# UNMEASURABLE is not a failure: pan and orbit genuinely cannot be seen by
# a mono level/spectrum probe, and reporting them as broken would train
# PLN to ignore the report.
return self.outcome in ("MOVED", "UNMEASURABLE")
def _probe_control(ctl, seconds: float, port: str) -> ControlVerdict:
from at import lens as _lens
assert ctl.cc not in _lens.FORBIDDEN_CC, f"CC{ctl.cc} is Ardour-owned"
orbits = ctl.orbits or []
base_kw = dict(cc=ctl.cc, kind=ctl.kind, lens=ctl.lens, orbits=orbits)
if ctl.cc in _lens.NEVER_PROBE:
# CC93 arms panic: it silences every gPanic'd stream. "Probe every control"
# plus "one control is a kill switch" is how a self-test mutes a rehearsal.
return ControlVerdict(**base_kw, outcome="UNMEASURABLE",
detail="global kill switch (panic gate) — reported, "
"never swept")
if not ctl.measurable:
return ControlVerdict(**base_kw, outcome="UNMEASURABLE",
detail=f"{ctl.kind} has no lens a mono probe can see")
if not orbits:
return ControlVerdict(**base_kw, outcome="INCONCLUSIVE",
detail="could not attribute this CC to an orbit")
lcxl.send_cc(port, ctl.cc, ctl.rest)
time.sleep(CONTROL_SETTLE)
b1 = measure(orbits, seconds)
b2 = measure(orbits, seconds)
probe = ctl.probes[0]
lcxl.send_cc(port, ctl.cc, probe)
time.sleep(CONTROL_SETTLE)
after = measure(orbits, seconds)
lcxl.send_cc(port, ctl.cc, ctl.rest) # ALWAYS restore, to rest not to zero
# Aggregate across the control's own orbits only, and take the LARGEST swing
# rather than the mean: a control that moves one of its four orbits is wired,
# and averaging would bury it.
best = None
for dn in orbits:
if dn not in b1 or dn not in b2 or dn not in after:
continue
v1 = _lens.lens_value(b1[dn], ctl.lens)
v2 = _lens.lens_value(b2[dn], ctl.lens)
va = _lens.lens_value(after[dn], ctl.lens)
drift = abs(v2 - v1)
base = statistics.mean([v1, v2])
swing = abs(va - base)
cand = (swing - 2 * max(drift, _lens_floor(ctl.lens)), dn, base, va, drift, swing)
if best is None or cand[0] > best[0]:
best = cand
if best is None:
return ControlVerdict(**base_kw, outcome="INCONCLUSIVE",
detail="no capture for any of this control's orbits")
_, dn, base, va, drift, swing = best
unit = _lens.LENS_UNITS.get(ctl.lens, "")
kw = dict(**base_kw, base=base, probed=va, drift=drift, probe_value=probe)
shown = f"d{dn} {ctl.lens} {base:.1f} -> {va:.1f} {unit} (swing {swing:.1f}, drift {drift:.1f})"
# A near-zero baseline is the one place a percentage lies, so this compares
# ABSOLUTE swing against the signal's own noise, never a ratio.
if ctl.lens == "rms" and base <= -60:
return ControlVerdict(**kw, outcome="NO_SIGNAL",
detail=f"nothing audible on d{dn} to begin with — "
f"cannot judge the control ({shown})")
if swing < 2 * max(drift, _lens_floor(ctl.lens)):
return ControlVerdict(**kw, outcome="INCONCLUSIVE",
detail=f"swing does not clearly beat the pattern's own "
f"drift ({shown})")
return ControlVerdict(**kw, outcome="MOVED",
detail=f"CC{ctl.cc}={probe} moves the sound ({shown})")
def _lens_floor(lens: str) -> float:
"""The smallest change worth calling real, per lens.
Not a tuning knob for taste — it stops a lens whose drift happens to be ~0 on a
quiet capture from making any tiny swing look significant (2 * max(drift, 0) is
zero, and then everything "moves").
"""
return {"centroid": 40.0, "rms": 1.0, "low_ratio": 0.02, "high_ratio": 0.02,
"onsets": 0.3, "duty": 0.05}.get(lens, 0.0)
def cmd_track_plan(path: Path, as_json: bool = False) -> int:
"""The probe plan, with NO audio and NO MIDI sent.
This exists so the half of --track that can be checked without monitors — which
controls were found, which lens each gets, what value will be sent, what will be
restored — is checkable at all. "Built but never run" is how the LED work shipped
a colour ramp that was never called; a dry run makes the enumeration falsifiable
tonight and leaves only the audio for the morning.
"""
sys.path.insert(0, str(TOOLS))
from at import lens as _lens
from pvlint.core import Track as PvTrack
track = PvTrack(path=str(path), text=path.read_text())
controls = _lens.controls(track)
rows = []
for c in controls:
assert c.cc not in _lens.FORBIDDEN_CC, \
f"lens returned Ardour-owned CC{c.cc} — that must be impossible"
rows.append({"cc": c.cc, "kind": c.kind, "lens": c.lens or None,
"orbits": c.orbits, "rest": c.rest, "probe": c.probes[0],
"bipolar": c.bipolar, "measurable": c.measurable,
"evidence": c.evidence})
if as_json:
print(json.dumps({"status": "plan", "track": str(path), "controls": rows},
indent=2, default=str))
return 0
print(f"pv-at --track {path.name} --dry-run (no audio, no MIDI sent)")
print(f" orbits declared: {sorted(track.declared_orbits())}")
print(f" {'cc':>4} {'kind':<9} {'lens':<11} {'rest':>4} {'probe':>5} orbits")
for r in rows:
mark = " " if r["measurable"] else "!"
print(f" {mark}{r['cc']:>4} {r['kind']:<9} {(r['lens'] or 'UNMEASURABLE'):<11} "
f"{r['rest']:>4} {r['probe']:>5} "
f"{','.join(f'd{o}' for o in r['orbits']) or '?'}")
meas = [r for r in rows if r["measurable"]]
bip = [r for r in rows if r["bipolar"]]
print(f"\n {len(rows)} controls, {len(meas)} measurable, "
f"{len(rows)-len(meas)} unmeasurable (marked !)")
print(f" {len(bip)} bipolar (rest at CENTRE 64, not 0): "
f"{[r['cc'] for r in bip] or 'none'}")
lenses: dict[str, int] = {}
for r in meas:
lenses[r["lens"]] = lenses.get(r["lens"], 0) + 1
print(" lens mix: " + ", ".join(f"{k}x{v}" for k, v in sorted(lenses.items())))
print(f" no Ardour-owned CC in the plan (77-84 excluded by construction) ✓")
return 0
def cmd_track(path: Path, seconds: float, as_json: bool, keep: bool) -> int:
sys.path.insert(0, str(TOOLS))
from at import lens as _lens
from pvlint.core import Track as PvTrack
track = PvTrack(path=str(path), text=path.read_text())
controls = _lens.controls(track)
if not controls:
msg = f"pv-at: {path.name} binds no controls this tool can see"
print(json.dumps({"status": "no_controls", "track": str(path)})
if as_json else msg, file=sys.stderr)
return 1
port = None
try:
port = lcxl.sc_midi_input_port()
except Exception:
port = None
if not port:
why = "no LCXL sequencer port — cannot send virtual CC"
print(json.dumps({"status": "rig_not_ready", "why": why})
if as_json else f"pv-at: rig not ready — {why}", file=sys.stderr)
return 2
if not as_json:
n_meas = sum(1 for c in controls if c.measurable)
print(f"pv-at --track {path.name}: {len(controls)} controls "
f"({n_meas} measurable), orbits {sorted(track.declared_orbits())}")
print(f" each control: rest -> 2 baselines -> probe -> restore, "
f"{seconds:g}s per capture")
print(f" ~{len(controls) * (3 * seconds + 2 * CONTROL_SETTLE) / 60:.0f} min "
f"total. Ctrl-C is safe (controls are restored per control, not at exit).\n")
play(path, settle=TRACK_SETTLE)
verdicts = [_probe_control(c, seconds, port) for c in controls]
if not keep:
hush()
if as_json:
print(json.dumps({"status": "ran", "track": str(path),
"results": [vars(v) for v in verdicts]}, default=str))
else:
order = {"NO_IMPACT": 0, "NO_SIGNAL": 1, "INCONCLUSIVE": 2,
"MOVED": 3, "UNMEASURABLE": 4}
for v in sorted(verdicts, key=lambda v: (order.get(v.outcome, 9), v.cc)):
print(f" [{v.outcome:<13s}] CC{v.cc:<3d} {v.kind:<9s} {v.detail}")
moved = sum(1 for v in verdicts if v.outcome == "MOVED")
unmeas = sum(1 for v in verdicts if v.outcome == "UNMEASURABLE")
incon = sum(1 for v in verdicts if v.outcome == "INCONCLUSIVE")
dead = [v for v in verdicts if v.outcome in ("NO_IMPACT", "NO_SIGNAL")]
print(f"\npv-at: {moved} moved, {len(dead)} dead, {incon} inconclusive, "
f"{unmeas} unmeasurable")
if incon:
print(" INCONCLUSIVE is not a pass. Re-run with a longer --seconds; if it "
"stays inconclusive the control's effect is smaller than the "
"pattern's own variation, which is worth knowing too.")
if dead:
print(" DEAD controls are the ones to fix before the gig:")
for v in dead:
print(f" CC{v.cc} on {','.join(f'd{o}' for o in v.orbits)}")
return 0 if all(v.passed for v in verdicts) else 1
# --------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="pv-at", description=__doc__.split("\n")[0])
......@@ -288,8 +530,28 @@ def main(argv: list[str] | None = None) -> int:
help="skip the case that sends virtual CC")
ap.add_argument("--keep-playing", action="store_true",
help="do not hush at the end")
ap.add_argument("--track", type=Path,
help="probe EVERY control this .tidal binds (#74), using the "
"per-control lens from at/lens.py")
ap.add_argument("--dry-run", action="store_true",
help="with --track: print the probe plan and exit, no audio")
args = ap.parse_args(argv)
if args.track:
path = args.track if args.track.exists() else TOOLS.parent / args.track
if not path.exists():
print(f"pv-at: no such track {args.track}", file=sys.stderr)
return 2
if args.dry_run:
return cmd_track_plan(path, args.json)
rig = rig_state()
if not rig.ready:
print(json.dumps({"status": "rig_not_ready", "why": rig.why_not()})
if args.json else f"pv-at: rig not ready — {rig.why_not()}",
file=sys.stderr)
return 2
return cmd_track(path, args.seconds, args.json, args.keep_playing)
if args.list:
for n, (doc, _) in CASES.items():
print(f" {n:22s} {doc}")
......
......@@ -143,6 +143,13 @@ GATES = ("midiOn", "midiOff", "midiG", "novaOn", "novaOff", "range",
DJ_FILTERS = frozenset({49, 50, 51})
# CC93 is the PANIC gate (gPanic reads "^93"; the SC bridge sets it from the
# 73+74+91+92 chord). Sending it high silences every gPanic'd stream. It is a global
# kill switch, not a musical control, so an acceptance test must never sweep it —
# "probe every control" plus "one control is a kill switch" is how a self-test
# silences a rehearsal and gets blamed on the rig. Reported, never probed.
NEVER_PROBE = frozenset({93})
@dataclass
class Control:
......@@ -186,6 +193,30 @@ class Control:
f"on {orb}")
# An ALL-FALSE mask is a MUTE, not a density gate, and the difference decides the
# lens: `mask "f*16"` silences the stream (measure rms), `mask "t!7 f"` drops one
# event in eight (measure onsets). Same keyword, opposite measurements — the
# distinction lives in the mask PATTERN, not the function name.
#
# This was found by running the plan against gimme_acid: gMute1/2/3 are three
# identical `mask "f*16"` helpers and they came back classified as mute, density,
# density. rms would have been the wrong lens on two of PLN's three mutes.
MUTE_MASK = re.compile(r'\bmask\s+\(?\s*"([^"]*)"')
# Repeat/multiplier suffixes carry DIGITS that are not values: `f*16` is sixteen
# falses, and a naive "does it contain a 1" test reads that 16 as a true and calls a
# full mute a density gate. Strip the suffixes before looking at the values.
MASK_SUFFIX = re.compile(r"[*!@/]\s*[\d.]+")
def _is_mute_mask(text: str) -> bool:
"""True when a `mask` argument is all-false, i.e. the stream is silenced."""
for body in MUTE_MASK.findall(text):
values = MASK_SUFFIX.sub("", body)
if "t" not in values and "1" not in values:
return True
return False
def _effect_in(text: str) -> tuple[str, str, str] | None:
"""First effect keyword in a line, as (keyword, kind, lens).
......@@ -194,6 +225,8 @@ def _effect_in(text: str) -> tuple[str, str, str] | None:
order alone would have handed every such line to whichever effect happened to sit
higher in the list.
"""
if _is_mute_mask(text):
return ("mask(all-false)", "mute", "rms")
best = None
for kw, kind, lens in EFFECT_LENS:
for m in re.finditer(rf"\b{re.escape(kw)}\b", text):
......@@ -240,6 +273,115 @@ def _scan(text: str) -> dict[int, tuple[str, str, str]]:
return out
# --------------------------------------------------------------------------- #
# BootTidal's own g* helpers
# --------------------------------------------------------------------------- #
# The reason this exists: gimme_acid applies gF1/gF2/gF3 on SIX orbits, and the
# first per-track plan reported "0 bipolar, no CC 49/50/51 at all". Those helpers
# are defined in BootTidal.hs, not in the track, so a scan of the .tidal file alone
# is blind to the three DJ filters — the controls PLN reaches for most. Same for
# gM1/gM2/gM3, which is every mute in the rig.
#
# PARSED, never copied (parsers-over-copy): BootTidal is the source of truth and it
# moves — gF1 was rewired onto gDJF, and midiGGlobal was retired on 2026-07-29. A
# hardcoded table would rot into a confidently wrong report, which is worse than no
# report. If BootTidal.hs is missing the functions below return {} and per-track
# analysis degrades to what the file itself says, rather than failing.
BOOTTIDAL = Path(__file__).resolve().parents[2] / "BootTidal.hs"
# ` gF1 = gDJF "^49"` / ` gM1 = gMask . gMute1`. A definition WITH a parameter
# (`gDJF ch = ...`) is deliberately excluded here: it has no CC of its own, its
# caller supplies one. Those are collected separately, as lens providers.
BOOT_DEF_RE = re.compile(r"^\s+(g[A-Za-z0-9_']*)\s*=\s*(\S.*)$")
BOOT_FUNC_RE = re.compile(r"^\s+(g[A-Za-z0-9_']*)\s+([a-z][A-Za-z0-9_']*)\s*=\s*(\S.*)$")
_BOOT_CACHE: dict[str, dict[str, dict[int, tuple[str, str, str]]]] = {}
def boot_helpers(path: Path | None = None) -> dict[str, dict[int, tuple[str, str, str]]]:
"""helper name -> {cc: (kind, lens, evidence)} for every g* in BootTidal.hs.
Resolves composition (`gM1 = gMask . gMute1` inherits both CCs) and borrows the
lens from a parameterised helper when one is applied (`gF1 = gDJF "^49"` gets
filter/centroid from gDJF's lpf+hpf body, since "gDJF" itself is not a keyword
any table would know).
"""
p = path or BOOTTIDAL
key = str(p)
if key in _BOOT_CACHE:
return _BOOT_CACHE[key]
out: dict[str, dict[int, tuple[str, str, str]]] = {}
try:
text = p.read_text()
except OSError:
_BOOT_CACHE[key] = out
return out
funcs: dict[str, str] = {} # parameterised: name -> body (lens provider)
defs: dict[str, str] = {} # zero-arg: name -> rhs
# A continuation must LOOK like one. Appending every indented line to whatever was
# defined last made `gM3 = gMask . gMute3` swallow the unrelated Launchpad block
# that follows it and report CC 7 and CC 9 as mutes. Real Haskell continuations
# open with an operator.
cont = re.compile(r"^\s+[.#$(),+|]")
last: tuple[dict, str] | None = None
for raw in text.splitlines():
code = strip_comment(raw)
if not code.strip():
last = None # a blank line ends any continuation
continue
mf = BOOT_FUNC_RE.match(code)
if mf:
funcs[mf.group(1)] = funcs.get(mf.group(1), "") + " " + mf.group(3)
last = (funcs, mf.group(1))
continue
md = BOOT_DEF_RE.match(code)
if md:
defs[md.group(1)] = md.group(2)
last = (defs, md.group(1))
continue
if last is not None and cont.match(code):
# gDJF's hpf line sits on its own line and carries half the lens evidence,
# so continuations have to be folded in — into the RIGHT definition.
store, name = last
store[name] += " " + code.strip()
else:
last = None
def classify(name: str, rhs: str) -> tuple[str, str, str] | None:
text_for_lens = rhs
for fname, fbody in funcs.items():
if re.search(rf"\b{re.escape(fname)}\b", rhs):
text_for_lens = f"{fbody} {rhs}"
break
return _effect_in(text_for_lens)
def ccs_of(name: str, depth: int = 0) -> dict[int, tuple[str, str, str]]:
if depth > 6 or name not in defs:
return {}
rhs = defs[name]
hit = classify(name, rhs)
got: dict[int, tuple[str, str, str]] = {}
for c in CC_RE.findall(rhs):
cc = int(c)
if cc in FORBIDDEN_CC:
continue
got[cc] = ((hit[1], hit[2], f"BootTidal {name} = {rhs.strip()[:70]}")
if hit else ("unknown", "", f"BootTidal {name}"))
for other in defs:
if other != name and re.search(rf"\b{re.escape(other)}\b", rhs):
for cc, meta in ccs_of(other, depth + 1).items():
got.setdefault(cc, meta)
return got
for name in defs:
got = ccs_of(name)
if got:
out[name] = got
_BOOT_CACHE[key] = out
return out
def controls(track: Track) -> list[Control]:
"""Every CC this track binds, with the lens that can see it and the orbits it hits.
......@@ -248,7 +390,9 @@ def controls(track: Track) -> list[Control]:
a helper's CC only to the line that defines it would say "CC51 affects no orbits",
which is exactly backwards -- in you_my_sunshine that one helper owned FOUR.
"""
helpers: dict[str, dict[int, tuple[str, str, str]]] = {}
# BootTidal's helpers first, so a track's own `let` of the same name SHADOWS
# them — which is what Tidal does, and several tracks redefine gMute locally.
helpers: dict[str, dict[int, tuple[str, str, str]]] = dict(boot_helpers())
for line in track.lines:
code = strip_comment(line)
m = LET_RE.match(code)
......
......@@ -193,6 +193,139 @@ def test_lens_value_reads_every_declared_lens():
assert name in lens.LENS_UNITS
# --------------------------------------------------------------- BootTidal helpers
# These exist because the first --track plan on gimme_acid reported "0 bipolar, no
# CC 49/50/51 at all" — on a track that applies gF1/gF2/gF3 to SIX orbits. The
# helpers live in BootTidal.hs, so scanning only the .tidal file was blind to the
# three DJ filters and to every mute in the rig.
def test_boot_helpers_finds_the_three_dj_filters():
h = lens.boot_helpers()
assert h["gF1"] == {49: ("filter", "centroid", h["gF1"][49][2])}
assert set(h["gF2"]) == {50}
assert set(h["gF3"]) == {51}
def test_the_dj_filter_lens_is_borrowed_from_the_parameterised_helper():
"""`gF1 = gDJF "^49"` carries no effect keyword of its own — "gDJF" is not in any
table. The lens has to come from gDJF's body (lpf + hpf), which sits on two
separate lines."""
assert lens.boot_helpers()["gF1"][49][1] == "centroid"
def test_boot_helpers_resolves_composition():
"""`gM1 = gMask . gMute1` inherits BOTH — the mask on CC41 and the mute on CC73."""
assert set(lens.boot_helpers()["gM1"]) == {41, 73}
assert set(lens.boot_helpers()["gM2"]) == {41, 74}
assert set(lens.boot_helpers()["gM3"]) == {41, 75}
def test_the_mutes_are_measured_by_LEVEL_and_the_mask_by_DENSITY():
"""Same keyword, opposite lenses. gMask is `mask "t!7 f"` (drops one event in
eight -> onsets); gMute is `mask "f*16"` (silences the stream -> rms)."""
h = lens.boot_helpers()
assert h["gMask"][41][1] == "onsets"
for n, cc in ((1, 73), (2, 74), (3, 75)):
assert h[f"gMute{n}"][cc][0] == "mute"
assert h[f"gMute{n}"][cc][1] == "rms"
def test_all_three_mutes_are_classified_IDENTICALLY():
"""The bug this caught: gMute1/2/3 are three textually identical helpers and they
came back mute, density, density. rms would have been the wrong lens on two of
PLN's three mutes, so two working mutes would have reported NO_IMPACT."""
h = lens.boot_helpers()
kinds = {(h[f"gMute{n}"][cc][0], h[f"gMute{n}"][cc][1])
for n, cc in ((1, 73), (2, 74), (3, 75))}
assert len(kinds) == 1
@pytest.mark.parametrize("body,is_mute", [
("f*16", True), # the regression: "16" contains a 1, read as a true value
("f*8", True),
("f", True),
("f f f f", True),
("f(4,32)", True), # all-false euclid still masks everything out
("0*16", True),
("t!7 f", False), # a density gate: seven pass, one drops
("t f", False),
("1 0 1 0", False),
("t*16", False),
])
def test_an_all_false_mask_is_a_mute_and_anything_else_is_density(body, is_mute):
assert lens._is_mute_mask(f'mask "{body}"') is is_mute
def test_a_continuation_line_cannot_leak_into_the_wrong_helper():
"""`gM3 = gMask . gMute3` is followed by the Launchpad block, and appending every
indented line to whatever was defined last made gM3 report CC 7 and CC 9 as
mutes. A continuation has to LOOK like one (open with an operator)."""
assert set(lens.boot_helpers()["gM3"]) == {41, 75}
assert 7 not in lens.boot_helpers()["gM3"]
def test_gpanic_is_found_but_marked_never_to_probe():
"""CC93 arms panic — it silences every gPanic'd stream. Reported, never swept:
"probe every control" plus "one control is a kill switch" is how a self-test
mutes a rehearsal and gets blamed on the rig."""
assert 93 in lens.boot_helpers()["gPanic"]
assert 93 in lens.NEVER_PROBE
def test_boot_helpers_never_returns_an_ardour_owned_cc():
for name, ccs in lens.boot_helpers().items():
assert not (set(ccs) & lens.FORBIDDEN_CC), name
def test_boot_helpers_degrades_to_empty_when_boottidal_is_missing(tmp_path):
"""Analysis must fall back to what the .tidal file itself says, not explode."""
assert lens.boot_helpers(tmp_path / "nope.hs") == {}
def test_a_track_using_gF1_gets_cc49_attributed_to_its_orbits():
"""The integration that the unit tests above cannot prove: the boot helper has to
reach `controls()` and land on the ORBITS that apply it."""
from pvlint.core import load
t = load(str(TOOLS.parent / "live/midi/nova/acid/gimme_acid.tidal"))
by_cc = {c.cc: c for c in lens.controls(t)}
assert 49 in by_cc and by_cc[49].orbits, "gF1's CC49 reached no orbit"
assert by_cc[49].kind == "filter"
assert by_cc[49].bipolar and by_cc[49].rest == 64
assert by_cc[49].probes[0] == 10
# and the mutes land on their orbits too
assert by_cc[75].orbits and by_cc[75].lens == "rms"
def test_a_local_let_shadows_the_boot_helper_of_the_same_name(tmp_path):
"""Tidal semantics: a track's own `let gMute = ...` wins. Several tracks redefine
these, and reporting the boot version would describe code that is not running."""
from pvlint.core import Track as T
t = T(path="x.tidal", text='let gMute1 = (midiOn "^60" (ply 4))\n\n'
'd1 $ gMute1 $ s "bd"\n')
by_cc = {c.cc: c for c in lens.controls(t)}
assert 60 in by_cc
assert by_cc[60].orbits == [1]
def test_every_opal_track_resolves_all_three_dj_filters():
"""The guard that would have caught the original miss. Every track in the set
applies gF1/gF2/gF3 somewhere, so all three must appear with orbits attached."""
import re as _re
from pvlint.core import load
setlist = TOOLS.parent / "armada" / "setlist_opal2026.txt"
rows = [ln.split("#")[0].strip() for ln in setlist.read_text().splitlines()]
tracks = [r for r in rows if r]
assert len(tracks) == 13
for rel in tracks:
t = load(str(TOOLS.parent / rel))
by_cc = {c.cc: c for c in lens.controls(t)}
text = (TOOLS.parent / rel).read_text()
for cc, helper in ((49, "gF1"), (50, "gF2"), (51, "gF3")):
if _re.search(rf"\b{helper}\b", text):
assert cc in by_cc, f"{rel}: {helper} applied but CC{cc} not found"
assert by_cc[cc].orbits, f"{rel}: CC{cc} attributed to no orbit"
# ------------------------------------------------------- corpus coverage
def test_the_classifier_covers_the_real_corpus():
......
"""Tests for pv-at --track, the per-track control acceptance runner (#74).
PLN's ask: "all controls have impact on sound... even the crushes should change
noticeably". The runner answers that per control, and the answers it gives are only
worth anything if the JUDGEMENT is right — so this file tests the judgement (three
outcomes, drift gating, restore-to-rest, the kill-switch guard) without needing
audio. The audio half can only be validated with monitors up.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
TOOLS = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(TOOLS))
def _load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
m = importlib.util.module_from_spec(spec)
sys.modules[name] = m
spec.loader.exec_module(m)
return m
from at import lens # noqa: E402
at_main = _load("at_main", TOOLS / "at" / "__main__.py")
# --------------------------------------------------------------------------- #
# the three outcomes
# --------------------------------------------------------------------------- #
def _v(outcome: str, **kw):
return at_main.ControlVerdict(cc=kw.pop("cc", 49), kind=kw.pop("kind", "filter"),
lens=kw.pop("lens", "centroid"),
orbits=kw.pop("orbits", [1]),
outcome=outcome, **kw)
def test_moved_passes():
assert _v("MOVED").passed
def test_no_impact_fails():
"""A control that does nothing is the whole point of the test."""
assert not _v("NO_IMPACT").passed
def test_inconclusive_is_NOT_a_pass():
"""Two outcomes would force a lie. A swing that does not beat the pattern's own
drift is genuinely unknown, and calling it a pass is how a green suite comes to
mean nothing."""
assert not _v("INCONCLUSIVE").passed
def test_no_signal_fails_and_is_distinct_from_no_impact():
""""nothing was playing" and "the control is dead" have different fixes, and
reporting them the same way sent a whole evening chasing SuperDirt once."""
assert not _v("NO_SIGNAL").passed
assert _v("NO_SIGNAL").outcome != _v("NO_IMPACT").outcome
def test_unmeasurable_passes_because_it_is_not_a_defect():
"""pan and orbit cannot be seen by a mono level/spectrum probe. Reporting them as
broken every run would train PLN to ignore the report."""
assert _v("UNMEASURABLE", kind="pan", lens="").passed
# --------------------------------------------------------------------------- #
# the drift gate — a swing only counts if it beats the signal's own variation
# --------------------------------------------------------------------------- #
def test_every_lens_has_a_floor():
"""2 * max(drift, 0) is zero, so a lens whose drift happens to be ~0 on a quiet
capture would make ANY tiny swing look significant."""
for _kw, _kind, name in lens.EFFECT_LENS:
if name:
assert at_main._lens_floor(name) > 0, name
def test_the_floor_is_scaled_to_the_lens_unit():
"""A 1 dB rms change and a 1 Hz centroid change are not comparable quantities;
one floor for both would be meaningless in at least one of them."""
assert at_main._lens_floor("centroid") > at_main._lens_floor("rms")
assert at_main._lens_floor("low_ratio") < 1.0
def test_an_unknown_lens_gets_no_floor_rather_than_a_guessed_one():
assert at_main._lens_floor("something-new") == 0.0
# --------------------------------------------------------------------------- #
# safety: what may and may not be sent
# --------------------------------------------------------------------------- #
def test_the_panic_gate_is_never_swept():
"""CC93 arms panic and silences every gPanic'd stream. Probing it would mute the
rehearsal it is meant to validate."""
assert 93 in lens.NEVER_PROBE
ctl = lens.Control(cc=93, orbits=[1], kind="level", lens="rms")
v = at_main._probe_control(ctl, seconds=1.0, port="0:0")
assert v.outcome == "UNMEASURABLE"
assert "kill switch" in v.detail
def test_an_unmeasurable_control_is_reported_without_sending_any_midi():
"""No port is touched, so this is safe to assert on with no rig at all."""
ctl = lens.Control(cc=8, orbits=[1], kind="pan", lens="")
v = at_main._probe_control(ctl, seconds=1.0, port="0:0")
assert v.outcome == "UNMEASURABLE"
def test_a_control_with_no_orbit_is_inconclusive_not_probed():
ctl = lens.Control(cc=60, orbits=[], kind="crush", lens="centroid")
v = at_main._probe_control(ctl, seconds=1.0, port="0:0")
assert v.outcome == "INCONCLUSIVE"
def test_the_ardour_fader_bank_can_never_reach_the_probe():
"""Excluded by construction in lens, and asserted again at the probe. CC77 down
is total silence, so this is the one place belt AND braces is proportionate."""
for cc in range(77, 85):
assert cc in lens.FORBIDDEN_CC
ctl = lens.Control(cc=cc, orbits=[1], kind="level", lens="rms")
with pytest.raises(AssertionError):
at_main._probe_control(ctl, seconds=1.0, port="0:0")
def test_lens_never_returns_a_forbidden_cc_from_a_real_track():
from pvlint.core import load
t = load(str(TOOLS.parent / "live/midi/nova/acid/gimme_acid.tidal"))
assert not ({c.cc for c in lens.controls(t)} & lens.FORBIDDEN_CC)
# --------------------------------------------------------------------------- #
# rest values — the #48 footgun
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("cc", sorted(lens.DJ_FILTERS))
def test_a_dj_filter_rests_at_centre_and_probes_downward(cc):
"""djf 0.05 is a ~26 Hz low-pass, i.e. silence. Parking a DJF at 0 to establish a
"baseline" measures silence and then calls the track broken."""
c = lens.Control(cc=cc, orbits=[1], kind="filter", lens="centroid")
assert c.bipolar
assert c.rest == 64
assert c.probes[0] == 10 and 118 in c.probes
def test_a_unipolar_control_rests_at_zero():
c = lens.Control(cc=53, orbits=[1], kind="crush", lens="centroid")
assert not c.bipolar
assert c.rest == 0
assert c.probes[0] == 127
# --------------------------------------------------------------------------- #
# the dry run — the half that IS checkable without monitors
# --------------------------------------------------------------------------- #
OPAL = TOOLS.parent / "armada" / "setlist_opal2026.txt"
def _set_tracks():
return [ln.split("#")[0].strip() for ln in OPAL.read_text().splitlines()
if ln.split("#")[0].strip()]
@pytest.mark.parametrize("rel", _set_tracks())
def test_the_probe_plan_builds_for_every_track_in_the_set(rel, capsys):
""""Built but never run" is how the LED work shipped a colour ramp that was never
called. The plan is falsifiable with no audio, so it gets asserted."""
assert at_main.cmd_track_plan(TOOLS.parent / rel) == 0
out = capsys.readouterr().out
assert "no Ardour-owned CC in the plan" in out
assert "bipolar (rest at CENTRE 64" in out
@pytest.mark.parametrize("rel", _set_tracks())
def test_every_track_in_the_set_finds_its_dj_filters_as_bipolar(rel):
from pvlint.core import load
controls = lens.controls(load(str(TOOLS.parent / rel)))
bip = [c for c in controls if c.bipolar]
assert bip, f"{rel}: no bipolar control found — gF1/2/3 went missing"
assert all(c.rest == 64 for c in bip)
@pytest.mark.parametrize("rel", _set_tracks())
def test_every_track_binds_a_workable_number_of_controls(rel):
"""A track reporting 0 controls means the parse failed, not that the track is
simple. Measured range across the set on 2026-07-29: 15-29."""
from pvlint.core import load
n = len(lens.controls(load(str(TOOLS.parent / rel))))
assert 5 <= n <= 60, f"{rel}: {n} controls — the parse probably broke"
def test_the_plan_json_is_machine_readable(capsys):
import json
assert at_main.cmd_track_plan(
TOOLS.parent / "live/midi/nova/acid/gimme_acid.tidal", as_json=True) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["status"] == "plan"
ccs = {c["cc"] for c in payload["controls"]}
assert {49, 50, 51} <= ccs
for c in payload["controls"]:
assert c["cc"] not in lens.FORBIDDEN_CC
assert c["rest"] in (0, 64)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment