#!/usr/bin/env python3
"""probe-chain — measure the LIVE signal at every stage of the ParVagues chain.

Why this exists (2026-07-27, J-8 to OPAL)
-----------------------------------------
An entire evening went into "d4/d5/d9-d12 make no sound". Every layer was
inspected by hand and pronounced healthy: Tidal patterns, the g* helpers,
SuperDirt's sample banks (all 723 registered), libsndfile readability, orbit
count, PipeWire links, Ardour routing, the session's saved faders. All static
analysis. All of it agreed the rig was fine, while the rig was demonstrably not.

PLN's correction was the right one: we have audio tooling, so LOOK AT THE AUDIO.
A silent orbit is not a mystery if you can measure where the signal stops.

This probe taps the chain at three points simultaneously, without disturbing it:

    SuperCollider:out_N/N+1   ->  the orbit as SuperDirt renders it
    ardour:Tidal NN/out       ->  after Ardour's fader/mute/plugins
    ardour:Master/out         ->  the mix

and reports peak + RMS dBFS for each. The stage where level goes to -inf is the
stage that is eating the sound, and that single reading replaces a whole evening
of hypotheses:

    SC silent                      -> Tidal or SuperDirt (pattern, or no buffer)
    SC hot, Tidal NN silent        -> Ardour: fader, mute, or a plugin
    Tidal NN hot, Master silent    -> Ardour bus routing
    all hot but nothing audible    -> past Ardour: device sink, monitoring, cabling

Taps are additive PipeWire links into capture streams, so nothing existing is
unlinked and the audio path is untouched. Safe to run mid-set.

Usage
-----
    tools/probe-chain.py                    # probe d1..d12, 4 seconds
    tools/probe-chain.py -d 4 5 9 10 11 12  # only the suspects
    tools/probe-chain.py -s 8               # longer window for sparse patterns

Play the track FIRST, then run this — it measures what is happening now.

Orbit -> port mapping is derived, not hardcoded: SuperDirt is started with
`~dirt.start(57120, [0, 2, 4, ...])` so orbit k (0-indexed) writes bus 2k, and
PipeWire exposes bus b as out_(b+1)/out_(b+2). dN is orbit N-1.
"""

from __future__ import annotations

import argparse
import math
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import time
import wave
from pathlib import Path

SC_NODE = "SuperCollider"
RATE = 48000


def sh(cmd: list[str], timeout: float = 10.0) -> str:
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return r.stdout
    except Exception:
        return ""


def sc_ports_for(dn: int) -> tuple[str, str]:
    """dN -> the pair of SuperCollider output ports carrying that orbit."""
    bus = 2 * (dn - 1)                      # orbit dN-1 writes bus 2*(dN-1)
    return f"{SC_NODE}:out_{bus + 1}", f"{SC_NODE}:out_{bus + 2}"


def existing_ports() -> set[str]:
    out = set()
    for line in sh(["pw-link", "-o"]).splitlines():
        out.add(line.strip())
    return out


class Tap:
    """A 2-channel pw-record capture with explicitly linked inputs.

    --target=0 keeps pw-record from auto-connecting to the default source; we
    then link exactly the ports we want. That is the whole trick, and it is why
    this can tap a mid-chain node instead of only a device.
    """

    def __init__(self, label: str, srcs: tuple[str, str], outdir: Path):
        self.label = label
        self.srcs = srcs
        self.path = outdir / f"{re.sub(r'[^A-Za-z0-9]+', '_', label)}.wav"
        self.proc: subprocess.Popen | None = None
        self.linked = 0
        self.node_name: str | None = None

    def start(self, node_name: str) -> None:
        self.proc = subprocess.Popen(
            ["pw-record", "--target", "0", "--rate", str(RATE), "--channels", "2",
             "-P", '{ node.name = "%s" }' % node_name, str(self.path)],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )

    def link(self, node_name: str) -> None:
        # pw-record names its inputs input_FL / input_FR.
        self.node_name = node_name
        dsts = (f"{node_name}:input_FL", f"{node_name}:input_FR")

        # CUT ANY AUTO-CONNECTION FIRST.
        #
        # `--target 0` is supposed to stop pw-record connecting itself to the
        # default source, and usually does — but not reliably. On 2026-07-28 a
        # run came back showing five different orbits at an IDENTICAL +0.0 dBFS
        # peak, which is not something twelve independent orbits do: it was one
        # shared source (the mix) being measured five times, because the capture
        # nodes had picked up links nobody asked for. That produced a table
        # saying orbits had recovered while Ardour's own meters showed no input
        # on those tracks — a FALSE POSITIVE, which is far worse than a missing
        # reading. A measurement tool that invents signal is not a tool.
        for src, dst in self._current_inputs():
            subprocess.run(["pw-link", "-d", src, dst],
                           capture_output=True, text=True)

        for src, dst in zip(self.srcs, dsts):
            r = subprocess.run(["pw-link", src, dst], capture_output=True, text=True)
            if r.returncode == 0:
                self.linked += 1

    def _current_inputs(self) -> list[tuple[str, str]]:
        """Every (source, dest) link currently terminating on this capture node."""
        if not self.node_name:
            return []
        pairs, src = [], None
        for line in sh(["pw-link", "-l"]).splitlines():
            if not line.startswith((" ", "\t")):
                src = line.strip()
            elif "|->" in line and src:
                dst = line.split("|->", 1)[1].strip()
                if dst.startswith(self.node_name + ":"):
                    pairs.append((src, dst))
        return pairs

    def verify(self) -> tuple[bool, list[str]]:
        """Confirm we are measuring EXACTLY the two ports we intended.

        Called after the capture window, not before: an auto-connection can
        appear at any point while the node is alive, so checking only at setup
        would still let a contaminated reading through.
        """
        actual = sorted(s for s, _ in self._current_inputs())
        expected = sorted(self.srcs)
        return (actual == expected), actual

    def stop(self) -> None:
        if self.proc:
            self.proc.terminate()
            try:
                self.proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                self.proc.kill()

    def levels(self) -> tuple[float, float]:
        """(peak dBFS, rms dBFS); -inf for digital silence."""
        try:
            with wave.open(str(self.path), "rb") as w:
                n, sw = w.getnframes(), w.getsampwidth()
                raw = w.readframes(n)
        except Exception:
            return (-math.inf, -math.inf)
        if not raw or sw != 2:
            # pw-record defaults to s16; anything else we simply don't judge.
            return (-math.inf, -math.inf)
        vals = struct.unpack(f"<{len(raw)//2}h", raw[: (len(raw) // 2) * 2])
        if not vals:
            return (-math.inf, -math.inf)
        peak = max(abs(v) for v in vals) / 32768.0
        rms = math.sqrt(sum(v * v for v in vals) / len(vals)) / 32768.0
        to_db = lambda x: -math.inf if x <= 0 else 20 * math.log10(x)
        return (to_db(peak), to_db(rms))


def fmt(db: float) -> str:
    return "  -inf  " if db == -math.inf else f"{db:+7.1f}"


def verdict(sc: float, trk: float, mst: float) -> str:
    SIL = -70.0  # below this is silence for our purposes
    if sc <= SIL and trk <= SIL and mst <= SIL:
        return "SILENT AT SOURCE -> Tidal/SuperDirt (pattern, or no buffer)"
    if sc > SIL and trk <= SIL:
        return "Ardour eats it -> fader/mute/plugin on this track"
    if trk > SIL and mst <= SIL:
        return "Ardour bus routing -> track not reaching Master"
    if sc > SIL:
        return "signal present through the chain"
    return "no signal at source, but downstream shows level (bleed?)"


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("-d", "--orbits", nargs="+", type=int,
                    default=list(range(1, 13)), help="dN orbits to probe")
    ap.add_argument("-s", "--seconds", type=float, default=4.0)
    args = ap.parse_args()

    for tool in ("pw-record", "pw-link"):
        if not shutil.which(tool):
            print(f"probe-chain: FAIL — {tool} not found", file=sys.stderr)
            return 2

    ports = existing_ports()
    if not any(p.startswith(f"{SC_NODE}:out_") for p in ports):
        print("probe-chain: FAIL — SuperCollider has no output ports; is it running?",
              file=sys.stderr)
        return 2

    tmp = Path(tempfile.mkdtemp(prefix="probe-chain-"))
    taps: list[tuple[int, Tap, Tap]] = []
    master = Tap("Master", ("ardour:Master/audio_out 1", "ardour:Master/audio_out 2"), tmp)

    print(f"probe-chain: tapping {len(args.orbits)} orbit(s) for {args.seconds:g}s "
          f"— play the track NOW if you have not already\n")

    all_taps: list[tuple[Tap, str]] = []
    for dn in args.orbits:
        sc = Tap(f"d{dn}_sc", sc_ports_for(dn), tmp)
        trk = Tap(f"d{dn}_track",
                  (f"ardour:Tidal {dn:02d}/audio_out 1", f"ardour:Tidal {dn:02d}/audio_out 2"),
                  tmp)
        taps.append((dn, sc, trk))
        all_taps += [(sc, f"probe_d{dn}_sc"), (trk, f"probe_d{dn}_trk")]
    all_taps.append((master, "probe_master"))

    try:
        for tap, node in all_taps:
            tap.start(node)
        time.sleep(1.2)                      # let the streams appear in the graph
        for tap, node in all_taps:
            tap.link(node)
        time.sleep(args.seconds)
        # Verify while the nodes are STILL ALIVE — their links disappear with the
        # process, so this cannot be done after stop().
        contaminated = {}
        for tap, _ in all_taps:
            ok, actual = tap.verify()
            if not ok:
                contaminated[tap.label] = actual
    finally:
        for tap, _ in all_taps:
            tap.stop()

    m_peak, m_rms = master.levels()

    # Peak AND rms: a lone peak can be one stray sample (or a click), while rms
    # says whether there is actually sustained content. Judging on peak alone is
    # how you mistake a transient for a working orbit.
    print(f"  {'orbit':6s} {'SC peak':>8s} {'SC rms':>8s} {'trk peak':>9s} "
          f"{'trk rms':>8s}   diagnosis")
    unlinked = []
    for dn, sc, trk in taps:
        s_peak, s_rms = sc.levels()
        t_peak, t_rms = trk.levels()
        if sc.linked < 2:
            unlinked.append(f"d{dn} SC")
        if trk.linked < 2:
            unlinked.append(f"d{dn} track")
        bad = [lbl for lbl in (sc.label, trk.label) if lbl in contaminated]
        note = ("!! UNRELIABLE — tap picked up extra sources; ignore this row"
                if bad else verdict(s_peak, t_peak, m_peak))
        print(f"  d{dn:<5d} {fmt(s_peak)} {fmt(s_rms)} {fmt(t_peak):>9s} "
              f"{fmt(t_rms):>8s}   {note}")

    if contaminated:
        print(f"\n  !! {len(contaminated)} tap(s) were NOT measuring only what they claimed.")
        print("     Rows above marked UNRELIABLE must be discarded, not interpreted.")
        for label, actual in list(contaminated.items())[:6]:
            print(f"       {label}: actually linked to {actual or '(nothing)'}")
        print("     Re-run; if it persists, something is auto-connecting capture streams.")

    print(f"\n  Master bus: peak {fmt(m_peak).strip()} dBFS, rms {fmt(m_rms).strip()} dBFS")
    if unlinked:
        print(f"\n  NOTE: could not tap {len(unlinked)} point(s): {', '.join(unlinked[:8])}"
              "\n  (a missing Ardour track, or a port name that differs — those rows read"
              "\n   -inf because nothing was captured, NOT because the signal is absent.)")
    print(f"\n  captures kept in {tmp} for listening / spectral work")
    return 0


if __name__ == "__main__":
    sys.exit(main())
