#!/usr/bin/env python3
"""control-lens — prove a control actually CHANGES THE SOUND, not just that it exists.

Why this exists (2026-07-28, J-7 to OPAL)
-----------------------------------------
PLN's ask was precise: *"send fake midi events for each, thus test all controls
have impact on sound ;) even the crushes should change noticeably what you
see/'hear'"*. The word that matters is **noticeably**. Two prior tools each
answer half the question and neither answers this one:

  * `lcxl-init.py` proves a CC was SENT. It cannot tell you anything landed.
  * `probe-chain.py` proves an orbit makes LEVEL. But **rms cannot see a filter
    or a bit-crusher** — those move TIMBRE, sometimes at constant loudness. A
    "no change in dBFS" verdict on a DJ filter is not a negative result, it is a
    meaningless one. Judging a filter by its loudness is the same class of
    mistake as judging a fade by an aggregate (feedback_measure_the_time_axis).

So this tool measures the RIGHT QUANTITY PER CONTROL KIND:

    filter / crush   -> SPECTRAL CENTROID + band energies   (timbre moved?)
    gain / mute      -> rms dBFS                            (level moved?)
    ply / density    -> ONSET COUNT per second              (more events?)

Method, per control: capture a BASELINE at the seeded value, then drive the CC to
each probe value through **the same path a physical knob takes** (aseqsend -> SC's
MIDIFunc.cc -> /ctrl -> Tidal's sStateMV — reused verbatim from lcxl-init.py, not
reinvented), re-capture, and compare. A control whose every measurement sits
inside the noise floor of the baseline is reported DEAD, which is a finding: it
means the knob does nothing and today that is completely invisible.

Honesty rules, learned the hard way
-----------------------------------
  * A capture whose tap did not verify is DISCARDED, never interpreted (872676a).
  * "No measured change" is reported as NO CHANGE, never softened into "subtle" —
    a measurement tool that invents an effect is not a tool.
  * The comparison is only valid if the ORBIT WAS AUDIBLE in the baseline. A
    silent orbit yields "no change" for every control on it, which would frame a
    dead fader as twelve dead knobs. Silent baseline -> SKIPPED, loudly.

Usage
-----
    tools/control-lens.py --orbit 4 --cc 49 --kind filter --values 0 64 127
    tools/control-lens.py --orbit 1 --cc 91 --kind density --values 0 127
    tools/control-lens.py --spec live/techno/vague_de_crime.tidal   # (see --spec)

Needs numpy (system python3 has it). Requires SC + the track already playing —
this tool never boots or evaluates anything, so it is safe to run while a set is
up.
"""
from __future__ import annotations

import argparse
import importlib.util
import math
import subprocess
import sys
import tempfile
import time
import wave
from pathlib import Path

import numpy as np

TOOLS = Path(__file__).resolve().parent


def _load(name: str, path: Path):
    """Import a module whose filename is not a valid identifier (probe-chain)."""
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


# Reuse, do not reimplement: the tap machinery and the MIDI send path are both
# already debugged, and a second copy of either would drift out of agreement with
# the tool PLN actually trusts.
probe = _load("probe_chain", TOOLS / "probe-chain.py")
lcxl = _load("lcxl_init", TOOLS / "lcxl-init.py")

SR = probe.RATE

# A control is only credible as "effective" if it moves its own measure by more
# than session-to-session wobble. These thresholds are deliberately generous:
# the question is "noticeably", not "at all", and a false ACTIVE is worse than a
# false NO CHANGE (it would send PLN on stage trusting a dead knob).
THRESH = {
    "filter":  ("centroid", 12.0, "% centroid shift"),
    "crush":   ("centroid", 12.0, "% centroid shift"),
    "gain":    ("rms_db",    3.0, "dB rms"),
    "density": ("onsets",   20.0, "% onsets/s"),
}


def capture(orbit: int, seconds: float) -> tuple[np.ndarray | None, str]:
    """Mono float32 of SuperCollider's output for one orbit, or (None, reason)."""
    tmp = Path(tempfile.mkdtemp(prefix="control-lens-"))
    tap = probe.Tap(f"d{orbit}_sc", probe.sc_ports_for(orbit), tmp)
    node = f"lens_d{orbit}"
    try:
        tap.start(node)
        time.sleep(1.2)                 # let the stream appear in the graph
        tap.link(node)
        time.sleep(seconds)
        ok, actual = tap.verify()       # while the node is still alive
    finally:
        tap.stop()
    if not ok:
        return None, f"tap contaminated (measured {actual})"
    if tap.linked < 1:
        return None, "tap linked nothing"
    try:
        with wave.open(str(tap.path), "rb") as w:
            if w.getsampwidth() != 2:
                return None, f"unexpected sample width {w.getsampwidth()}"
            raw = w.readframes(w.getnframes())
            ch = w.getnchannels()
    except Exception as exc:            # noqa: BLE001 — any decode failure is a discard
        return None, f"unreadable capture: {exc}"
    sig = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
    if ch > 1:
        sig = sig.reshape(-1, ch).mean(axis=1)
    if sig.size < SR // 2:
        return None, "capture too short"
    return sig, ""


def onsets_per_s(sig: np.ndarray) -> float:
    """Crude but honest onset rate: peaks in a 10 ms-hop energy envelope.

    Deliberately not librosa — this runs on the live rig under system python3,
    and the question ("did event density change?") needs a RELATIVE number, not
    a musicologically correct one.
    """
    hop = SR // 100
    n = len(sig) // hop
    if n < 8:
        return 0.0
    env = np.sqrt(np.array([np.mean(sig[i*hop:(i+1)*hop] ** 2) for i in range(n)]) + 1e-12)
    # Onset = a rise well above the local floor, with a 60 ms refractory gap so
    # one transient is not counted six times.
    floor = np.median(env)
    thresh = max(floor * 3.0, env.max() * 0.12)
    count, last = 0, -99
    for i in range(1, n):
        if env[i] > thresh and env[i] > env[i-1] * 1.6 and i - last > 6:
            count += 1
            last = i
    return count / (n / 100.0)


def measure(sig: np.ndarray) -> dict:
    w = np.hanning(len(sig))
    S = np.abs(np.fft.rfft(sig * w)) ** 2
    f = np.fft.rfftfreq(len(sig), 1 / SR)
    tot = S.sum() + 1e-20
    bands = {
        "<150":    100 * S[f < 150].sum() / tot,
        "150-2k":  100 * S[(f >= 150) & (f < 2000)].sum() / tot,
        "2k+":     100 * S[f >= 2000].sum() / tot,
    }
    return {
        "rms_db": 20 * math.log10(math.sqrt(float(np.mean(sig ** 2))) + 1e-9),
        "centroid": float((f * S).sum() / tot),
        "onsets": onsets_per_s(sig),
        "bands": bands,
    }


def pct(new: float, old: float) -> float:
    return 100.0 * (new - old) / old if old > 1e-9 else float("inf")


def delta_for(kind: str, base: dict, test: dict) -> tuple[float, float, str]:
    """(measured delta, threshold, unit label) for this control kind."""
    key, thr, unit = THRESH[kind]
    if key == "rms_db":
        return test["rms_db"] - base["rms_db"], thr, unit
    return pct(test[key], base[key]), thr, unit


def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--orbit", type=int, required=True, help="dN to listen to")
    ap.add_argument("--cc", type=int, required=True, help="the control's CC number")
    ap.add_argument("--kind", choices=sorted(THRESH), required=True,
                    help="what to MEASURE: filter/crush -> centroid, gain -> rms, "
                         "density -> onsets")
    ap.add_argument("--values", nargs="+", type=int, required=True,
                    help="CC values to drive (first capture is the baseline BEFORE "
                         "any send)")
    ap.add_argument("--restore", type=int, default=None,
                    help="CC value to leave the control at (default: leave last)")
    ap.add_argument("-s", "--seconds", type=float, default=8.0,
                    help="capture length per value (default 8s ~ 4 bars at 120)")
    ap.add_argument("--label", default="", help="name for the report line")
    args = ap.parse_args()

    if 77 <= args.cc <= 84:
        print("control-lens: REFUSING — CC 77-84 are MIDI-learned to ARDOUR TRACK "
              "GAINS (77 is global gain; down = total silence). Driving them "
              "desyncs the physical faders.", file=sys.stderr)
        return 2

    port = lcxl.sc_midi_input_port()
    if not port:
        print("control-lens: FAIL — no SuperCollider MIDI input port; is sclang up?",
              file=sys.stderr)
        return 2

    name = args.label or f"CC{args.cc}"
    print(f"control-lens: d{args.orbit} / {name} / kind={args.kind} "
          f"via SC MIDI in ({port})\n")

    base, why = capture(args.orbit, args.seconds)
    if base is None:
        print(f"control-lens: DISCARD — baseline unusable: {why}", file=sys.stderr)
        return 2
    b = measure(base)
    if b["rms_db"] < -70.0:
        print(f"control-lens: SKIPPED — d{args.orbit} is SILENT at baseline "
              f"({b['rms_db']:.1f} dB rms). Every control would read 'no change'; "
              f"fix the orbit first, then judge its controls.", file=sys.stderr)
        return 3

    print(f"  {'value':>6s} {'rms dB':>8s} {'centroid':>9s} {'onset/s':>8s}  "
          f"{'<150':>5s} {'150-2k':>6s} {'2k+':>5s}   delta")
    print(f"  {'base':>6s} {b['rms_db']:8.1f} {b['centroid']:8.0f}Hz "
          f"{b['onsets']:8.1f}  {b['bands']['<150']:5.1f} "
          f"{b['bands']['150-2k']:6.1f} {b['bands']['2k+']:5.1f}")

    best, rows = 0.0, []
    for v in args.values:
        if not lcxl.send_cc(port, args.cc, v):
            print(f"  {v:6d}   SEND FAILED — not judged", file=sys.stderr)
            continue
        time.sleep(1.5)                 # let the change take on the next events
        sig, why = capture(args.orbit, args.seconds)
        if sig is None:
            print(f"  {v:6d}   DISCARD ({why})", file=sys.stderr)
            continue
        m = measure(sig)
        d, thr, unit = delta_for(args.kind, b, m)
        best = max(best, abs(d))
        rows.append((v, m, d, unit))
        print(f"  {v:6d} {m['rms_db']:8.1f} {m['centroid']:8.0f}Hz "
              f"{m['onsets']:8.1f}  {m['bands']['<150']:5.1f} "
              f"{m['bands']['150-2k']:6.1f} {m['bands']['2k+']:5.1f}   "
              f"{d:+.1f} {unit}")

    if args.restore is not None:
        lcxl.send_cc(port, args.cc, args.restore)
        print(f"\n  restored CC{args.cc} -> {args.restore}")

    _, thr, unit = THRESH[args.kind]
    if not rows:
        print("\ncontrol-lens: NO VERDICT — every capture was discarded.",
              file=sys.stderr)
        return 2
    if best >= thr:
        print(f"\ncontrol-lens: {name} is EFFECTIVE — best swing {best:.1f} {unit} "
              f"(>= {thr:g} threshold)")
        return 0
    print(f"\ncontrol-lens: {name} shows NO CHANGE — best swing only {best:.1f} "
          f"{unit} (< {thr:g}). Either the control is not wired to this orbit, or "
          f"it is wired but inaudible. Do NOT read this as 'subtle'.",
          file=sys.stderr)
    return 1


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