ACG Projects
File /2026-01-24_picard-data-conversation/work/render.py
render.py
Download Open raw
from __future__ import annotations

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


def _run(cmd: list[str]) -> None:
    subprocess.run(cmd, check=True)


def _probe_duration_seconds(path: Path) -> float:
    out = subprocess.check_output(
        [
            "ffprobe",
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=nw=1:nk=1",
            str(path),
        ],
        text=True,
    ).strip()
    return float(out)


def _fmt_time(seconds: float) -> str:
    ms = int(round(seconds * 1000.0))
    hh = ms // 3_600_000
    ms -= hh * 3_600_000
    mm = ms // 60_000
    ms -= mm * 60_000
    ss = ms // 1_000
    ms -= ss * 1_000
    return f"{hh:02d}:{mm:02d}:{ss:02d},{ms:03d}"


def _silence_path(work_dir: Path, seconds: float) -> Path:
    ms = int(round(seconds * 1000.0))
    return work_dir / f"silence_{ms}ms.wav"


def _ensure_silence(work_dir: Path, seconds: float) -> Path:
    path = _silence_path(work_dir, seconds)
    if path.exists():
        return path
    _run(
        [
            "ffmpeg",
            "-y",
            "-f",
            "lavfi",
            "-i",
            "anullsrc=r=48000:cl=stereo",
            "-t",
            f"{seconds:.3f}",
            "-c:a",
            "pcm_s16le",
            str(path),
        ]
    )
    return path


def _norm_wav(in_wav: Path, out_wav: Path) -> None:
    if out_wav.exists():
        return
    _run(
        [
            "ffmpeg",
            "-y",
            "-i",
            str(in_wav),
            "-ar",
            "48000",
            "-ac",
            "2",
            "-c:a",
            "pcm_s16le",
            str(out_wav),
        ]
    )


def _pause_for(text: str) -> float:
    t = text.strip()
    if t.endswith(("?", "…")):
        return 0.60
    if t.endswith(("!", ".")) and len(t) > 60:
        return 0.45
    if t.endswith((".", "!", ";", ":")):
        return 0.35
    return 0.30


def _safe_stem(s: str) -> str:
    s = s.strip().lower()
    s = re.sub(r"[^a-z0-9]+", "_", s).strip("_")
    return s[:40] or "line"


def _parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Render a Picard/Data conversation using qwen3-tts voice-clone.")
    p.add_argument("--dialogue", required=True, help="Path to dialogue.json")
    p.add_argument("--out-wav", required=True, help="Output wav path")
    p.add_argument("--out-srt", required=True, help="Output srt path")
    p.add_argument("--work-dir", required=True, help="Project work dir (stores clips)")
    p.add_argument("--device-map", default="cpu")
    p.add_argument("--dtype", default="float32")
    return p.parse_args()


def main() -> int:
    args = _parse_args()
    dialogue_path = Path(args.dialogue).expanduser().resolve()
    work_dir = Path(args.work_dir).expanduser().resolve()
    out_wav = Path(args.out_wav).expanduser().resolve()
    out_srt = Path(args.out_srt).expanduser().resolve()

    clips_raw = work_dir / "clips_raw"
    clips_norm = work_dir / "clips_norm"
    clips_raw.mkdir(parents=True, exist_ok=True)
    clips_norm.mkdir(parents=True, exist_ok=True)
    out_wav.parent.mkdir(parents=True, exist_ok=True)
    out_srt.parent.mkdir(parents=True, exist_ok=True)

    root_dir = Path(__file__).resolve().parents[3]
    tts_dir = root_dir / "services" / "TTS" / "implementations" / "qwen3-tts"
    tts_cli = tts_dir / "tts.py"
    tts_venv_py = tts_dir / "runtime" / ".venv" / "bin" / "python"

    items = json.loads(dialogue_path.read_text(encoding="utf-8"))
    if not isinstance(items, list) or not items:
        raise SystemExit("dialogue.json must be a non-empty list")

    # Ensure runtime exists (fast no-op if already installed)
    _run(["python", str(tts_cli), "--install"])

    # Generate all raw clips in one model load (much faster than invoking the CLI per line)
    _run(
        [
            str(tts_venv_py),
            str((work_dir / "render_in_venv.py").resolve()),
            "--dialogue",
            str(dialogue_path),
            "--out-dir",
            str(clips_raw),
            "--device-map",
            args.device_map,
            "--dtype",
            args.dtype,
        ]
    )

    silence_cache: dict[float, Path] = {}

    concat_list = work_dir / "concat.txt"
    srt_lines: list[str] = []
    t_cursor = 0.0
    idx = 1

    concat_entries: list[Path] = []

    for i, item in enumerate(items, start=1):
        speaker = str(item.get("speaker", "")).strip().upper()
        text = str(item.get("text", "")).strip()
        if not speaker or not text:
            raise SystemExit(f"Invalid dialogue entry at index {i}: {item!r}")

        stem = f"{i:03d}_{speaker.lower()}_{_safe_stem(text)}"
        raw_wav = (clips_raw / f"{i:03d}_{speaker.lower()}.wav").resolve()
        norm_wav = (clips_norm / f"{stem}.wav").resolve()

        if not raw_wav.exists():
            raise SystemExit(f"Missing rendered clip for line {i} ({speaker}): {raw_wav}")

        _norm_wav(raw_wav, norm_wav)
        dur = _probe_duration_seconds(norm_wav)

        # Subtitle entry for this line
        start = t_cursor
        end = t_cursor + dur
        srt_lines.append(str(idx))
        srt_lines.append(f"{_fmt_time(start)} --> {_fmt_time(end)}")
        srt_lines.append(f"{speaker}: {text}")
        srt_lines.append("")
        idx += 1

        concat_entries.append(norm_wav)

        pause = _pause_for(text)
        if pause not in silence_cache:
            silence_cache[pause] = _ensure_silence(work_dir, pause)
        concat_entries.append(silence_cache[pause])
        t_cursor = end + pause

    concat_list.write_text("\n".join([f"file '{p.as_posix()}'" for p in concat_entries]) + "\n", encoding="utf-8")

    # Concatenate into final wav
    tmp_out = out_wav.with_suffix(".tmp.wav")
    _run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c", "copy", str(tmp_out)])
    tmp_out.replace(out_wav)

    out_srt.write_text("\n".join(srt_lines), encoding="utf-8")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Paste