#!/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())
