#!/usr/bin/env python3
"""cut_lens — find where a track REALLY starts, when the transition is a crossfade.

WHY THE EXISTING VERIFIER IS NOT ENOUGH
---------------------------------------
`tidal-ears master cuts verify` passed 13/15 boundaries on OPAL-26 while PLN's
ear found errors of 9, 10 and 21 seconds at three of the ones it passed. It is
not a bad tool — it catches the *stutter* shape (a short fragment, a gap, then
the real start) and on #3/#4 it agreed with his ear to within a second. It is
blind to the shape that actually dominates this set:

    ParVagues switches tracks by CROSSFADE, and every dN is a 4-cycle fade.
    Across a switch the ORBIT SET barely changes — d1 is a kick before and a
    kick after — so every activity-based detector sees continuity and passes.
    What changes is the SAMPLE on each orbit, i.e. the TIMBRE.

So this measures timbre. For each orbit it profiles a reference deep inside the
outgoing track and one deep inside the incoming track, then slides a window
across the transition and asks, per orbit: which reference does this sound like?
The boundary is where the incoming reference wins on the ACTIVE orbits.

VALIDATE IT BEFORE YOU BELIEVE IT
---------------------------------
`--truth` takes PLN's ear-verified boundaries and reports this lens's error
against them. Run that FIRST: an instrument that cannot reproduce the five
answers we already know has no business proposing the eight we don't.

>>> RESULT, 2026-08-16: THIS LENS FAILED ITS OWN VALIDATION. <<<
Against PLN's five ear-verified boundaries it missed by a MEDIAN OF 24 SECONDS,
worst 40 s, with only 1-5 orbits ever voting. Its proposals were discarded and
the cut was corrected from his ear instead (judge_specs/opal26_boundaries_ear
.json). It is kept because the VALIDATION HARNESS is the valuable part, and
because the next person to have this idea should see the result before spending
an afternoon on it.

Where it probably goes wrong, for whoever picks it up:
  * The reference windows are taken from each track's midpoint, but a livecoded
    track is not stationary — its middle can be timbrally further from its own
    head than from the neighbouring track.
  * Requiring `sep >= 0.08` plus audibility leaves too few voting orbits, so one
    bad orbit dominates a weighted mean. A median over more orbits, or a
    per-orbit change-point with agreement voting, would be sturdier.
  * A 6 s sustained-crossing rule cannot resolve a 4-cycle crossfade at 124 BPM
    (~7.7 s), which is precisely the span being measured.

USAGE
    python3 cut_lens.py --spec judge_specs/opal26.json            # propose all
    python3 cut_lens.py --spec judge_specs/opal26.json --truth truth.json
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

import numpy as np

HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from audio_lens import load_window, profile  # noqa: E402

SEARCH = 45.0     # seconds either side of the nominal cut to consider
WIN = 4.0         # profiling window
STEP = 1.0        # slide
REF = 25.0        # reference window taken from deep inside each track
ACTIVE_DB = -50.0  # an orbit must be audible to get a vote


def mmss(t):
    return f"{int(t) // 60}:{int(t) % 60:02d}"


def timbral(a, b):
    """Distance between two spectral profiles: band energies + log centroid.

    Bands carry the sound's shape, centroid its register. Both are needed —
    two different samples can share a centroid, and a filter sweep moves the
    centroid without changing the sample.
    """
    if a is None or b is None:
        return None
    ba = np.array([a["bands"][k] for k in sorted(a["bands"])])
    bb = np.array([b["bands"][k] for k in sorted(b["bands"])])
    band = np.abs(ba - bb).sum() / 200.0                       # 0..1
    ca, cb = max(a["centroid"], 1.0), max(b["centroid"], 1.0)
    cen = min(abs(np.log2(ca / cb)) / 4.0, 1.0)                # 0..1, 4 octaves
    return float(0.65 * band + 0.35 * cen)


def stem_time(t, keeps):
    acc = 0.0
    for a, b in keeps:
        span = b - a
        if t < acc + span:
            return a + (t - acc)
        acc += span
    return keeps[-1][1]


def orbit_ref(path, t0, t1):
    try:
        sig = load_window(path, t0, t1)
        return profile(sig) if sig.size else None
    except Exception:
        return None


def profile_slices(path, t0, span, grid_rel, win, sr=44100):
    """Profiles at many offsets from ONE decode.

    The first version called ffmpeg per window: 14 boundaries x 12 orbits x ~90
    windows ~= 15k process spawns, which is 25 minutes of `execve` and about
    four seconds of arithmetic. Decode the span once, slice it in numpy.
    """
    try:
        sig = load_window(path, t0, t0 + span)
    except Exception:
        return [None] * len(grid_rel)
    out = []
    n = int(win * sr)
    for rel in grid_rel:
        a = int(rel * sr)
        seg = sig[a:a + n]
        out.append(profile(seg) if seg.size >= n // 2 else None)
    return out


def analyse_boundary(stems, keeps, seg_prev, seg_next, verbose=False):
    """Estimated true boundary (master time) between two segments."""
    nominal = seg_next["start"]
    lo = max(seg_prev["start"] + 5, nominal - SEARCH)
    hi = min(seg_next["end"] - 5, nominal + SEARCH)

    # References from the CALM middle of each track, well clear of both edges.
    pm = (seg_prev["start"] + seg_prev["end"]) / 2
    nm = (seg_next["start"] + seg_next["end"]) / 2

    votes, weights = [], []
    grid = np.arange(lo, hi - WIN, STEP)
    for path in stems:
        rp = orbit_ref(path, stem_time(pm, keeps), stem_time(pm, keeps) + REF)
        rn = orbit_ref(path, stem_time(nm, keeps), stem_time(nm, keeps) + REF)
        if rp is None or rn is None:
            continue
        # An orbit that is silent in either track cannot discriminate, and an
        # orbit whose two references are identical (same sample both sides)
        # carries no information about where the switch happened.
        if max(rp["rms_db"], rn["rms_db"]) < ACTIVE_DB:
            continue
        sep = timbral(rp, rn)
        if sep is None or sep < 0.08:
            continue

        # One decode across the whole search span. `keeps` only matters if a cut
        # falls inside the window; none of OPAL-26's boundaries do, and the
        # offset is checked below.
        st0 = stem_time(float(grid[0]), keeps)
        if abs(stem_time(float(grid[-1]), keeps) - (st0 + (grid[-1] - grid[0]))) > 0.01:
            raise SystemExit("a master edit falls inside a boundary search window — "
                             "decode-once is invalid there; narrow SEARCH")
        profs = profile_slices(path, st0, float(grid[-1] - grid[0]) + WIN,
                               [float(t - grid[0]) for t in grid], WIN)
        series = []
        for p in profs:
            if p is None or p["rms_db"] < ACTIVE_DB:
                series.append(np.nan)
                continue
            dp, dn = timbral(p, rp), timbral(p, rn)
            series.append(dp - dn)          # >0 == sounds like the NEXT track
        series = np.array(series, dtype=float)
        if np.all(np.isnan(series)):
            continue

        # First sustained crossing into "sounds like next", requiring it to
        # STAY there — a single window flipping is a hi-hat, not a handover.
        need = int(6 / STEP)
        cross = None
        for i in range(len(series) - need):
            w = series[i:i + need]
            if np.nanmean(w > 0) >= 0.8 and np.nansum(~np.isnan(w)) >= need * 0.6:
                cross = float(grid[i])
                break
        if cross is not None:
            votes.append(cross)
            weights.append(sep)
            if verbose:
                print(f"      {path.name[-6:]:>6}  sep={sep:.2f}  crosses at {mmss(cross)}")

    if not votes:
        return None, 0, []
    votes = np.array(votes)
    weights = np.array(weights)
    est = float(np.average(votes, weights=weights))
    return est, len(votes), votes.tolist()


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--spec", required=True)
    ap.add_argument("--truth", help="JSON {track_number: true_start_seconds} from PLN's ear")
    ap.add_argument("--out", help="write proposed segments JSON")
    ap.add_argument("-v", "--verbose", action="store_true")
    a = ap.parse_args()

    spec = json.loads(Path(a.spec).read_text())
    segs = json.loads(Path(spec["segments"]).read_text()) if spec.get("segments") else \
        json.loads((Path(spec["clipsDir"]).parent / "segments_v3.json").read_text())
    keeps = spec["keeps"]
    stems = sorted(Path(spec["stemsDir"]).glob(spec.get("stemsGlob", "*.wav")))
    truth = {int(k): float(v) for k, v in json.loads(Path(a.truth).read_text()).items()} if a.truth else {}

    print(f"cut-lens over {len(segs)} segments, {len(stems)} orbits\n")
    print(f"{'#':>2} {'title':<30} {'nominal':>9} {'proposed':>9} {'shift':>8} {'n':>3}  {'truth':>9} {'err':>7}")
    errs, out = [], []
    for i in range(1, len(segs)):
        prev, nxt = segs[i - 1], segs[i]
        if a.verbose:
            print(f"\n  -- boundary into #{nxt['track']} {nxt['title']}")
        est, n, _ = analyse_boundary(stems, keeps, prev, nxt, a.verbose)
        row = f"{nxt['track']:>2} {nxt['title'][:30]:<30} {nxt['start']:>9.1f}"
        if est is None:
            print(row + f" {'—':>9} {'—':>8} {0:>3}")
            continue
        shift = est - nxt["start"]
        row += f" {est:>9.1f} {shift:>+8.1f} {n:>3}"
        if nxt["track"] in truth:
            t = truth[nxt["track"]]
            err = est - t
            errs.append(abs(err))
            row += f"  {t:>9.1f} {err:>+7.1f}"
        print(row)
        out.append({"track": nxt["track"], "title": nxt["title"],
                    "nominal": nxt["start"], "proposed": round(est, 2)})

    if errs:
        print(f"\nVALIDATION against {len(errs)} ear-verified boundaries: "
              f"median |err| = {np.median(errs):.1f}s, worst = {max(errs):.1f}s")
        print("A lens that cannot reproduce the known answers must not be trusted "
              "on the unknown ones.")
    if a.out:
        Path(a.out).write_text(json.dumps(out, indent=2))
        print(f"\n✓ {a.out}")
    return 0


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