← Files AgentBrokoARCHIVED FILE

src/video_forge/audio.py

12.8 KB · Sep 30, 2026 · 23:15 UTC

↓ Download file

"""audio: text-to-speech voiceover, a music bed, ducking, and the final mux.

Flow (driven by build.py):
  synth_vo(spec, out_dir)        -> per-scene VO clips (mono float32 @ SR) or None
  scene_durations(spec, vo)      -> VO-driven per-scene seconds (fed to Timeline)
  build_mix(spec, out_dir, tl, vo) -> mixed.wav (VO + ducked music), or None
  mux(silent_mp4, mixed_wav, out)  -> final.mp4

Design choices for robustness:
  * All decoding goes through ffmpeg to raw float32 (handles wav/mp3/etc alike).
  * Ducking and normalisation are done in numpy — no fragile ffmpeg filtergraphs —
    then a single mux copies the video and encodes AAC.
  * Everything degrades: no TTS -> music-only; no music file -> a subtle synth pad;
    nothing at all -> build.py falls back to the silent video.
"""

import os
import subprocess
import sys
import wave

import numpy as np

from ._ffmpeg import ffmpeg_bin

SR = 44100
LEAD = 0.35          # silence before a scene's VO starts
TAIL = 0.55          # silence after a scene's VO ends
_MIN_FLOOR = 2.4     # a scene never shorter than this, even for a 3-word line


# --------------------------------------------------------------------------
# decoding / encoding helpers
# --------------------------------------------------------------------------
def _load_audio_mono(path, sr=SR):
    """Decode any audio file to a mono float32 numpy array via ffmpeg."""
    if not path or not os.path.exists(path):
        return None
    cmd = [ffmpeg_bin(), "-v", "error", "-i", path, "-ac", "1", "-ar", str(sr),
           "-f", "f32le", "-"]
    try:
        out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                             check=True).stdout
    except (subprocess.CalledProcessError, OSError):
        return None
    if not out:
        return None
    return np.frombuffer(out, dtype=np.float32).copy()


def _write_wav(path, samples, sr=SR):
    """Write a mono float32 [-1,1] array as 16-bit PCM wav."""
    x = np.clip(samples, -1.0, 1.0)
    pcm = (x * 32767.0).astype("<i2")
    with wave.open(path, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(sr)
        w.writeframes(pcm.tobytes())
    return path


# --------------------------------------------------------------------------
# TTS backends
# --------------------------------------------------------------------------
_PS_SCRIPT = r"""param([string]$TextFile,[string]$OutFile,[int]$Rate,[string]$Voice)
Add-Type -AssemblyName System.Speech
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer
if ($Voice) {
  try { $s.SelectVoice($Voice) }
  catch {
    try {
      $ci = [System.Globalization.CultureInfo]::new($Voice)
      $s.SelectVoiceByHints([System.Speech.Synthesis.VoiceGender]::Female,
                            [System.Speech.Synthesis.VoiceAge]::Adult, 0, $ci)
    } catch {}
  }
}
$s.Rate = $Rate
$s.SetOutputToWaveFile($OutFile)
$t = Get-Content -Raw -Encoding UTF8 $TextFile
$s.Speak($t)
$s.Dispose()
"""


def _tts_windows(text, out_wav, voice_dir, voice="en-US", rate=0):
    if sys.platform != "win32":
        raise RuntimeError("windows TTS backend requires Windows")
    ps_path = os.path.join(voice_dir, "_say.ps1")
    if not os.path.exists(ps_path):
        with open(ps_path, "w", encoding="utf-8") as fh:
            fh.write(_PS_SCRIPT)
    txt_path = out_wav + ".txt"
    with open(txt_path, "w", encoding="utf-8") as fh:
        fh.write(text)
    cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
           "-File", ps_path, txt_path, out_wav, str(int(rate)), str(voice or "")]
    subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL,
                   stderr=subprocess.PIPE)
    return out_wav if os.path.exists(out_wav) else None


def _tts_elevenlabs(text, out_path, voice="Rachel", **_):
    import requests
    key = os.environ.get("ELEVENLABS_API_KEY")
    if not key:
        raise RuntimeError("ELEVENLABS_API_KEY not set")
    vid = os.environ.get("ELEVENLABS_VOICE_ID", voice)
    url = f"https://api.elevenlabs.io/v1/text-to-speech/{vid}"
    r = requests.post(url, headers={"xi-api-key": key},
                      json={"text": text, "model_id": "eleven_multilingual_v2"},
                      timeout=60)
    r.raise_for_status()
    with open(out_path, "wb") as fh:
        fh.write(r.content)
    return out_path


def _tts_openai(text, out_path, voice="alloy", **_):
    import requests
    key = os.environ.get("OPENAI_API_KEY")
    if not key:
        raise RuntimeError("OPENAI_API_KEY not set")
    r = requests.post("https://api.openai.com/v1/audio/speech",
                      headers={"Authorization": f"Bearer {key}"},
                      json={"model": "gpt-4o-mini-tts", "voice": voice,
                            "input": text, "response_format": "mp3"},
                      timeout=60)
    r.raise_for_status()
    with open(out_path, "wb") as fh:
        fh.write(r.content)
    return out_path


def _tts_edge(text, out_mp3, voice="en-US-ChristopherNeural", rate=0):
    import asyncio
    import edge_tts
    rate_str = f"+{rate}%" if rate > 0 else (f"{rate}%" if rate < 0 else "+0%")
    async def _run():
        comm = edge_tts.Communicate(text, voice or "en-US-ChristopherNeural", rate=rate_str)
        await comm.save(out_mp3)
    asyncio.run(_run())
    return out_mp3 if os.path.exists(out_mp3) else None


def _tts_say(text, out_aiff):
    cmd = ["say", "-o", out_aiff, text]
    subprocess.run(cmd, check=True)
    return out_aiff if os.path.exists(out_aiff) else None


def _tts_espeak(text, out_wav):
    cmd = ["espeak", "-w", out_wav, text]
    subprocess.run(cmd, check=True)
    return out_wav if os.path.exists(out_wav) else None


def synth_vo(spec, out_dir):
    """Synthesize one VO clip per scene. Returns a list aligned to spec['scenes'],
    each entry {"samples":np.float32, "dur":sec} or None (empty/failed)."""
    voice = spec.get("voice", {})
    backend = (voice.get("backend") or ("windows" if sys.platform == "win32" else "edge")).lower()
    vname = voice.get("voice")
    rate = int(voice.get("rate", 0) or 0)
    scenes = spec["scenes"]

    vo_dir = os.path.join(out_dir, "_vo")
    os.makedirs(vo_dir, exist_ok=True)

    out = []
    for i, sc in enumerate(scenes):
        text = (sc.get("vo") or "").strip()
        if not text:
            out.append(None)
            continue
        try:
            if backend in ("edge", "edge-tts"):
                raw = _tts_edge(text, os.path.join(vo_dir, f"s{i:02d}.mp3"),
                                voice=vname or "en-US-ChristopherNeural", rate=rate)
            elif backend == "windows":
                if sys.platform == "win32":
                    raw = _tts_windows(text, os.path.join(vo_dir, f"s{i:02d}.wav"),
                                       vo_dir, voice=vname or "en-US", rate=rate)
                else:
                    raw = _tts_edge(text, os.path.join(vo_dir, f"s{i:02d}.mp3"),
                                    voice=vname or "en-US-ChristopherNeural", rate=rate)
            elif backend in ("say", "mac"):
                raw = _tts_say(text, os.path.join(vo_dir, f"s{i:02d}.aiff"))
            elif backend == "espeak":
                raw = _tts_espeak(text, os.path.join(vo_dir, f"s{i:02d}.wav"))
            elif backend == "elevenlabs":
                raw = _tts_elevenlabs(text, os.path.join(vo_dir, f"s{i:02d}.mp3"),
                                      voice=vname or "Rachel")
            elif backend == "openai":
                raw = _tts_openai(text, os.path.join(vo_dir, f"s{i:02d}.mp3"),
                                  voice=vname or "alloy")
            else:
                # fallback attempt windows or edge
                try:
                    raw = _tts_edge(text, os.path.join(vo_dir, f"s{i:02d}.mp3"),
                                    voice=vname or "en-US-ChristopherNeural", rate=rate)
                except Exception:
                    raw = _tts_windows(text, os.path.join(vo_dir, f"s{i:02d}.wav"),
                                       vo_dir, voice=vname or "en-US", rate=rate)
        except Exception as e:
            print(f"[audio] scene {i} TTS failed ({e})", file=sys.stderr)
            out.append(None)
            continue

        samples = _load_audio_mono(raw)
        if samples is None or samples.size == 0:
            out.append(None)
            continue
        # trim trailing near-silence so scene cuts land tight on the words
        samples = _trim_silence(samples)
        out.append({"samples": samples, "dur": samples.size / SR})
    if all(v is None for v in out):
        return None
    return out


def _trim_silence(x, thresh=0.006):
    idx = np.where(np.abs(x) > thresh)[0]
    if idx.size == 0:
        return x
    end = min(x.size, idx[-1] + int(0.12 * SR))
    start = max(0, idx[0] - int(0.04 * SR))
    return x[start:end]


# --------------------------------------------------------------------------
# durations
# --------------------------------------------------------------------------
def scene_durations(spec, vo):
    from .spec import default_duration
    scenes = spec["scenes"]
    durs = []
    for i, sc in enumerate(scenes):
        base = default_duration(sc)
        clip = vo[i] if vo and i < len(vo) else None
        if clip:
            floor = max(_MIN_FLOOR, min(base, 3.2))
            durs.append(round(max(clip["dur"] + LEAD + TAIL, floor), 3))
        else:
            durs.append(base)
    return durs


# --------------------------------------------------------------------------
# music bed + mix
# --------------------------------------------------------------------------
def _synth_pad(n, sr=SR):
    """A subtle, slowly-swelling ambient chord — un-annoying background."""
    t = np.arange(n) / sr
    freqs = [110.0, 164.81, 220.0, 329.63]      # A2 / E3 / A3 / E4
    pad = np.zeros(n, dtype=np.float64)
    for k, f in enumerate(freqs):
        detune = 1.0 + 0.0016 * (k - 1.5)
        pad += np.sin(2 * np.pi * f * detune * t) * (0.6 ** k)
    lfo = 0.5 + 0.5 * np.sin(2 * np.pi * 0.05 * t)   # 20s swell
    pad *= 0.10 * (0.6 + 0.4 * lfo)
    # soft 1s fades so it eases in/out with the video
    f = int(sr * 1.0)
    if n > 2 * f:
        ramp = np.linspace(0, 1, f)
        pad[:f] *= ramp
        pad[-f:] *= ramp[::-1]
    return pad.astype(np.float32)


def _music_bed(spec, n, sr=SR):
    music = spec.get("music", {})
    gain = 10.0 ** (float(music.get("gain_db", -19)) / 20.0)
    m = _load_audio_mono(music.get("file")) if music.get("file") else None
    if m is None or m.size == 0:
        bed = _synth_pad(n, sr)
    else:
        if m.size < n:
            reps = int(np.ceil(n / m.size))
            m = np.tile(m, reps)
        bed = m[:n].astype(np.float32)
    return bed * gain


def _envelope(x, sr=SR, win=0.12):
    """Smoothed, normalised 0..1 loudness envelope of the VO for ducking."""
    a = np.abs(x)
    w = max(1, int(sr * win))
    k = np.ones(w, dtype=np.float64) / w
    env = np.convolve(a, k, mode="same")
    peak = env.max()
    if peak <= 1e-6:
        return np.zeros_like(env)
    env = env / peak
    return np.clip(env * 1.4, 0.0, 1.0)      # widen so quiet VO still ducks


def build_mix(spec, out_dir, timeline, vo):
    total = timeline.total_seconds()
    n = int(round(total * SR)) + SR              # +1s tail
    vo_buf = np.zeros(n, dtype=np.float64)

    have_vo = False
    if vo:
        for i, clip in enumerate(vo):
            if not clip:
                continue
            have_vo = True
            start = timeline.scene_start(i) + LEAD
            s0 = int(round(start * SR))
            seg = clip["samples"].astype(np.float64)
            e0 = min(n, s0 + seg.size)
            if s0 < n and e0 > s0:
                vo_buf[s0:e0] += seg[:e0 - s0]

    music = _music_bed(spec, n, SR).astype(np.float64)
    if have_vo:
        duck = 10.0 ** (float(spec.get("music", {}).get("duck_db", -11)) / 20.0)
        env = _envelope(vo_buf)
        music *= duck + (1.0 - duck) * (1.0 - env)

    mix = vo_buf + music
    # peak-limit to -1.5 dBFS (scale down only; never boost quiet mixes)
    peak = np.abs(mix).max()
    target = 10.0 ** (-1.5 / 20.0)
    if peak > target:
        mix *= target / peak

    if not have_vo and (spec.get("music", {}).get("file") is None):
        # music-only with a synth pad is fine, but if it's essentially silent, skip
        if np.abs(mix).max() < 1e-4:
            return None

    return _write_wav(os.path.join(out_dir, "mixed.wav"), mix, SR)


# --------------------------------------------------------------------------
# mux
# --------------------------------------------------------------------------
def mux(silent_mp4, mixed_wav, out_path):
    cmd = [
        ffmpeg_bin(), "-hide_banner", "-loglevel", "error", "-y",
        "-i", silent_mp4, "-i", mixed_wav,
        "-map", "0:v:0", "-map", "1:a:0",
        "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
        "-shortest", "-movflags", "+faststart",
        out_path,
    ]
    subprocess.run(cmd, check=True)
    return out_path

SHA-256: c24b82c8091263c9dbd64d7da0defd045c19b136f8940625004b8a695aea3a94