from __future__ import annotations import asyncio from dataclasses import dataclass from pathlib import Path import edge_tts from moviepy.audio.io.AudioFileClip import AudioFileClip from .config import AppConfig @dataclass(frozen=True) class AudioAsset: path: Path duration_s: float def _audio_duration_seconds(path: Path) -> float: # MoviePy uses ffmpeg and provides reliable duration for mp3. clip = AudioFileClip(str(path)) try: return float(clip.duration or 0.0) finally: clip.close() async def synthesize_one(text: str, out_path: Path, voice: str, rate: str, volume: str) -> AudioAsset: out_path.parent.mkdir(parents=True, exist_ok=True) communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate, volume=volume) await communicate.save(str(out_path)) dur = _audio_duration_seconds(out_path) return AudioAsset(path=out_path, duration_s=dur) async def synthesize_scenes(narrations: list[str], cfg: AppConfig) -> list[AudioAsset]: voice = str(cfg.get("tts.voice", "zh-CN-XiaoxiaoNeural")) rate = str(cfg.get("tts.rate", "+0%")) volume = str(cfg.get("tts.volume", "+0%")) out_dir = Path(str(cfg.get("tts.output_dir", "./assets/audio"))) out_dir.mkdir(parents=True, exist_ok=True) tasks: list[asyncio.Task[AudioAsset]] = [] for idx, text in enumerate(narrations, start=1): out_path = out_dir / f"scene_{idx:02d}.mp3" tasks.append(asyncio.create_task(synthesize_one(text, out_path, voice, rate, volume))) return await asyncio.gather(*tasks)