"""vox — the lyric-aware vocal sampler (the toaster).

The bar-aligned finder (loops.analyze_stem) and the onset chopper
(loops.analyze_chops) both cut vocals *blind to what is sung* — they slice on
transients and score on seam/zc mechanics. But a vocal one-shot's VALUE to a
livecoder is the LYRIC: "she's a super freak", "kinky girl", a dub ad-lib. This
module cuts vocals into **phrase-level, lyric-named** one-shots.

The pipeline (design fixed; DSP reuses grade.py, naming from naming.py):

  1. transcribe   — whisper word-level timestamps on the (clean, demucs) vocal
     stem. Heavy dep, so shelled out to the `whisper` CLI like separate.py shells
     to demucs; the transcription JSON is CACHED in the workspace so re-runs are
     instant. Model size escalates empirically (small → medium) when the known
     hooks don't surface — dub/patois vocals defeat the small model.
  2. segment      — group words into phrases on inter-word gaps (≥ GAP_S), capped
     at MAX_PHRASE_S; keep per-phrase avg word confidence.
  3. rank         — feature-engineered ICONICITY: repetition (a hook repeats),
     hook-keyword match (from --hooks or the source title), ASR clarity, segment
     energy vs stem median, and a duration sweet-spot. Hooks float to the top.
  4. cut          — small pre-pad, post-pad to the gap, zero-crossing snap at both
     edges, short fades → clickless one-shots at as-cut level (no normalize).
  5. grade        — each cut graded role='one_shot' (grade.py); drop < C tier.
  6. name         — NN_vox_<lyricslug> (naming.lint-clean); dedup identical texts,
     keep the best-graded instance (up to 2 takes of THE hook).

    python3 -m engine.vox <vocals.wav> [--workspace DIR] [--hooks super freak] \
        [--top N] [--json]
"""
from __future__ import annotations

import json
import re
import subprocess
from pathlib import Path
from typing import Callable, Optional

import numpy as np
import soundfile as sf
from pydantic import BaseModel, Field

from . import grade as G
from . import naming

# ── tunables ────────────────────────────────────────────────────────────────
GAP_S = 0.45              # inter-word gap that starts a new phrase (tuned on real dub)
MAX_PHRASE_S = 6.0        # cap a phrase; a long line splits at the widest interior gap
PRE_PAD_S = 0.040         # lead-in before the first word onset
POST_PAD_S = 0.120        # tail after the last word (or up to the next-word gap)
FADE_MS = 6.0             # in/out fade to guarantee clickless edges (3–10 ms band)
ZC_TOL_MS = 12.0          # zero-crossing snap tolerance at each edge
EXPORT_SR = 44100         # B8: SuperDirt rate
DUR_SWEET = (0.6, 4.0)    # iconicity duration sweet-spot (s)
MIN_TIER = "C"            # drop cuts below this grade tier

# loopability (kit-level): the Super freak dub is 132.5 BPM. A vocal one-shot that
# happens to span ~an integer beat / 1-2-4-bar multiple can be loopAt'd alongside the
# instrument loops. beats reported for every phrase; the best such hooks also ship a
# bar-quantized _loop variant (edges pulled to the exact grid, still zc-snapped).
DEFAULT_BPM = 132.5
LOOP_TOL = 0.03           # within ±3% of an integer-beat / bar multiple ⇒ loop-capable

# iconicity feature weights (provisional; tuned on the superfreak corpus)
ICON_W = {"repeat": 0.40, "hook": 0.25, "clarity": 0.15, "energy": 0.10, "dur": 0.10}
_TIER_ORDER = {"S": 5, "A": 4, "B": 3, "C": 2, "D": 1}

# stopwords excluded from n-gram/keyword hook matching (function words don't hook)
_STOP = {
    "a", "an", "the", "and", "or", "but", "is", "are", "was", "were", "be", "been",
    "to", "of", "in", "on", "at", "it", "its", "i", "you", "he", "she", "her", "his",
    "we", "they", "them", "my", "me", "your", "that", "this", "with", "for", "so",
    "do", "don", "t", "s", "m", "re", "ll", "ve", "oh", "yeah", "na",
}


# ── data model ────────────────────────────────────────────────────────────────
class Word(BaseModel):
    text: str
    start: float
    end: float
    prob: float = 1.0


class Phrase(BaseModel):
    """A group of contiguous words = one candidate vocal one-shot."""
    text: str
    start: float
    end: float
    conf: float                       # avg ASR word probability
    words: list[Word] = Field(default_factory=list)
    # populated by rank()/cut()/grade
    iconicity: float = 0.0
    repeat: float = 0.0
    hook: float = 0.0
    energy: float = 0.0
    grade: float = 0.0
    tier: str = "?"
    name: str = ""
    # loopability (kit-level, filled by annotate_beats)
    beats: float = 0.0            # duration in beats at the kit BPM
    loop_capable: bool = False    # lands within LOOP_TOL of an integer-beat/bar multiple
    loop_beats: int = 0           # nearest musical beat count it snaps to (0 = none)

    @property
    def dur(self) -> float:
        return self.end - self.start


# ── 1. transcribe (whisper shell-out, cached) ──────────────────────────────────
def transcribe(vocals: Path, workspace: Optional[Path] = None, *, model: str = "medium",
               language: str = "en", device: Optional[str] = None,
               progress: Optional[Callable[[str], None]] = None,
               force: bool = False) -> dict:
    """Word-level whisper transcription of `vocals`, cached at workspace/vox_transcript.json.

    Shells out to the `whisper` CLI (heavy-dep convention, cf. separate.py→demucs) with
    --word_timestamps True --output_format json. The cache makes re-runs instant; pass
    force=True to re-transcribe (e.g. after bumping the model). Returns the raw whisper
    result dict (segments[].words[] carry start/end/probability).
    """
    ws = Path(workspace) if workspace else Path(vocals).parent
    cache = ws / "vox_transcript.json"
    if cache.exists() and not force:
        data = json.loads(cache.read_text())
        if data.get("_vox_model") == model:      # honour a model change
            return data
    ws.mkdir(parents=True, exist_ok=True)

    cmd = ["whisper", str(vocals), "--model", model, "--language", language,
           "--word_timestamps", "True", "--output_format", "json",
           "--output_dir", str(ws)]
    if device:
        cmd += ["--device", device]
    if progress:
        progress(f"whisper {model} transcribing (cpu, ~2min for medium)…")
    subprocess.run(cmd, check=True, capture_output=True, text=True)

    # whisper writes <stem>.json into output_dir
    out = ws / f"{Path(vocals).stem}.json"
    data = json.loads(out.read_text())
    data["_vox_model"] = model
    cache.write_text(json.dumps(data, indent=2))
    if out.resolve() != cache.resolve():
        out.unlink(missing_ok=True)
    return data


def words_from_transcript(data: dict) -> list[Word]:
    """Flatten whisper segments → a clean word list (start/end/prob), sorted by time."""
    words: list[Word] = []
    for seg in data.get("segments", []):
        for w in seg.get("words", []):
            txt = str(w.get("word", "")).strip()
            if not txt:
                continue
            words.append(Word(text=txt, start=float(w["start"]), end=float(w["end"]),
                              prob=float(w.get("probability", 1.0))))
    words.sort(key=lambda x: x.start)
    return words


# ── 2. phrase segmentation ──────────────────────────────────────────────────────
def _clean_text(s: str) -> str:
    return re.sub(r"\s+", " ", re.sub(r"[^A-Za-z0-9' ]+", " ", s)).strip()


def segment_phrases(words: list[Word], *, gap_s: float = GAP_S,
                    max_phrase_s: float = MAX_PHRASE_S) -> list[Phrase]:
    """Group words into phrases: a gap ≥ gap_s between consecutive words starts a new
    phrase; a phrase exceeding max_phrase_s is split at its widest interior gap.
    """
    if not words:
        return []
    groups: list[list[Word]] = [[words[0]]]
    for prev, w in zip(words, words[1:]):
        if w.start - prev.end >= gap_s:
            groups.append([w])
        else:
            groups[-1].append(w)

    # split over-long phrases at the widest interior gap (recursively)
    def _split(g: list[Word]) -> list[list[Word]]:
        if len(g) < 2 or (g[-1].end - g[0].start) <= max_phrase_s:
            return [g]
        gaps = [(g[i + 1].start - g[i].end, i) for i in range(len(g) - 1)]
        _, k = max(gaps)                          # widest interior gap
        return _split(g[:k + 1]) + _split(g[k + 1:])

    phrases: list[Phrase] = []
    for g in groups:
        for sub in _split(g):
            txt = _clean_text(" ".join(w.text for w in sub))
            if not txt:
                continue
            conf = float(np.mean([w.prob for w in sub]))
            phrases.append(Phrase(text=txt, start=sub[0].start, end=sub[-1].end,
                                  conf=round(conf, 4), words=sub))
    return phrases


# ── 3. iconicity ranking ────────────────────────────────────────────────────────
def _tokens(text: str) -> list[str]:
    return [t for t in re.split(r"\s+", text.lower().strip()) if t]


def _content_tokens(text: str) -> list[str]:
    out = []
    for t in _tokens(text):
        base = t.split("'")[0]                # she's→she, don't→don, walkin'→walkin
        if base in _STOP or t in _STOP or len(base) <= 1:
            continue
        out.append(base if base else t)
    return out


def _ngrams(tokens: list[str], n: int) -> list[str]:
    return [" ".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1)]


def hooks_from_title(title: str) -> list[str]:
    """Default hook keywords = significant (content) words from the source title.

    Drops the boilerplate around a YouTube title (artist names, 'meets', 'ft.',
    decorative stars) heuristically and keeps content words as hook seeds.
    """
    t = re.sub(r"[★☆|•\-–—]+", " ", title.lower())
    t = re.sub(r"\b(meets|feat|featuring|ft|remix|cover|official|video|audio|prod)\b", " ", t)
    return sorted(set(_content_tokens(t)))


def rank_phrases(phrases: list[Phrase], *, hooks: Optional[list[str]] = None,
                 energies: Optional[dict[int, float]] = None) -> list[Phrase]:
    """Score each phrase's ICONICITY in place and return sorted (desc).

    Features (each 0..1, combined by ICON_W):
      • repeat  — how often this phrase's normalized text / content n-grams recur
                  across the track. Hooks repeat; a one-off ad-lib does not.
      • hook    — fraction of hook keywords present (title-derived or --hooks).
      • clarity — avg ASR word confidence.
      • energy  — segment RMS vs stem median (loud, committed delivery), if provided.
      • dur     — duration sweet-spot (DUR_SWEET); too-short=click, too-long≠one-shot.
    """
    hook_set = {h.lower() for h in (hooks or [])}

    # repetition corpus: count normalized phrase texts and content bigrams/trigrams
    norm = [" ".join(_content_tokens(p.text)) for p in phrases]
    text_ct: dict[str, int] = {}
    for s in norm:
        if s:
            text_ct[s] = text_ct.get(s, 0) + 1
    gram_ct: dict[str, int] = {}
    for p in phrases:
        toks = _content_tokens(p.text)
        for n in (2, 3):
            for g in set(_ngrams(toks, n)):
                gram_ct[g] = gram_ct.get(g, 0) + 1
    max_text = max(text_ct.values(), default=1)
    max_gram = max(gram_ct.values(), default=1)

    for i, p in enumerate(phrases):
        toks = _content_tokens(p.text)
        n = norm[i]
        # repetition: blend exact-phrase recurrence with best recurring n-gram
        rep_text = (text_ct.get(n, 1) - 1) / max(max_text - 1, 1) if n else 0.0
        best_gram = 0
        for gn in (2, 3):
            for g in set(_ngrams(toks, gn)):
                best_gram = max(best_gram, gram_ct.get(g, 0))
        rep_gram = (best_gram - 1) / max(max_gram - 1, 1)
        repeat = max(rep_text, rep_gram)

        # hook keywords present in the phrase
        hook = 0.0
        if hook_set:
            present = sum(1 for h in hook_set if h in set(toks))
            hook = min(1.0, present / max(1, min(len(hook_set), 2)))

        clarity = _clamp01(p.conf)
        energy = _clamp01(energies.get(i, 0.0)) if energies else 0.0
        lo, hi = DUR_SWEET
        if lo <= p.dur <= hi:
            dur = 1.0
        elif p.dur < lo:
            dur = _clamp01(p.dur / lo)
        else:
            dur = _clamp01(1.0 - (p.dur - hi) / hi)

        p.repeat = round(repeat, 4)
        p.hook = round(hook, 4)
        p.energy = round(energy, 4)
        w = ICON_W
        p.iconicity = round(w["repeat"] * repeat + w["hook"] * hook +
                            w["clarity"] * clarity + w["energy"] * energy +
                            w["dur"] * dur, 4)
    return sorted(phrases, key=lambda x: -x.iconicity)


def _clamp01(x: float) -> float:
    return 0.0 if x < 0 else 1.0 if x > 1 else float(x)


def segment_energies(y_mono: np.ndarray, sr: int, phrases: list[Phrase]) -> dict[int, float]:
    """Per-phrase RMS normalized against the stem's active median → 0..1 energy feature."""
    if y_mono.size == 0:
        return {}
    win = int(0.05 * sr)
    frames = np.array([np.sqrt(np.mean(y_mono[i:i + win] ** 2) + 1e-12)
                       for i in range(0, max(1, y_mono.size - win), win)])
    active = frames[frames > np.percentile(frames, 40)]        # ignore silence
    med = float(np.median(active)) if active.size else 1e-6
    out: dict[int, float] = {}
    for i, p in enumerate(phrases):
        a, b = int(p.start * sr), int(p.end * sr)
        seg = y_mono[a:b]
        if seg.size < 2:
            out[i] = 0.0
            continue
        rms = float(np.sqrt(np.mean(seg ** 2) + 1e-12))
        out[i] = _clamp01((rms / (med + 1e-9)) / 2.0)          # med ⇒ 0.5, 2×med ⇒ 1.0
    return out


# ── 4. cut (pre/post-pad, zc-snap, fade) ────────────────────────────────────────
def _snap_zc(mono: np.ndarray, idx: int, sr: int) -> int:
    """Snap a boundary to the nearest zero crossing within ZC_TOL_MS (loops._snap_zc rule)."""
    tol = int(sr * ZC_TOL_MS / 1000)
    lo, hi = max(0, idx - tol), min(len(mono) - 1, idx + tol)
    seg = mono[lo:hi]
    if seg.size < 2:
        return int(min(max(idx, 0), len(mono) - 1))
    zc = np.where(np.diff(np.signbit(seg)))[0]
    return int(lo + zc[np.argmin(np.abs(zc - (idx - lo)))]) if zc.size else \
        int(min(max(idx, 0), len(mono) - 1))


def cut_phrase(y: np.ndarray, sr: int, phrase: Phrase, *, next_start: Optional[float] = None,
               pre_pad_s: float = PRE_PAD_S, post_pad_s: float = POST_PAD_S,
               fade_ms: float = FADE_MS) -> np.ndarray:
    """Cut a clickless one-shot for `phrase` from stem `y` (n,ch) or (n,).

    Small pre-pad before the first word; post-pad after the last word but never into
    the next phrase (clamped to next_start if given). Both edges zero-crossing snapped
    (on the mono reference), then short in/out fades applied so edge samples ≈ 0 —
    guarantees no click regardless of where the snap landed. Level is kept as-cut.
    """
    y2 = y if y.ndim == 2 else y[:, None]
    mono = y2.mean(axis=1)
    n = mono.size
    a = int((phrase.start - pre_pad_s) * sr)
    post_cap = phrase.end + post_pad_s
    if next_start is not None:
        post_cap = min(post_cap, next_start - 0.010)
    b = int(post_cap * sr)
    a = max(0, min(a, n - 2))
    b = max(a + 2, min(b, n))
    a = _snap_zc(mono, a, sr)
    b = _snap_zc(mono, b, sr)
    if b <= a:
        b = min(n, a + 2)
    clip = y2[a:b].astype(np.float32).copy()

    f = min(int(fade_ms / 1000 * sr), clip.shape[0] // 2)
    if f > 1:
        ramp = np.linspace(0.0, 1.0, f, dtype=np.float32)[:, None]
        clip[:f] *= ramp
        clip[-f:] *= ramp[::-1]
    return clip


# ── loopability (kit-level: beats + bar-quantized variant) ──────────────────────
# musical beat counts worth loopAt'ing a vocal to: a beat, a 2-beat, a bar (4),
# 2 bars (8), 4 bars (16). We favour these over arbitrary integer beats.
_LOOP_BEATS = (1, 2, 3, 4, 6, 8, 16)


def annotate_beats(phrases: list[Phrase], *, bpm: float = DEFAULT_BPM,
                   tol: float = LOOP_TOL) -> None:
    """Fill each phrase's beats / loop_capable / loop_beats (in place).

    A phrase is loop-capable if its duration is within ±tol of one of _LOOP_BEATS
    at `bpm` — i.e. it can be trimmed/extended a hair onto the grid and loopAt'd.
    """
    beat_s = 60.0 / bpm
    for p in phrases:
        p.beats = round(p.dur / beat_s, 3)
        best = min(_LOOP_BEATS, key=lambda n: abs(p.beats - n))
        rel = abs(p.beats - best) / best
        p.loop_capable = rel <= tol
        p.loop_beats = best if p.loop_capable else 0


def cut_phrase_loop(y: np.ndarray, sr: int, phrase: Phrase, *, bpm: float = DEFAULT_BPM,
                    fade_ms: float = FADE_MS) -> Optional[np.ndarray]:
    """Bar-quantized loop variant of a loop-capable phrase: edges pulled to the EXACT
    beat grid (phrase.loop_beats * beat_s), then zc-snapped within ±ZC_TOL_MS and faded.

    Anchored at the phrase's first-word onset (minus the small pre-pad so the syllable
    isn't clipped); the tail is placed at exactly loop_beats away so dur == an integer
    beat multiple, giving a clean loopAt. Returns None if the phrase isn't loop-capable.
    """
    if not phrase.loop_capable or phrase.loop_beats <= 0:
        return None
    y2 = y if y.ndim == 2 else y[:, None]
    mono = y2.mean(axis=1)
    n = mono.size
    beat_s = 60.0 / bpm
    target = phrase.loop_beats * beat_s
    a = int((phrase.start - PRE_PAD_S) * sr)
    a = max(0, min(a, n - 2))
    a = _snap_zc(mono, a, sr)
    b = a + int(round(target * sr))               # EXACT grid length from the anchor
    b = _snap_zc(mono, b, sr)                      # ±ZC_TOL_MS only — stays on grid
    b = max(a + 2, min(b, n))
    clip = y2[a:b].astype(np.float32).copy()
    f = min(int(fade_ms / 1000 * sr), clip.shape[0] // 2)
    if f > 1:
        ramp = np.linspace(0.0, 1.0, f, dtype=np.float32)[:, None]
        clip[:f] *= ramp
        clip[-f:] *= ramp[::-1]
    return clip


# ── 5+6. grade + name ────────────────────────────────────────────────────────────
def lyric_slug(text: str, *, max_tokens: int = 3, taken: Optional[set] = None) -> str:
    """`NN_vox_<lyricslug>` body slug from a phrase's most salient content words.

    Keeps up to max_tokens content words (falls back to raw tokens if a phrase is all
    stopwords, e.g. an ad-lib "oh yeah"). Returns just the <lyricslug> body — the caller
    joins it via naming.suggest_name so the NN_ prefix + dedup stay in one place.
    """
    toks = _content_tokens(text) or _tokens(text)
    toks = toks[:max_tokens]
    slug = naming.slug_token("".join(toks)) or "vox"
    return slug


def grade_cut(clip: np.ndarray, sr: int, path: str = "<vox>") -> G.LoopGrade:
    """Grade a cut as a one-shot (grade.py, role='one_shot' weighting)."""
    return G.grade_array(clip.T if clip.ndim == 2 else clip, sr,
                         path=path, role="vocals")


# ── orchestration ─────────────────────────────────────────────────────────────────
def analyze_vox(vocals: Path, *, workspace: Optional[Path] = None,
                hooks: Optional[list[str]] = None, title: Optional[str] = None,
                model: str = "medium", top_n: int = 12, bpm: float = DEFAULT_BPM,
                progress: Optional[Callable[[str], None]] = None,
                transcript: Optional[dict] = None) -> list[Phrase]:
    """Full read-only pipeline: transcribe → segment → energy → rank. No files written.

    `transcript` lets a caller (or a test) inject a whisper-shaped dict and skip the
    shell-out entirely. `hooks` overrides the default title-derived hook keywords.
    """
    if transcript is None:
        transcript = transcribe(vocals, workspace, model=model, progress=progress)
    words = words_from_transcript(transcript)
    phrases = segment_phrases(words)
    y, sr = G.load_audio(vocals)
    mono = G._mono(y)
    energies = segment_energies(mono, sr, phrases)
    if hooks is None:
        hooks = hooks_from_title(title) if title else \
            hooks_from_title(transcript.get("_vox_title", "")) or None
    ranked = rank_phrases(phrases, hooks=hooks, energies=energies)
    annotate_beats(ranked, bpm=bpm)
    return ranked[:top_n] if top_n else ranked


def grade_and_tier(phrases: list[Phrase], vocals: Path, *, bpm: float = DEFAULT_BPM) -> None:
    """Cut each phrase as a one-shot, grade it, and fill grade/tier in place."""
    y, sr = G.load_audio(vocals)
    ych = y.T if y.ndim == 2 else y
    starts = sorted(p.start for p in phrases)
    for p in phrases:
        nxt = next((s for s in starts if s > p.end), None)
        clip = cut_phrase(ych, sr, p, next_start=nxt)
        g = grade_cut(clip, sr)
        p.grade, p.tier = g.grade, g.tier


def _tier_ge(tier: str, floor: str) -> bool:
    return _TIER_ORDER.get(tier, 0) >= _TIER_ORDER.get(floor, 0)


def select_ship(phrases: list[Phrase], *, max_files: int = 9, min_tier: str = MIN_TIER,
                max_loops: int = 2) -> tuple[list[Phrase], list[Phrase]]:
    """Curate what ships: dedup identical texts (keep best-graded), gate on tier, then
    pick the top-iconicity phrases as one-shots and up to `max_loops` loop-capable hooks.

    Returns (one_shots, loops). Total files = len(one_shots)+len(loops) ≤ max_files.
    `phrases` must already be graded+ranked (iconicity desc). THE hook may keep up to 2
    takes; every other text dedups to its single best-graded instance.
    """
    # dedup by normalized text → keep the best-graded (then most iconic) instance,
    # allowing up to 2 takes of the single most-iconic text (THE hook).
    hook_norm = " ".join(_content_tokens(phrases[0].text)) if phrases else ""
    by_text: dict[str, list[Phrase]] = {}
    for p in phrases:
        by_text.setdefault(" ".join(_content_tokens(p.text)) or p.text.lower(), []).append(p)
    kept: list[Phrase] = []
    for norm, group in by_text.items():
        group = sorted(group, key=lambda x: (-x.grade, -x.iconicity))
        n_keep = 2 if norm == hook_norm else 1
        kept.extend(group[:n_keep])
    kept = [p for p in kept if _tier_ge(p.tier, min_tier)]
    kept.sort(key=lambda x: -x.iconicity)

    # loop variants: best loop-capable hooks (≥ B tier so a loopAt is clean)
    loops = [p for p in kept if p.loop_capable and _tier_ge(p.tier, "B")][:max_loops]
    loop_ids = {id(p) for p in loops}
    n_oneshot = max_files - len(loops)
    one_shots = [p for p in kept][:n_oneshot]
    # ensure any phrase chosen as a loop is also present as a one-shot anchor if room,
    # but never exceed max_files
    return one_shots, loops


def ship_vox(vocals: Path, kit: str, *, one_shots: list[Phrase], loops: list[Phrase],
             start_index: int = 3, bpm: float = DEFAULT_BPM,
             samples_root: Optional[Path] = None,
             replace_indices: Optional[list[int]] = None) -> list[tuple[Path, Phrase, bool]]:
    """Write the curated vox one-shots (+ loop variants) into Samples/<kit>/, numbered
    from start_index. Deletes `replace_indices` old files first. Returns
    [(path, phrase, is_loop)]. Re-grades on disk are the caller's job (verify).
    """
    from . import publish
    root = samples_root or publish.samples_root()
    out_dir = root / kit
    out_dir.mkdir(parents=True, exist_ok=True)

    # remove superseded files (e.g. old 03..06 chops)
    if replace_indices:
        for f in list(out_dir.glob("*.wav")):
            m = re.match(r"^(\d{2})_", f.name)
            if m and int(m.group(1)) in replace_indices:
                f.unlink()

    y, sr = G.load_audio(vocals)
    ych = y.T if y.ndim == 2 else y
    starts = sorted(p.start for p in [*one_shots, *loops])
    taken: set[str] = set()
    written: list[tuple[Path, Phrase, bool]] = []
    idx = start_index

    def _write(clip: np.ndarray, base_slug: str, phrase: Phrase, is_loop: bool):
        nonlocal idx
        # loop variant → NN_vox_<slug>_loop (slug as section keeps `loop` a distinct token,
        # since suggest_name slugs each part separately; a single character would collapse it)
        if is_loop:
            nm = naming.suggest_name(idx, "vox", section=base_slug, character="loop", taken=taken)
        else:
            nm = naming.suggest_name(idx, "vox", character=base_slug, taken=taken)
        taken.add(nm)
        dest = out_dir / f"{nm}.wav"
        sf.write(str(dest), clip, EXPORT_SR if sr == EXPORT_SR else sr, subtype="PCM_24")
        written.append((dest, phrase, is_loop))
        phrase.name = nm
        idx += 1

    for p in one_shots:
        nxt = next((s for s in starts if s > p.end), None)
        clip = cut_phrase(ych, sr, p, next_start=nxt)
        _write(clip, lyric_slug(p.text), p, False)
    for p in loops:
        clip = cut_phrase_loop(ych, sr, p, bpm=bpm)
        if clip is None:
            continue
        _write(clip, lyric_slug(p.text), p, True)

    if written:
        try:
            publish.link_kit(kit)
        except Exception:
            pass
    return written


def render_auditions(shipped: list[tuple[Path, Phrase, bool]], audition_dir: Path,
                     *, gap_s: float = 0.3, bpm: float = DEFAULT_BPM) -> Path:
    """Copy each shipped wav into audition_dir and build vox_medley.wav (all phrases
    sequenced with `gap_s` silence between). Returns the medley path.
    """
    import shutil
    audition_dir.mkdir(parents=True, exist_ok=True)
    pieces: list[np.ndarray] = []
    sr_ref = EXPORT_SR
    for path, _p, _loop in shipped:
        shutil.copy2(path, audition_dir / path.name)
        clip, sr_ref = sf.read(str(path), always_2d=True, dtype="float32")
        pieces.append(clip)
        pieces.append(np.zeros((int(gap_s * sr_ref), clip.shape[1]), dtype=np.float32))
    medley_path = audition_dir / "vox_medley.wav"
    if pieces:
        medley = np.concatenate(pieces, axis=0)
        sf.write(str(medley_path), medley, sr_ref, subtype="PCM_24")
    return medley_path


def _print_table(phrases: list[Phrase]) -> None:
    print(f"  {'#':>2}  {'t0':>6} {'dur':>5} {'beats':>5} {'loop':>5}  {'icon':>5} "
          f"{'rep':>4} {'hook':>4} {'tier':>4}  text")
    for i, p in enumerate(phrases):
        loop = f"{p.loop_beats}b" if p.loop_capable else "—"
        print(f"  {i:>2}  {p.start:6.1f} {p.dur:5.2f} {p.beats:5.2f} {loop:>5}  "
              f"{p.iconicity:5.3f} {p.repeat:4.2f} {p.hook:4.2f} {p.tier:>4}  {p.text[:56]}")


# ── CLI ─────────────────────────────────────────────────────────────────────────
def _main(argv=None) -> int:
    import argparse
    ap = argparse.ArgumentParser(description="lyric-aware vocal sampler (transcribe→rank)")
    ap.add_argument("vocals")
    ap.add_argument("--workspace", default=None)
    ap.add_argument("--hooks", nargs="+", default=None)
    ap.add_argument("--title", default=None, help="source title for default hook seeds")
    ap.add_argument("--model", default="medium")
    ap.add_argument("--top", type=int, default=12)
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args(argv)

    ws = Path(a.workspace) if a.workspace else None
    ranked = analyze_vox(Path(a.vocals), workspace=ws, hooks=a.hooks, title=a.title,
                         model=a.model, top_n=a.top,
                         progress=lambda m: print(f"… {m}", flush=True))
    grade_and_tier(ranked, Path(a.vocals))       # cut+grade so the table carries a tier
    if a.json:
        print(json.dumps([p.model_dump(exclude={"words"}) for p in ranked], indent=2))
    else:
        _print_table(ranked)
    return 0


if __name__ == "__main__":
    raise SystemExit(_main())
