#!/usr/bin/env python3
"""tempo-lens — measure the tempo an orbit is actually FELT at, from its audio.

Why this exists (2026-07-28, J-7 to OPAL)
-----------------------------------------
Ordering a set by BPM needs the BPM, and `setcps` is not it. `setcps(80/60/4)`
declares four beats to a cycle at 80 — but whether that is *heard* as 80 or as 160
depends entirely on what the patterns put inside the cycle. Half-time notation is
standard in dnb, and `gimme_acid` is written 80 while the backlog calls it 160. That
one question decides whether the track sits beside the 160 BPM peak or is the
second-slowest thing in the set, and no amount of reading the file can settle it.

So measure the kick. Onset times from spectral flux, then the histogram of
inter-onset intervals — the MODE, not the mean, because a mean over a pattern with
ghost notes lands between two real values and is a tempo nothing is playing.

The answer is reported as a FAMILY, not a number: tempo from audio is only ever
determined up to a factor of two (a listener may feel 80 or 160 in the same music,
and both are correct). What the tool can say is which subdivision the events
actually occupy, and that is what a set order needs.

Usage
-----
    tools/tempo-lens.py --orbit 1 -s 20        # the kick orbit, 20s
    tools/tempo-lens.py --orbit 1 --declared 80

Needs the track already playing (this tool never boots or evaluates anything) and
numpy. Safe to run during a set.
"""
from __future__ import annotations

import argparse
import importlib.util
import sys
from pathlib import Path

import numpy as np

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


def _load(name: str, path: Path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


lens = _load("control_lens", TOOLS / "control-lens.py")
SR = lens.SR


# 200 Hz, not 100. At 100 Hz a lag of 48.39 frames (124 BPM) has to be rounded to 48,
# which misaligns every later beat and CRUSHES the correlation at the true period —
# measured 0.70 at the true lag versus 0.85 at exactly twice it, because 2x happened
# to land on a whole frame (96.8 -> 97). That is how a clean 124 BPM click track got
# estimated at 61.9. Finer frames plus the smoothing below make the correlation
# tolerant of a fractional period, which is the actual requirement.
HOP_HZ = 200
SMOOTH_MS = 15.0


def flux_envelope(sig: np.ndarray) -> np.ndarray:
    """Spectral-flux novelty curve, smoothed — the input to tempo estimation.

    The smoothing is not cosmetic. An impulse-thin novelty peak only correlates with
    another impulse-thin peak if the lag is an exact whole number of frames; giving
    each peak ~15 ms of width means a period of 48.39 frames still matches itself.
    """
    win, hop = 1024, SR // HOP_HZ
    n = (len(sig) - win) // hop
    if n < 64:
        return np.array([])
    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)
    sigma = max(1.0, SMOOTH_MS * HOP_HZ / 1000.0)
    half = int(3 * sigma)
    k = np.exp(-0.5 * (np.arange(-half, half + 1) / sigma) ** 2)
    return np.convolve(flux, k / k.sum(), mode="same")


def tempo_from_envelope(env: np.ndarray, lo_bpm=55.0, hi_bpm=210.0):
    """(bpm, strength, curve) by AUTOCORRELATING the novelty curve.

    The first version of this took the MODE of the inter-onset intervals, and it
    failed on the very first track it was pointed at. gimme_acid's d1 is a
    continuous 303-ish line, so the flux peaks every ~80 ms of its own internal
    movement and the modal gap was 0.080 s — reported as "750 events/min", with no
    simple ratio to the declared 80 BPM. The tool was right to refuse a verdict, but
    it had no business asking that question: **counting gaps between events cannot
    find a beat when the events are not beats.**

    Autocorrelation asks the right question — "at what lag does this signal resemble
    itself?" — which is a statement about PERIODICITY and is unbothered by extra
    onsets inside each period. Restricting the lag search to a musical range
    (55-210 BPM) does the rest.
    """
    if env.size < 4 * HOP_HZ:
        return None, 0.0, None
    x = env - env.mean()
    raw = np.correlate(x, x, mode="full")[len(x) - 1:]
    if raw[0] <= 0:
        return None, 0.0, None
    # UNBIASED: divide each lag by the number of samples that actually overlapped.
    # np.correlate sums over N-k terms at lag k, so without this the curve decays
    # with lag purely for arithmetic reasons and the peak is dragged toward SHORTER
    # lags — i.e. every tempo reads HIGH. Measured as a uniform +1.4 to +2.0% across
    # twelve synthetic click tracks, which is ~2 BPM at techno tempo: enough to swap
    # a 124 track and a 127 track in a set order. A bias that consistent is never the
    # music, it is the estimator.
    counts = np.arange(len(x), 0, -1)
    ac = (raw / counts) / (raw[0] / counts[0])
    lo_lag = max(2, int(HOP_HZ * 60.0 / hi_bpm))
    hi_lag = min(len(ac) - 1, int(HOP_HZ * 60.0 / lo_bpm))
    if hi_lag <= lo_lag:
        return None, 0.0, None
    window = ac[lo_lag:hi_lag + 1]
    peak = float(window.max())
    if peak <= 0:
        return None, 0.0, None

    # OCTAVE DISAMBIGUATION — take the FASTEST pulse that still explains the signal.
    #
    # A perfectly periodic signal correlates just as well at TWICE the lag (two
    # periods is also a match), so a plain argmax picks an arbitrary metrical level
    # and often the wrong one. Caught on synthetic click tracks before this tool ever
    # informed a decision: a clean 124 BPM kick was estimated at 61.9, a clean 160 at
    # 80.0 — both exactly half, both with correlation 0.85+, i.e. confidently wrong.
    #
    # Among all lags whose correlation is within 12% of the peak, choose the SHORTEST.
    # That names the fastest pulse the audio supports, which is the stable convention;
    # the caller is then shown the half/double family explicitly, because tempo from
    # audio is only ever determined up to a factor of two and pretending otherwise is
    # the actual error.
    # Candidates must be LOCAL MAXIMA, not merely "high".
    #
    # The shortest-lag rule below exists to choose between metrical levels, but on a
    # smoothed curve each peak is a plateau ~15 ms wide, so a bare threshold test also
    # slides down the LEFT FLANK of the correct peak and returns a lag ~2% short —
    # which is where the stubborn uniform +1.8% tempo bias actually came from. It was
    # never the octave logic and never the overlap normalisation; it was comparing
    # points on one hill as if they were different hills.
    interior = window[1:-1]
    is_peak = (interior >= window[:-2]) & (interior > window[2:])
    peaks = np.flatnonzero(is_peak) + 1
    tall = peaks[window[peaks] >= 0.88 * peak]
    best = int(tall[0]) + lo_lag if tall.size else int(np.argmax(window)) + lo_lag

    # Parabolic interpolation across the three bins around the chosen lag. The true
    # period is not a whole number of frames, so the integer bin is systematically
    # off — measured a consistent +1.8% before this, which is 2 BPM at techno tempo
    # and enough to make a 124 track and a 127 track swap places in a set order.
    if 0 < best < len(ac) - 1:
        y0, y1, y2 = ac[best - 1], ac[best], ac[best + 1]
        denom = y0 - 2 * y1 + y2
        if denom != 0:
            shift = 0.5 * (y0 - y2) / denom
            if abs(shift) <= 1.0:
                best = best + shift
    return 60.0 * HOP_HZ / best, float(peak), ac


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 — use the KICK orbit (usually d1)")
    ap.add_argument("-s", "--seconds", type=float, default=20.0)
    ap.add_argument("--declared", type=float, default=None,
                    help="the track's setcps BPM, to compare against")
    args = ap.parse_args()

    sig, why = lens.capture(args.orbit, args.seconds)
    if sig is None:
        print(f"tempo-lens: DISCARD — {why}", file=sys.stderr)
        return 2
    env = flux_envelope(sig)
    if env.size == 0:
        print(f"tempo-lens: INCONCLUSIVE — no usable novelty curve on d{args.orbit}",
              file=sys.stderr)
        return 3
    bpm, strength, _ = tempo_from_envelope(env)
    if bpm is None:
        print("tempo-lens: INCONCLUSIVE — no periodicity in the musical range",
              file=sys.stderr)
        return 3

    print(f"tempo-lens: d{args.orbit}, {args.seconds:g}s of audio")
    print(f"  strongest periodicity: {bpm:.1f} BPM "
          f"(autocorrelation {strength:.2f})")
    if strength < 0.15:
        print("  WEAK — that correlation is low enough that the number should not be "
              "trusted. Try the kick orbit, or a longer -s.")
    print("\n  the same music, expressed at each metrical level "
          "(audio fixes tempo only up to a factor of 2):")
    for mult, label in ((0.5, "half-time"), (1.0, "as measured"), (2.0, "double-time")):
        print(f"    {bpm * mult:6.1f} BPM   ({label})")

    if args.declared:
        print(f"\n  declared setcps BPM: {args.declared:g}")
        for mult in (0.25, 0.5, 1, 2, 4):
            if abs(bpm * mult - args.declared) < 0.06 * args.declared:
                if mult == 1:
                    print(f"  MATCH — the pulse IS the declared tempo. "
                          f"'{args.declared:g}' is what a listener feels.")
                else:
                    print(f"  MATCH at {mult:g}x — the audible pulse is "
                          f"{bpm:.0f} BPM while the file declares "
                          f"{args.declared:g}. Neither is wrong; for ORDERING A SET "
                          f"use the felt {bpm:.0f}, since that is what the room "
                          f"responds to.")
                break
        else:
            print("  NO simple ratio matches the declared BPM. Either this is not the "
                  "kick orbit, or the pulse is genuinely elsewhere — listen before "
                  "acting on it.")
    return 0


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