#!/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 timeline(self, bin_s: float) -> list[float]:
        """Per-bin rms dBFS across the capture — the shape of the level IN TIME.

        WHY THIS EXISTS (PLN, 2026-07-28: "do you lack tooling to detect these over
        time? do you just listen to the loops once lol" — yes, and he was right).
        A single aggregate peak/rms per orbit CANNOT show a slope. A pattern that
        sounds for 3 s and then dies looks, in a 14 s aggregate, exactly like a quiet
        sparse pattern: same low rms, same lone peak. That ambiguity made me call d7
        "sparse" when it was actually decaying — the `dN` = `xfade N` tail of the
        OUTGOING pattern while a silent new one faded in.

        Binning turns that guess into a reading: a fade is a monotonic slope, a sparse
        part is spiky-but-flat, and a dead orbit is a cliff to -inf that never returns.
        """
        try:
            with wave.open(str(self.path), "rb") as w:
                n, sw = w.getnframes(), w.getsampwidth()
                ch, fr = w.getnchannels(), w.getframerate()
                raw = w.readframes(n)
        except Exception:
            return []
        if not raw or sw != 2:
            return []
        vals = struct.unpack(f"<{len(raw)//2}h", raw[: (len(raw) // 2) * 2])
        per_bin = max(1, int(fr * ch * bin_s))
        out: list[float] = []
        for i in range(0, len(vals), per_bin):
            chunk = vals[i : i + per_bin]
            if len(chunk) < per_bin // 2:      # drop a ragged final bin
                break
            rms = math.sqrt(sum(v * v for v in chunk) / len(chunk)) / 32768.0
            out.append(-math.inf if rms <= 0 else 20 * math.log10(rms))
        return out

    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}"


SPARK = " ▁▂▃▄▅▆▇█"


def sparkline(series: list[float], floor: float = -80.0) -> str:
    """Render per-bin dBFS as blocks. Silence renders as a space, so a die-off is a
    visibly empty tail rather than a number you have to interpret."""
    out = []
    for db in series:
        if db == -math.inf or db <= floor:
            out.append(SPARK[0])
            continue
        frac = (db - floor) / (0.0 - floor)
        idx = max(1, min(len(SPARK) - 1, int(round(frac * (len(SPARK) - 1)))))
        out.append(SPARK[idx])
    return "".join(out)


def _lvl(db: float) -> float:
    """-inf as a comparable number, so min()/max() over a series with silence works."""
    return -120.0 if db == -math.inf else db


def shape_of(series: list[float], bin_s: float) -> str:
    """Classify the level's behaviour over time.

    The distinction that matters on this rig: DECAYING is the `dN` = `xfade N` tail of
    an outgoing pattern while a SILENT new one fades in — i.e. "my eval did not take".
    A sparse part is loud-then-quiet-then-loud (spiky, no trend). Judging on an
    aggregate confuses the two, which is exactly what went wrong with d7.
    """
    live = [d for d in series if d != -math.inf]
    if not live:
        return "SILENT for the whole window"
    if len(series) < 4:
        return "window too short to judge a trend — raise -s"

    q = max(1, len(series) // 4)
    head = [d for d in series[:q] if d != -math.inf]
    tail = [d for d in series[-q:] if d != -math.inf]
    head_avg = sum(head) / len(head) if head else -math.inf

    if not tail:
        return (f"DIED — audible at first ({head_avg:+.1f} dB), then SILENT for the last "
                f"{q * bin_s:g}s. Classic xfade tail: the pattern you hear is the OLD one "
                f"dying; the new one is silent (eval did not take)")
    tail_avg = sum(tail) / len(tail)
    drop = head_avg - tail_avg

    # GATED before DECAYING. A decay only ever goes DOWN; a gated or sparse part
    # goes down and COMES BACK. Comparing head-quartile to tail-quartile cannot
    # tell them apart, and on 2026-07-28 it cried wolf: piment_bresilien's d10 is
    # a `mask "<t f!7>"` riser — one cycle on, seven off — and it was reported
    # "DECAYING, almost certainly an xfade tail" in two consecutive runs. That is
    # the same missing-information mistake as judging a fade by an aggregate, one
    # level up: a monotonicity question answered with two averages.
    #
    # So ask the question directly: after the level's lowest point, does it ever
    # recover most of the way back? If yes it is not decaying, whatever the
    # quartiles say. A tool that cries wolf on a healthy riser gets ignored on the
    # night it is right.
    # Evidence for "gated" is REPETITION, not the position of the single lowest
    # bin: if the dip happens to land at the end of the window there is no
    # "after" to recover into, and a real gated riser gets called a decay anyway.
    # So count how many times the level crosses its own midpoint downward. A
    # monotonic fade crosses ONCE, by definition. Two or more crossings is a
    # cycle, and a cycle is a part.
    if drop > 12 and len(live) >= 4:
        lvls = [_lvl(d) for d in series]
        mid = (max(lvls) + min(lvls)) / 2.0
        crossings = sum(1 for a, b in zip(lvls, lvls[1:]) if a > mid >= b)
        if crossings >= 2:
            return (f"GATED/SPARSE — {crossings} dips below its own midpoint "
                    f"({min(lvls):+.1f} to {max(lvls):+.1f} dB), so it RECURS rather than "
                    f"fades: a masked/gated part (e.g. mask \"<t f!7>\") or a sparse one. "
                    f"A decay crosses once, by definition")
    # One low FINAL bin is a capture-edge artifact, not a fade. The window simply
    # closed inside a gap between events. Seen 2026-07-28 on perfect.tidal's d4:
    # |▇▇▆▆▇▇▆▅▆▆▇▆▃| — visibly steady for 12 bins, and called "DECAYING, 13 dB
    # down, almost certainly an xfade tail" on the strength of the thirteenth. So
    # ask whether the verdict survives dropping that last bin; if it does not, the
    # last bin WAS the verdict.
    if drop > 12 and len(series) >= 6:
        inner = [d for d in series[:-1] if d != -math.inf]
        if inner:
            q2 = max(1, len(series[:-1]) // 4)
            inner_tail = [d for d in series[:-1][-q2:] if d != -math.inf]
            if inner_tail and head_avg - (sum(inner_tail) / len(inner_tail)) <= 12:
                return (f"STEADY — {head_avg:+.1f} dB, with a dip in the FINAL bin only "
                        f"({series[-1]:+.1f} dB). That is the capture window closing "
                        f"inside a gap between events, not a fade — the trend without "
                        f"it is flat")
    if drop > 12:
        return (f"DECAYING — {head_avg:+.1f} -> {tail_avg:+.1f} dB ({drop:.0f} dB down), "
                f"never recovering. Almost certainly an xfade tail, not a part")
    if drop < -12:
        return f"RISING — {head_avg:+.1f} -> {tail_avg:+.1f} dB (fading IN)"
    return f"STEADY — {head_avg:+.1f} -> {tail_avg:+.1f} dB (a real, sustained part)"


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)
    ap.add_argument("-t", "--timeline", nargs="?", type=float, const=1.0,
                    metavar="BIN_S",
                    help="also show level OVER TIME per orbit (default 1s bins). Use "
                         "this whenever a pattern 'plays then fades' — an aggregate "
                         "cannot tell a decay from a sparse part.")
    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 args.timeline and not bad:
            series = sc.timeline(args.timeline)
            if series:
                print(f"         SC over time [{args.timeline:g}s bins] "
                      f"|{sparkline(series)}|  {shape_of(series, args.timeline)}")

    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())
