Commit 3d628ff4 by PLN (Algolia)

fix(rig): the yellow-LED loop was our own systemd service — and the "broken"…

fix(rig): the yellow-LED loop was our own systemd service — and the "broken" orbits were closed Ardour faders

Two independent silent failures, both found tonight, both invisible from the
editor. Neither was in Tidal, which is where every hypothesis pointed.

## 1. The yellow-button loop: we were fighting ourselves

Symptom, standing for weeks: paint an LCXL button a colour, press it, it snaps
to yellow and wipes the colour. `aconnect -d 14:0 24:0` fixed it — and then it
came back, every time, "after a Tidal reboot". We blamed SuperCollider's
`MIDIIn.connectAll`, Ardour's control surface, WirePlumber.

It was `~/.local/bin/midi-autoconnect.sh`, a service we wrote ourselves. Its
rule 2 connected `Midi Through:0 -> Launch Control XL:0`, commented
"LED/visual feedback", and re-asserted it every 2 seconds. That IS the loop:

    press -> LCXL emits Note On velocity 127
          -> LCXL:0 -> Midi Through:0 -> (rule 2) -> LCXL:0
          -> LCXL reads an inbound note velocity as an LED COLOUR
          -> 127 = 0x7F = full red + full green = YELLOW

Not "reboot recreates it" — a 2-second reconcile loop was silently undoing the
operator's fix within two seconds of every manual cut. The rule bought nothing:
the HUD drives the LEDs directly by SysEx to the LCXL's own port.

Rule 2 is now INVERTED — it disconnects instead of connecting, so the return
leg is actively severed rather than merely not created (Ardour and SC can both
recreate it independently; same shape as every other binding on this rig — one
that must be re-asserted, never assumed). LCXL -> Midi Through -> Ardour still
works, so click-to-map fader learn is unaffected.

Rule 3 (Midi Through -> every aseqdump) is deleted too: aseqdump clients
already subscribe to the LCXL directly, so the extra hop delivered every CC
TWICE to the HUD — duplicated work on the renderer we are trying to keep off
the audio thread's back (see the typing-crackle investigation).

Verified: after `systemctl --user restart`, `Connecting To` on client 14 no
longer lists 24:0, and stays that way across reconciles.

## 2. tools/check-mix.py — the mixer is part of the signal path

An evening went into "d7 plays one bar then fades to nothing" and "d9-d12 make
no sound, I see the meter move but nothing on master". We chased the
untouched-control mute-bomb, the DJF helpers, orbit routing, a missing sample
bank. Measured and eliminated, in order: PipeWire links (all 24 present,
out_17->Tidal 09 ... out_23->Tidal 12), Ardour route outputs (all 12 active and
connected to Master), the sample bank (like_sugar present, 22 files, `:21`
valid, inside a loaded glob since April), and the helpers themselves
(check-boot.sh pass 3: all 13 audible against an empty control map).

Then: **Tidal 02, 06, 07, 08 and 12 have their faders at -inf dB.** d7 in
bombe_dj is Tidal 07. d12 in the_revolution is Tidal 12. Exact match.

Why it cost a whole evening rather than a glance:
  * Ardour meters are POST-fader by default, so a shut fader shows NO meter —
    the display agrees with the "Tidal is broken" theory instead of refuting it.
  * `dN` is `xfade N`, so a re-eval fades the OUTGOING pattern out over ~4
    cycles. With the fader shut you hear the previous pattern dying: "plays a
    bar then slowly goes to zero", which reads exactly like a filter closing.
  * The faders are MIDI-learnable from the LCXL, so a physical fader left down
    persists into the saved session and outlives every reboot of everything.

The tie-breaker: `d12` in the_revolution uses no `g*` helper and no `^NN` at
all. Such a pattern cannot be silenced by anything inside Tidal — so its
silence proved the fault was downstream, and the fader was the only downstream
thing left unverified. A control case with no confounders beat four sessions of
hypothesising.

check-mix.py reads the session XML and flags, per Tidal route: fader at/near
-inf, muted, or not routed to Master. Exit 1 so `gig-up.sh` can gate on it.
It states its own limitation rather than implying more than it checks: the
session is read from DISK, so a clean report means "boots healthy", not "the
running mixer is healthy". Live verification needs Ardour's OSC surface, not
yet wired.
parent fdcf185a
#!/usr/bin/env python3
"""check-mix — audit the Ardour "Tidal Multi" mixer for silent-by-configuration orbits.
Why this exists (2026-07-27, mid-rehearsal, J-8 to OPAL)
-------------------------------------------------------
A whole evening was lost to "d7 plays one bar then fades to nothing", "d9-d12
make no sound at all", and "I see the track meter move but nothing on master".
Every hypothesis pointed at Tidal: the untouched-control mute-bomb, the DJF
helpers, orbit routing, a missing sample bank. All of it was wrong.
The actual cause was **Ardour track faders sitting at 0.0** (-inf dB) on 8 of
the 12 Tidal tracks. Nothing in the livecoding stack can tell you that. Tidal
happily emits events, SuperDirt happily plays them, PipeWire happily delivers
them to Ardour — and Ardour throws them away at the fader. The orbit is
"working" at every layer you can inspect from the editor.
What made it so expensive to find:
* Ardour's meters are POST-fader by default. A closed fader shows no meter
movement, so the display agrees with the "Tidal is broken" theory instead
of contradicting it.
* `dN` is `xfade N`, so a re-eval crossfades the OUTGOING pattern out over ~4
cycles. With the fader shut you hear the previous pattern dying — "plays a
bar then slowly goes to zero" — which reads exactly like a filter closing.
* The faders are MIDI-learnable from the LCXL, so a physical fader left down
from a previous session silently persists into the saved session file.
The tie-breaker that finally settled it: `d12` in
`live/midi/nova/jazz/the_revolution_will_be_sampled.tidal` uses no `g*` helper
and no `^NN` control reference at all. Such a pattern CANNOT be silenced by
anything inside Tidal — so its silence proved the fault was downstream, and the
fader was the only downstream thing still unverified.
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.
Usage
-----
tools/check-mix.py # audit, human-readable
tools/check-mix.py --quiet # only problems (for gig-up.sh)
Exit codes: 0 = every orbit can reach the master; 1 = at least one is
silent-by-configuration; 2 = could not read the session.
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
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 needs Ardour's OSC surface, which is not wired up yet.
"""
from __future__ import annotations
import argparse
import math
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
SESSION = Path.home() / "Work/Sound/Ardour/Tidal Multi/Tidal Multi.ardour"
# Below this linear gain a track is inaudible in practice (~ -60 dB).
SILENT_GAIN = 1e-3
# A fader this low is probably a leftover, not an intentional mix choice.
SUSPICIOUS_DB = -30.0
def to_db(linear: float) -> float:
return -math.inf if linear <= 0 else 20.0 * math.log10(linear)
def fader_of(route: ET.Element) -> float | None:
"""Linear gain of the route's main fader, or None if not found.
Ardour nests the fader as a Processor named 'Amp'/'Fader' carrying a
Controllable named 'gaincontrol'. Match on the Controllable name rather
than the processor name, which has varied across Ardour versions.
"""
for proc in route.iter("Processor"):
for ctl in proc.iter("Controllable"):
if ctl.get("name") == "gaincontrol":
try:
return float(ctl.get("value", "nan"))
except ValueError:
return None
return None
def reaches_master(route: ET.Element) -> bool:
for io in route.findall("IO"):
if io.get("direction") != "Output":
continue
for port in io.findall("Port"):
for conn in port.findall("Connection"):
if "Master" in (conn.get("other") or ""):
return True
return False
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")
args = ap.parse_args()
path = Path(args.session)
if not path.exists():
print(f"check-mix: FAIL — no session at {path}", file=sys.stderr)
return 2
try:
root = ET.parse(path).getroot()
except ET.ParseError as exc:
print(f"check-mix: FAIL — cannot parse session: {exc}", file=sys.stderr)
return 2
rows, problems = [], []
master_ok = True
for route in root.iter("Route"):
name = route.get("name") or ""
if not (name.startswith("Tidal") or name == "Master"):
continue
gain = fader_of(route)
mm = route.find("MuteMaster")
muted = (mm.get("muted") == "1") if mm is not None else False
to_mst = name == "Master" or reaches_master(route)
db = to_db(gain) if gain is not None else None
why = []
if gain is None:
why.append("no fader found")
elif gain <= SILENT_GAIN:
why.append(f"fader at {'-inf' if gain <= 0 else f'{db:.1f}'} dB — SILENT")
elif db is not None and db < SUSPICIOUS_DB:
why.append(f"fader very low ({db:.1f} dB)")
if muted:
why.append("MUTED")
if not to_mst:
why.append("not routed to Master")
rows.append((name, db, muted, to_mst, why))
if why:
if name == "Master":
master_ok = False
problems.append((name, why))
if not rows:
print("check-mix: FAIL — no Tidal/Master routes in the session", file=sys.stderr)
return 2
# Sort Tidal 01..12 numerically, Master last.
def key(r):
return (1, 0) if r[0] == "Master" else (0, int(r[0].split()[-1]))
rows.sort(key=key)
if not args.quiet:
print(f"check-mix: {path}")
print(" (session as last SAVED — save Ardour before trusting this)\n")
print(f" {'track':10s} {'fader':>9s} {'mute':4s} {'->master':8s} note")
for name, db, muted, to_mst, why in rows:
shown = "n/a" if db is None else ("-inf" if db == -math.inf else f"{db:+.1f} dB")
print(
f" {name:10s} {shown:>9s} {'YES' if muted else '-':4s} "
f"{'yes' if to_mst else 'NO':8s} {'; '.join(why)}"
)
print()
if problems:
print("check-mix: FAIL — these orbits cannot be heard as configured:", file=sys.stderr)
for name, why in problems:
print(f" {name}: {'; '.join(why)}", file=sys.stderr)
print(
"\n Fix in Ardour: raise the fader (or un-mute) on each track above.\n"
" Then SAVE the session, so the fix survives the next launch.\n"
" Remember Ardour meters are POST-fader: a shut fader shows no meter,\n"
" which looks exactly like Tidal emitting nothing. It isn't.",
file=sys.stderr,
)
return 1
print(f"check-mix: OK — all {len(rows) - 1} Tidal orbits reach the master bus."
if master_ok else "check-mix: OK — orbits fine.")
return 0
if __name__ == "__main__":
sys.exit(main())
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