Commit 6a94b47b by PLN (Algolia)

check-mix: audit the record button, and let the gate say NO-GO on it

Twelve tools audit this rig's faders, mutes, routing, ghosts, preload,
samples and thermals. None audited arm state, and on 2026-09-24 Tidal 08
sat at rec-enable=0 for the whole set — alone among the twelve — so Ardour
never created the file. Eleven stems, and that orbit was in all fifteen
tracks' control maps.

Arm lives in the same mixer, in the same XML, under the same verb, so it
goes in check-mix rather than a new screw. Only the Tidal NN routes block:
Master has no rec-enable at all, which is how Ardour says 'bus' — reported
as n/a, because sending PLN hunting a button that does not exist is worse
than saying nothing. Mic and Keys are reported, never gated.

A new 'ardour armed' probe carries its own fix (click record + Ctrl+S, not
raise + Ctrl+S), and 'ardour faders' now passes --no-arm so neither probe
reports the other's failure. Run against the session as it stands, the gate
says NO-GO and names Tidal 08.

The disk caveat cuts both ways and the docstring says which: this proves
the session BOOTS armed. It is exactly right for the Tidal 08 case, where
saved and live agreed all night. Nothing here reads Ardour's OSC surface —
that surface reported arm:false for all 76 minutes it was recording.
parent ce663177
...@@ -8,12 +8,12 @@ done 20 days ago. second best time is now"*. ...@@ -8,12 +8,12 @@ done 20 days ago. second best time is now"*.
Each line pays off a `TODO_GIG` "NOT TONIGHT" entry or a loss measured in Each line pays off a `TODO_GIG` "NOT TONIGHT" entry or a loss measured in
`armada/tide-table/take103_2026-09-24_recovered_set.md`. `armada/tide-table/take103_2026-09-24_recovered_set.md`.
- [ ] **1. Latch double-press.** `lap_reset` clears the driver's latch and sends - [x] **1. Latch double-press.** `lap_reset` clears the driver's latch and sends
no CC, so Tidal's bus keeps 127 while the driver believes 0 — the next no CC, so Tidal's bus keeps 127 while the driver believes 0 — the next
press re-sends 127 into a bus already at 127 and reads as dead. 37 of press re-sends 127 into a bus already at 127 and reads as dead. 37 of
these across 14 of 15 track changes on the 24th. Fix is a deletion: carry these across 14 of 15 track changes on the 24th. Fix is a deletion: carry
the latch. (`TODO_GIG`: "Latch double-press on the mute path") the latch. (`TODO_GIG`: "Latch double-press on the mute path")
- [ ] **2. Nothing checks record-arm.** `Tidal 08` sat at `rec-enable=0` all - [x] **2. Nothing checks record-arm.** `Tidal 08` sat at `rec-enable=0` all
night; twelve tools audit faders, mutes, routing, ghosts, preload — none night; twelve tools audit faders, mutes, routing, ghosts, preload — none
audit arm. Extend `check-mix.py`, add a `check-gig.py` probe. audit arm. Extend `check-mix.py`, add a `check-gig.py` probe.
- [ ] **3. gig-log loses its MIDI address.** Bound seq port `133:0` at 00:59, - [ ] **3. gig-log loses its MIDI address.** Bound seq port `133:0` at 00:59,
......
...@@ -138,10 +138,26 @@ PROBES: tuple[Probe, ...] = ( ...@@ -138,10 +138,26 @@ PROBES: tuple[Probe, ...] = (
fix="tools/fader-baseline.py --restore (close Ardour first) — " fix="tools/fader-baseline.py --restore (close Ardour first) — "
"or --capture if these levels ARE the new intent"), "or --capture if these levels ARE the new intent"),
Probe("ardour faders", [PY, "tools/check-mix.py", "--quiet"], Probe("ardour faders", [PY, "tools/check-mix.py", "--quiet", "--no-arm"],
fix="raise it on the desk, then Ctrl+S in Ardour, then re-run. " fix="raise it on the desk, then Ctrl+S in Ardour, then re-run. "
"Still -inf after a save = genuinely down."), "Still -inf after a save = genuinely down."),
# A fader that is down loses a sound you can hear going missing. An UNARMED
# track loses a stem you find out about the next day, and by then the gig is
# over. 2026-09-24: `Tidal 08` sat at rec-enable=0 for the whole set, alone
# among the twelve, so Ardour never created the file — eleven stems, and the
# orbit was in all fifteen tracks' control maps. Twelve tools audited this
# rig and none audited the record button.
#
# Separate probe from "ardour faders" on purpose, even though it reads the
# same XML: the FIX is different (click record + Ctrl+S, not raise + Ctrl+S)
# and design rule 2 says a probe is a question plus the fix for its answer.
# Both now pass --no-arm / --arm so neither reports the other's failure.
Probe("ardour armed", [PY, "tools/check-mix.py", "--arm", "--quiet"],
fix="click record on the named track in Ardour, then Ctrl+S. "
"Master is a BUS and cannot be armed — for the mix itself the "
"net is tools/gig_record.sh, or a Mix track fed from Master."),
# Added after a dropout NOTHING else in the gate could have caught: every # Added after a dropout NOTHING else in the gate could have caught: every
# component was healthy and the fault was in the graph around them. It also # component was healthy and the fault was in the graph around them. It also
# catches the laptop microphone that had wired itself into the d1 stem — # catches the laptop microphone that had wired itself into the d1 stem —
......
...@@ -34,19 +34,53 @@ fader was the only downstream thing still unverified. ...@@ -34,19 +34,53 @@ fader was the only downstream thing still unverified.
The lesson is the rig's recurring one, one layer further out than usual: the The lesson is the rig's recurring one, one layer further out than usual: the
signal path does not end at the code you can read. Check the mixer. signal path does not end at the code you can read. Check the mixer.
The second question, added 2026-09-25: IS IT ARMED?
---------------------------------------------------
The 2026-09-24 set recorded eleven orbits. `Tidal 08` carried
`<Controllable name="rec-enable" value="0"/>` — alone among the twelve — so
Ardour never created the file. Not a silent stem, an absent one, and the LCXL
control map listed orbit 8 in all fifteen tracks of the night.
Twelve tools audit this rig's faders, mutes, routing, ghosts, preload, samples
and thermals. None audited the record button. It is the same shape of miss as
the fader one above — a downstream toggle that no layer of the livecoding stack
can see, and whose failure is silent until you look for the file the next day.
Arm state lives in the same mixer, is read from the same XML, and belongs to
the same verb (Check Mix), so it lives here rather than in a new screw.
`Master` is reported and never blocking: it is a BUS, with no playlist and no
`rec-enable` at all, so Ardour cannot arm it (the out-of-Ardour net is
`tools/gig_record.sh`). `Mic` and `Keys` are reported too — theirs is a taste
call, not a fault.
Usage Usage
----- -----
tools/check-mix.py # audit, human-readable tools/check-mix.py # audit, human-readable
tools/check-mix.py --quiet # only problems (for gig-up.sh) tools/check-mix.py --quiet # only problems (for gig-up.sh)
tools/check-mix.py --arm # arm state only (the gate's probe)
tools/check-mix.py --no-arm # the pre-2026-09-25 fader-only audit
Exit codes: 0 = every orbit can reach the master; 1 = at least one is Exit codes: 0 = every orbit can reach the master AND is armed; 1 = at least one
silent-by-configuration; 2 = could not read the session. is silent-by-configuration or unarmed; 2 = could not read the session.
CAVEAT, stated plainly: this reads the session file ON DISK. Ardour only writes CAVEAT, stated plainly: this reads the session file ON DISK. Ardour only writes
it on save, so a running session that has been touched since its last save can it on save, so a running session that has been touched since its last save can
differ. Treat a clean report as "the session boots healthy", not "the running differ. Treat a clean report as "the session boots healthy", not "the running
mixer is healthy" — and save the session before trusting it. Verifying the LIVE mixer is healthy" — and save the session before trusting it.
mixer needs Ardour's OSC surface, which is not wired up yet.
That caveat cuts BOTH ways on arm state, and the direction matters:
* it is exactly right for the Tidal 08 case — the route was saved unarmed and
was never armed all night, so the saved state and the live state agreed,
and this check would have caught it before a note was played.
* a route armed in the GUI and not saved still reads 0 here, and the reverse
reads 1. So a PASS means "this session boots armed", not "Ardour is armed
right now". Press Ctrl+S, then believe it.
Verifying the LIVE mixer needs Ardour's OSC surface, and on 2026-09-24 that
surface reported `arm:false roll:false` for all 76 minutes it was actually
recording (every gig-log `rec` record carries `conflict: true`). Until that is
diagnosed, nothing here reads OSC.
""" """
from __future__ import annotations from __future__ import annotations
...@@ -117,6 +151,37 @@ def fader_of(route: ET.Element) -> float | None: ...@@ -117,6 +151,37 @@ def fader_of(route: ET.Element) -> float | None:
return None return None
def controllable(route: ET.Element, name: str) -> str | None:
"""A named `<Controllable>`'s `value`, anywhere under this route.
Ardour nests them (Route > Controllable, and inside processors), and the
attribute order is not stable across versions — so match on `name`, never
on position. Absent means the route has no such control at all, which for
`rec-enable` is how a BUS is told apart from a TRACK.
"""
for c in route.iter("Controllable"):
if c.get("name") == name:
return c.get("value")
return None
def arm_of(route: ET.Element) -> tuple[bool | None, bool]:
"""(armed, rec_safe). `armed is None` means this route cannot be armed.
A track has both a playlist and a `rec-enable`; a bus has neither. Ardour
writes the linear `value` as "0"/"1", so it is compared as a string-to-float
rather than trusting a bare truthiness test on "0".
"""
raw = controllable(route, "rec-enable")
if raw is None:
return None, False
safe = controllable(route, "rec-safe")
try:
return float(raw) > 0.5, (safe is not None and float(safe) > 0.5)
except ValueError:
return None, False
def reaches_master(route: ET.Element) -> bool: def reaches_master(route: ET.Element) -> bool:
for io in route.findall("IO"): for io in route.findall("IO"):
if io.get("direction") != "Output": if io.get("direction") != "Output":
...@@ -132,7 +197,13 @@ def main() -> int: ...@@ -132,7 +197,13 @@ def main() -> int:
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("session", nargs="?", default=str(SESSION)) ap.add_argument("session", nargs="?", default=str(SESSION))
ap.add_argument("--quiet", action="store_true", help="print only problems") ap.add_argument("--quiet", action="store_true", help="print only problems")
ap.add_argument("--arm", action="store_true",
help="arm state only — every Tidal NN must be record-armed")
ap.add_argument("--no-arm", action="store_true",
help="skip the arm audit (the pre-2026-09-25 behaviour)")
args = ap.parse_args() args = ap.parse_args()
do_gain = not args.arm
do_arm = not args.no_arm
path = Path(args.session) path = Path(args.session)
if not path.exists(): if not path.exists():
...@@ -147,8 +218,23 @@ def main() -> int: ...@@ -147,8 +218,23 @@ def main() -> int:
rows, problems = [], [] rows, problems = [], []
master_ok = True master_ok = True
arm_rows, unarmed = [], []
for route in root.iter("Route"): for route in root.iter("Route"):
name = route.get("name") or "" name = route.get("name") or ""
if do_arm and (name.startswith("Tidal") or name in ("Master", "Mic", "Keys")):
armed, safe = arm_of(route)
arm_rows.append((name, armed, safe))
# Only the orbit stems are blocking. Master cannot be armed at all
# (it is a bus); Mic and Keys are a taste call, reported not gated.
if name.startswith("Tidal"):
if armed is None:
unarmed.append((name, "no rec-enable control — is this a BUS?"))
elif not armed:
unarmed.append((name, "NOT ARMED — this orbit records no stem"))
elif safe:
unarmed.append((name, "rec-safe is on — arming it is blocked"))
if not (name.startswith("Tidal") or name == "Master"): if not (name.startswith("Tidal") or name == "Master"):
continue continue
...@@ -186,6 +272,50 @@ def main() -> int: ...@@ -186,6 +272,50 @@ def main() -> int:
rows.sort(key=key) rows.sort(key=key)
def arm_key(r):
n = r[0]
return (0, int(n.split()[-1]), "") if n.startswith("Tidal") else (1, 0, n)
arm_rows.sort(key=arm_key)
if do_arm and not args.quiet:
print(f"check-mix: {path}")
print(" (session as last SAVED — press Ctrl+S in Ardour before trusting this)\n")
print(f" {'track':10s} {'armed':>6s} note")
for name, armed, safe in arm_rows:
if armed is None:
shown, note = "n/a", "a bus — Ardour cannot arm it"
elif armed:
shown, note = "YES", "rec-safe ON — cannot be armed" if safe else ""
else:
shown, note = "no", ("records no stem" if name.startswith("Tidal")
else "not recorded, by choice")
print(f" {name:10s} {shown:>6s} {note}")
print()
if do_arm and unarmed:
sys.stdout.flush()
print("check-mix: FAIL — these orbits would record NOTHING:", file=sys.stderr)
for name, why in unarmed:
print(f" {name}: {why}", file=sys.stderr)
print(
"\n Fix in Ardour: click the record button on each track above,\n"
" then SAVE (Ctrl+S) so the arm survives the next launch.\n"
" On 2026-09-24 this was Tidal 08, for the whole set: eleven stems\n"
" instead of twelve, and the orbit was in every track's control map.\n"
" Master is a BUS and cannot be armed — the out-of-Ardour safety net\n"
" is tools/gig_record.sh.",
file=sys.stderr,
)
if not do_gain:
return 1
if args.arm:
print(f"check-mix: OK — all "
f"{sum(1 for n, a, _ in arm_rows if n.startswith('Tidal') and a)} "
f"Tidal orbits are record-armed.")
return 0
if not args.quiet: if not args.quiet:
print(f"check-mix: {path}") print(f"check-mix: {path}")
print(" (session as last SAVED — save Ardour before trusting this)\n") print(" (session as last SAVED — save Ardour before trusting this)\n")
...@@ -199,6 +329,7 @@ def main() -> int: ...@@ -199,6 +329,7 @@ def main() -> int:
print() print()
if problems: if problems:
sys.stdout.flush()
print("check-mix: FAIL — these orbits cannot be heard as configured:", file=sys.stderr) print("check-mix: FAIL — these orbits cannot be heard as configured:", file=sys.stderr)
for name, why in problems: for name, why in problems:
print(f" {name}: {'; '.join(why)}", file=sys.stderr) print(f" {name}: {'; '.join(why)}", file=sys.stderr)
...@@ -211,8 +342,13 @@ def main() -> int: ...@@ -211,8 +342,13 @@ def main() -> int:
) )
return 1 return 1
print(f"check-mix: OK — all {len(rows) - 1} Tidal orbits reach the master bus." if unarmed:
if master_ok else "check-mix: OK — orbits fine.") return 1
armed_n = sum(1 for n, a, _ in arm_rows if n.startswith("Tidal") and a)
tail = f" and all {armed_n} are record-armed." if do_arm else "."
print(f"check-mix: OK — all {len(rows) - 1} Tidal orbits reach the master bus{tail}"
if master_ok else f"check-mix: OK — orbits fine{tail}")
return 0 return 0
......
"""An unarmed orbit must be a NO-GO, not a discovery made the next morning.
THE LOSS THIS TEST GUARDS (2026-09-24)
`Tidal 08` carried `<Controllable name="rec-enable" value="0"/>`, alone among
the twelve routes. Ardour therefore never created `Take103_Tidal 08` — an
ABSENT stem, not a silent one — while the LCXL control map listed orbit 8 in
all fifteen tracks of the set. Nothing in the rig looked at the record button.
The XML shapes below are the ones that actually matter, and each is taken from
a real route in `../Ardour/Tidal Live/Tidal Live.ardour`:
* a TRACK has a playlist and a `rec-enable` -> armable, must be armed
* a BUS (Master) has neither -> `None`, never blocking
* `rec-safe` on means arming is blocked -> a fault, not a state
* attribute ORDER is not stable across Ardour versions, so the reader must
match on `name=` and never on position
Synthetic XML on purpose: a test that reads PLN's live session passes or fails
on where his hands were last night, which is not a property of the code.
"""
from __future__ import annotations
import importlib.util
import pathlib
import sys
import xml.etree.ElementTree as ET
import pytest
TOOLS = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(TOOLS))
_spec = importlib.util.spec_from_file_location("cm", TOOLS / "check-mix.py")
cm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(cm)
def route(xml: str) -> ET.Element:
return ET.fromstring(xml.strip())
TRACK_ARMED = '''
<Route name="Tidal 01" default-type="audio" audio-playlist="100">
<Controllable name="rec-enable" id="647" flags="Toggle,RealTime" value="1"/>
<Controllable name="rec-safe" id="649" flags="Toggle,RealTime" value="0"/>
</Route>'''
TRACK_UNARMED = '''
<Route name="Tidal 08" default-type="audio" audio-playlist="722">
<Controllable name="rec-enable" id="717" flags="Toggle,RealTime" value="0"/>
<Controllable name="rec-safe" id="719" flags="Toggle,RealTime" value="0"/>
</Route>'''
MASTER_BUS = '''
<Route name="Master" default-type="audio" strict-io="1">
<Controllable name="solo" id="72" value="0"/>
</Route>'''
TRACK_REC_SAFE = '''
<Route name="Tidal 05" default-type="audio" audio-playlist="500">
<Controllable name="rec-safe" value="1"/>
<Controllable name="rec-enable" value="1"/>
</Route>'''
NESTED = '''
<Route name="Tidal 09" default-type="audio" audio-playlist="792">
<Processor name="Amp" type="amp">
<Controllable name="gaincontrol" value="2.0"/>
</Processor>
<Controllable name="rec-enable" value="1"/>
</Route>'''
def test_an_armed_track_reads_armed():
assert cm.arm_of(route(TRACK_ARMED)) == (True, False)
def test_the_tidal_08_shape_reads_unarmed():
"""The exact bytes that cost a stem."""
assert cm.arm_of(route(TRACK_UNARMED)) == (False, False)
def test_a_bus_is_not_unarmed_it_is_unarmable():
"""`None`, not `False` — the difference between 'fix this' and 'cannot'.
Master has no `rec-enable` at all, which is how Ardour says 'bus'. Reporting
it as a failure would send PLN hunting a button that does not exist.
"""
armed, safe = cm.arm_of(route(MASTER_BUS))
assert armed is None and safe is False
def test_rec_safe_is_reported_even_when_armed():
"""Armed + rec-safe is a trap: it looks right and cannot record."""
assert cm.arm_of(route(TRACK_REC_SAFE)) == (True, True)
def test_the_reader_finds_controllables_nested_in_processors():
"""Attribute order and nesting both vary; `name=` is the only anchor."""
assert cm.arm_of(route(NESTED)) == (True, False)
def test_controllable_returns_none_when_absent():
assert cm.controllable(route(MASTER_BUS), "rec-enable") is None
assert cm.controllable(route(TRACK_ARMED), "rec-enable") == "1"
@pytest.mark.parametrize("raw", ["", "yes", "nan-ish"])
def test_an_unparseable_value_is_unknown_not_armed(raw):
"""Silence is the wrong default for a possible false negative — but so is
claiming armed. Unknown reads as `None` and the gate names it."""
r = route(f'<Route name="Tidal 02"><Controllable name="rec-enable" value="{raw}"/></Route>')
assert cm.arm_of(r)[0] is None
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