#!/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)
"""
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. 20s is short enough to
# catch it and long enough to never eat a break or a drop's pre-silence (the longest
# deliberate gap in the set is ~4 bars, under 8s at these tempos).
SILENT_RUN_S = 20.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.
    """
    out: list[dict] = []

    def emit(a: int, b: int, kind: str, why: str) -> None:
        out.append({"start_s": a * bucket_s, "end_s": min(dur, b * bucket_s),
                    "dur_s": min(dur, b * bucket_s) - a * bucket_s,
                    "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:
        for a, b in runs(lambda i: active[i] == 0):
            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)
        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" % avg)
        else:
            emit(a, b, "playing", "%.2f orbits avg — a real part, keep" % avg)
    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["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"]]
        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']})")

    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 regions the lens called `dead` — never `loop`, never `playing`.
    cuts: list[tuple[float, float]] = []
    if trim:
        cuts = _dead_regions(take_n, session, bucket_s, sr)
        if cuts:
            print("trimming (dead regions only):")
            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 dead to cut")

    def keep(t_from: float, t_to: float) -> bool:
        mid = (t_from + t_to) / 2
        return not any(a <= mid < b for a, b in cuts)

    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")
                if keep(pos / sr, (pos + got) / sr):
                    w.write(blk)
                    written += got
                    peak = max(peak, float(np.max(np.abs(blk))) if got else 0.0)
                    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


def _dead_regions(take_n: int, session: Path, bucket_s: float,
                  sr: int) -> list[tuple[float, float]]:
    """Offsets (seconds into the take) of everything in CUTTABLE.

    Goes through `classify()` — the same call the report makes — so the mixer can
    never cut something the report called `keep`.
    """
    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["start_s"], c["end_s"])
            for c in classify(ns, GRAIN_S, dur, cc_n, active, len(t["orbits"]))
            if c["kind"] in CUTTABLE]


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")
    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")
    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 == "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())
