#!/usr/bin/env python3
"""take-lens — read a recorded Ardour take through the gig log.

WHY THIS EXISTS (2026-07-29)

PLN recorded 28 minutes of OPAL practice and asked for "a master, split per track
loaded time, cleanup parts obviously 'practice or plugging in' that are scarce VS
intro/outro (can we detect this?)".

The answer needed three datasets that had never been joined:

  1. WHAT WAS RECORDED — Ardour writes one file per orbit per take, named
     `Take<N>_Tidal <orbit>-<seq>%<L|R>.wav`. The take number and the file length
     give an exact wall-clock window, because the mtime is the moment recording
     stopped. No session XML parsing needed.
  2. WHAT THE HANDS DID — `tools/gig-log.py` logs coalesced MIDI CC at 1 Hz. Hands
     idle for six minutes is the single clearest signature of "plugging in".
  3. WHAT THE SOUND DID — per-orbit RMS and peak. Needed because idle hands alone
     are ambiguous: you can play a track for three minutes without touching a knob.
     Only `idle hands AND flat audio` means dead air.

Plus, from 2026-07-29 onward, `k:"track"` records (see gig-log.py) give the loaded
.tidal at every moment — which turns "split per track" from inference into a read.
Takes recorded BEFORE that lands have no track timeline; this tool says so plainly
rather than guessing, because a confident wrong boundary is worse than no boundary
(the existing Bandcamp splits carry crossfade bleed for exactly that reason).

WHAT IT DOES NOT DO
It proposes; it does not cut. Output is a report plus an EDL JSON for
`armada/tide-table/edl_render.py`. Trims are a taste call and the ear is the gate.

USAGE
  tools/take-lens.py takes                    # what takes exist, with wall-clock windows
  tools/take-lens.py lens 94                  # correlate take 94: hands, sound, tracks
  tools/take-lens.py lens 94 --bucket 10      # finer time resolution
  tools/take-lens.py lens 94 --edl OUT.json   # also write a proposed EDL
  tools/take-lens.py lens 94 --no-audio       # MIDI only (instant, no 3 GB read)
  tools/take-lens.py pack 94                  # sidecar meta+midi+edl beside the audio
  tools/take-lens.py pack 94 --compress       # ...and wavpack it, verified lossless
  tools/take-lens.py mix 94 --out M.wav --trim --gain-db -16
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
from pathlib import Path

SESSION = Path(os.environ.get(
    "PARVAGUES_ARDOUR_SESSION",
    os.path.expanduser("~/Work/Sound/Ardour/Tidal Live")))
LOG_DIR = Path(os.environ.get(
    "GIG_LOG_DIR", os.path.expanduser("~/.local/share/parvagues/gig-log")))

# Ardour source naming for this session. The %L/%R suffix is the channel: each orbit
# records as TWO mono files, so only %L is read for activity (a stereo orbit whose
# channels differ in ENERGY would be a routing bug, not a musical event).
SRC_RE = re.compile(r"Take(\d+)_Tidal (\d+)-(\d+)%([LR])\.wav$")

BAR = " ▁▂▃▄▅▆▇█"

# A hands-idle run at least this long is a TRIM CANDIDATE — but only when the audio
# agrees. 45s was chosen because it is longer than any musical phrase in the set at
# 120-140 BPM (a 16-bar section is ~32s) and so cannot be mistaken for "playing a
# part without touching anything".
IDLE_RUN_S = 45.0
# SILENCE, on the other hand, needs no corroboration from the hands and can be much
# shorter. Learned the hard way on take 94: the first version of this tool required
# idle hands AND silence, and so kept ~50s of DIGITAL silence (-180 dBFS, verified by
# audio_lens profile) in the middle of the mix — because PLN was busy on the
# controller with nothing sounding. That state is "fiddling in silence", which is
# exactly the practice/plugging-in the trim exists to remove.
#
# 10s, not 20s. The 20s bar was chosen so the trimmer could never eat a musical break
# (4 bars is under 8s at 120-140 BPM) — but PLN heard the result and it had become the
# opposite guarantee: "silence around 18 is not normal its a bad transition ahah in a
# real mixing work wed cut it proper and fade or at least trim most". All three gaps
# that survived the first trim measured 17s, 18s and 11s — every one of them sat just
# under the bar. A threshold that protects musical rests was preserving every bad
# transition instead.
SILENT_RUN_S = 10.0
# ...and the answer to a bad transition is not "delete every trace of it" either. A
# reported silence is CLAMPED, not removed: the last SILENT_KEEP_S before the music
# resumes is kept as a breath, and everything before that goes. That is "or at least
# trim most" implemented literally, and it means the rule stays safe even if it fires
# on something musical — the worst case is a shortened rest, not a missing one.
SILENT_KEEP_S = 2.0
# Every cut this tool made before today was a BUTT-JOIN, which in a live room is a
# click. Each removal now fades out into the cut and back in out of it. 250ms is long
# enough to be inaudible as a transient and far too short to smear a downbeat.
SEAM_FADE_S = 0.25
# A dead patch split by a one-second tail crossing the threshold is still one dead
# patch. Blips no longer than this, with at most one orbit sounding, are bridged.
SILENT_BRIDGE_S = 2.0
# Below this, an orbit is considered silent for the bucket. -60 dBFS is well under
# the noise floor of the capture chain (the known 223 Hz vsink noise sits lower) and
# well above true digital silence, so a decaying reverb tail still counts as sound.
SILENT_DBFS = -60.0


def sparkline(vals: list[float], lo: float | None = None,
              hi: float | None = None) -> str:
    """Eight-level sparkline. Empty string for no data.

    Scaled to the data unless bounds are given. A near-zero baseline is NOT turned
    into a percentage — the right lens per control, learned the hard way.
    """
    if not vals:
        return ""
    lo = min(vals) if lo is None else lo
    hi = max(vals) if hi is None else hi
    if hi <= lo:
        return BAR[0] * len(vals)
    out = []
    for v in vals:
        f = (v - lo) / (hi - lo)
        out.append(BAR[max(0, min(8, int(round(8 * f))))])
    return "".join(out)


def hhmmss(t: float) -> str:
    return time.strftime("%H:%M:%S", time.localtime(t))


def hhmm(t: float) -> str:
    return time.strftime("%H:%M", time.localtime(t))


# ---------------------------------------------------------------- takes discovery

def find_takes(session: Path = SESSION) -> dict[int, dict]:
    """Map take number -> {orbits: {n: path}, dur_s, start, end, sr}.

    Duration comes from the frame count via soundfile when available, falling back
    to size/(sr*bytes) — the fallback is only used for the listing, never for a cut.
    `end` is the file mtime: Ardour writes until the transport stops, so the last
    write IS the end of the take. `start` is end - duration.
    """
    try:
        import soundfile as sf
    except ImportError:
        sf = None
    takes: dict[int, dict] = {}
    inter = session / "interchange"
    if not inter.is_dir():
        return takes
    for f in inter.rglob("*.wav"):
        m = SRC_RE.search(f.name)
        if not m or m.group(4) != "L":
            continue
        take, orbit = int(m.group(1)), int(m.group(2))
        st = f.stat()
        t = takes.setdefault(take, {"orbits": {}, "dur_s": 0.0, "end": 0.0,
                                    "sr": 48000, "exact": False})
        t["orbits"][orbit] = f
        t["end"] = max(t["end"], st.st_mtime)
        if sf is not None:
            try:
                info = sf.info(str(f))
                t["sr"] = info.samplerate
                t["subtype"] = info.subtype           # PCM_24, FLOAT, ...
                t["dur_s"] = max(t["dur_s"], info.frames / info.samplerate)
                t["exact"] = True
                continue
            except Exception:
                pass
        # Fallback only, and only for the listing. NOTE this guessed 3 bytes/frame
        # and was WRONG by 33% on this session, which are 32-bit FLOAT: it put the
        # take's start 7 minutes early and invented a 6-minute dead intro that did
        # not exist. Never cut from an estimated length.
        t["dur_s"] = max(t["dur_s"], st.st_size / (t["sr"] * 4))
    for t in takes.values():
        t["start"] = t["end"] - t["dur_s"]
    return takes


def cmd_takes(session: Path) -> int:
    takes = find_takes(session)
    if not takes:
        print(f"take-lens: no Take*_Tidal sources under {session}/interchange",
              file=sys.stderr)
        return 1
    print(f"session: {session}")
    print("take  orbits  minutes  window                    len")
    for n in sorted(takes):
        t = takes[n]
        miss = [o for o in range(1, 13) if o not in t["orbits"]]
        flag = "" if not miss else f"  MISSING orbits {miss}"
        mark = "" if t["exact"] else "  (length estimated)"
        print(f"{n:>4}  {len(t['orbits']):>6}  {t['dur_s']/60:>7.1f}  "
              f"{hhmmss(t['start'])} -> {hhmmss(t['end'])}{mark}{flag}")
    return 0


# ------------------------------------------------------------------- the gig log

def logs_covering(t0: float, t1: float, d: Path = LOG_DIR) -> list[Path]:
    """Every gig log whose [first, last] sample range overlaps the window.

    A take can straddle a log rotation (a restart of the recorder mid-session), so
    this must never assume the newest log holds everything. Reads only the first and
    last lines to decide — the files run to tens of MB.
    """
    out = []
    for p in sorted(d.glob("gig-*.jsonl")):
        try:
            with p.open("rb") as f:
                first = f.readline().decode("utf-8", "replace")
                try:
                    f.seek(-4096, os.SEEK_END)
                except OSError:
                    f.seek(0)
                tail = f.read().decode("utf-8", "replace").splitlines()
            a = json.loads(first).get("t")
            b = None
            for line in reversed(tail):
                try:
                    b = json.loads(line).get("t")
                except json.JSONDecodeError:
                    continue
                if b:
                    break
            if a is None or b is None:
                continue
            if a <= t1 and b >= t0:
                out.append(p)
        except (OSError, json.JSONDecodeError, KeyError):
            continue
    return out


def read_window(paths: list[Path], t0: float, t1: float) -> dict:
    """Pull every record inside the window, grouped by kind."""
    got = {"cc": [], "note": [], "s": [], "track": [], "mark": []}
    for p in paths:
        with p.open() as f:
            for line in f:
                if '"t":' not in line:
                    continue
                try:
                    r = json.loads(line)
                except json.JSONDecodeError:
                    continue
                t = r.get("t")
                k = r.get("k")
                if t is None or k not in got or not (t0 <= t <= t1):
                    continue
                got[k].append(r)
    for v in got.values():
        v.sort(key=lambda r: r["t"])
    return got


# ------------------------------------------------------------------ audio energy

def orbit_energy(path: Path, bucket_s: float, nb: int, sr: int) -> dict:
    """Per-bucket RMS and peak dBFS for one orbit, plus overall peak.

    Read sequentially in bucket-sized blocks: one pass, no seeking, constant memory.
    A whole 28-minute orbit is ~240 MB and takes about a second off warm cache.
    """
    import numpy as np
    import soundfile as sf
    n = int(bucket_s * sr)
    rms = [0.0] * nb
    pk = [0.0] * nb
    i = 0
    with sf.SoundFile(str(path)) as f:
        while i < nb:
            blk = f.read(n, dtype="float32", always_2d=False)
            if blk is None or len(blk) == 0:
                break
            if blk.ndim > 1:
                blk = blk.mean(axis=1)
            rms[i] = float(np.sqrt(np.mean(np.square(blk, dtype="float64"))))
            pk[i] = float(np.max(np.abs(blk)))
            i += 1

    def db(x: float) -> float:
        return 20.0 * __import__("math").log10(x) if x > 1e-12 else -120.0

    return {"rms_db": [db(v) for v in rms], "peak_db": [db(v) for v in pk],
            "peak_all_db": db(max(pk) if pk else 0.0)}


# The grain everything is DECIDED at. Display coarseness is a separate knob.
GRAIN_S = 1.0


def analyse(t: dict, rec: dict, grain_s: float, audio: bool):
    """Per-grain hands and sound for one take. One audio pass, one truth.

    Returns (n_steps, cc_n, notes, ctrls, active, loud, energy) where every list is
    indexed by grain step. Shared by the report and the mixer so they can never be
    looking at different numbers.
    """
    dur, sr = t["dur_s"], t["sr"]
    ns = max(1, int(dur // grain_s) + 1)

    def step_of(ts: float) -> int:
        return min(ns - 1, max(0, int((ts - t["start"]) // grain_s)))

    cc_n = [0] * ns
    ctrls: list[set] = [set() for _ in range(ns)]
    for r in rec.get("cc", []):
        i = step_of(r["t"])
        cc_n[i] += r.get("n", 1)
        if r.get("cc") is not None:
            ctrls[i].add(r["cc"])
    notes = [0] * ns
    for r in rec.get("note", []):
        notes[step_of(r["t"])] += 1

    energy: dict[int, dict] = {}
    if audio:
        try:
            for orb in sorted(t["orbits"]):
                energy[orb] = orbit_energy(t["orbits"][orb], grain_s, ns, sr)
        except ImportError as e:
            print(f"  audio skipped: {e}")
            energy = {}
    active = [0] * ns
    loud = [-120.0] * ns
    for e in energy.values():
        for i, v in enumerate(e["rms_db"]):
            if v > SILENT_DBFS:
                active[i] += 1
            loud[i] = max(loud[i], v)
    return ns, cc_n, notes, ctrls, active, loud, energy


# ------------------------------------------------------------- classification

def classify(nb: int, bucket_s: float, dur: float, cc_n: list[int],
             active: list[int], n_orbits: int) -> list[dict]:
    """Every trim candidate, in one place.

    THE single source of the cut rule. It used to be duplicated between the report
    and the mixer, which is the worst possible arrangement here: a mixer that trims
    something the report called `keep` destroys material silently. Both callers now
    ask this function.

    Kinds, in priority order:
      silence  every orbit under SILENT_DBFS for >= SILENT_RUN_S. Trim, no matter
               what the hands were doing — see the SILENT_RUN_S note.
      dead     hands idle >= IDLE_RUN_S and nothing sounding. Trim.
      loop     hands idle >= IDLE_RUN_S, a couple of orbits droning. Probably
               plugging in; flag but do not auto-cut.
      playing  hands idle >= IDLE_RUN_S, most orbits going. A real part. KEEP.

    Each candidate carries BOTH the detected region (`start_s`/`end_s`) and the
    region a mixer would actually remove (`cut_start_s`/`cut_end_s`), because for
    `silence` those differ: the tail is kept as a breath. Computing the cut here
    rather than in the mixer is the whole point of this function — a mixer that
    derives its own bounds is a mixer that can silently disagree with the report.
    """
    out: list[dict] = []

    def emit(a: int, b: int, kind: str, why: str) -> None:
        s, e = a * bucket_s, min(dur, b * bucket_s)
        # A silence is clamped to a breath; anything else is removed whole.
        cs, ce = (s, max(s, e - SILENT_KEEP_S)) if kind == "silence" else (s, e)
        out.append({"start_s": s, "end_s": e, "dur_s": e - s,
                    "cut_start_s": cs, "cut_end_s": ce, "cut_dur_s": ce - cs,
                    "kind": kind, "why": why})

    def runs(pred) -> list[tuple[int, int]]:
        found, cur = [], None
        for i in range(nb):
            if pred(i):
                cur = i if cur is None else cur
            elif cur is not None:
                found.append((cur, i))
                cur = None
        if cur is not None:
            found.append((cur, nb))
        return found

    have_audio = any(active)
    silent_spans: list[tuple[int, int]] = []
    if have_audio:
        # CLOSE the silence first. A dead patch is rarely digitally clean end to
        # end: one orbit's tail crossing -60 dBFS for a second splits it into
        # pieces that each fall under SILENT_RUN_S, and the whole patch survives.
        # Measured on take 94: a 21s dead stretch came through as 4.25 + 11.25 +
        # 4.25 with two blips between, so the trim removed none of it.
        #
        # Only ONE orbit may be sounding in a bridged blip. That is the difference
        # between a decaying tail and a musical event — a real hit lights several
        # orbits — and it is what keeps this from swallowing a one-shot.
        sil = [active[i] == 0 for i in range(nb)]
        bridge = max(1, int(SILENT_BRIDGE_S / bucket_s))
        for a, b in runs(lambda i: not sil[i]):
            if (a > 0 and b < nb and (b - a) <= bridge
                    and max(active[a:b], default=99) <= 1):
                for i in range(a, b):
                    sil[i] = True
        for a, b in runs(lambda i: sil[i]):
            if (b - a) * bucket_s >= SILENT_RUN_S:
                silent_spans.append((a, b))
                emit(a, b, "silence", "every orbit under %.0f dBFS" % SILENT_DBFS)

    for a, b in runs(lambda i: cc_n[i] == 0):
        if (b - a) * bucket_s < IDLE_RUN_S:
            continue
        # Already covered by a silence span? Do not report it twice. Measured by
        # OVERLAP, not containment: the hands-idle run and the silence run rarely
        # start on the same step (one began 1s earlier on take 94) and strict
        # containment then reported the same stretch twice, once as `silence` and
        # once as `loop` — contradicting itself in the same table.
        span = b - a
        covered = sum(max(0, min(b, sb) - max(a, sa)) for sa, sb in silent_spans)
        if span and covered / span >= 0.8:
            continue
        if not have_audio:
            emit(a, b, "unknown", "no audio read")
            continue
        seg = active[a:b]
        avg = sum(seg) / len(seg)
        # A hands-idle run can CONTAIN a shorter silence without being mostly
        # silent (25% overlap fails the 0.8 dedup above). Say so, or the table
        # reads as self-contradictory: a span labelled "a real part, keep" sitting
        # across a stretch the next line marks TRIM.
        hole = (f"; holds {covered:.0f}s of trimmed silence" if covered else "")
        if max(seg, default=0) == 0:
            emit(a, b, "dead", "no orbit above %.0f dBFS" % SILENT_DBFS)
        elif avg <= max(2, n_orbits * 0.25):
            emit(a, b, "loop", "%.2f orbits avg, hands off%s" % (avg, hole))
        else:
            emit(a, b, "playing", "%.2f orbits avg — a real part, keep%s" % (avg, hole))
    out.sort(key=lambda c: c["start_s"])
    return out


# Kinds the mixer will actually cut. `loop` is deliberately NOT here: it needs an
# ear, because a droning pad under idle hands can be an intro as easily as a
# distraction.
CUTTABLE = ("silence", "dead")


# ------------------------------------------------------------------------- lens

def cmd_lens(take_n: int, bucket_s: float, session: Path,
             audio: bool, edl_out: Path | None) -> int:
    takes = find_takes(session)
    if take_n not in takes:
        print(f"take-lens: no take {take_n} (have {sorted(takes)})", file=sys.stderr)
        return 1
    t = takes[take_n]
    t0, t1, dur, sr = t["start"], t["end"], t["dur_s"], t["sr"]
    nb = max(1, int(dur // bucket_s) + 1)

    print(f"take {take_n}  {hhmmss(t0)} -> {hhmmss(t1)}  "
          f"{dur/60:.1f} min  {len(t['orbits'])} orbits  {sr} Hz")
    if not t["exact"]:
        print("  NOTE length is estimated from file size — do not cut from it")

    paths = logs_covering(t0, t1)
    if not paths:
        print("  no gig log covers this window — hands + xruns unavailable")
        rec = {"cc": [], "note": [], "s": [], "track": [], "mark": []}
    else:
        print(f"  gig log: {len(paths)} file(s)")
        rec = read_window(paths, t0, t1)

    # Analysis runs at GRAIN_S (1s) ALWAYS; --bucket only sets the width of the
    # sparkline. That separation is not cosmetic — the first version analysed at the
    # display grain, and a 17-second silence straddling two 15-second buckets left
    # NO bucket fully silent, so it detected none of the 103 seconds of digital
    # silence that a direct 1-second measurement of the same mix found. Detection
    # resolution must not be a display preference.
    ns, cc_n, notes, ctrls, active, loud, energy = analyse(
        t, rec, GRAIN_S, audio)
    nb = max(1, int(ns * GRAIN_S // bucket_s) + 1)

    # --- report --------------------------------------------------------------
    W = 60
    per = max(1, int(bucket_s / GRAIN_S))
    print(f"\nanalysis grain {GRAIN_S:.0f}s ({ns} steps); display {bucket_s:.0f}s")
    hands = sparkline([float(sum(cc_n[i*per:(i+1)*per])) for i in range(nb)], lo=0.0)
    orbs = sparkline([float(max(active[i*per:(i+1)*per], default=0))
                      for i in range(nb)], lo=0.0,
                     hi=float(len(t["orbits"]) or 1)) if energy else ""
    lvl = sparkline([max(loud[i*per:(i+1)*per], default=-120.0)
                     for i in range(nb)], lo=-60.0, hi=0.0) if energy else ""
    for i in range(0, nb, W):
        print(f"\n  {hhmm(t0 + i*bucket_s)}")
        print(f"    hands  {hands[i:i+W]}")
        if orbs:
            print(f"    orbits {orbs[i:i+W]}")
            print(f"    level  {lvl[i:i+W]}")

    print(f"\nhands: {sum(cc_n)} CC + {sum(notes)} notes, "
          f"{len(set().union(*ctrls)) if any(ctrls) else 0} distinct controllers")
    idle = sum(1 for v in cc_n if v == 0)
    print(f"       {idle}/{ns} s hands-idle ({100*idle/ns:.0f}%)")
    if energy:
        sil = sum(1 for v in active if v == 0)
        print(f"sound: {sil}/{ns} s with every orbit under {SILENT_DBFS:.0f} dBFS "
              f"({100*sil/ns:.0f}%)")

    if energy:
        peaks = {o: e["peak_all_db"] for o, e in energy.items()}
        hot = sorted(peaks.items(), key=lambda kv: -kv[1])
        sub = t.get("subtype", "?")
        # A float file is NOT clipped by exceeding 0 dBFS — 32-bit float carries
        # values above 1.0 losslessly, so the waveform is intact and a gain cut
        # fully recovers it. Calling that "clipping" would be a false alarm that
        # sends PLN re-recording material that is actually fine. It only becomes
        # real clipping on export to integer, or at the master bus. In a PCM file
        # the same number means samples were truncated and the audio IS damaged.
        isfloat = "FLOAT" in str(sub).upper()
        print(f"\nper-orbit peak dBFS (source, pre-master; {sub}):")
        if isfloat:
            print("  32-bit float: over 0 dBFS is HEADROOM, not damage — a gain cut")
            print("  recovers it exactly. It clips only on int export or the master.")
        for orb, pdb in hot:
            if pdb >= 0.0:
                flag = "  <-- OVER 0 (recoverable)" if isfloat else "  <-- CLIPPED"
            elif pdb > -3.0:
                flag = "  <-- hot"
            else:
                flag = ""
            print(f"  d{orb:<3} {pdb:>7.2f}{flag}")
        over = [o for o, p in peaks.items() if p >= 0.0]
        if over:
            worst = max(peaks.values())
            print(f"  {len(over)}/{len(peaks)} orbits over 0 dBFS, worst +{worst:.1f} dB")
            print(f"  -> a uniform -{worst + 6:.0f} dB on the record bus would put the")
            print(f"     hottest orbit at the -6 dBFS source target.")
        silent = [o for o, p in peaks.items() if p <= SILENT_DBFS]
        if silent:
            print(f"  silent all take: {['d%d' % o for o in silent]}")

    # --- track timeline ------------------------------------------------------
    print("")
    if rec["track"]:
        print("track timeline (from the editor — ground truth):")
        for r in rec["track"]:
            print(f"  {hhmmss(r['t'])}  {r.get('path')}")
    else:
        print("track timeline: NONE for this take.")
        print("  Nothing published the loaded track while it recorded (gig-log gained")
        print("  k:\"track\" on 2026-07-29). Boundaries inside this take can only be")
        print("  inferred. Later takes split exactly.")

    # --- trim candidates ------------------------------------------------------
    cands = classify(ns, GRAIN_S, dur, cc_n, active, len(t["orbits"]))
    for c in cands:
        c["start"] = t0 + c["start_s"]
        c["end"] = t0 + c["end_s"]

    cut_s = sum(c["cut_dur_s"] for c in cands if c["kind"] in CUTTABLE)
    print(f"\ncandidates: {len(cands)}  "
          f"(cuttable {cut_s:.0f}s of {dur:.0f}s = {100*cut_s/dur:.0f}%)")
    for c in cands:
        tag = {"silence": "TRIM", "dead": "TRIM", "loop": "trim?",
               "playing": "keep", "unknown": "?"}[c["kind"]]
        # A silence shows what is REMOVED vs what is DETECTED, because they differ:
        # the last SILENT_KEEP_S is left as a breath before the music resumes.
        cut = ("" if c["kind"] not in CUTTABLE or
               abs(c["cut_dur_s"] - c["dur_s"]) < 0.05
               else f"  cut {c['cut_dur_s']:.0f}s, {SILENT_KEEP_S:.0f}s kept")
        print(f"  [{tag:>5}] {c['kind']:<8} {hhmmss(c['start'])} -> "
              f"{hhmmss(c['end'])}  {c['dur_s']:>5.0f}s  "
              f"+{c['start_s']/60:>5.1f}min  ({c['why']}){cut}")

    if edl_out:
        edl = {
            "take": take_n, "session": str(session),
            "start_epoch": t0, "end_epoch": t1, "dur_s": dur, "sr": sr,
            "bucket_s": bucket_s,
            "orbits": sorted(t["orbits"]),
            "track_timeline": [{"t": r["t"], "offset_s": r["t"] - t0,
                                "path": r.get("path")} for r in rec["track"]],
            "trim_candidates": cands,
            "peak_dbfs": {f"d{o}": e["peak_all_db"] for o, e in energy.items()},
            "hands": {"cc": sum(cc_n), "notes": sum(notes),
                      "idle_buckets": idle, "buckets": nb},
            "generated_by": "tools/take-lens.py",
        }
        edl_out.write_text(json.dumps(edl, indent=2) + "\n")
        print(f"\nEDL -> {edl_out}")
    return 0


# ------------------------------------------------------------------------- mix

def cmd_mix(take_n: int, session: Path, out: Path, gain_db: float | None,
            trim: bool, bucket_s: float) -> int:
    """Sum the orbit stems into one stereo file, gain-corrected and trimmed.

    Streams in one-second blocks across all 24 mono files at once: constant memory,
    one sequential pass per file. Writes 32-bit float so a sum that overshoots stays
    recoverable — the same reason the source stems are float.

    `--gain-db auto` derives the trim from the measured peak so the hottest orbit
    lands on the -6 dBFS source target. UNIFORM across orbits, never per-orbit: a
    per-orbit trim would silently re-balance the mix PLN performed.
    """
    import numpy as np
    import soundfile as sf

    takes = find_takes(session)
    if take_n not in takes:
        print(f"take-lens: no take {take_n} (have {sorted(takes)})", file=sys.stderr)
        return 1
    t = takes[take_n]
    sr, dur = t["sr"], t["dur_s"]
    orbits = sorted(t["orbits"])

    # Pair up %L/%R. A missing side is NOT silently mono-ed: say so, because a
    # one-sided orbit means a routing fault worth knowing about.
    pairs: dict[int, list[Path]] = {}
    for orb in orbits:
        lp = t["orbits"][orb]
        rp = Path(str(lp).replace("%L.wav", "%R.wav"))
        if not rp.exists():
            print(f"  d{orb}: no %R side — using %L for both channels")
            rp = lp
        pairs[orb] = [lp, rp]

    if gain_db is None:
        gain_db = 0.0
    g = 10.0 ** (gain_db / 20.0)

    # Trim only what classify() marked CUTTABLE — never `loop`, never `playing`.
    cuts: list[tuple[float, float]] = []
    if trim:
        cuts = cut_regions(take_n, session, sr)
        if cuts:
            print("trimming (silence clamped to a breath, dead removed whole):")
            for a, b in cuts:
                print(f"  {a/60:6.2f}min -> {b/60:6.2f}min  ({b-a:.0f}s)")
        else:
            print("trimming: nothing to cut")
    cuts_smp = [(int(a * sr), int(b * sr)) for a, b in cuts]
    fade = int(SEAM_FADE_S * sr)

    BLK = 1.0
    n = int(BLK * sr)
    total = int(dur * sr)
    out.parent.mkdir(parents=True, exist_ok=True)
    handles = {orb: [sf.SoundFile(str(p)) for p in ps] for orb, ps in pairs.items()}
    written = 0
    peak = 0.0
    sumsq = 0.0
    nsamp = 0
    try:
        with sf.SoundFile(str(out), "w", samplerate=sr, channels=2,
                          subtype="FLOAT") as w:
            pos = 0
            while pos < total:
                acc = np.zeros((n, 2), dtype="float64")
                got = 0
                for orb, (fl, fr) in handles.items():
                    a = fl.read(n, dtype="float32", always_2d=False)
                    b = fr.read(n, dtype="float32", always_2d=False)
                    if a is None or len(a) == 0:
                        continue
                    got = max(got, len(a))
                    acc[:len(a), 0] += a
                    if b is not None and len(b):
                        acc[:len(b), 1] += b
                if got == 0:
                    break
                blk = (acc[:got] * g).astype("float32")
                # Sample-accurate, not block-accurate. The old version decided
                # keep/drop per one-second block by its midpoint, which quantised
                # every boundary to +-0.5s and butt-joined the result.
                mask, env = _seam_env(pos, got, cuts_smp, fade, np)
                blk *= env[:, None]
                blk = blk[mask]
                if len(blk):
                    w.write(blk)
                    written += len(blk)
                    peak = max(peak, float(np.max(np.abs(blk))))
                    sumsq += float(np.sum(np.square(blk, dtype="float64")))
                    nsamp += blk.size
                pos += got
    finally:
        for hs in handles.values():
            for h in hs:
                h.close()

    import math
    pdb = 20 * math.log10(peak) if peak > 0 else -120.0
    rdb = 20 * math.log10(math.sqrt(sumsq / nsamp)) if nsamp else -120.0
    print(f"\nwrote {out}")
    print(f"  {written/sr/60:.2f} min  (source {dur/60:.2f} min, "
          f"{(dur - written/sr):.0f}s trimmed)")
    print(f"  gain applied {gain_db:+.1f} dB over {len(orbits)} orbits summed")
    print(f"  peak {pdb:+.2f} dBFS   rms {rdb:.2f} dBFS")
    if pdb >= 0.0:
        print("  NOTE still over 0 dBFS — float output so it is intact, but it needs")
        print("  more attenuation before any integer export or upload.")
    if pdb <= -40.0:
        print("  SUSPICIOUS: this is very quiet for a full mix — verify before serving.")
        return 3
    print("\nnext: verify the spectral balance rather than trusting the sum —")
    print(f"  python3 armada/tide-table/audio_lens.py profile '{out}'")
    return 0


# ------------------------------------------------------------------------ pack

# Where a packed take lives. Beside the audio, deliberately: the entire point is that
# the performance data stops living in a directory that a prune timer owns.
def pack_dir(session: Path, take_n: int) -> Path:
    return session / "takes" / f"take{take_n}"


def cmd_pack(take_n: int, session: Path, out_dir: Path | None,
             compress: bool) -> int:
    """Make a take self-describing, then make it affordable.

    Two problems with one answer. Today the audio is permanent and the performance
    is not: Ardour's sources live forever under the session while the MIDI and the
    track timeline sit in `~/.local/share/parvagues/gig-log/`, which `prune()`
    deletes on a timer. So the 29862 CC events that explain take 94 are on a
    countdown and the audio they explain is not. Copying the window's records out,
    next to the audio, ends that.

    And 12 orbits x stereo x 32-bit float x 48 kHz is 277 MB/min — 16.6 GB/hour of
    practice. `wavpack -h` gets ~4x on real material (measured per-orbit: 42% on the
    busiest, 7% on a sparse one) and is verified lossless here on every file before
    anything is reported as archived.

    THE .WAV IS NEVER DELETED. Removal is a separate, explicit, human step after the
    take has been heard. A silent data-losing archiver is not worth 4x.
    """
    takes = find_takes(session)
    if take_n not in takes:
        print(f"take-lens: no take {take_n} (have {sorted(takes)})", file=sys.stderr)
        return 1
    t = takes[take_n]
    t0, t1, dur, sr = t["start"], t["end"], t["dur_s"], t["sr"]
    if not t["exact"]:
        print("take-lens: length is ESTIMATED for this take — refusing to pack, "
              "because every window bound below would be a guess", file=sys.stderr)
        return 1

    out = out_dir or pack_dir(session, take_n)
    out.mkdir(parents=True, exist_ok=True)
    print(f"take {take_n}  {hhmmss(t0)} -> {hhmmss(t1)}  {dur/60:.1f} min")
    print(f"pack -> {out}")

    # --- the performance ------------------------------------------------------
    paths = logs_covering(t0, t1)
    rec = read_window(paths, t0, t1) if paths else {
        "cc": [], "note": [], "s": [], "track": [], "mark": []}

    midi_p = out / f"take{take_n}.midi.jsonl"
    n_midi = 0
    with midi_p.open("w") as f:
        # Sorted across kinds so the file reads as one timeline, and offset_s added
        # so a reader never has to know the take's epoch to place an event.
        for r in sorted((r for k in ("cc", "note", "track", "mark")
                         for r in rec.get(k, [])), key=lambda r: r["t"]):
            r = dict(r, offset_s=round(r["t"] - t0, 3))
            f.write(json.dumps(r, separators=(",", ":")) + "\n")
            n_midi += 1
    # Records != events: a `cc` record is a one-second bucket carrying `n`. Printing
    # only the record count would understate the performance by ~25x on this take.
    n_ev = sum(r.get("n", 1) for k in ("cc", "note") for r in rec.get(k, []))
    print(f"  midi   {n_midi:>7} records ({n_ev} events) -> {midi_p.name}")

    # --- the measurements -----------------------------------------------------
    ns, cc_n, notes, ctrls, active, loud, energy = analyse(t, rec, GRAIN_S, True)
    cands = classify(ns, GRAIN_S, dur, cc_n, active, len(t["orbits"]))

    # xrun is a RUNNING TOTAL since the log started, not a per-tick delta — see
    # gig-log.py's CUMULATIVE tuple, which exists for exactly this mistake. Summing
    # it reported 58,668,469 xruns for a 21-minute take. Window value = last - first,
    # the same rebase gig-log's own slice_session() does.
    ss = [r for r in rec.get("s", []) if r.get("xrun") is not None]
    xruns = max(0, int(ss[-1]["xrun"]) - int(ss[0]["xrun"])) if len(ss) > 1 else 0
    hdr = None
    for p in paths:
        try:
            with p.open() as f:
                h = json.loads(f.readline())
            if h.get("k") == "hdr":
                hdr = h
                break
        except (OSError, json.JSONDecodeError):
            continue

    meta = {
        "take": take_n,
        "session": str(session),
        "start_epoch": t0, "end_epoch": t1,
        "start_local": hhmmss(t0), "end_local": hhmmss(t1),
        "iso": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(t0)),
        "dur_s": dur, "sr": sr, "subtype": t.get("subtype"),
        "orbits": sorted(t["orbits"]),
        "peak_dbfs": {f"d{o}": round(e["peak_all_db"], 2)
                      for o, e in energy.items()},
        "silent_orbits": [f"d{o}" for o, e in energy.items()
                          if e["peak_all_db"] <= SILENT_DBFS],
        "hands": {"cc": sum(cc_n), "notes": sum(notes),
                  "controllers": sorted(set().union(*ctrls)) if any(ctrls) else [],
                  "idle_s": sum(1 for v in cc_n if v == 0), "steps": ns},
        "xruns": xruns,
        "gig_logs": [p.name for p in paths],
        "track_timeline": [{"offset_s": round(r["t"] - t0, 3),
                            "path": r.get("path")} for r in rec["track"]],
        "gear": (hdr or {}).get("gear"),
        "boot": {k: (hdr or {}).get(k)
                 for k in ("mode", "gov", "fmax", "gpu", "iso")} if hdr else None,
        # The honest limit, stated IN the artifact so a future reader cannot miss it.
        "midi_fidelity": (
            "COALESCED, not sample-accurate. gig-log groups CC per "
            "(port, channel, controller) per second, keeping count + first/last/"
            "min/max. That is a faithful summary of a knob sweep and is what makes "
            "the log cheap, but a .mid rendered from it would be a reconstruction, "
            "not a recording. The JSONL is the truth."),
        "generated_by": "tools/take-lens.py pack",
    }
    if not rec["track"]:
        meta["track_timeline_note"] = (
            "NONE. Nothing published the loaded track while this take recorded "
            "(gig-log gained k:\"track\" on 2026-07-29). Boundaries inside this "
            "take can only be inferred — do not synthesise them.")
    (out / f"take{take_n}.meta.json").write_text(json.dumps(meta, indent=2) + "\n")
    print(f"  meta   {len(t['orbits'])} orbits, {sum(cc_n)} CC, {xruns} xruns"
          f"{'' if rec['track'] else ', NO track timeline'}")

    edl = {"take": take_n, "session": str(session),
           "start_epoch": t0, "end_epoch": t1, "dur_s": dur, "sr": sr,
           "orbits": sorted(t["orbits"]),
           "track_timeline": meta["track_timeline"],
           "trim_candidates": cands,
           "seam_fade_s": SEAM_FADE_S, "silence_keep_s": SILENT_KEEP_S,
           "generated_by": "tools/take-lens.py pack"}
    (out / f"take{take_n}.edl.json").write_text(json.dumps(edl, indent=2) + "\n")
    cut_s = sum(c["cut_dur_s"] for c in cands if c["kind"] in CUTTABLE)
    print(f"  edl    {len(cands)} candidates, {cut_s:.0f}s cuttable "
          f"({100*cut_s/dur:.0f}%)")

    if not compress:
        print("\n(no --compress: sidecar only)")
        return 0
    return _archive(t, out, take_n)


def _archive(t: dict, out: Path, take_n: int) -> int:
    """wavpack every source of the take, verify each, report. Never deletes."""
    import shutil
    import subprocess
    if not shutil.which("wavpack") or not shutil.which("wvunpack"):
        print("\ncompress: wavpack/wvunpack not on PATH — skipped", file=sys.stderr)
        return 2
    srcs: list[Path] = []
    for lp in t["orbits"].values():
        srcs.append(lp)
        rp = Path(str(lp).replace("%L.wav", "%R.wav"))
        if rp.exists():
            srcs.append(rp)
    wvdir = out / "wv"
    wvdir.mkdir(exist_ok=True)
    print(f"\ncompress: {len(srcs)} sources -> {wvdir}")
    raw = comp = 0
    bad = []
    for i, s in enumerate(sorted(srcs), 1):
        dst = wvdir / (s.stem + ".wv")
        if not dst.exists():
            r = subprocess.run(["wavpack", "-h", "-q", "-y", str(s), "-o", str(dst)],
                               capture_output=True, text=True)
            if r.returncode != 0:
                bad.append((s.name, "wavpack: " + r.stderr.strip()[:120]))
                continue
        # -vm verifies the decoded md5 against the one stored at encode time. An
        # archiver that reports a ratio without checking is reporting a hope.
        v = subprocess.run(["wvunpack", "-vm", "-q", str(dst)],
                           capture_output=True, text=True)
        if v.returncode != 0:
            bad.append((s.name, "VERIFY FAILED: " + v.stderr.strip()[:120]))
            continue
        raw += s.stat().st_size
        comp += dst.stat().st_size
        print(f"  [{i:>2}/{len(srcs)}] {s.stat().st_size/1e6:>7.1f} MB -> "
              f"{dst.stat().st_size/1e6:>6.1f} MB  {dst.name}")
    if raw:
        print(f"\n  {raw/1e9:.2f} GB -> {comp/1e9:.2f} GB  "
              f"({100*comp/raw:.0f}%, saved {(raw-comp)/1e9:.2f} GB) — "
              f"all verified lossless")
    if bad:
        print(f"\n  {len(bad)} FAILED:", file=sys.stderr)
        for name, why in bad:
            print(f"    {name}: {why}", file=sys.stderr)
        return 1
    print("  the .wav sources are UNTOUCHED. Removing them is a separate, human")
    print("  step — after the take has been heard, never before.")
    return 0


def _seam_env(p0: int, n: int, cuts_smp: list[tuple[int, int]], fade: int, np):
    """Keep-mask + fade envelope for the sample range [p0, p0+n).

    Every removal fades OUT over the last `fade` samples before it and back IN over
    the first `fade` samples after it. Overlapping fades take the minimum, so two
    cuts a few hundred ms apart cannot sum back above unity.

    This is a cut-with-fades, not a crossfade: nothing overlaps, so the timeline
    stays a plain concatenation and the reported cut durations remain exact.
    """
    idx = np.arange(p0, p0 + n)
    keep = np.ones(n, dtype=bool)
    env = np.ones(n, dtype="float32")
    for cs, ce in cuts_smp:
        keep &= ~((idx >= cs) & (idx < ce))
        if fade <= 0:
            continue
        m = (idx >= cs - fade) & (idx < cs)
        if m.any():
            env[m] = np.minimum(env[m], (cs - idx[m]) / fade)
        m = (idx >= ce) & (idx < ce + fade)
        if m.any():
            env[m] = np.minimum(env[m], (idx[m] - ce + 1) / fade)
    return keep, env


def cut_regions(take_n: int, session: Path, sr: int) -> list[tuple[float, float]]:
    """Offsets (seconds into the take) of everything a mixer should remove.

    Goes through `classify()` — the same call the report makes — and uses the
    `cut_*` bounds it computed, so the mixer can never cut something the report
    called `keep`, nor remove more of a silence than the report said it would.
    """
    takes = find_takes(session)
    t = takes[take_n]
    t0, t1, dur = t["start"], t["end"], t["dur_s"]
    paths = logs_covering(t0, t1)
    rec = read_window(paths, t0, t1) if paths else {"cc": []}
    ns, cc_n, _notes, _ctrls, active, _loud, _e = analyse(t, rec, GRAIN_S, True)
    return [(c["cut_start_s"], c["cut_end_s"])
            for c in classify(ns, GRAIN_S, dur, cc_n, active, len(t["orbits"]))
            if c["kind"] in CUTTABLE and c["cut_dur_s"] > 0]


# -------------------------------------------------------------------- selftest

def cmd_selftest() -> int:
    """Exercise the cut rules on synthetic input, with no rig and no audio.

    Written because two of the rules below could not be validated on take 94: the
    silence BRIDGE never fires there (measured: zero bridgeable blips), and the
    seam fade is inaudible by construction. A rule you have never seen fire is not
    a rule, it is a hope — same lesson as check-boot's Pass 5.
    """
    import math
    fails = []

    def check(name: str, ok: bool, detail: str = "") -> None:
        print(f"  {'PASS' if ok else 'FAIL'}  {name}{'  ' + detail if detail else ''}")
        if not ok:
            fails.append(name)

    print("classify:")
    n = 60
    # A 20s silence in the middle of a playing take.
    act = [4] * n
    for i in range(20, 40):
        act[i] = 0
    c = classify(n, 1.0, float(n), [5] * n, act, 12)
    sil = [x for x in c if x["kind"] == "silence"]
    check("a 20s silence is detected", len(sil) == 1)
    if sil:
        check("...and CLAMPED, not removed",
              abs(sil[0]["cut_dur_s"] - (20 - SILENT_KEEP_S)) < 1e-6,
              f"cut {sil[0]['cut_dur_s']:.0f}s of {sil[0]['dur_s']:.0f}s")
        check("...leaving the breath at the END (music resumes into it)",
              abs(sil[0]["cut_end_s"] - (sil[0]["end_s"] - SILENT_KEEP_S)) < 1e-6)

    # Shorter than the bar: a musical rest must survive untouched.
    act = [4] * n
    for i in range(20, 28):
        act[i] = 0
    check("an 8s rest is NOT cut (4 bars at 120-140 BPM)",
          not [x for x in classify(n, 1.0, float(n), [5] * n, act, 12)
               if x["kind"] == "silence"])

    # The bridge: a 20s dead patch split by a 1s single-orbit tail.
    act = [4] * n
    for i in range(20, 40):
        act[i] = 0
    act[30] = 1
    c = classify(n, 1.0, float(n), [5] * n, act, 12)
    sil = [x for x in c if x["kind"] == "silence"]
    check("a 1-orbit tail does not split a dead patch",
          len(sil) == 1 and sil[0]["dur_s"] >= 19,
          f"{len(sil)} span(s)")

    # ...but a real event (several orbits) must break it. The property is NOT "no
    # span survives" — the left half is exactly SILENT_RUN_S long and legitimately
    # qualifies on its own. The property is that no span CONTAINS the hit, i.e. the
    # trim can never remove a moment where the music was actually playing.
    act[30] = 5
    sil = [x for x in classify(n, 1.0, float(n), [5] * n, act, 12)
           if x["kind"] == "silence"]
    swallowed = [x for x in sil if x["start_s"] <= 30 < x["end_s"]]
    check("a MULTI-orbit hit is never inside a trimmed span", not swallowed,
          f"{len(sil)} span(s), none covering the hit")

    print("seam fade:")
    try:
        import numpy as np
    except ImportError:
        print("  SKIP  numpy unavailable")
    else:
        cuts = [(1000, 2000)]
        keep, env = _seam_env(0, 3000, cuts, 100, np)
        check("the cut region is dropped", int((~keep).sum()) == 1000)
        check("fade-out reaches ~0 at the cut edge", env[999] < 0.02,
              f"env[999]={env[999]:.3f}")
        check("fade-in starts at ~0 after the cut", env[2000] < 0.02,
              f"env[2000]={env[2000]:.3f}")
        check("unity away from any seam",
              float(env[0]) == 1.0 and float(env[2999]) == 1.0)
        # Two cuts closer together than the fade must not sum above unity.
        _, env2 = _seam_env(0, 3000, [(1000, 1100), (1150, 1250)], 200, np)
        check("overlapping fades never exceed unity", float(env2.max()) <= 1.0)

    print(f"\n{'ALL PASS' if not fails else str(len(fails)) + ' FAILED: ' + ', '.join(fails)}")
    return 0 if not fails else 1


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--session", type=Path, default=SESSION)
    sub = ap.add_subparsers(dest="cmd", required=True)
    sub.add_parser("takes", help="list recorded takes with wall-clock windows")
    sub.add_parser("selftest", help="exercise the cut rules on synthetic input")
    lens = sub.add_parser("lens", help="correlate one take: hands, sound, tracks")
    lens.add_argument("take", type=int)
    lens.add_argument("--bucket", type=float, default=15.0,
                      help="seconds per bucket (default 15)")
    lens.add_argument("--no-audio", action="store_true",
                      help="skip the audio read (instant, but trims stay ambiguous)")
    lens.add_argument("--edl", type=Path, help="write a proposed EDL JSON here")
    pk = sub.add_parser("pack", help="sidecar the take's MIDI/meta/EDL beside the "
                                     "audio, and optionally archive it")
    pk.add_argument("take", type=int)
    pk.add_argument("--out", type=Path,
                    help="pack dir (default <session>/takes/take<N>/)")
    pk.add_argument("--compress", action="store_true",
                    help="also wavpack every source, verified lossless "
                         "(the .wav is never deleted)")
    mix = sub.add_parser("mix", help="sum the orbits to one stereo file")
    mix.add_argument("take", type=int)
    mix.add_argument("--out", type=Path, required=True)
    mix.add_argument("--gain-db", type=float, default=0.0,
                     help="uniform gain on every orbit (e.g. -16)")
    mix.add_argument("--trim", action="store_true",
                     help="drop regions the lens calls `dead` (hands idle AND silent)")
    mix.add_argument("--bucket", type=float, default=15.0)
    a = ap.parse_args(argv)
    if a.cmd == "takes":
        return cmd_takes(a.session)
    if a.cmd == "selftest":
        return cmd_selftest()
    if a.cmd == "pack":
        return cmd_pack(a.take, a.session, a.out, a.compress)
    if a.cmd == "mix":
        return cmd_mix(a.take, a.session, a.out, a.gain_db, a.trim, a.bucket)
    return cmd_lens(a.take, a.bucket, a.session, not a.no_audio, a.edl)


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