mirror of
https://github.com/digitalsamba/claude-code-video-toolkit.git
synced 2026-09-18 19:41:13 +08:00
290d7d345b
* Adopt uv for Python dependency management Replace manual pip/venv setup with `uv sync` (pyproject.toml + uv.lock), and switch all tool invocations across docs, skills, commands, and examples to `uv run`. Optional extras: --extra whisper, --extra modal. tools/requirements.txt is kept as a pip fallback. * FIX: bring uv migration current with main — post-June deps and docs Adds websocket-client (60db websocket transport) to core deps and a 'youtube' extra for the google-* packages (tools/youtube_upload.py), regenerates uv.lock, bumps pyproject version to 0.18.0, and converts the 51 'python3 tools/…' invocations added since the PR was opened (60db, YouTube, Kiro docs, publish command) to 'uv run'. Verified with uv sync --frozen --extra youtube; voiceover, sixtydb_tts, youtube_upload and migrate_to_kiro all run through the project environment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Asharib Ali <asharibarain4@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1205 lines
42 KiB
Python
1205 lines
42 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate voiceover audio using ElevenLabs or Qwen3-TTS.
|
|
|
|
Usage:
|
|
# From script file (ElevenLabs, default)
|
|
uv run tools/voiceover.py --script VOICEOVER-SCRIPT.md --output public/audio/voiceover.mp3
|
|
|
|
# From stdin (for AI piping)
|
|
echo "Hello world" | uv run tools/voiceover.py --output voiceover.mp3
|
|
|
|
# With custom voice
|
|
uv run tools/voiceover.py --script script.txt --voice-id ABC123 --output out.mp3
|
|
|
|
# JSON output for machine parsing
|
|
uv run tools/voiceover.py --script script.txt --output out.mp3 --json
|
|
|
|
# Per-scene generation (recommended)
|
|
uv run tools/voiceover.py --scene-dir public/audio/scenes --json
|
|
|
|
# With concat for SadTalker narrator
|
|
uv run tools/voiceover.py --scene-dir public/audio/scenes --concat public/audio/voiceover-concat.mp3
|
|
|
|
# Using Qwen3-TTS provider
|
|
uv run tools/voiceover.py --provider qwen3 --speaker Ryan --scene-dir public/audio/scenes --json
|
|
uv run tools/voiceover.py --provider qwen3 --tone warm --scene-dir public/audio/scenes --json
|
|
uv run tools/voiceover.py --provider qwen3 --instruct "Speak warmly" --script script.txt --output out.mp3
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
# Add parent to path for local imports
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from config import (
|
|
get_brand_dir,
|
|
get_elevenlabs_api_key,
|
|
get_sixtydb_api_key,
|
|
get_sixtydb_voice_id,
|
|
get_voice_id,
|
|
load_brand_voice_config,
|
|
)
|
|
|
|
|
|
def _get_elevenlabs_imports():
|
|
"""Lazy import ElevenLabs SDK (only when provider=elevenlabs)."""
|
|
try:
|
|
from elevenlabs import VoiceSettings, save
|
|
from elevenlabs.client import ElevenLabs
|
|
return ElevenLabs, VoiceSettings, save
|
|
except ImportError:
|
|
print(
|
|
"Error: ElevenLabs Python package not installed.\n"
|
|
"\n"
|
|
"You have 3 options:\n"
|
|
"\n"
|
|
" 1. Install ElevenLabs:\n"
|
|
" uv sync\n"
|
|
"\n"
|
|
" 2. Use Qwen3-TTS instead (free, self-hosted):\n"
|
|
" uv run tools/voiceover.py --provider qwen3 --speaker Ryan --scene-dir public/audio/scenes --json\n"
|
|
" (Requires RunPod account — run: uv run tools/qwen3_tts.py --setup)\n"
|
|
"\n"
|
|
" 3. Skip voiceover entirely:\n"
|
|
" Videos render fine without audio. Add voiceover later when ready.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate voiceover using ElevenLabs or Qwen3-TTS",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# ElevenLabs (default)
|
|
uv run tools/voiceover.py --script VOICEOVER-SCRIPT.md --output public/audio/voiceover.mp3
|
|
uv run tools/voiceover.py --scene-dir public/audio/scenes --json
|
|
|
|
# Qwen3-TTS
|
|
uv run tools/voiceover.py --provider qwen3 --speaker Ryan --scene-dir public/audio/scenes --json
|
|
uv run tools/voiceover.py --provider qwen3 --tone warm --scene-dir public/audio/scenes --json
|
|
uv run tools/voiceover.py --provider qwen3 --instruct "Speak warmly" --script script.txt --output out.mp3
|
|
""",
|
|
)
|
|
parser.add_argument(
|
|
"--script",
|
|
"-s",
|
|
type=str,
|
|
help="Path to script file (reads from stdin if not provided)",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
"-o",
|
|
type=str,
|
|
help="Output audio file path (.mp3). Required for single-file mode.",
|
|
)
|
|
parser.add_argument(
|
|
"--scene-dir",
|
|
type=str,
|
|
help="Directory of .txt scripts to process (per-scene mode). Each .txt generates a .mp3.",
|
|
)
|
|
parser.add_argument(
|
|
"--concat",
|
|
type=str,
|
|
help="Output path for concatenated audio (use with --scene-dir for SadTalker)",
|
|
)
|
|
|
|
# Provider selection
|
|
parser.add_argument(
|
|
"--provider",
|
|
type=str,
|
|
default="elevenlabs",
|
|
choices=["elevenlabs", "qwen3", "60db"],
|
|
help="TTS provider (default: elevenlabs)",
|
|
)
|
|
|
|
# ElevenLabs-specific options
|
|
parser.add_argument(
|
|
"--voice-id",
|
|
"-v",
|
|
type=str,
|
|
help="ElevenLabs voice ID (uses default from toolkit-registry.json if not provided)",
|
|
)
|
|
parser.add_argument(
|
|
"--model",
|
|
"-m",
|
|
type=str,
|
|
default="eleven_multilingual_v2",
|
|
choices=["eleven_multilingual_v2", "eleven_flash_v2_5", "eleven_turbo_v2_5", "eleven_v3"],
|
|
help="ElevenLabs model (default: eleven_multilingual_v2). eleven_v3 is alpha.",
|
|
)
|
|
parser.add_argument(
|
|
"--stability",
|
|
type=float,
|
|
default=0.85,
|
|
help="Voice stability 0-1 (default: 0.85, higher = more consistent)",
|
|
)
|
|
parser.add_argument(
|
|
"--similarity",
|
|
type=float,
|
|
default=0.95,
|
|
help="Similarity boost 0-1 (default: 0.95, higher = closer to original)",
|
|
)
|
|
parser.add_argument(
|
|
"--style",
|
|
type=float,
|
|
default=0.0,
|
|
help="Style exaggeration 0-1 (default: 0.0, lower = more neutral)",
|
|
)
|
|
parser.add_argument(
|
|
"--speed",
|
|
type=float,
|
|
default=1.0,
|
|
help="Speech speed multiplier (default: 1.0)",
|
|
)
|
|
|
|
# 60db-specific options (stability/similarity/speed are shared, on the 0-1 scale)
|
|
parser.add_argument(
|
|
"--transport",
|
|
type=str,
|
|
default="synthesize",
|
|
choices=["synthesize", "stream", "websocket"],
|
|
help="60db API transport (default: synthesize)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-enhance",
|
|
dest="enhance",
|
|
action="store_false",
|
|
help="Disable 60db audio enhancement (on by default)",
|
|
)
|
|
parser.set_defaults(enhance=True)
|
|
|
|
# Qwen3-TTS-specific options
|
|
parser.add_argument(
|
|
"--speaker",
|
|
type=str,
|
|
default="Ryan",
|
|
help="Qwen3-TTS speaker name (default: Ryan). Use 'uv run tools/qwen3_tts.py --list-voices' to see options.",
|
|
)
|
|
parser.add_argument(
|
|
"--language",
|
|
type=str,
|
|
default="Auto",
|
|
help="Qwen3-TTS language hint (default: Auto)",
|
|
)
|
|
parser.add_argument(
|
|
"--instruct",
|
|
type=str,
|
|
default="",
|
|
help="Qwen3-TTS emotion/style instruction (e.g., 'Speak warmly'). Overrides --tone.",
|
|
)
|
|
parser.add_argument(
|
|
"--tone",
|
|
type=str,
|
|
help="Qwen3-TTS tone preset (e.g., 'warm', 'professional'). See 'uv run tools/qwen3_tts.py --list-tones'.",
|
|
)
|
|
parser.add_argument(
|
|
"--ref-audio",
|
|
type=str,
|
|
help="Qwen3-TTS reference audio file for voice cloning",
|
|
)
|
|
parser.add_argument(
|
|
"--ref-text",
|
|
type=str,
|
|
help="Qwen3-TTS transcript of reference audio (required with --ref-audio)",
|
|
)
|
|
parser.add_argument(
|
|
"--temperature",
|
|
type=float,
|
|
help="Qwen3-TTS expressiveness (default: model default ~0.7, range: 0.3-1.5)",
|
|
)
|
|
parser.add_argument(
|
|
"--top-p",
|
|
type=float,
|
|
help="Qwen3-TTS nucleus sampling (default: model default ~0.8, range: 0.1-1.0)",
|
|
)
|
|
|
|
# Cloud GPU provider (for Qwen3-TTS)
|
|
parser.add_argument(
|
|
"--cloud",
|
|
type=str,
|
|
default="modal",
|
|
choices=["runpod", "modal"],
|
|
help="Cloud GPU provider for Qwen3-TTS (default: modal)",
|
|
)
|
|
|
|
# Brand integration
|
|
parser.add_argument(
|
|
"--brand",
|
|
type=str,
|
|
help="Brand name to load voice config from (e.g., 'default', 'digital-samba')",
|
|
)
|
|
|
|
# Common options
|
|
parser.add_argument(
|
|
"--max-wpm",
|
|
type=float,
|
|
default=None,
|
|
help="Pace clamp (any provider): takes exceeding this words-per-minute "
|
|
"are slowed with pitch-preserving atempo (floor 0.85x). Try 165. "
|
|
"Results always include wpm + a pacing label for QC.",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Output result as JSON (for machine parsing)",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Show what would be done without making API calls",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def read_script(script_path: str | None) -> str:
|
|
"""Read script from file or stdin."""
|
|
if script_path:
|
|
with open(script_path) as f:
|
|
return f.read().strip()
|
|
else:
|
|
if sys.stdin.isatty():
|
|
print("Reading script from stdin (Ctrl+D to end):", file=sys.stderr)
|
|
return sys.stdin.read().strip()
|
|
|
|
|
|
def get_audio_duration(file_path: str) -> float | None:
|
|
"""Get audio duration using ffprobe if available."""
|
|
import subprocess
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
"format=duration",
|
|
"-of",
|
|
"csv=p=0",
|
|
file_path,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode == 0:
|
|
return float(result.stdout.strip())
|
|
except (FileNotFoundError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def generate_single_audio(
|
|
client,
|
|
script: str,
|
|
output_path: Path,
|
|
voice_id: str,
|
|
model: str,
|
|
stability: float,
|
|
similarity: float,
|
|
style: float,
|
|
speed: float,
|
|
max_wpm: float | None = None,
|
|
) -> dict:
|
|
"""Generate a single audio file from script text using ElevenLabs. Returns result dict."""
|
|
_, VoiceSettings, save = _get_elevenlabs_imports()
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
audio = client.text_to_speech.convert(
|
|
text=script,
|
|
voice_id=voice_id,
|
|
model_id=model,
|
|
voice_settings=VoiceSettings(
|
|
stability=stability,
|
|
similarity_boost=similarity,
|
|
style=style,
|
|
speed=speed,
|
|
),
|
|
)
|
|
|
|
save(audio, str(output_path))
|
|
|
|
duration = get_audio_duration(str(output_path))
|
|
|
|
result = {
|
|
"success": True,
|
|
"output": str(output_path),
|
|
"script_chars": len(script),
|
|
}
|
|
if duration:
|
|
result["duration_seconds"] = round(duration, 2)
|
|
result["duration_frames_30fps"] = int(duration * 30)
|
|
|
|
_apply_pacing_qc(result, script, max_wpm)
|
|
return result
|
|
|
|
|
|
def _apply_pacing_qc(result: dict, script: str, max_wpm: float | None) -> None:
|
|
"""Add wpm/pacing fields to a result; clamp pace in place if max_wpm set."""
|
|
from pacing import clamp_pace, pace_label
|
|
|
|
if not result.get("success") or not result.get("output"):
|
|
return
|
|
if max_wpm:
|
|
clamp = clamp_pace(result["output"], script, max_wpm, verbose=False)
|
|
if clamp.get("applied"):
|
|
new_dur = clamp["duration_seconds"]
|
|
result["duration_seconds"] = new_dur
|
|
result["duration_frames_30fps"] = int(new_dur * 30) if new_dur else None
|
|
result["pace_adjusted"] = {
|
|
"original_wpm": clamp["original_wpm"],
|
|
"atempo": clamp["atempo"],
|
|
}
|
|
wpm, label = pace_label(script, result.get("duration_seconds"))
|
|
result["wpm"] = wpm
|
|
result["pacing"] = label
|
|
|
|
|
|
def generate_single_audio_60db(
|
|
script: str,
|
|
output_path: Path,
|
|
voice_id: str | None,
|
|
stability: float,
|
|
similarity: float,
|
|
speed: float,
|
|
enhance: bool,
|
|
transport: str,
|
|
api_key: str | None,
|
|
max_wpm: float | None = None,
|
|
) -> dict:
|
|
"""Generate a single audio file from script text using 60db. Returns result dict.
|
|
|
|
stability/similarity are passed on the unified 0-1 scale and converted to
|
|
60db's native 0-100 inside sixtydb_tts.generate_audio().
|
|
"""
|
|
from sixtydb_tts import generate_audio
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
result = generate_audio(
|
|
text=script,
|
|
output_path=str(output_path),
|
|
voice_id=voice_id,
|
|
stability=stability,
|
|
similarity=similarity,
|
|
speed=speed,
|
|
enhance=enhance,
|
|
transport=transport,
|
|
api_key=api_key,
|
|
verbose=False,
|
|
)
|
|
_apply_pacing_qc(result, script, max_wpm)
|
|
return result
|
|
|
|
def generate_single_audio_qwen3(
|
|
script: str,
|
|
output_path: Path,
|
|
speaker: str = "Ryan",
|
|
language: str = "Auto",
|
|
instruct: str = "",
|
|
ref_audio: str | None = None,
|
|
ref_text: str | None = None,
|
|
temperature: float | None = None,
|
|
top_p: float | None = None,
|
|
cloud: str = "runpod",
|
|
max_wpm: float | None = None,
|
|
) -> dict:
|
|
"""Generate a single audio file from script text using Qwen3-TTS. Returns result dict."""
|
|
from qwen3_tts import generate_audio
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
return generate_audio(
|
|
text=script,
|
|
output_path=str(output_path),
|
|
speaker=speaker,
|
|
language=language,
|
|
instruct=instruct,
|
|
ref_audio=ref_audio,
|
|
ref_text=ref_text,
|
|
verbose=False,
|
|
temperature=temperature,
|
|
top_p=top_p,
|
|
cloud=cloud,
|
|
max_wpm=max_wpm,
|
|
)
|
|
|
|
|
|
def generate_batch_audio_qwen3(
|
|
scripts: list[str],
|
|
output_paths: list[Path],
|
|
speaker: str = "Ryan",
|
|
language: str = "Auto",
|
|
ref_audio: str | None = None,
|
|
ref_text: str | None = None,
|
|
temperature: float | None = None,
|
|
top_p: float | None = None,
|
|
cloud: str = "runpod",
|
|
timeout_per_scene: int = 120,
|
|
max_wpm: float | None = None,
|
|
) -> list[dict]:
|
|
"""Generate multiple audio files in a single Qwen3-TTS call.
|
|
|
|
For clone mode: the voice_clone_prompt is extracted ONCE from the
|
|
reference and reused across all scripts — the Qwen-recommended pattern
|
|
for consistent voice across long-form narration.
|
|
|
|
Returns a list of result dicts matching the input order.
|
|
"""
|
|
from qwen3_tts import generate_audio
|
|
|
|
for p in output_paths:
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
result = generate_audio(
|
|
text=scripts,
|
|
output_path=[str(p) for p in output_paths],
|
|
speaker=speaker,
|
|
language=language,
|
|
ref_audio=ref_audio,
|
|
ref_text=ref_text,
|
|
verbose=False,
|
|
temperature=temperature,
|
|
top_p=top_p,
|
|
cloud=cloud,
|
|
timeout=timeout_per_scene,
|
|
max_wpm=max_wpm,
|
|
)
|
|
|
|
if not result.get("success"):
|
|
# Propagate error as one failure per scene so the caller can report cleanly
|
|
err = result.get("error", "batch generation failed")
|
|
return [{"success": False, "error": err, "output": str(p)} for p in output_paths]
|
|
|
|
return [
|
|
{"success": True, **item}
|
|
for item in result.get("outputs", [])
|
|
]
|
|
|
|
|
|
def process_scene_directory(
|
|
scene_dir: Path,
|
|
dry_run: bool = False,
|
|
json_output: bool = False,
|
|
# Provider
|
|
provider: str = "elevenlabs",
|
|
# ElevenLabs params
|
|
client=None,
|
|
voice_id: str = "",
|
|
model: str = "eleven_multilingual_v2",
|
|
stability: float = 0.85,
|
|
similarity: float = 0.95,
|
|
style: float = 0.0,
|
|
speed: float = 1.0,
|
|
# 60db params
|
|
enhance: bool = True,
|
|
transport: str = "synthesize",
|
|
sixtydb_api_key: str | None = None,
|
|
# Qwen3 params
|
|
speaker: str = "Ryan",
|
|
language: str = "Auto",
|
|
instruct: str = "",
|
|
ref_audio: str | None = None,
|
|
ref_text: str | None = None,
|
|
temperature: float | None = None,
|
|
top_p: float | None = None,
|
|
cloud: str = "runpod",
|
|
max_wpm: float | None = None,
|
|
) -> list[dict]:
|
|
"""Process all .txt files in directory, generate .mp3 for each."""
|
|
txt_files = sorted(scene_dir.glob("*.txt"))
|
|
|
|
if not txt_files:
|
|
print(f"Error: No .txt files found in {scene_dir}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# First pass: collect scenes + their per-scene instruct overrides
|
|
scenes = []
|
|
for txt_file in txt_files:
|
|
mp3_file = txt_file.with_suffix(".mp3")
|
|
script = txt_file.read_text().strip()
|
|
|
|
if not script:
|
|
print(f"Warning: Empty script in {txt_file.name}, skipping", file=sys.stderr)
|
|
continue
|
|
|
|
scene_instruct = instruct
|
|
if provider == "qwen3":
|
|
import re
|
|
first_line = script.split("\n", 1)[0].strip()
|
|
m = re.match(r"^\[(tone|instruct):\s*(.+?)\]\s*$", first_line, re.IGNORECASE)
|
|
if m:
|
|
kind, value = m.group(1).lower(), m.group(2).strip()
|
|
if kind == "tone":
|
|
from qwen3_tts import resolve_tone
|
|
scene_instruct = resolve_tone(value, "")
|
|
else:
|
|
scene_instruct = value
|
|
script = script.split("\n", 1)[1].strip() if "\n" in script else ""
|
|
|
|
scenes.append({
|
|
"txt_file": txt_file,
|
|
"mp3_file": mp3_file,
|
|
"script": script,
|
|
"instruct": scene_instruct,
|
|
})
|
|
|
|
results = []
|
|
total_duration = 0.0
|
|
total_chars = sum(len(s["script"]) for s in scenes)
|
|
|
|
# Dry run path — no generation
|
|
if dry_run:
|
|
for s in scenes:
|
|
scene_result = {
|
|
"dry_run": True,
|
|
"script": str(s["txt_file"]),
|
|
"output": str(s["mp3_file"]),
|
|
"script_chars": len(s["script"]),
|
|
}
|
|
if provider == "qwen3" and s["instruct"]:
|
|
scene_result["instruct"] = s["instruct"]
|
|
results.append(scene_result)
|
|
if not json_output:
|
|
tone_note = f" [instruct: {s['instruct']}]" if s["instruct"] != instruct else ""
|
|
print(f" {s['txt_file'].name} → {s['mp3_file'].name} ({len(s['script'])} chars){tone_note}")
|
|
return results, total_duration, total_chars
|
|
|
|
# Decide batch vs serial for qwen3:
|
|
# - Clone mode: batch-safe (instruct is ignored by handler → all scenes share prompt)
|
|
# - Custom_voice: only batchable if all scenes share the same instruct
|
|
can_batch_qwen3 = (
|
|
provider == "qwen3"
|
|
and len(scenes) > 1
|
|
and (
|
|
ref_audio is not None # clone — instruct ignored, always batchable
|
|
or all(s["instruct"] == scenes[0]["instruct"] for s in scenes)
|
|
)
|
|
)
|
|
|
|
if can_batch_qwen3:
|
|
if not json_output:
|
|
mode_label = "clone (shared prompt)" if ref_audio else "custom_voice (shared instruct)"
|
|
print(f"Batching {len(scenes)} scenes in one Qwen3 call — mode: {mode_label}", file=sys.stderr)
|
|
|
|
batch_results = generate_batch_audio_qwen3(
|
|
scripts=[s["script"] for s in scenes],
|
|
output_paths=[s["mp3_file"] for s in scenes],
|
|
speaker=speaker,
|
|
language=language,
|
|
ref_audio=ref_audio,
|
|
ref_text=ref_text,
|
|
temperature=temperature,
|
|
top_p=top_p,
|
|
cloud=cloud,
|
|
max_wpm=max_wpm,
|
|
)
|
|
|
|
for s, r in zip(scenes, batch_results):
|
|
r["script"] = str(s["txt_file"])
|
|
results.append(r)
|
|
if r.get("duration_seconds"):
|
|
total_duration += r["duration_seconds"]
|
|
if not json_output:
|
|
if r.get("success"):
|
|
dur = f" ({r.get('duration_seconds', '?')}s{_pace_note(r)})"
|
|
print(f" {s['mp3_file'].name}{dur}", file=sys.stderr)
|
|
else:
|
|
print(f" {s['mp3_file'].name} [FAILED: {r.get('error')}]", file=sys.stderr)
|
|
return results, total_duration, total_chars
|
|
|
|
# Fallback: serial per-scene generation
|
|
for s in scenes:
|
|
if not json_output:
|
|
print(f"Generating {s['mp3_file'].name}...", file=sys.stderr)
|
|
|
|
if provider == "qwen3":
|
|
result = generate_single_audio_qwen3(
|
|
script=s["script"],
|
|
output_path=s["mp3_file"],
|
|
speaker=speaker,
|
|
language=language,
|
|
instruct=s["instruct"],
|
|
ref_audio=ref_audio,
|
|
ref_text=ref_text,
|
|
temperature=temperature,
|
|
top_p=top_p,
|
|
cloud=cloud,
|
|
max_wpm=max_wpm,
|
|
)
|
|
elif provider == "60db":
|
|
result = generate_single_audio_60db(
|
|
script=s["script"],
|
|
output_path=s["mp3_file"],
|
|
voice_id=voice_id or None,
|
|
stability=stability,
|
|
similarity=similarity,
|
|
speed=speed,
|
|
enhance=enhance,
|
|
transport=transport,
|
|
api_key=sixtydb_api_key,
|
|
max_wpm=max_wpm,
|
|
)
|
|
else:
|
|
result = generate_single_audio(
|
|
client=client,
|
|
script=s["script"],
|
|
output_path=s["mp3_file"],
|
|
voice_id=voice_id,
|
|
model=model,
|
|
stability=stability,
|
|
similarity=similarity,
|
|
style=style,
|
|
speed=speed,
|
|
max_wpm=max_wpm,
|
|
)
|
|
result["script"] = str(s["txt_file"])
|
|
results.append(result)
|
|
|
|
if result.get("duration_seconds"):
|
|
total_duration += result["duration_seconds"]
|
|
|
|
if not json_output:
|
|
duration_str = f" ({result.get('duration_seconds', '?')}s{_pace_note(result)})"
|
|
print(f" {s['mp3_file'].name}{duration_str}", file=sys.stderr)
|
|
|
|
return results, total_duration, total_chars
|
|
|
|
|
|
def _pace_note(result: dict) -> str:
|
|
"""Human-mode pacing annotation for a per-scene result line."""
|
|
wpm = result.get("wpm")
|
|
if not wpm:
|
|
return ""
|
|
note = f", {wpm:.0f} wpm"
|
|
if result.get("pace_adjusted"):
|
|
note += f" [clamped from {result['pace_adjusted']['original_wpm']:.0f}]"
|
|
elif result.get("pacing") in ("fast", "slow"):
|
|
note += f" [{result['pacing'].upper()} — try --max-wpm 165]"
|
|
return note
|
|
|
|
|
|
def concat_audio_files(mp3_files: list[Path], output_path: Path) -> dict:
|
|
"""Use ffmpeg concat demuxer to join audio files."""
|
|
import subprocess
|
|
import tempfile
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Create concat list file
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
|
for mp3 in mp3_files:
|
|
# FFmpeg concat requires specific format with escaped paths
|
|
escaped_path = str(mp3).replace("'", "'\\''")
|
|
f.write(f"file '{escaped_path}'\n")
|
|
concat_list = f.name
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-y", # Overwrite output
|
|
"-f", "concat",
|
|
"-safe", "0",
|
|
"-i", concat_list,
|
|
"-c", "copy",
|
|
str(output_path),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
print(f"Error concatenating audio: {result.stderr}", file=sys.stderr)
|
|
return {"success": False, "error": result.stderr}
|
|
|
|
duration = get_audio_duration(str(output_path))
|
|
return {
|
|
"success": True,
|
|
"output": str(output_path),
|
|
"duration_seconds": round(duration, 2) if duration else None,
|
|
"source_files": len(mp3_files),
|
|
}
|
|
finally:
|
|
Path(concat_list).unlink(missing_ok=True)
|
|
|
|
|
|
def main():
|
|
load_dotenv()
|
|
args = parse_args()
|
|
|
|
provider = args.provider
|
|
|
|
# Validate argument combinations
|
|
if args.scene_dir and args.script:
|
|
print("Error: Cannot use both --scene-dir and --script", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.concat and not args.scene_dir:
|
|
print("Error: --concat requires --scene-dir", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if not args.scene_dir and not args.output:
|
|
print("Error: --output is required for single-file mode", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if args.ref_audio and not args.ref_text:
|
|
print("Error: --ref-text is required with --ref-audio", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Brand voice config resolution
|
|
if args.brand:
|
|
voice_config = load_brand_voice_config(args.brand)
|
|
if not voice_config:
|
|
print(f"Error: Brand '{args.brand}' not found or has no voice.json", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if provider == "qwen3":
|
|
qwen3_cfg = voice_config.get("qwen3", {})
|
|
clone_cfg = qwen3_cfg.get("clone", {})
|
|
if clone_cfg and not args.ref_audio:
|
|
brand_dir = get_brand_dir(args.brand)
|
|
|
|
if "design" in clone_cfg:
|
|
# VoiceDesign mode — design a character voice from a brief,
|
|
# cache it, and use that cached wav as the clone reference.
|
|
design = clone_cfg["design"]
|
|
if "seedText" not in design or "instruct" not in design:
|
|
print("Error: clone.design requires both 'seedText' and 'instruct'", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
cached_rel = design.get("cachedRef", "assets/voice-design-ref.wav")
|
|
cached_path = brand_dir / cached_rel
|
|
|
|
if not cached_path.exists():
|
|
print(
|
|
f"Designing brand voice for '{args.brand}' (first use)...\n"
|
|
f" Instruct: {design['instruct'][:80]}...",
|
|
file=sys.stderr,
|
|
)
|
|
cached_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
from qwen3_tts import generate_audio as _qwen3_generate
|
|
design_result = _qwen3_generate(
|
|
text=design["seedText"],
|
|
output_path=str(cached_path),
|
|
design_instruct=design["instruct"],
|
|
language=(design.get("language") or "English"),
|
|
output_format="wav",
|
|
verbose=True,
|
|
cloud=args.cloud,
|
|
)
|
|
if not design_result.get("success"):
|
|
print(
|
|
f"Error: voice design failed: {design_result.get('error')}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
print(f" Cached designed voice at {cached_path}", file=sys.stderr)
|
|
|
|
args.ref_audio = str(cached_path)
|
|
args.ref_text = design["seedText"]
|
|
|
|
elif "refAudio" in clone_cfg:
|
|
# Legacy: user-recorded reference audio
|
|
ref_audio_path = brand_dir / clone_cfg["refAudio"]
|
|
if ref_audio_path.exists():
|
|
args.ref_audio = str(ref_audio_path)
|
|
args.ref_text = clone_cfg.get("refText", "")
|
|
else:
|
|
print(f"Warning: Clone ref audio not found: {ref_audio_path}", file=sys.stderr)
|
|
|
|
# Apply speaker/language/instruct defaults from brand
|
|
if qwen3_cfg.get("speaker") and args.speaker == "Ryan":
|
|
args.speaker = qwen3_cfg["speaker"]
|
|
if qwen3_cfg.get("language") and args.language == "Auto":
|
|
args.language = qwen3_cfg["language"]
|
|
if qwen3_cfg.get("instruct") and not args.instruct:
|
|
args.instruct = qwen3_cfg["instruct"]
|
|
if qwen3_cfg.get("tone") and not args.tone and not args.instruct:
|
|
args.tone = qwen3_cfg["tone"]
|
|
elif provider == "elevenlabs":
|
|
# Apply voice ID from brand if not explicitly provided
|
|
if not args.voice_id and voice_config.get("voiceId") and voice_config["voiceId"] != "YOUR_VOICE_ID_HERE":
|
|
args.voice_id = voice_config["voiceId"]
|
|
elif provider == "60db":
|
|
# Apply 60db voice ID + settings from the brand's `sixtydb` block
|
|
sixtydb_cfg = voice_config.get("sixtydb", {})
|
|
if not args.voice_id and sixtydb_cfg.get("voiceId") and sixtydb_cfg["voiceId"] != "YOUR_VOICE_ID_HERE":
|
|
args.voice_id = sixtydb_cfg["voiceId"]
|
|
sixtydb_settings = sixtydb_cfg.get("settings", {})
|
|
# Brand settings fill in only when the CLI left a default in place.
|
|
if "stability" in sixtydb_settings and args.stability == 0.85:
|
|
args.stability = sixtydb_settings["stability"]
|
|
if "similarity" in sixtydb_settings and args.similarity == 0.95:
|
|
args.similarity = sixtydb_settings["similarity"]
|
|
if "speed" in sixtydb_settings and args.speed == 1.0:
|
|
args.speed = sixtydb_settings["speed"]
|
|
if "enhance" in sixtydb_settings and args.enhance is True:
|
|
args.enhance = sixtydb_settings["enhance"]
|
|
|
|
# Resolve tone preset → instruct text for Qwen3
|
|
if provider == "qwen3" and (args.tone or args.instruct):
|
|
from qwen3_tts import resolve_tone
|
|
args.instruct = resolve_tone(args.tone, args.instruct)
|
|
|
|
# Warn if tone/instruct used with clone (clone mode ignores instruct)
|
|
if provider == "qwen3" and args.ref_audio and args.instruct:
|
|
print(
|
|
"Note: --tone/--instruct is ignored when using a cloned voice.\n"
|
|
" The clone's tone comes from your reference recording.\n"
|
|
" Tip: Run /voice-clone to record a new reference with a different feel.",
|
|
file=sys.stderr,
|
|
)
|
|
args.instruct = ""
|
|
|
|
# Provider-specific setup
|
|
client = None
|
|
voice_id = None
|
|
sixtydb_api_key = None
|
|
|
|
if provider == "60db":
|
|
sixtydb_api_key = get_sixtydb_api_key()
|
|
if not sixtydb_api_key:
|
|
print(
|
|
"Error: No 60db API key found.\n"
|
|
"\n"
|
|
"You have 3 options:\n"
|
|
"\n"
|
|
" 1. Add a 60db key:\n"
|
|
" echo \"SIXTYDB_API_KEY=sk_live_your_key\" >> .env\n"
|
|
"\n"
|
|
" 2. Use ElevenLabs or Qwen3-TTS instead:\n"
|
|
" uv run tools/voiceover.py --provider elevenlabs --scene-dir public/audio/scenes --json\n"
|
|
" uv run tools/voiceover.py --provider qwen3 --scene-dir public/audio/scenes --json\n"
|
|
"\n"
|
|
" 3. Skip voiceover entirely:\n"
|
|
" Videos render fine without audio. Add voiceover later when ready.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
from sixtydb_tts import DEFAULT_VOICE_ID as _SIXTYDB_DEFAULT_VOICE
|
|
voice_id = args.voice_id or get_sixtydb_voice_id() or _SIXTYDB_DEFAULT_VOICE
|
|
|
|
if provider == "elevenlabs":
|
|
api_key = get_elevenlabs_api_key()
|
|
if not api_key:
|
|
print(
|
|
"Error: No ElevenLabs API key found.\n"
|
|
"\n"
|
|
"You have 3 options:\n"
|
|
"\n"
|
|
" 1. Add an ElevenLabs key:\n"
|
|
" echo \"ELEVENLABS_API_KEY=your_key\" >> .env\n"
|
|
"\n"
|
|
" 2. Use Qwen3-TTS instead (free, self-hosted):\n"
|
|
" uv run tools/voiceover.py --provider qwen3 --speaker Ryan --scene-dir public/audio/scenes --json\n"
|
|
" (Requires RunPod account — run: uv run tools/qwen3_tts.py --setup)\n"
|
|
"\n"
|
|
" 3. Skip voiceover entirely:\n"
|
|
" Videos render fine without audio. Add voiceover later when ready.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
voice_id = args.voice_id or get_voice_id()
|
|
if not voice_id:
|
|
print(
|
|
"Error: No voice ID provided and none found in toolkit-registry.json",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
ElevenLabs, _, _ = _get_elevenlabs_imports()
|
|
client = ElevenLabs(api_key=api_key)
|
|
|
|
# Per-scene mode
|
|
if args.scene_dir:
|
|
scene_dir = Path(args.scene_dir)
|
|
if not scene_dir.is_dir():
|
|
print(f"Error: Scene directory not found: {scene_dir}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if not args.json:
|
|
txt_count = len(list(scene_dir.glob("*.txt")))
|
|
provider_label = {"qwen3": "Qwen3-TTS", "60db": "60db"}.get(provider, "ElevenLabs")
|
|
print(f"Processing {txt_count} scene scripts in {scene_dir} ({provider_label})...", file=sys.stderr)
|
|
|
|
if args.dry_run:
|
|
if not args.json:
|
|
print("Would generate:")
|
|
results, total_duration, total_chars = process_scene_directory(
|
|
scene_dir=scene_dir,
|
|
dry_run=True,
|
|
json_output=args.json,
|
|
provider=provider,
|
|
client=client,
|
|
voice_id=voice_id or "",
|
|
model=args.model,
|
|
stability=args.stability,
|
|
similarity=args.similarity,
|
|
style=args.style,
|
|
speed=args.speed,
|
|
enhance=args.enhance,
|
|
transport=args.transport,
|
|
sixtydb_api_key=sixtydb_api_key,
|
|
speaker=args.speaker,
|
|
language=args.language,
|
|
instruct=args.instruct,
|
|
ref_audio=args.ref_audio,
|
|
ref_text=args.ref_text,
|
|
temperature=args.temperature,
|
|
top_p=args.top_p,
|
|
cloud=args.cloud,
|
|
)
|
|
result = {
|
|
"dry_run": True,
|
|
"mode": "per_scene",
|
|
"provider": provider,
|
|
"scene_dir": str(scene_dir),
|
|
"total_chars": total_chars,
|
|
"scenes": results,
|
|
}
|
|
if provider == "elevenlabs":
|
|
result["voice_id"] = voice_id
|
|
result["model"] = args.model
|
|
result["settings"] = {
|
|
"stability": args.stability,
|
|
"similarity": args.similarity,
|
|
"style": args.style,
|
|
"speed": args.speed,
|
|
}
|
|
elif provider == "60db":
|
|
result["voice_id"] = voice_id
|
|
result["transport"] = args.transport
|
|
result["settings"] = {
|
|
"stability": args.stability,
|
|
"similarity": args.similarity,
|
|
"speed": args.speed,
|
|
"enhance": args.enhance,
|
|
}
|
|
else:
|
|
result["speaker"] = args.speaker
|
|
result["language"] = args.language
|
|
if args.instruct:
|
|
result["instruct"] = args.instruct
|
|
if args.temperature is not None:
|
|
result["temperature"] = args.temperature
|
|
if args.top_p is not None:
|
|
result["top_p"] = args.top_p
|
|
if args.concat:
|
|
result["concat_output"] = args.concat
|
|
if args.json:
|
|
print(json.dumps(result, indent=2))
|
|
return
|
|
|
|
# Generate per-scene audio
|
|
results, total_duration, total_chars = process_scene_directory(
|
|
scene_dir=scene_dir,
|
|
dry_run=False,
|
|
json_output=args.json,
|
|
provider=provider,
|
|
client=client,
|
|
voice_id=voice_id or "",
|
|
model=args.model,
|
|
stability=args.stability,
|
|
similarity=args.similarity,
|
|
style=args.style,
|
|
speed=args.speed,
|
|
enhance=args.enhance,
|
|
transport=args.transport,
|
|
sixtydb_api_key=sixtydb_api_key,
|
|
speaker=args.speaker,
|
|
language=args.language,
|
|
instruct=args.instruct,
|
|
ref_audio=args.ref_audio,
|
|
ref_text=args.ref_text,
|
|
temperature=args.temperature,
|
|
top_p=args.top_p,
|
|
cloud=args.cloud,
|
|
max_wpm=args.max_wpm,
|
|
)
|
|
|
|
# Build final result
|
|
result = {
|
|
"success": True,
|
|
"mode": "per_scene",
|
|
"provider": provider,
|
|
"scene_dir": str(scene_dir),
|
|
"total_chars": total_chars,
|
|
"total_duration_seconds": round(total_duration, 2),
|
|
"total_duration_frames_30fps": int(total_duration * 30),
|
|
"scenes": results,
|
|
}
|
|
if provider == "elevenlabs":
|
|
result["voice_id"] = voice_id
|
|
result["model"] = args.model
|
|
elif provider == "60db":
|
|
result["voice_id"] = voice_id
|
|
result["transport"] = args.transport
|
|
|
|
# Concat if requested
|
|
if args.concat:
|
|
mp3_files = [Path(r["output"]) for r in results if r.get("success")]
|
|
if not args.json:
|
|
print(f"\nConcatenating {len(mp3_files)} files...", file=sys.stderr)
|
|
concat_result = concat_audio_files(mp3_files, Path(args.concat))
|
|
result["concat"] = concat_result
|
|
if not args.json and concat_result.get("success"):
|
|
print(f" {args.concat} ({concat_result.get('duration_seconds')}s)", file=sys.stderr)
|
|
|
|
if args.json:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(f"\nPer-scene audio generated:", file=sys.stderr)
|
|
print(f" Total: {total_duration:.1f}s ({int(total_duration * 30)} frames @ 30fps)", file=sys.stderr)
|
|
print(f" Characters: {total_chars}", file=sys.stderr)
|
|
return
|
|
|
|
# Single-file mode (original behavior)
|
|
script = read_script(args.script)
|
|
if not script:
|
|
print("Error: Empty script", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
output_path = Path(args.output)
|
|
|
|
# Dry run mode
|
|
if args.dry_run:
|
|
result = {
|
|
"dry_run": True,
|
|
"mode": "single",
|
|
"provider": provider,
|
|
"script_length": len(script),
|
|
"script_chars": len(script),
|
|
"output": str(output_path),
|
|
}
|
|
if provider == "elevenlabs":
|
|
result["voice_id"] = voice_id
|
|
result["model"] = args.model
|
|
result["settings"] = {
|
|
"stability": args.stability,
|
|
"similarity": args.similarity,
|
|
"style": args.style,
|
|
"speed": args.speed,
|
|
}
|
|
elif provider == "60db":
|
|
result["voice_id"] = voice_id
|
|
result["transport"] = args.transport
|
|
result["settings"] = {
|
|
"stability": args.stability,
|
|
"similarity": args.similarity,
|
|
"speed": args.speed,
|
|
"enhance": args.enhance,
|
|
}
|
|
else:
|
|
result["speaker"] = args.speaker
|
|
result["language"] = args.language
|
|
if args.instruct:
|
|
result["instruct"] = args.instruct
|
|
if args.temperature is not None:
|
|
result["temperature"] = args.temperature
|
|
if args.top_p is not None:
|
|
result["top_p"] = args.top_p
|
|
if args.json:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
print(f"Would generate voiceover:")
|
|
if provider == "elevenlabs":
|
|
print(f" Voice ID: {voice_id}")
|
|
print(f" Model: {args.model}")
|
|
elif provider == "60db":
|
|
print(f" Voice ID: {voice_id}")
|
|
print(f" Transport: {args.transport}")
|
|
else:
|
|
print(f" Speaker: {args.speaker}")
|
|
print(f" Language: {args.language}")
|
|
print(f" Script: {len(script)} characters")
|
|
print(f" Output: {output_path}")
|
|
return
|
|
|
|
# Generate voiceover
|
|
if not args.json:
|
|
provider_label = {"qwen3": "Qwen3-TTS", "60db": "60db"}.get(provider, "ElevenLabs")
|
|
print(f"Generating voiceover ({len(script)} chars, {provider_label})...", file=sys.stderr)
|
|
|
|
if provider == "qwen3":
|
|
result = generate_single_audio_qwen3(
|
|
script=script,
|
|
output_path=output_path,
|
|
speaker=args.speaker,
|
|
language=args.language,
|
|
instruct=args.instruct,
|
|
ref_audio=args.ref_audio,
|
|
ref_text=args.ref_text,
|
|
temperature=args.temperature,
|
|
top_p=args.top_p,
|
|
cloud=args.cloud,
|
|
max_wpm=args.max_wpm,
|
|
)
|
|
elif provider == "60db":
|
|
result = generate_single_audio_60db(
|
|
script=script,
|
|
output_path=output_path,
|
|
voice_id=voice_id,
|
|
stability=args.stability,
|
|
similarity=args.similarity,
|
|
speed=args.speed,
|
|
enhance=args.enhance,
|
|
transport=args.transport,
|
|
api_key=sixtydb_api_key,
|
|
max_wpm=args.max_wpm,
|
|
)
|
|
else:
|
|
result = generate_single_audio(
|
|
client=client,
|
|
script=script,
|
|
output_path=output_path,
|
|
voice_id=voice_id,
|
|
model=args.model,
|
|
stability=args.stability,
|
|
similarity=args.similarity,
|
|
style=args.style,
|
|
speed=args.speed,
|
|
max_wpm=args.max_wpm,
|
|
)
|
|
|
|
result["mode"] = "single"
|
|
result["provider"] = provider
|
|
if provider == "elevenlabs":
|
|
result["voice_id"] = voice_id
|
|
result["model"] = args.model
|
|
elif provider == "60db":
|
|
result["voice_id"] = voice_id
|
|
result["transport"] = args.transport
|
|
|
|
if args.json:
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
if result.get("success"):
|
|
print(f"Voiceover saved to: {output_path}", file=sys.stderr)
|
|
duration = result.get("duration_seconds")
|
|
if duration:
|
|
print(
|
|
f"Duration: {duration:.2f}s ({int(duration * 30)} frames @ 30fps{_pace_note(result)})",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print(f"Error: {result.get('error', 'Unknown error')}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|