#!/usr/bin/env python3
"""Is the rig MAKING SOUND right now? Ask the synth, not the stack.

Companion to audio-lens.py. That one watches the audio DEVICE; this one watches
the audio ENGINE. Between them they answer, in one line, the question that cost
PLN three interruptions on 2026-08-14: when the sound stops, is it Tidal that
stopped asking, or the interface that stopped playing?

The trick is that scsynth will tell you, for free, over the same UDP port it
already listens on. `/status` returns a `/status.reply` carrying the live synth
count and the peak DSP load. A SuperDirt rendering silence sits at its idle
floor with zero synths; a SuperDirt being fed by Tidal spawns a synth per event
and the peak load moves. So:

    synths > 0 or peak CPU above the floor   ->  Tidal is emitting
    answers /status but flat at the floor    ->  engine fine, NOBODY IS ASKING
    does not answer /status                  ->  the server itself is gone

That middle line is the one no other check in this repo could produce. On
2026-08-14 every lens said healthy — sclang's OSC loop answered, scsynth
answered, all 48 SuperCollider->Ardour links were up, the interface was clocking
at 48 kHz — and the sound was still gone, because Tidal had quietly stopped
emitting while its process stayed alive. This tool names that state.

It is passive and read-only: /status is a query, it allocates nothing, and it
goes to scsynth's existing port. It never touches the default server object and
never plays a tone.

  tools/rig-pulse.py                 watch until Ctrl-C
  tools/rig-pulse.py --watch 600     watch 10 min, then summarise
  tools/rig-pulse.py --once          one reading
"""
from __future__ import annotations

import argparse
import socket
import struct
import sys
import time
from datetime import datetime

SCSYNTH = ("127.0.0.1", 57110)
SCLANG = ("127.0.0.1", 57120)

# There is no universal idle threshold, and assuming one is how the first
# version of this tool shipped broken. It called SOUND on `synths > 0 or peak >
# 1.5%` — but THIS rig idles at 71 synths / 3531 ugens / 17.7% peak, because
# start_and_midi.scd registers the Mutable-Instruments global effects on every
# one of 14 orbits and those synths live forever. So the tool read SOUND
# continuously, including while PLN sat in silence, and would never once have
# fired. A check that cannot fail is not a check.
#
# The floor is a property of the boot, not a constant, so LEARN it: track the
# running minimum and treat anything above it as activity. Tidal events add
# synths and ugens on top of the permanent graph; an idle rig sits perfectly
# still (five samples over 8 s gave ugens=3531 every single time, zero
# variance). That stillness is the signal.
# Activity is detected by VARIANCE, not by level — and this took two wrong
# versions to get right.
#
# v1 assumed a universal idle threshold ("synths > 0 or peak > 1.5%"). But this
# rig idles at 71 synths / 3531 ugens / 17.7% peak, because start_and_midi.scd
# registers the Mutable-Instruments global effects on all 14 orbits and those
# synths live forever. The tool read SOUND continuously, including through a
# silence PLN was actively complaining about, and could never once have fired.
#
# v2 learned the floor as a running minimum. Also wrong: the floor RISES as more
# of the graph gets instantiated — measured 71 synths at 15:02 and 103 at 15:05
# — so an all-time minimum leaves everything above it looking like activity
# forever. A baseline that only ever ratchets down cannot track a rig that
# ratchets up.
#
# What actually separates the two states is stillness. An idle SuperDirt is
# EXACTLY constant: five samples over 8 s returned ugens=3531 every single time,
# not approximately. Tidal events spawn and free synths continuously, so a rig
# being played jitters sample to sample (4300 vs 4144 across two seconds). So:
# no change across the window means nobody is asking, whatever the level is.
#
# Known limit, stated rather than hidden: a perfectly static drone with a
# constant voice count would read SILENT. Tidal events are discrete and the
# count moves, so this holds for how this rig is actually played.
WINDOW = 5          # samples compared for movement


class Activity:
    """Is the synth graph MOVING? Level-independent, so it survives a rig whose
    idle baseline changes between boots — or during one."""

    def __init__(self, window: int = WINDOW):
        self.window = window
        self.recent: list[tuple[int, int]] = []

    def update(self, ugens: int, synths: int):
        self.recent.append((ugens, synths))
        if len(self.recent) > self.window:
            self.recent.pop(0)

    def moving(self) -> bool | None:
        """True/False, or None while still filling the window."""
        if len(self.recent) < min(3, self.window):
            return None
        return len(set(self.recent)) > 1

    def describe(self) -> str:
        if not self.recent:
            return "no samples"
        u = [r[0] for r in self.recent]
        return f"ugens {min(u)}-{max(u)} over last {len(u)} samples"


def pad(b: bytes) -> bytes:
    return b + b"\0" * ((4 - len(b) % 4) % 4)


def osc_msg(addr: str) -> bytes:
    return pad(addr.encode() + b"\0") + pad(b",\0")


def osc_parse(data: bytes):
    """(address, [args]) for a reply. Reads the typetag rather than assuming
    offsets — the first version of this hardcoded them and produced a synth
    count of 1684275200, a number obviously wrong enough to catch, which is
    luckier than it deserved."""
    def take_str(b, i):
        e = b.index(b"\0", i)
        return b[i:e].decode("utf-8", "replace"), i + (((e - i) + 4) // 4) * 4
    addr, i = take_str(data, 0)
    if i >= len(data):
        return addr, []
    tags, i = take_str(data, i)
    out = []
    for t in tags[1:]:
        if t == "i":
            out.append(int.from_bytes(data[i:i + 4], "big", signed=True)); i += 4
        elif t == "f":
            out.append(struct.unpack(">f", data[i:i + 4])[0]); i += 4
        elif t == "d":
            out.append(struct.unpack(">d", data[i:i + 8])[0]); i += 8
        elif t == "s":
            s, i = take_str(data, i); out.append(s)
    return addr, out


class Probe:
    def __init__(self, timeout=1.5):
        self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.s.bind(("127.0.0.1", 0))
        self.s.settimeout(timeout)

    def call(self, addr: str, target, expect: str):
        try:
            self.s.sendto(osc_msg(addr), target)
            deadline = time.time() + self.s.gettimeout()
            while time.time() < deadline:
                data, _ = self.s.recvfrom(65535)
                a, vals = osc_parse(data)
                if a == expect:
                    return vals
        except (socket.timeout, OSError):
            pass
        return None

    def status(self):
        """(ugens, synths, avg_cpu, peak_cpu) or None if scsynth is not answering.

        /status.reply args: unused, nUGens, nSynths, nGroups, nSynthDefs,
        avgCPU, peakCPU, sampleRate, actualSampleRate.
        """
        v = self.call("/status", SCSYNTH, "/status.reply")
        if not v or len(v) < 7:
            return None
        return int(v[1]), int(v[2]), float(v[5]), float(v[6])

    def sclang_alive(self) -> bool:
        return self.call("/pv/ping", SCLANG, "/pv/pong") is not None


def verdict(st, sclang_ok, act: Activity) -> tuple[str, str]:
    if st is None:
        return "DOWN", ("scsynth is not answering /status — the audio server is gone"
                        if sclang_ok else
                        "neither scsynth nor sclang answering — the rig is down")
    ugens, synths, avg, peak = st
    act.update(ugens, synths)
    m = act.moving()
    if m is None:
        return "WARMUP", f"{synths} synths / {ugens} ugens — filling the window"
    if m:
        return "SOUND", f"{synths} synths / {ugens} ugens, peak {peak:.1f}%"
    return "SILENT", (f"graph frozen at {synths} synths / {ugens} ugens "
                      f"(peak {peak:.1f}%) — engine healthy, nobody is asking")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--watch", type=float, default=0)
    ap.add_argument("--once", action="store_true")
    ap.add_argument("--interval", type=float, default=2.0)
    a = ap.parse_args()

    p = Probe()

    act = Activity()
    if a.once:
        # One reading cannot show movement, so --once takes the window it needs.
        for _ in range(WINDOW):
            st = p.status()
            if st is None:
                break
            act.update(st[0], st[1])
            time.sleep(0.4)
        v, why = verdict(st, p.sclang_alive(), act)
        print(f"{v}: {why}")
        return 0 if v != "DOWN" else 1

    print("rig-pulse — passive /status polling of scsynth (no tones, no allocation)")
    print("activity = MOVEMENT in the synth graph, level-independent — see the header\n")

    prev = None
    t0 = time.time()
    counts = {"SOUND": 0, "SILENT": 0, "DOWN": 0, "WARMUP": 0}
    transitions = []
    try:
        while True:
            st = p.status()
            v, why = verdict(st, p.sclang_alive() if st is None else True, act)
            counts[v] += 1
            if v != prev:
                line = f"  {datetime.now():%H:%M:%S}  {v:<6} {why}"
                print(line, flush=True)
                transitions.append(line)
                prev = v
            if a.watch and time.time() - t0 >= a.watch:
                break
            time.sleep(a.interval)
    except KeyboardInterrupt:
        print()

    dur = time.time() - t0
    tot = sum(counts.values()) or 1
    print(f"\n=== {dur:.0f}s ===")
    for k in ("SOUND", "SILENT", "DOWN", "WARMUP"):
        print(f"  {k:<7} {counts[k]:5d} samples  {100*counts[k]/tot:5.1f}%")
    print(f"  transitions: {len(transitions)}")
    print(f"  window: {act.describe()}")
    if counts["SILENT"] and not counts["DOWN"]:
        print("\n  The engine was healthy while silent for part of this window.\n"
              "  That is Tidal not emitting — look UP the stack, not down.")
    return 0


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