#!/usr/bin/env python3
"""Watch the audio interface the way it actually fails: passively, over time.

Written 2026-08-14, after "je jouais d'un seul coup plus de son" — sound gone
mid-play, with SuperCollider healthy, Tidal alive, the PipeWire graph fully
linked and Ardour's master at -0.2 dB. Every one-shot check said FINE. The tell
was PLN's ear, not the checks: system volume beeps had gone "legato 0.2" —
truncated. A device that cuts the first N ms off a short sound is a device that
is being STOPPED AND RESTARTED, and none of the static lenses can see that,
because each one samples a moment and the fault lives between moments.

What this reads, and why each one:

  hw_ptr        The only honest proof that audio is MOVING. `state: RUNNING`
                is what a wedged device says too — it means the stream is set
                up, not that frames are flowing. Two reads and a subtraction
                give the effective rate, which is also how a clock problem
                shows itself (a device running 44100 into a 48000 graph plays
                everything ~9% fast; that IS the "legato" symptom).
  trigger_time  Changes ONLY when the stream is (re)started. It is therefore a
                restart counter that nothing else in the stack exposes: no log
                line is printed, no error is raised, PipeWire reports the node
                `running` on both sides of it. This is the field that found it.
  state         XRUN / SETUP / SUSPENDED as distinct from RUNNING.
  card number   Resolved BY NAME on every poll, never cached. A replug can move
                the device from card1 to card2, and a lens hardcoded to card1
                would report "gone" forever and miss the whole after-half of an
                unplug/replug test — which is the exact test this was built for.

It is passive: it opens no PCM, plays nothing, captures nothing, and takes no
audio path. That matters — a probe that opens the device perturbs the thing it
measures, and this rig has already had one investigation where the capture
itself caused the xruns being investigated.

  tools/audio-lens.py                     watch until Ctrl-C
  tools/audio-lens.py --watch 60          watch 60 s, then summarise
  tools/audio-lens.py --once              one snapshot, no watching
  tools/audio-lens.py --device 'Launch'   match a different card by name
"""
from __future__ import annotations

import argparse
import re
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path

PROC = Path("/proc/asound")
DEFAULT_DEVICE = "UMC202HD"

# Kernel lines worth counting across a run. The clock-source one is specific to
# this interface: 60+ of them arrived in a burst when the UMC re-enumerated on
# 2026-08-14, and a device whose clock source the driver rejected is a device
# whose rate you cannot trust.
KERNEL_PATTERNS = {
    "clock-source rejected": r"clock source .* not valid",
    "usb disconnect": r"USB disconnect",
    "usb enumerate": r"New USB device found",
    "xhci error": r"xhci_hcd.*(error|timeout|halt)",
}


def now() -> str:
    return datetime.now().strftime("%H:%M:%S")


def find_card(name_match: str) -> tuple[int, str] | tuple[None, None]:
    """(card_number, description) for the first card whose entry mentions
    `name_match`. Resolved fresh every call — see the docstring."""
    try:
        txt = (PROC / "cards").read_text()
    except OSError:
        return None, None
    for line in txt.splitlines():
        m = re.match(r"\s*(\d+)\s*\[([^\]]+)\]\s*:\s*(.*)", line)
        if m and name_match.lower() in line.lower():
            return int(m.group(1)), m.group(3).strip()
    return None, None


def pick_pcm(card: int, direction: str) -> str | None:
    """Name of the PCM device to watch, e.g. 'pcm0p'.

    A USB interface has one playback PCM and picking it is trivial. The
    laptop's built-in SOF card has EIGHT (pcm0p pcm1c pcm2p pcm31p pcm4c
    pcm5p pcm6p pcm7p) — headphones, speakers, mics, HDMI — and which one
    carries the sound depends on where the jack is. Hardcoding pcm0p there
    would have quietly watched the speakers while PLN played out of the
    headphone jack into an amp: a lens reporting perfect health about a
    device nobody is listening to.

    So: prefer a RUNNING substream, fall back to the lowest-numbered one that
    exists. Re-resolved on every poll, because which PCM is live changes when
    a jack moves.
    """
    base = PROC / f"card{card}"
    try:
        cands = sorted(
            (p for p in base.glob(f"pcm*{direction}") if p.is_dir()),
            key=lambda p: int(re.sub(r"\D", "", p.name) or 0),
        )
    except OSError:
        return None
    fallback = None
    for p in cands:
        if fallback is None:
            fallback = p.name
        try:
            if (p / "sub0" / "status").read_text().startswith("state: RUNNING"):
                return p.name
        except OSError:
            continue
    return fallback


def read_sub(card: int, direction: str, pcm: str | None = None) -> dict | None:
    """Parse one substream's status. direction is 'p' (playback) or 'c'."""
    pcm = pcm or pick_pcm(card, direction)
    if pcm is None:
        return None
    p = PROC / f"card{card}" / pcm / "sub0" / "status"
    try:
        txt = p.read_text()
    except OSError:
        return None
    if txt.startswith("closed"):
        return {"state": "closed"}
    out = {"state": "?"}
    m = re.search(r"state:\s*(\w+)", txt)
    if m:
        out["state"] = m.group(1)
    for key in ("hw_ptr", "appl_ptr", "trigger_time", "tstamp", "delay", "avail"):
        m = re.search(rf"{key}\s*:\s*([0-9.]+)", txt)
        if m:
            out[key] = float(m.group(1))
    return out


def nominal_rate(card: int, pcm: str | None = None) -> int | None:
    """The rate the hardware says it is running at.

    stream0 is a USB-audio-only file, so on the built-in SOF card it does not
    exist and the first version of this returned None — which made every rate
    check silently unavailable on exactly the device PLN fell back to when the
    interface died. hw_params is the portable answer.
    """
    try:
        txt = (PROC / f"card{card}" / "stream0").read_text()
        m = re.search(r"Momentary freq = (\d+) Hz", txt)
        if m:
            return int(m.group(1))
    except OSError:
        pass
    for name in ([pcm] if pcm else []) + ["pcm0p"]:
        try:
            txt = (PROC / f"card{card}" / name / "sub0" / "hw_params").read_text()
        except OSError:
            continue
        m = re.search(r"^rate:\s*(\d+)", txt, re.M)
        if m:
            return int(m.group(1))
    return None


def kernel_counts() -> dict[str, int]:
    """Count matching kernel lines in the recent journal.

    Deliberately -n (a fixed tail) rather than --since: a time-bounded journal
    scan is pathologically slow on this box. Counts are only ever compared to
    another count from the same command, so the window being fixed is fine.
    """
    counts = {k: 0 for k in KERNEL_PATTERNS}
    try:
        txt = subprocess.run(["journalctl", "-k", "-n", "2000", "--no-pager", "-o", "cat"],
                             capture_output=True, text=True, timeout=20).stdout
    except (OSError, subprocess.SubprocessError):
        return counts
    for label, pat in KERNEL_PATTERNS.items():
        counts[label] = len(re.findall(pat, txt, re.I))
    return counts


class Watcher:
    def __init__(self, device: str, verbose: bool):
        self.device = device
        self.verbose = verbose
        self.prev: dict[str, dict] = {}
        self.pcms: dict[str, str] = {}
        self.ref: dict[str, dict] = {}   # rate reference, kept >=1s back
        self.card: int | None = None
        self.events: list[str] = []
        self.restarts = {"playback": 0, "capture": 0}
        self.rates: dict[str, list[float]] = {"playback": [], "capture": []}
        self.gone_since: float | None = None

    def log(self, msg: str, keep: bool = True):
        line = f"  {now()}  {msg}"
        print(line, flush=True)
        if keep:
            self.events.append(line)

    def poll(self):
        card, desc = find_card(self.device)

        # --- presence, which a replug test turns on and off by design
        if card is None:
            if self.card is not None or self.gone_since is None:
                self.log(f"DEVICE GONE — no card matching {self.device!r}")
                self.gone_since = time.time()
                self.card = None
                self.prev.clear()
                self.pcms.clear()
                self.ref.clear()
            return
        if self.card != card:
            if self.gone_since is not None:
                gap = time.time() - self.gone_since
                self.log(f"DEVICE BACK as card{card} after {gap:.1f}s — {desc}")
                self.gone_since = None
            else:
                # Not a gap: either the first poll, or the card RENUMBERED
                # without us seeing it vanish (fast replug between polls).
                what = "found" if self.card is None else f"MOVED from card{self.card}"
                self.log(f"device {what}: card{card} — {desc}")
            self.card = card

        for direction, key in (("p", "playback"), ("c", "capture")):
            pcm = pick_pcm(card, direction)
            # Which PCM is live is itself an event: on a multi-PCM card it moves
            # when a jack moves, and it must be said out loud or the numbers
            # below describe a device the reader is not thinking about.
            if pcm and self.pcms.get(key) != pcm:
                if self.pcms.get(key):
                    self.log(f"{key:<8} now on {pcm} (was {self.pcms[key]})")
                self.pcms[key] = pcm
            cur = read_sub(card, direction, pcm)
            if cur is None:
                continue
            old = self.prev.get(key)
            self.prev[key] = cur

            if old is None:
                self.log(f"{key:<8} {cur['state']}", keep=False)
                continue

            # A stream that just (re)started has a hw_ptr counting from zero, so
            # the first delta across the seam is meaningless. Measuring it anyway
            # printed "RATE OFF - 0 Hz effective (-100%)" on every reconnect
            # during the 2026-08-14 replug test: a false alarm, fired at exactly
            # the moment the operator most needs to trust the tool. A lens that
            # cries wolf at every gig start is worse than no lens.
            fresh = False

            if cur["state"] != old["state"]:
                self.log(f"{key:<8} state {old['state']} -> {cur['state']}")
                fresh = True

            # The restart detector. trigger_time moving means the stream was
            # stopped and started again — silence, and a truncated head on
            # whatever plays next.
            if cur.get("trigger_time") and old.get("trigger_time") and \
                    cur["trigger_time"] != old["trigger_time"]:
                lived = cur["trigger_time"] - old["trigger_time"]
                self.restarts[key] += 1
                self.log(f"{key:<8} !! STREAM RESTARTED "
                         f"(previous stream lived {lived:.1f}s)")
                fresh = True

            # Effective rate: the arithmetic that separates "set up" from
            # "moving", and a clock mismatch from a healthy device.
            #
            # Measured against a REFERENCE sample kept at least a second back,
            # not against the previous poll. The first version compared
            # consecutive polls and required dt > 0.5 s — but the default poll
            # interval is 0.25 s, so that condition was almost never true and
            # the rate and STALL checks silently never ran. A 900 s watch
            # collected two rate samples. The restart detector carried the whole
            # investigation while half the tool was dead code, which is the
            # failure mode where a green check proves nothing at all.
            ref = self.ref.get(key)
            if fresh or ref is None:
                self.ref[key] = cur
                ref = None
            dh = cur.get("hw_ptr", 0) - (ref or {}).get("hw_ptr", 0)
            dt = cur.get("tstamp", 0) - (ref or {}).get("tstamp", 0)
            if ref is not None and dt >= 1.0 and dh >= 0 and cur["state"] == "RUNNING":
                self.ref[key] = cur
                eff = dh / dt
                self.rates[key].append(eff)
                nom = nominal_rate(card, self.pcms.get(key)) or 48000
                if dh == 0:
                    self.log(f"{key:<8} !! STALLED — RUNNING but hw_ptr frozen "
                             f"for {dt:.1f}s")
                elif abs(eff - nom) / nom > 0.02:
                    self.log(f"{key:<8} !! RATE OFF — {eff:.0f} Hz effective vs "
                             f"{nom} nominal ({100*(eff-nom)/nom:+.1f}%)")
                elif self.verbose:
                    self.log(f"{key:<8} {eff:.0f} Hz", keep=False)

    def summary(self, dur: float, kern_before: dict, kern_after: dict):
        print(f"\n=== {dur:.0f}s on {self.device} " + "=" * 34)
        for key in ("playback", "capture"):
            r = self.rates[key]
            if r:
                print(f"  {key:<9} {len(r)} samples  "
                      f"rate min/med/max {min(r):.0f}/{sorted(r)[len(r)//2]:.0f}/{max(r):.0f} Hz"
                      f"   restarts={self.restarts[key]}")
            else:
                print(f"  {key:<9} no RUNNING samples   restarts={self.restarts[key]}")
        deltas = {k: kern_after[k] - kern_before[k] for k in kern_before}
        hot = {k: v for k, v in deltas.items() if v}
        print("  kernel   " + (", ".join(f"{k} +{v}" for k, v in hot.items())
                               if hot else "nothing new"))
        if self.events:
            print(f"  events   {len(self.events)}")
        else:
            print("  events   none — device stayed up, streams never restarted")
        # The point of the whole tool, said out loud.
        total = sum(self.restarts.values())
        if total:
            print(f"\n  VERDICT: {total} stream restart(s). Each one is a gap in the "
                  f"sound\n           and a truncated head on the next note. This is "
                  f"the fault.")
        else:
            print("\n  VERDICT: no restarts observed in this window. If the symptom "
                  "happened\n           here, it was not the interface stopping.")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--device", default=DEFAULT_DEVICE,
                    help=f"substring matched against /proc/asound/cards (default {DEFAULT_DEVICE})")
    ap.add_argument("--watch", type=float, default=0,
                    help="seconds to watch (default: until Ctrl-C)")
    ap.add_argument("--once", action="store_true", help="one snapshot and exit")
    ap.add_argument("--interval", type=float, default=0.25)
    ap.add_argument("--verbose", action="store_true", help="print every rate sample")
    a = ap.parse_args()

    card, desc = find_card(a.device)
    if card is None:
        print(f"No sound card matching {a.device!r} in /proc/asound/cards.")
        print("Cards present:")
        try:
            print("  " + (PROC / "cards").read_text().replace("\n", "\n  "))
        except OSError:
            pass
        return 1

    if a.once:
        print(f"card{card} — {desc}   nominal {nominal_rate(card, pick_pcm(card, 'p'))} Hz")
        for direction, key in (("p", "playback"), ("c", "capture")):
            pcm = pick_pcm(card, direction)
            s = read_sub(card, direction, pcm) or {}
            age = (s.get("tstamp", 0) - s.get("trigger_time", 0)) if s.get("trigger_time") else None
            head = f"  {key:<9} {str(pcm or '-'):<7} state={s.get('state','?'):<9}"
            print(f"{head} stream age {age:.1f}s" if age is not None else head)
        print("\nA snapshot cannot see a restart — that needs two reads. "
              "Use --watch N.")
        return 0

    w = Watcher(a.device, a.verbose)
    kern_before = kernel_counts()
    print(f"audio-lens on card{card} — {desc}")
    print(f"nominal {nominal_rate(card, pick_pcm(card, 'p'))} Hz · polling {a.interval}s · "
          f"{'watching ' + str(int(a.watch)) + 's' if a.watch else 'Ctrl-C to stop'}")
    print("passive: opens no PCM, plays nothing\n")

    t0 = time.time()
    try:
        while True:
            w.poll()
            if a.watch and time.time() - t0 >= a.watch:
                break
            time.sleep(a.interval)
    except KeyboardInterrupt:
        print()
    w.summary(time.time() - t0, kern_before, kernel_counts())
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
