#!/usr/bin/env python3
"""Sit a music bed under a voice, at a level derived from both measurements.

The flat sidechain compressor is why background music in an edit is either
inaudible or in the way. It ducks *all* of the music by the same amount, so the
only knob is "how buried", and the bed has to be pushed far enough down that its
bass and its top disappear along with the mids that were actually the problem.

Here the bed is split into three bands and each is treated for what it does:

  * **low** (< 250 Hz) — never competes with speech intelligibility, ducks least
    and carries the presence of the track under a talking head.
  * **mid** (250–3500 Hz) — the band the voice needs. Ducked hardest and given a
    static dip as well, because this is the collision.
  * **high** (> 3500 Hz) — air and cymbals, audible over speech without masking
    it. Ducks lightly.

The result is a bed that stays *present* at a much lower masking cost: the same
perceived music level with the speech band cleared, instead of a uniformly
quiet track.

Levels are not guessed either. Both inputs are measured with `ebur128`, and the
bed is placed a stated number of LU below the voice. In the gaps — before the
first word, between blocks, after the last — the ducker releases and the track
comes up on its own, which is what makes an ending feel finished.
"""
from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from media_probe import loudness, probe  # noqa: E402
from skill_config import emit, require_binary, utf8_stdout  # noqa: E402

# How far below the voice the bed sits, per intent. Speech-under levels in
# broadcast practice land between 15 and 22 LU down; below 24 the bed stops
# reading as music and becomes noise the viewer cannot name.
#
# The numbers below were lowered after a listening pass: at 19 LU under the
# voice the bed measured correctly and was, in the viewer's words, "barely
# there". Broadcast practice quotes 15–22 LU for speech-under, but that range
# assumes a bed that keeps playing between sentences on a continuous mix. In a
# tightly recut talking head the gaps are gone — every pause the bed could have
# surfaced in has been cut out — so the same figure lands several dB quieter in
# perception than it does on a meter. 15 LU is the working value for this
# pipeline; the three-band ducking is what makes it safe to sit that close.
INTENTS: dict[str, dict] = {
    "under_talk": {"duck_lu": 15.0, "duck_mid_db": 11.0, "duck_low_db": 5.0,
                   "duck_high_db": 7.0, "mid_dip_db": 3.5, "outro_swell_db": 6.0,
                   "use": "Разговорное видео. Музыка держит тон, речь ведёт."},
    "support": {"duck_lu": 12.0, "duck_mid_db": 9.0, "duck_low_db": 4.0,
                "duck_high_db": 5.5, "mid_dip_db": 3.0, "outro_swell_db": 7.0,
                "use": "Музыка заметна между фразами, под речью уходит."},
    "drive": {"duck_lu": 10.0, "duck_mid_db": 8.0, "duck_low_db": 3.0,
              "duck_high_db": 4.5, "mid_dip_db": 2.5, "outro_swell_db": 8.0,
              "use": "Клиповый монтаж: трек — половина смысла."},
    "bed": {"duck_lu": 20.0, "duck_mid_db": 13.0, "duck_low_db": 6.0,
            "duck_high_db": 8.0, "mid_dip_db": 4.0, "outro_swell_db": 5.0,
            "use": "Едва слышимая подложка. Только чтобы тишина не звенела."},
}


def ramp01(start: float, seconds: float) -> str:
    """0 before `start`, rising to 1 over `seconds`, then held.

    Written without `min`/`max` because their arguments are comma-separated, and
    a comma inside a filter option is read by the graph parser as the separator
    between two filters. `(x+|x|)/2` is max(0,x); applying it twice clamps to 1.
    """
    raw = f"((t-{start:.4f})/{max(0.05, seconds):.4f})"
    low = f"(({raw}+abs({raw}))/2)"
    return f"(1-((1-{low})+abs(1-{low}))/2)"


def ff(value: float) -> str:
    return f"{value:.4f}".rstrip("0").rstrip(".") or "0"


def hf_transient_rate(path: Path) -> float:
    """Сколько резких атак выше 5 кГц приходится на секунду трека.

    Это та величина, из-за которой бас одновременно «не слышно» и «биты
    перебивают голос». Трёхполосный дакер держит верх слабее всех, потому что
    воздух и тарелки сидят над речью и обычно её не маскируют. Для тянущегося
    верха это верно, для ударного — нет: хэт это щелчок, он переживает любой
    дакинг с релизом, достаточно длинным чтобы не качать, и оказывается
    единственным, что слышно из трека, пока его тело задавлено.
    """
    done = subprocess.run(
        ["ffmpeg", "-v", "error", "-nostdin", "-i", str(path), "-t", "60",
         "-map", "0:a:0", "-f", "f32le", "-ac", "1", "-ar", "22050", "-"],
        capture_output=True)
    if done.returncode or not done.stdout:
        return 0.0
    try:
        import numpy as np
    except ImportError:
        return 0.0
    signal = np.frombuffer(done.stdout, dtype="<f4").astype(np.float64)
    if signal.size < 8192:
        return 0.0
    frame, hop = 2048, 512
    count = 1 + (signal.size - frame) // hop
    view = np.lib.stride_tricks.as_strided(
        signal, shape=(count, frame), strides=(signal.strides[0] * hop, signal.strides[0]))
    spectrum = np.abs(np.fft.rfft(view * np.hanning(frame), axis=1))
    freqs = np.fft.rfftfreq(frame, 1.0 / 22050)
    high = np.sqrt((spectrum[:, freqs >= 5000] ** 2).sum(axis=1))
    flux = np.maximum(0.0, np.diff(high))
    if flux.size < 4:
        return 0.0
    hits = (flux > flux.mean() + 2.0 * flux.std()).sum()
    return float(hits / (signal.size / 22050.0))


def first_and_last_word(timeline: Path | None) -> tuple[float | None, float | None]:
    if not timeline or not timeline.is_file():
        return None, None
    data = json.loads(timeline.read_text(encoding="utf-8"))
    words = data.get("words") or []
    if words:
        return float(words[0]["start"]), float(words[-1]["end"])
    clips = data.get("clips") or []
    if clips:
        return 0.0, float(clips[-1].get("global_end", 0.0))
    return None, None


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("video", help="Cut with the finished voice track")
    parser.add_argument("music")
    parser.add_argument("output")
    parser.add_argument("--intent", default="under_talk", choices=list(INTENTS))
    parser.add_argument("--duck-lu", type=float,
                        help="Override how far below the voice the bed sits, in LU")
    parser.add_argument("--tame-hats", type=float, default=None,
                        help="Дополнительно придавить верх баса, дБ. По умолчанию "
                             "считается из самого трека: чем больше резких атак выше "
                             "5 кГц, тем сильнее. 0 отключает.")
    parser.add_argument("--music-start", type=float, default=0.0,
                        help="Seconds into the track to start from")
    parser.add_argument("--intro-swell", type=float, default=0.0,
                        help="Seconds before the first word where the bed plays open")
    parser.add_argument("--outro-swell", type=float, default=2.5,
                        help="Seconds after the last word where the bed comes back up")
    parser.add_argument("--timeline", help="timeline.json — used to find the first and last word")
    parser.add_argument("--fade-in", type=float, default=1.0)
    parser.add_argument("--fade-out", type=float, default=2.0)
    parser.add_argument("--list", action="store_true")
    args = parser.parse_args()

    if args.list:
        emit({"intents": {name: {**spec} for name, spec in INTENTS.items()}})
        return

    require_binary("ffmpeg")
    video = Path(args.video).resolve()
    music = Path(args.music).resolve()
    for path in (video, music):
        if not path.is_file():
            raise SystemExit(f"Not found: {path}")

    info = probe(video)
    if not info.get("has_audio"):
        raise SystemExit(f"{video} has no voice track to duck against")
    duration = float((info.get("video") or {}).get("duration") or info.get("duration") or 0.0)

    voice_loudness = loudness(video)
    music_loudness = loudness(music)
    voice_i = voice_loudness.get("integrated_lufs")
    music_i = music_loudness.get("integrated_lufs")
    if voice_i is None or music_i is None:
        raise SystemExit("ebur128 did not report integrated loudness for one of the inputs")

    spec = INTENTS[args.intent]
    duck_lu = float(args.duck_lu if args.duck_lu is not None else spec["duck_lu"])
    # The gain that puts the bed exactly duck_lu below the measured voice, before
    # any ducking. Everything after this is dynamic.
    bed_gain_db = round((voice_i - duck_lu) - music_i, 2)

    first_word, last_word = first_and_last_word(Path(args.timeline) if args.timeline else None)
    swell_open = max(0.0, first_word - 0.15) if (first_word and args.intro_swell) else 0.0
    outro_at = last_word if last_word else None

    music_duration = float(probe(music).get("duration") or 0.0)
    loop_needed = music_duration - args.music_start < duration

    # Sidechain key: the voice, band-limited to the range that actually masks.
    # Keying the ducker off the full-band voice means a plosive or a chair creak
    # opens the gate as wide as a spoken sentence.
    graph = [
        f"[0:a]aformat=sample_rates=48000:channel_layouts=stereo,asplit=2[voice][keyraw]",
        "[keyraw]highpass=f=200,lowpass=f=4000,acompressor=threshold=-30dB:ratio=4:"
        "attack=5:release=200[key]",
        "[key]asplit=3[key_l][key_m][key_h]",
    ]

    # How percussive the top of this track is, and therefore how much of it has
    # to come off. A pad-and-strings bed needs none of this; a hip-hop or rock
    # instrumental with a busy hat pattern needs several dB, or the hats are the
    # only thing the viewer hears while the body of the track is fully ducked.
    hf_rate = hf_transient_rate(music)
    if args.tame_hats is None:
        tame_db = round(min(7.0, max(0.0, (hf_rate - 2.0) * 2.2)), 1)
    else:
        tame_db = max(0.0, float(args.tame_hats))

    bed_chain = [
        "aformat=sample_rates=48000:channel_layouts=stereo",
        f"volume={bed_gain_db:.2f}dB",
        # Static carve in the speech band: cheaper than ducking and it never
        # pumps, so the dynamic stage only has to handle what is left.
        f"equalizer=f=1600:t=q:w=1.1:g=-{spec['mid_dip_db']:.1f}",
    ]
    if tame_db > 0.3:
        # A shelf, not a low-pass: the goal is to stop the hats poking through,
        # not to make the bed sound like it is playing in the next room.
        bed_chain.append(f"treble=g=-{tame_db:.1f}:f=6000:width_type=q:w=0.7")
    # Outro swell. Previously this option was parsed, reported and then never
    # reached the graph — the bed stayed at speech-under level through the end,
    # which is why an otherwise finished edit still stopped rather than ended.
    # After the last word there is no voice left to duck against, so the bed can
    # simply come up.
    swell_db = float(spec.get("outro_swell_db", 0.0))
    swell_from = None
    swell_skipped = None
    if not outro_at:
        swell_skipped = "не передан --timeline, конец речи неизвестен"
    elif args.outro_swell <= 0 or swell_db <= 0:
        swell_skipped = "подъём отключён параметрами"
    elif outro_at >= duration - 0.4:
        swell_skipped = (f"после последнего слова осталось {duration - outro_at:.2f} с — "
                         "подниматься негде. Это не ошибка микса, а отсутствие хвоста "
                         "в самом монтаже: дай ролику 1.5–2 с после последнего слова.")
    if swell_skipped is None:
        swell_from = max(0.0, outro_at - 0.2)
        gain = ramp01(swell_from, args.outro_swell)
        # `volume` needs eval=frame: its default is to evaluate the expression
        # once at init and hold that value for the whole stream.
        bed_chain.append(f"volume=eval=frame:volume='1+{10 ** (swell_db / 20.0) - 1:.4f}*{gain}'")
    if args.fade_in > 0:
        bed_chain.append(f"afade=t=in:st=0:d={ff(args.fade_in)}:curve=qsin")
    if args.fade_out > 0 and duration > args.fade_out:
        bed_chain.append(f"afade=t=out:st={ff(duration - args.fade_out)}:d={ff(args.fade_out)}:curve=qsin")
    graph.append("[1:a]" + ",".join(bed_chain) + f",atrim=duration={ff(duration)}[bed]")

    # Three bands, each ducked by its own amount. `acrossover` keeps the split
    # phase-coherent, so summing the bands back does not comb-filter the track.
    graph.append("[bed]acrossover=split=250 3500[bl][bm][bh]")
    for label, key, name in (("bl", "key_l", "low"), ("bm", "key_m", "mid"), ("bh", "key_h", "high")):
        reduction = spec[f"duck_{name}_db"]
        ratio = max(1.5, 10 ** (reduction / 20.0) / 2.2)
        release = {"low": 420, "mid": 260, "high": 320}[name]
        graph.append(
            f"[{label}][{key}]sidechaincompress=threshold=0.03:ratio={ratio:.2f}:"
            f"attack=12:release={release}:makeup=1:level_sc=1[d{name}]")
    graph.append("[dlow][dmid][dhigh]amix=inputs=3:normalize=0:dropout_transition=0[ducked]")

    graph.append("[voice][ducked]amix=inputs=2:weights='1 1':normalize=0:"
                 "dropout_transition=0,alimiter=limit=0.97[aout]")

    command = ["ffmpeg", "-y", "-v", "error", "-nostdin", "-i", str(video)]
    if loop_needed:
        command += ["-stream_loop", "-1"]
    command += ["-ss", f"{args.music_start:.3f}", "-i", str(music),
                "-filter_complex", ";".join(graph),
                "-map", "0:v?", "-map", "[aout]",
                "-c:v", "copy", "-c:a", "aac", "-b:a", "224k", "-ar", "48000", "-ac", "2",
                "-shortest", "-movflags", "+faststart", str(Path(args.output).resolve())]

    done = subprocess.run(command, capture_output=True, text=True, errors="replace")
    if done.returncode:
        emit({"status": "failed", "graph": ";".join(graph),
              "tail": (done.stderr or "").strip()[-1800:]})
        raise SystemExit(done.returncode)

    output = Path(args.output).resolve()
    after = loudness(output)
    emit({
        "status": "ok",
        "output": str(output),
        "intent": args.intent,
        "intent_use": spec["use"],
        "voice_lufs": voice_i,
        "music_lufs_source": music_i,
        "bed_below_voice_lu": duck_lu,
        "bed_gain_applied_db": bed_gain_db,
        "bands": {
            "low_<250Hz": f"-{spec['duck_low_db']} дБ под речью — не маскирует, держит тело трека",
            "mid_250-3500Hz": f"-{spec['duck_mid_db']} дБ под речью плюс статический "
                              f"-{spec['mid_dip_db']} дБ — это единственная реальная коллизия",
            "high_>3500Hz": f"-{spec['duck_high_db']} дБ под речью — воздух слышен поверх голоса",
        },
        "music_looped": loop_needed,
        "music_start_seconds": args.music_start,
        "intro_open_until": round(swell_open, 2) if swell_open else None,
        "outro_swell": ({"from": round(swell_from, 2), "gain_db": swell_db,
                         "seconds": args.outro_swell}
                        if swell_from is not None
                        else {"applied": False, "why": swell_skipped}),
        "outro_from": round(outro_at, 2) if outro_at else None,
        "loudness_after": after,
        "note": ("Итоговая громкость здесь не выравнивается под платформу: это делает "
                 "audio_fx.py одним последним проходом."),
        "graph": ";".join(graph),
    })


if __name__ == "__main__":
    main()
