#!/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:
    """Onset rate from SPECTRAL FLUX, not raw envelope ratio.

    The first version of this counted bins where energy rose 1.6x over the
    previous bin. On a percussive orbit that works; on a SUSTAINED one (d4 here is
    a bass) it returns a flat 0.0 events/s — and a baseline of zero made the
    percentage delta `inf`, so the tool cheerfully declared a control EFFECTIVE on
    the strength of a division by zero. That is the precise failure this file's
    docstring promises not to commit, so it is fixed rather than tolerated.

    Spectral flux (half-wave-rectified increase in per-band magnitude) fires on a
    NEW NOTE even when total level barely moves, which is exactly what `ply` does:
    it subdivides events without making things louder. Still deliberately not
    librosa — this must run on the live rig under system python3 — and still only
    needs to be RELATIVE, not musicologically correct.
    """
    win, hop = 1024, SR // 100          # ~21 ms window, 10 ms hop
    n = (len(sig) - win) // hop
    if n < 16:
        return 0.0
    w = np.hanning(win)
    frames = np.lib.stride_tricks.sliding_window_view(sig, win)[::hop][:n]
    mag = np.abs(np.fft.rfft(frames * w, axis=1))
    flux = np.maximum(0.0, np.diff(mag, axis=0)).sum(axis=1)
    if flux.max() <= 1e-9:
        return 0.0
    # Adaptive threshold: median + 2 * MAD is robust to a few huge transients in a
    # way that mean + k*std is not.
    med = float(np.median(flux))
    mad = float(np.median(np.abs(flux - med))) + 1e-9
    thresh = max(med + 2.5 * mad, flux.max() * 0.08)
    count, last = 0, -99
    for i in range(1, len(flux) - 1):
        # Local peak above threshold, with a 50 ms refractory gap so one transient
        # is not counted five times.
        if flux[i] > thresh and flux[i] >= flux[i-1] and flux[i] > flux[i+1] and i - last > 5:
            count += 1
            last = i
    return count / (len(flux) / 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,
    }


# Below this an onset rate is not a measurement, it is a rounding artifact. A
# percentage against a near-zero baseline is arbitrarily large, and on the first
# real run that made `ply` read "+inf %, EFFECTIVE" off a division by zero on a
# sustained bass orbit. NaN is returned instead so the caller must handle it.
MIN_ONSETS = 0.8


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


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")
    ap.add_argument("--repeats", type=int, default=2, metavar="N",
                    help="how many times to capture the BASELINE before touching the "
                         "control (default 2). The spread between them is the noise "
                         "floor a swing has to beat; 1 disables it and makes every "
                         "verdict weaker than it looks.")
    ap.add_argument("--no-aba", dest="aba", action="store_false",
                    help="skip the closing A-again capture (faster, but then a swing "
                         "cannot be told apart from a pattern that varies per cycle)")
    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)

    # NOISE FLOOR FROM REPEATED BASELINES, not from a single one.
    #
    # The closing A-again capture gives ONE estimate of self-drift, and on
    # 2026-07-28 that underestimated it badly: two captures of d2 at the SAME
    # control setting read 1.6 and 2.4 onsets/s — 33% apart, which happened to
    # equal gMask's whole measured swing — yet the closing capture matched the
    # opening one exactly, so the reported drift was 0.0%. A single repeat can
    # agree with the first reading by luck. Take several and use the SPREAD.
    floor_from_repeats = 0.0
    for _ in range(max(0, args.repeats - 1)):
        rep, why = capture(args.orbit, args.seconds)
        if rep is None:
            print(f"  baseline repeat discarded ({why})", file=sys.stderr)
            continue
        dr, _, _ = delta_for(args.kind, b, measure(rep))
        if not (math.isnan(dr) or math.isinf(dr)):
            floor_from_repeats = max(floor_from_repeats, abs(dr))
    if floor_from_repeats:
        print(f"  baseline repeated {args.repeats}x: it varies by "
              f"{floor_from_repeats:.1f} on its own\n")

    if args.kind == "density" and b["onsets"] < MIN_ONSETS:
        print(f"control-lens: INCONCLUSIVE — d{args.orbit} shows only "
              f"{b['onsets']:.2f} onsets/s at baseline, which is below what this "
              f"detector can measure a CHANGE against. Density is the wrong lens "
              f"for a sustained or near-silent orbit; test a ply control on a "
              f"PERCUSSIVE orbit instead. (Refusing to divide by ~zero and call "
              f"the result an effect.)", file=sys.stderr)
        return 3
    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)
        if math.isnan(d) or math.isinf(d):
            print(f"  {v:6d}   NOT JUDGED (delta undefined against this baseline)",
                  file=sys.stderr)
            continue
        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}")

    # A-B-A: re-measure the baseline condition at the END.
    #
    # Without this the design cannot separate "the control did it" from "the
    # pattern did it on its own", and on this rig patterns MOVE BY THEMSELVES:
    # d2 of vague_de_crime is `"~ c . <[~ c ~ c] [<c ~ ~ c> ...]>"`, i.e. a
    # different bar every cycle. On 2026-07-28 gMask showed an identical -42%
    # onset drop at BOTH test values, which is either a control that does not
    # revert or a pattern that simply varied — indistinguishable from an A-B
    # comparison alone. So measure A again and report the drift; a swing smaller
    # than the drift is not a result.
    drift = floor_from_repeats or None
    if args.aba:
        time.sleep(1.5)
        again, why = capture(args.orbit, args.seconds)
        if again is None:
            print(f"  A-B-A control capture discarded ({why})", file=sys.stderr)
        else:
            a2 = measure(again)
            d2, _, unit2 = delta_for(args.kind, b, a2)
            if not (math.isnan(d2) or math.isinf(d2)):
                drift = max(abs(d2), floor_from_repeats)
                print(f"  {'A again':>6s} {a2['rms_db']:8.1f} {a2['centroid']:8.0f}Hz "
                      f"{a2['onsets']:8.1f}  {a2['bands']['<150']:5.1f} "
                      f"{a2['bands']['150-2k']:6.1f} {a2['bands']['2k+']:5.1f}   "
                      f"{d2:+.1f} {unit2}  <- drift with the control back at base")

    _, thr, unit = THRESH[args.kind]
    if not rows:
        print("\ncontrol-lens: NO VERDICT — every capture was discarded.",
              file=sys.stderr)
        return 2
    if drift is not None and best < 2.0 * drift:
        print(f"\ncontrol-lens: {name} INCONCLUSIVE — best swing {best:.1f} {unit} is "
              f"not clear of the {drift:.1f} {unit} the signal drifted on its own with "
              f"the control back at baseline. The pattern may simply vary per cycle "
              f"(many do). Re-run with a longer -s, or judge it on a steadier orbit.",
              file=sys.stderr)
        return 3
    if best >= thr:
        extra = f", drift {drift:.1f}" if drift is not None else ""
        print(f"\ncontrol-lens: {name} is EFFECTIVE — best swing {best:.1f} {unit} "
              f"(>= {thr:g} threshold{extra})")
        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())
