Files
Tutorials/video-generator/scripts/make_voiceover_macos.py
T
2026-06-16 11:54:06 +03:30

108 lines
3.7 KiB
Python

import argparse
import os
import re
import subprocess
from pathlib import Path
SRT_BLOCK_RE = re.compile(
r"\s*(\d+)\s+([\d:,]+)\s+-->\s+([\d:,]+)\s+(.+?)(?=\n\s*\d+\s+\d\d:|\Z)",
re.S,
)
def to_seconds(timestamp: str) -> float:
h, m, rest = timestamp.split(":")
s, ms = rest.split(",")
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
def read_srt(path: Path) -> list[tuple[float, float, str]]:
text = path.read_text(encoding="utf-8-sig")
captions = []
for _, start, end, body in SRT_BLOCK_RE.findall(text):
clean_text = " ".join(line.strip() for line in body.strip().splitlines())
captions.append((to_seconds(start), to_seconds(end), clean_text))
if not captions:
raise ValueError(f"No captions found in {path}")
return captions
def resolve_path(value: str) -> Path:
return Path(value).expanduser().resolve()
def media_duration(path: Path) -> float:
result = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
check=True,
capture_output=True,
text=True,
)
return float(result.stdout.strip())
def run(command: list[str]) -> None:
print("Running:", " ".join(command))
subprocess.run(command, check=True)
def main() -> None:
parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on macOS.")
parser.add_argument("--srt", required=True, help="Input SRT file")
parser.add_argument("--voice", default="Daniel", help="macOS voice name, e.g. Daniel or Reed (English (US))")
parser.add_argument("--rate", type=int, default=185, help="Speech rate. Higher is faster. Example: 180-200")
parser.add_argument("--wav", required=True, help="Output WAV file")
parser.add_argument("--mp3", default=None, help="Optional output MP3 file")
args = parser.parse_args()
srt_path = resolve_path(args.srt)
wav_output = resolve_path(args.wav)
mp3_output = resolve_path(args.mp3) if args.mp3 else None
work_dir = Path(__file__).resolve().parent / "voice_parts"
if not srt_path.exists():
raise FileNotFoundError(f"SRT not found: {srt_path}")
work_dir.mkdir(parents=True, exist_ok=True)
wav_output.parent.mkdir(parents=True, exist_ok=True)
if mp3_output:
mp3_output.parent.mkdir(parents=True, exist_ok=True)
captions = read_srt(srt_path)
segment_files = []
for i, (start, end, text) in enumerate(captions, 1):
duration = end - start
raw_aiff = work_dir / f"raw_{i:03d}.aiff"
segment_wav = work_dir / f"seg_{i:03d}.wav"
run(["say", "-v", args.voice, "-r", str(args.rate), "-o", str(raw_aiff), text])
spoken_duration = media_duration(raw_aiff)
if spoken_duration > duration:
print(f"Warning: caption {i} speech is {spoken_duration:.2f}s but slot is {duration:.2f}s; it will be trimmed.")
run([
"ffmpeg", "-y",
"-i", str(raw_aiff),
"-af", f"apad,atrim=0:{duration}",
"-ar", "44100",
"-ac", "2",
str(segment_wav),
])
segment_files.append(segment_wav)
concat_list = work_dir / "list.txt"
concat_list.write_text("".join(f"file '{os.path.abspath(file)}'\n" for file in segment_files), encoding="utf-8")
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
print(f"Done: {wav_output}")
if mp3_output:
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
print(f"Done: {mp3_output}")
if __name__ == "__main__":
main()