Commit 080a203c by PLN (Algolia)

feat(faders): stop depending on PLN remembering to raise them

Third session running, he raised faders by hand and hit Ctrl+S, then asked the
right question:

    "ok raised and saved 10 and 12, saved, check again.
     but ideally we should not depend in that?"

He is right. The gate CAUGHT the problem all three times — the dependency was
never on the check, it was on him having hands free to fix it. On a Saturday at
19:00 in a field that is not a dependency you want.

THE MECHANISM, now understood: the Ardour session IS the boot state. He ends a
set with the faders pulled down, which is what you do at the end of a set,
Ardour saves that, and the next launch comes up silent. Today's save had NINE of
twelve at -inf, and all 15 setlist tracks would have lost an orbit — d10 carries
the riser convention (#33), so all ten risers would have been silent and he
would never have heard them go.

tools/fader-baseline.py: --capture / --check / --restore.

WHY THIS IS ALLOWED TO WRITE FADERS when the standing rule says never to. The
rule exists for three reasons — it desyncs the file from the physical desk,
CC77 down is total silence, and levels are MUSICAL INTENT an agent must not
invent. All three are about INVENTING a level on a LIVE desk. This does neither:
it replays a value PLN set and approved (stamped with date and source session),
it refuses to write under a live session so the desk always wins, and it can
only address routes named "Tidal NN" — Master, monitor and busses are not
reachable by this tool at all. It writes a .bak first, because a corrupted
performance session six days out is not a recoverable mistake.

--capture REFUSES if any fader is silent. Capturing end-of-set silence as the
baseline would enshrine the exact bug this undoes.

Baseline captured while the mix was green: 12 faders, 01 at 0.0 dB through 09/12
at +6.0 dB.

PROVEN BY MUTATION on a COPY, never his live session: pull the same nine faders
to 0.0, --check reports all nine with their baseline deltas and exits 1,
--restore puts them back, and check-mix.py independently agrees every orbit
reaches master again.

AND THE TEST FOUND A REAL BUG IN THE GUARD. The first version asked "is ANY
Ardour running" — the safe-LOOKING answer and the wrong one, because it refuses
to repair a session nobody has open, and it made the restore path untestable
while his live session was up. The hazard is writing UNDER a live session, so
the guard now matches on the resolved session path. Still refuses his open one;
no longer refuses everything.

Wired in two places: a HARD "fader baseline" check in the gate, and a --converge
action that restores automatically when Ardour is closed. gig-up: GO, and
--converge is a clean no-op on a healthy rig.
parent ad91ff1a
{
"captured": "2026-08-02T12:08:54+02:00",
"session": "/home/pln/Work/Sound/Ardour/Tidal Live/Tidal Live.ardour",
"note": "Levels PLN set and approved. Replayed by --restore, never invented.",
"faders": {
"Tidal 01": 1.0,
"Tidal 02": 1.0719250440597534,
"Tidal 03": 0.9858513474464417,
"Tidal 04": 0.9582463502883911,
"Tidal 05": 1.0719250440597534,
"Tidal 06": 1.0719250440597534,
"Tidal 07": 1.0719250440597534,
"Tidal 08": 1.1945180892944336,
"Tidal 09": 2.0,
"Tidal 10": 1.9106513261795044,
"Tidal 11": 1.0,
"Tidal 12": 2.0
},
"faders_db": {
"Tidal 01": 0.0,
"Tidal 02": 0.6,
"Tidal 03": -0.12,
"Tidal 04": -0.37,
"Tidal 05": 0.6,
"Tidal 06": 0.6,
"Tidal 07": 0.6,
"Tidal 08": 1.54,
"Tidal 09": 6.02,
"Tidal 10": 5.62,
"Tidal 11": 0.0,
"Tidal 12": 6.02
}
}
#!/usr/bin/env python3
"""fader-baseline — remember the fader levels PLN plays at, and put them back.
THE FAILURE THIS EXISTS FOR, three recurrences and counting (#124):
The Ardour session IS the boot state. PLN ends a set with the faders pulled down —
which is what you do at the end of a set — Ardour saves that, and the next launch
comes up silent. On 2026-08-02 the saved session had NINE of twelve Tidal tracks
at -inf, and every one of the 15 setlist tracks would have lost an orbit. The
riser convention (#33) lives on d10, so all ten risers would have been silent and
he would not have heard them go.
`check-mix.py` catches it. But catching it costs PLN a fader ride and a Ctrl+S
every time, and his answer to that was the right one:
"ok raised and saved 10 and 12, saved, check again. but ideally we should
not depend in that?"
So: CAPTURE the levels while they are good, and RESTORE them when they drift.
WHY THIS IS ALLOWED TO WRITE FADERS, WHEN THE STANDING RULE SAYS NEVER TO
The rule (memory: reference_ardour_saved_vs_live_recurs) is that you must never
write fader values from outside, for three good reasons: it desyncs the file from
the physical desk, CC77 is a global gain where down means total silence, and
levels are MUSICAL INTENT that an agent has no business inventing.
All three objections are about INVENTING a level on a LIVE desk. This tool does
neither:
* it never invents anything — it replays a value PLN himself set and approved,
recorded with the date and session it came from;
* it refuses to run while Ardour is running, so it cannot desync a live desk.
The desk wins whenever the desk exists;
* it only ever touches routes named "Tidal NN". Master, monitor, busses and
anything else in the session are not addressable by this tool at all.
It also always writes a .bak first, because this is PLN's performance session and
a corrupted one six days before a gig is not a recoverable mistake.
Usage:
tools/fader-baseline.py --capture # snapshot the current (good) levels
tools/fader-baseline.py --check # does the session still match? (exit 1 = no)
tools/fader-baseline.py --restore # put the baseline back (Ardour must be closed)
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
import pathlib
import re
import shutil
import sys
import xml.etree.ElementTree as ET
ROOT = pathlib.Path(__file__).resolve().parent.parent
BASELINE = ROOT / "armada" / "ardour_faders.json"
SESSION = pathlib.Path.home() / "Work/Sound/Ardour/Tidal Live/Tidal Live.ardour"
TIDAL_ROUTE = re.compile(r"^Tidal\s+(\d+)$")
# Below this a track is inaudible in practice; same constant check-mix.py uses.
SILENT = 1e-3
def to_db(linear: float) -> float:
return -math.inf if linear <= 0 else 20.0 * math.log10(linear)
def ardour_running(session: pathlib.Path | None = None) -> str | None:
"""The .ardour path a live Ardour holds, or None.
Matches check-mix.py: read argv, because the binary is `ardour-N.N.N` and a
name match on 'ardour' also hits this script and any grep for it.
Scoped to `session` when given. The first version asked only "is ANY Ardour
running", which is the safe-looking answer and the wrong one: it refuses to
repair a session nobody has open, and it made this tool's own restore path
untestable while PLN had his live session up. The hazard is writing UNDER a
live session, so that is exactly what we check for.
"""
want = session.resolve() if session else None
for proc in pathlib.Path("/proc").iterdir():
if not proc.name.isdigit():
continue
try:
argv = (proc / "cmdline").read_bytes().split(b"\0")
except OSError:
continue
if not argv or b"ardour" not in pathlib.Path(argv[0].decode(
"utf-8", "replace") or "x").name.encode():
continue
for arg in argv:
if not arg.endswith(b".ardour"):
continue
got = arg.decode("utf-8", "replace")
if want is None:
return got
try:
if pathlib.Path(got).resolve() == want:
return got
except OSError:
continue
return None
def gain_controllable(route: ET.Element) -> ET.Element | None:
"""The route's main fader element. Matched on the Controllable NAME, because
the enclosing Processor has been called both 'Amp' and 'Fader' across Ardour
versions and matching on that is how you silently check nothing."""
for proc in route.iter("Processor"):
for ctl in proc.iter("Controllable"):
if ctl.get("name") == "gaincontrol":
return ctl
return None
def read_faders(session: pathlib.Path) -> dict[str, float]:
tree = ET.parse(session)
out: dict[str, float] = {}
for route in tree.getroot().iter("Route"):
name = route.get("name") or ""
if not TIDAL_ROUTE.match(name):
continue
ctl = gain_controllable(route)
if ctl is None:
continue
try:
out[name] = float(ctl.get("value", "nan"))
except ValueError:
continue
return dict(sorted(out.items(), key=lambda kv: int(TIDAL_ROUTE.match(kv[0]).group(1))))
def load_baseline() -> dict:
if not BASELINE.exists():
raise SystemExit(f"fader-baseline: no baseline at {BASELINE}\n"
f" capture one while the mix is good: "
f"tools/fader-baseline.py --capture")
return json.loads(BASELINE.read_text())
def cmd_capture(session: pathlib.Path) -> int:
faders = read_faders(session)
if not faders:
raise SystemExit(f"fader-baseline: no 'Tidal NN' routes in {session}")
silent = [n for n, v in faders.items() if v <= SILENT]
if silent:
# Capturing end-of-set silence as the baseline would enshrine the very
# bug this tool exists to undo.
print(f"fader-baseline: REFUSING to capture — {len(silent)} fader(s) are "
f"silent: {', '.join(silent)}", file=sys.stderr)
print(" Raise them in Ardour and Ctrl+S first. A baseline is only "
"worth having if it is the mix you PLAY at.", file=sys.stderr)
return 1
BASELINE.write_text(json.dumps({
"captured": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
"session": str(session),
"note": "Levels PLN set and approved. Replayed by --restore, never invented.",
"faders": faders,
"faders_db": {n: round(to_db(v), 2) for n, v in faders.items()},
}, indent=2) + "\n")
print(f"fader-baseline: captured {len(faders)} fader(s) -> "
f"{BASELINE.relative_to(ROOT)}")
for n, v in faders.items():
print(f" {n:<10} {to_db(v):>7.1f} dB")
return 0
def cmd_check(session: pathlib.Path) -> int:
base = load_baseline()["faders"]
now = read_faders(session)
drift = []
for name, want in base.items():
got = now.get(name)
if got is None:
drift.append((name, None, want)); continue
# 0.5 dB of slack: PLN nudges levels between runs and that is not drift.
if got <= SILENT or abs(to_db(got) - to_db(want)) > 0.5:
drift.append((name, got, want))
if not drift:
print(f"fader-baseline: ok — {len(base)} fader(s) match the baseline "
f"captured {load_baseline()['captured'][:10]}")
return 0
print(f"fader-baseline: DRIFT — {len(drift)} fader(s) differ from the baseline:")
for name, got, want in drift:
g = "missing" if got is None else f"{to_db(got):.1f} dB"
print(f" {name:<10} {g:>10} baseline {to_db(want):.1f} dB")
print("\n Restore with: tools/fader-baseline.py --restore (Ardour must be closed)")
print(" Or, if these levels are the NEW intent: --capture")
return 1
def cmd_restore(session: pathlib.Path) -> int:
live = ardour_running(session)
if live:
print(f"fader-baseline: REFUSING — Ardour is running ({live}).", file=sys.stderr)
print(" Writing the file underneath a live session desyncs it from the "
"physical desk, and the desk wins on next touch. Close Ardour first.",
file=sys.stderr)
return 1
base = load_baseline()
want = base["faders"]
tree = ET.parse(session)
changed = []
for route in tree.getroot().iter("Route"):
name = route.get("name") or ""
if name not in want: # Master/monitor/busses are unreachable here
continue
ctl = gain_controllable(route)
if ctl is None:
continue
try:
cur = float(ctl.get("value", "nan"))
except ValueError:
cur = float("nan")
if not (abs(to_db(cur) - to_db(want[name])) <= 0.5):
ctl.set("value", repr(want[name]))
changed.append((name, cur, want[name]))
if not changed:
print("fader-baseline: nothing to restore — session already matches.")
return 0
bak = session.with_suffix(session.suffix + ".prefader.bak")
shutil.copy2(session, bak)
tree.write(session, encoding="utf-8", xml_declaration=True)
print(f"fader-baseline: restored {len(changed)} fader(s) "
f"(backup: {bak.name}, captured {base['captured'][:10]})")
for name, cur, w in changed:
print(f" {name:<10} {to_db(cur):>7.1f} -> {to_db(w):>6.1f} dB")
return 0
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--session", type=pathlib.Path, default=SESSION)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--capture", action="store_true")
g.add_argument("--check", action="store_true")
g.add_argument("--restore", action="store_true")
args = ap.parse_args()
if not args.session.exists():
raise SystemExit(f"fader-baseline: no session at {args.session}")
if args.capture:
return cmd_capture(args.session)
if args.check:
return cmd_check(args.session)
return cmd_restore(args.session)
if __name__ == "__main__":
sys.exit(main())
...@@ -164,6 +164,24 @@ if (( CONVERGE )); then ...@@ -164,6 +164,24 @@ if (( CONVERGE )); then
tools/check-preload.sh --fix >>"$LOG" 2>&1 && { echo " ${G}+${Z} preload plan regenerated"; did=1; } tools/check-preload.sh --fix >>"$LOG" 2>&1 && { echo " ${G}+${Z} preload plan regenerated"; did=1; }
fi fi
# THE FADERS, if and only if Ardour is closed. PLN, on being told to raise two of
# them by hand for the third session running: "but ideally we should not depend in
# that?" — correct, and the dependency was on him REMEMBERING.
#
# fader-baseline replays levels HE captured; it never invents one, it only ever
# addresses routes named "Tidal NN", and it refuses to write under a live session
# because the physical desk must always win. So this is a restore, not a mix
# decision, which is what makes it safe to automate at all.
if ! python3 tools/fader-baseline.py --check >/dev/null 2>&1; then
if python3 tools/fader-baseline.py --restore >>"$LOG" 2>&1; then
echo " ${G}+${Z} Ardour faders restored from baseline"; did=1
else
# Almost always "Ardour is open" — a legitimate refusal, not an error.
echo " ${Y}!${Z} faders differ from baseline and could not be restored"
echo " ${D}close Ardour, then: tools/fader-baseline.py --restore${Z}"
fi
fi
# The exact failure of 2026-08-01: unit ACTIVE, audio server DEAD. `start` is a no-op # The exact failure of 2026-08-01: unit ACTIVE, audio server DEAD. `start` is a no-op
# on an active unit, so this case needs a RESTART — and distinguishing the two is the # on an active unit, so this case needs a RESTART — and distinguishing the two is the
# entire lesson (a green unit is not sound). # entire lesson (a green unit is not sound).
...@@ -226,7 +244,7 @@ run "tools executable" \ ...@@ -226,7 +244,7 @@ run "tools executable" \
bad=0 bad=0
for f in tools/gig-up.sh tools/check-boot.sh tools/check-tracks.sh \ for f in tools/gig-up.sh tools/check-boot.sh tools/check-tracks.sh \
tools/check-preload.sh tools/sc-watchdog.sh tools/setlist.py \ tools/check-preload.sh tools/sc-watchdog.sh tools/setlist.py \
tools/check-drift.sh tools/take-segments.py; do tools/check-drift.sh tools/take-segments.py tools/fader-baseline.py; do
[ -e "$f" ] || continue [ -e "$f" ] || continue
[ -x "$f" ] || { echo "not executable on disk: $f"; bad=1; } [ -x "$f" ] || { echo "not executable on disk: $f"; bad=1; }
m=$(git ls-files -s "$f" 2>/dev/null | awk "{print \$1}") m=$(git ls-files -s "$f" 2>/dev/null | awk "{print \$1}")
...@@ -274,6 +292,10 @@ run "surface grid intact" \ ...@@ -274,6 +292,10 @@ run "surface grid intact" \
exit 1; }' exit 1; }'
# --- 2. the saved Ardour faders ---------------------------------------------- # --- 2. the saved Ardour faders ----------------------------------------------
run "fader baseline" \
"tools/fader-baseline.py --restore (close Ardour first) — or --capture if these levels ARE the new intent" \
python3 tools/fader-baseline.py --check
run "ardour faders" \ run "ardour faders" \
"raise it on the desk, then Ctrl+S in Ardour, then re-run. Still -inf after a save = genuinely down." \ "raise it on the desk, then Ctrl+S in Ardour, then re-run. Still -inf after a save = genuinely down." \
python3 tools/check-mix.py --quiet python3 tools/check-mix.py --quiet
......
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