Commit 7a88f1d8 by PLN (Algolia)

check-mix: audit the Mix capture track, and say how to add one

Master is a bus. It has no rec-enable, so Ardour can never record it, so an
unarmed orbit has nothing to fall back to — the whole cost of the 24th. The
answer inside Ardour is a `Mix` TRACK fed from Master/audio_out 1+2, armed:
stem grade, same take, same sample clock as the orbits.

`--mix` audits it, and exits 4 when there is no such route, because absence
is a choice and the gate must tell "you have not built the net" apart from
"your net is broken". Absent prints the 30-second Add Track recipe rather
than nagging. Once it exists, four things make it a net that looks present
and records nothing, and they are checked in the order they bite:

  1. output wired back to Master — a unity-gain FEEDBACK LOOP through the PA,
     which is the state you get by following Ardour's Add Track dialog and
     stopping, since a new track goes to Master by default;
  2. not armed — the Tidal 08 failure one route further out;
  3. input not on Master, or only half on it — one channel the mix and the
     other an orbit reads as a healthy file and is unusable;
  4. fader off unity while the route is DiskIOPostFader — the fader is IN the
     recording, and at -inf the file is digital silence.

Two gate probes, split the way the gig-record pair is: "mix track" ADVISES
that there is none, "mix track wired" BLOCKS on one that is broken.

Ten new tests. The mutated fixtures each break exactly one thing, so a
failure names the fault; ExtConnection is asserted NOT to count as an
in-session link, or a correctly wired track would report a feedback loop.
parent 66318b6e
......@@ -158,6 +158,36 @@ PROBES: tuple[Probe, ...] = (
"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."),
# THE MIX ITSELF HAS NO OWNER INSIDE ARDOUR. `Master` is a bus — no
# rec-enable at all — so when `Tidal 08` sat unarmed on 2026-09-24 there
# was nothing to fall back to and the orbit is simply gone. Two nets answer
# that and they fail differently: `tools/gig_record.sh` is OUTSIDE Ardour
# (survives a forgotten REC and a crash; opus off a sink monitor) and a
# `Mix` track is INSIDE it (stem grade, same take, same clock; shares
# Ardour's REC button). Complementary, not redundant.
#
# ADVISE, because not having built it is a choice and a gate that says
# NO-GO over a missing option is a gate that gets ignored.
Probe("mix track", shell(f"""
out=$({PY} tools/check-mix.py --mix --quiet 2>&1); rc=$?
[ "$rc" = 4 ] && {{ echo "$out"; exit 1; }} # absent — this probe asks that
exit 0"""),
kind=ADVISE,
fix="optional, and PLN's call: Ardour > Track > Add Track, stereo, "
"named `Mix`, input on Master/audio_out 1+2, output DISCONNECTED "
"(a new track goes to Master by default and that is a feedback "
"loop), armed, Ctrl+S. tools/check-mix.py --mix prints the steps."),
# BLOCK, once it exists. A net that looks present and records silence is
# worse than no net, because you stop carrying the other one. Same split as
# the gig-record pair below: one probe per question, one fix per probe.
Probe("mix track wired", shell(f"""
out=$({PY} tools/check-mix.py --mix --quiet 2>&1); rc=$?
[ "$rc" = 4 ] && exit 0 # absent — the probe above owns that
[ "$rc" = 0 ] || {{ echo "$out"; exit "$rc"; }}"""),
fix="tools/check-mix.py --mix names the fault. Then Ctrl+S: this "
"reads the session as last SAVED."),
# 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
# catches the laptop microphone that had wired itself into the d1 stem —
......
......@@ -53,15 +53,45 @@ the same verb (Check Mix), so it lives here rather than in a new screw.
`tools/gig_record.sh`). `Mic` and `Keys` are reported too — theirs is a taste
call, not a fault.
The third question, added 2026-09-25: IS THE MIX ITSELF CAPTURED?
----------------------------------------------------------------
`Master` is a bus. It has no `rec-enable`, so Ardour cannot record it, so an
unarmed orbit has nothing to fall back to — which is exactly what the 24th
cost. Two nets answer that, and they fail in different ways:
* `tools/gig_record.sh` lives OUTSIDE Ardour. It survives a forgotten REC and
a crashed Ardour, and it is opus off a sink monitor: a witness, not a master.
* a `Mix` TRACK inside Ardour, input `Master/audio_out 1+2`, armed. Stem
grade, same take, same sample clock as the orbits. It shares Ardour's REC
button, so it does not survive a forgotten REC — it survives an unarmed
orbit, a crashed orbit, and a stem you find out about the next day.
They are complementary, not redundant, and this audits the second one. It is
OPTIONAL — a session with no `Mix` route is a choice, reported and never
blocking. But once the route exists, four things can make it a net that looks
present and records nothing, in the order they bite:
1. NOT ARMED — the Tidal 08 failure, one route further out.
2. input not on Master — records the wrong thing, or silence.
3. output reaching Master — a unity-gain FEEDBACK LOOP through the PA.
This is the only entry here that is dangerous rather than merely
disappointing, so it is checked even in --quiet.
4. fader not at unity — these routes are `DiskIOPostFader`, so the FADER
IS IN THE RECORDING. At -inf the file is digital silence, and the take
looks perfectly healthy until you open it.
Usage
-----
tools/check-mix.py # audit, human-readable
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 --mix # the Mix capture track only
tools/check-mix.py --no-arm # the pre-2026-09-25 fader-only audit
Exit codes: 0 = every orbit can reach the master AND is armed; 1 = at least one
is silent-by-configuration or unarmed; 2 = could not read the session.
is silent-by-configuration or unarmed (or, under --mix, the Mix route exists and
is broken); 2 = could not read the session; 4 = --mix only, there is no `Mix`
route at all — a missing option, not a fault, which is why it is its own code.
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
......@@ -129,6 +159,37 @@ SILENT_GAIN = 1e-3
# A fader this low is probably a leftover, not an intentional mix choice.
SUSPICIOUS_DB = -30.0
# The name of the optional master-capture track. A name, not a pattern: it has
# to be typed identically into Ardour's Add Track dialog, so one spelling.
MIX_ROUTE = "Mix"
# How far off 0 dB the Mix fader may sit before the recording is not the mix.
# Generous on purpose — a hair of trim is a taste call, 3 dB is a mistake.
UNITY_TOL_DB = 1.0
# --mix, and there is no Mix route. Not 1: absence is a choice, and the gate
# needs to tell "you have not built the net" apart from "your net is broken".
EXIT_NO_MIX = 4
# The 30-second GUI version. It lives here, next to the check that reads its
# result, because a check that says "this is missing" without saying how to add
# it is a nag. Ardour's own Add Track dialog is the right tool for this — it
# allocates ids, a playlist and a diskstream correctly, and hand-editing the
# one session this rig performs from, to save thirty seconds of clicking, is a
# screw with no nut.
RECIPE = f"""
To add it (Ardour, ~30 s — and it must be SAVED to count):
1. Track > Add Track, Bus or VCA... > Audio Tracks, 1, stereo,
name it exactly `{MIX_ROUTE}`.
2. Window > Audio Connections: connect `{MIX_ROUTE}` INPUT to
Master/audio_out 1 and 2.
3. In the same window, DISCONNECT the `{MIX_ROUTE}` output from Master.
Ardour wires a new track to Master by default and that is a feedback
loop through the PA. This is the one step you cannot skip.
4. Leave its fader at 0.0 dB. These routes are DiskIOPostFader, so the
fader is in the recording.
5. Click its record button. Ctrl+S.
6. Prove it: tools/check-mix.py --mix
"""
def to_db(linear: float) -> float:
return -math.inf if linear <= 0 else 20.0 * math.log10(linear)
......@@ -182,6 +243,95 @@ def arm_of(route: ET.Element) -> tuple[bool | None, bool]:
return None, False
def io_targets(route: ET.Element, direction: str) -> list[str]:
"""Every in-session port this route's IO is wired to, in one direction.
`<Connection other="...">` is an in-session link; `<ExtConnection>` is a
link to the outside world (SuperCollider, the UMC) and is deliberately NOT
included — the Mix track's question is about Master, which is in-session.
"""
found: list[str] = []
for io in route.findall("IO"):
if io.get("direction") != direction:
continue
for port in io.findall("Port"):
for conn in port.findall("Connection"):
other = conn.get("other")
if other:
found.append(other)
return found
def mix_audit(route: ET.Element) -> tuple[list[str], list[str]]:
"""(problems, facts) for the `Mix` master-capture track.
Ordered by how much the failure costs, and the feedback loop is first
because it is the only one that can hurt a room rather than a take.
"""
problems: list[str] = []
facts: list[str] = []
out = io_targets(route, "Output")
back_to_master = [o for o in out if o.startswith("Master/")]
if back_to_master:
problems.append(
"output is wired back to " + ", ".join(back_to_master)
+ " — that is a FEEDBACK LOOP: disconnect the Mix track's output"
)
facts.append(f"output -> {', '.join(out) if out else 'nothing (correct)'}")
src = io_targets(route, "Input")
from_master = [s for s in src if s.startswith("Master/audio_out")]
stray = [s for s in src if not s.startswith("Master/audio_out")]
if not from_master:
problems.append(
"input is not on Master/audio_out — it would record "
+ (", ".join(src) if src else "nothing at all")
)
elif stray:
# Half-wired is its own failure and a nasty one: one channel carries
# the mix and the other carries an orbit, so the file looks fine, plays
# fine on one side, and is unusable.
problems.append(
"input is only PARTLY on Master — " + ", ".join(stray)
+ " is wired in too, so one channel is not the mix"
)
facts.append(f"input <- {', '.join(src) if src else 'nothing'}")
armed, safe = arm_of(route)
if armed is None:
problems.append("no rec-enable control — this is a BUS, not a track, "
"so Ardour can never record it")
elif not armed:
problems.append("NOT ARMED — the mix records nothing")
elif safe:
problems.append("rec-safe is on — arming it is blocked")
facts.append(f"armed {'yes' if armed else 'NO' if armed is not None else 'n/a'}")
gain = fader_of(route)
db = to_db(gain) if gain is not None else None
post = (route.get("disk-io-point") or "") == "DiskIOPostFader"
if gain is None:
problems.append("no fader found")
elif post and abs(db) > UNITY_TOL_DB:
problems.append(
f"fader at {'-inf' if db == -math.inf else f'{db:+.1f}'} dB and this "
f"route is DiskIOPostFader — the fader is IN the recording"
+ (" (the file would be silence)" if gain <= SILENT_GAIN else "")
)
facts.append(
f"fader {'n/a' if db is None else '-inf' if db == -math.inf else f'{db:+.1f} dB'}"
f" ({route.get('disk-io-point') or 'disk-io-point unset'})"
)
mm = route.find("MuteMaster")
if mm is not None and mm.get("muted") == "1":
# Mute is PostFader on these routes, so it silences the disk feed too.
problems.append("MUTED — post-fader mute, so the recording is silent")
return problems, facts
def reaches_master(route: ET.Element) -> bool:
for io in route.findall("IO"):
if io.get("direction") != "Output":
......@@ -193,17 +343,67 @@ def reaches_master(route: ET.Element) -> bool:
return False
def mix_report(root: ET.Element, path: Path, quiet: bool) -> int:
"""The `--mix` mode. See EXIT_NO_MIX for why absence has its own code."""
route = next((r for r in root.iter("Route")
if (r.get("name") or "") == MIX_ROUTE), None)
if route is None:
# stdout, not stderr, and its own exit code: this is an option nobody
# has taken yet, not a fault. The gate maps it to ADVISE.
print(f"check-mix: no `{MIX_ROUTE}` route in {path.name} — the mix is "
f"not captured inside Ardour.")
if not quiet:
print(RECIPE)
print(f"check-mix: no `{MIX_ROUTE}` track — the mix is captured only by "
f"tools/gig_record.sh (opus, outside Ardour).")
return EXIT_NO_MIX
problems, facts = mix_audit(route)
if not quiet:
print(f"check-mix: {path}")
print(" (session as last SAVED — press Ctrl+S in Ardour before "
"trusting this)\n")
print(f" {MIX_ROUTE}:")
for f in facts:
print(f" {f}")
print()
if problems:
sys.stdout.flush()
print(f"check-mix: FAIL — the `{MIX_ROUTE}` track is not a usable "
f"capture:", file=sys.stderr)
for why in problems:
print(f" {why}", file=sys.stderr)
print(f"\n Fix in Ardour on the {MIX_ROUTE} track, then Ctrl+S.\n"
f" A net that looks present and records silence is worse than\n"
f" no net, because you stop carrying the other one.",
file=sys.stderr)
# LAST LINE = THE FACT. gig_gate._detail() shows a failing probe's
# final line, read standing up in a field.
print(f"\ncheck-mix: `{MIX_ROUTE}` track broken — "
+ "; ".join(w.split(" — ")[0] for w in problems), file=sys.stderr)
return 1
print(f"check-mix: OK — `{MIX_ROUTE}` is armed, fed from Master, and not "
f"fed back into it.")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("session", nargs="?", default=str(SESSION))
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("--mix", action="store_true",
help=f"the optional `{MIX_ROUTE}` master-capture track only")
ap.add_argument("--no-arm", action="store_true",
help="skip the arm audit (the pre-2026-09-25 behaviour)")
args = ap.parse_args()
do_gain = not args.arm
do_arm = not args.no_arm
do_gain = not (args.arm or args.mix)
do_arm = not (args.no_arm or args.mix)
path = Path(args.session)
if not path.exists():
......@@ -215,6 +415,9 @@ def main() -> int:
print(f"check-mix: FAIL — cannot parse session: {exc}", file=sys.stderr)
return 2
if args.mix:
return mix_report(root, path, quiet=args.quiet)
rows, problems = [], []
master_ok = True
......@@ -222,7 +425,8 @@ def main() -> int:
for route in root.iter("Route"):
name = route.get("name") or ""
if do_arm and (name.startswith("Tidal") or name in ("Master", "Mic", "Keys")):
if do_arm and (name.startswith("Tidal")
or name in ("Master", "Mic", "Keys", MIX_ROUTE)):
armed, safe = arm_of(route)
arm_rows.append((name, armed, safe))
# Only the orbit stems are blocking. Master cannot be armed at all
......
......@@ -111,3 +111,112 @@ def test_an_unparseable_value_is_unknown_not_armed(raw):
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
# --------------------------------------------------------------------------- #
# The `Mix` capture track (2026-09-25).
#
# Master is a BUS: no `rec-enable`, so Ardour can never record it, so an
# unarmed orbit has nothing to fall back to — the whole cost of the 24th. A
# `Mix` TRACK fed from `Master/audio_out 1+2` closes that, and can be present
# and useless in four ways. Each shape below is one of them.
#
# The order of the assertions matters: the feedback loop is first because it is
# the only entry here that can hurt a ROOM rather than a take.
# --------------------------------------------------------------------------- #
MIX_GOOD = '''
<Route name="Mix" default-type="audio" audio-playlist="900" disk-io-point="DiskIOPostFader">
<IO name="Mix" direction="Input" default-type="audio">
<Port name="Mix/audio_in 1" type="audio" direction="Input">
<Connection other="Master/audio_out 1"/>
</Port>
<Port name="Mix/audio_in 2" type="audio" direction="Input">
<Connection other="Master/audio_out 2"/>
</Port>
</IO>
<IO name="Mix" direction="Output" default-type="audio">
<Port name="Mix/audio_out 1" type="audio" direction="Output">
<ExtConnection for="JACK"/>
</Port>
</IO>
<Controllable name="rec-enable" value="1"/>
<Controllable name="rec-safe" value="0"/>
<Processor name="Amp" type="amp">
<Controllable name="gaincontrol" value="1"/>
</Processor>
</Route>'''
def _mix(**edits: str) -> ET.Element:
"""MIX_GOOD with one thing broken. One knob per test, so a failure names
the fault instead of 'the fixture'."""
xml = MIX_GOOD
for old, new in edits.items():
xml = xml.replace(old.replace("__", " "), new)
return route(xml)
def test_a_correct_mix_track_has_no_problems():
problems, facts = cm.mix_audit(route(MIX_GOOD))
assert problems == []
assert any("armed yes" in f for f in facts)
def test_an_output_wired_back_to_master_is_a_feedback_loop():
"""The dangerous one. Ardour wires a NEW track to Master by default, so
this is the state you get by following the Add Track dialog and stopping."""
r = _mix(**{'<ExtConnection__for="JACK"/>': '<Connection other="Master/audio_in 1"/>'})
problems, _ = cm.mix_audit(r)
assert any("FEEDBACK" in p for p in problems)
def test_an_unarmed_mix_track_is_a_problem():
"""The Tidal 08 failure, one route further out."""
r = _mix(**{'name="rec-enable"__value="1"': 'name="rec-enable" value="0"'})
problems, _ = cm.mix_audit(r)
assert any("NOT ARMED" in p for p in problems)
def test_an_input_not_on_master_records_the_wrong_thing():
r = route(MIX_GOOD.replace("Master/audio_out", "Tidal 01/audio_out"))
problems, _ = cm.mix_audit(r)
assert any("not on Master/audio_out" in p for p in problems)
def test_half_the_input_on_master_is_its_own_failure():
"""One channel the mix, the other an orbit: the file looks healthy and is
unusable. Caught because 'at least one Master link' is not good enough."""
r = _mix(**{'other="Master/audio_out__1"': 'other="Tidal 01/audio_out 1"'})
problems, _ = cm.mix_audit(r)
assert any("PARTLY on Master" in p for p in problems)
def test_a_closed_fader_on_a_post_fader_disk_feed_records_silence():
"""`disk-io-point="DiskIOPostFader"` puts the fader IN the recording, so a
Mix track at -inf writes a healthy-looking file full of digital silence."""
r = _mix(**{'name="gaincontrol"__value="1"': 'name="gaincontrol" value="0"'})
problems, _ = cm.mix_audit(r)
assert any("DiskIOPostFader" in p for p in problems)
assert any("silence" in p for p in problems)
def test_a_pre_fader_disk_feed_does_not_care_about_the_fader():
"""The reason the check is conditional rather than always-on: pre-fader,
a closed fader is a monitoring choice and the file is still the mix."""
r = _mix(**{'disk-io-point="DiskIOPostFader"': 'disk-io-point="DiskIOPreFader"',
'name="gaincontrol"__value="1"': 'name="gaincontrol" value="0"'})
problems, _ = cm.mix_audit(r)
assert not any("fader" in p for p in problems)
def test_a_muted_mix_track_is_a_problem():
r = route(MIX_GOOD.replace("</Route>", '<MuteMaster muted="1"/></Route>'))
problems, _ = cm.mix_audit(r)
assert any("MUTED" in p for p in problems)
def test_ext_connections_are_not_read_as_in_session_links():
"""`<ExtConnection>` is the outside world. Counting it as a Master link
would report a feedback loop on a correctly wired track."""
assert cm.io_targets(route(MIX_GOOD), "Output") == []
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