""" Stage 7 — TTS Generation (MALE VOICE ONLY) Uses Edge TTS (free, no API key). Forces male voice for ALL speakers. """ import logging import asyncio import subprocess from pathlib import Path from typing import List, Dict from config import EDGE_TTS_VOICES, TTS_MAX_SPEED_FACTOR, FORCE_MALE_VOICE logger = logging.getLogger(__name__) def generate_dubbed_audio( translated_segments: List[Dict], speaker_profiles: Dict, target_language: str, output_dir: Path, progress_callback=None ) -> List[Dict]: """Generate TTS audio. ALWAYS uses male voice.""" tts_dir = output_dir / "tts_segments" tts_dir.mkdir(exist_ok=True) voices = EDGE_TTS_VOICES.get(target_language) if not voices: raise ValueError(f"No voices for '{target_language}'. Supported: {list(EDGE_TTS_VOICES.keys())}") # FORCE MALE VOICE voice = voices["male"] logger.info(f"Using MALE voice: {voice} (forced)") total = len(translated_segments) results = [] # Create or get event loop try: loop = asyncio.get_event_loop() if loop.is_closed(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) for idx, seg in enumerate(translated_segments): text = seg.get("translated_text", "").strip() if not text: seg_result = seg.copy() seg_result["tts_audio_path"] = None seg_result["tts_duration"] = 0 results.append(seg_result) continue output_path = tts_dir / f"seg_{idx:06d}.mp3" try: loop.run_until_complete(_generate_tts_segment(text, voice, output_path)) except Exception as e: logger.warning(f"TTS failed for segment {idx}: {e}") seg_result = seg.copy() seg_result["tts_audio_path"] = None seg_result["tts_duration"] = 0 results.append(seg_result) continue if not output_path.exists() or output_path.stat().st_size == 0: seg_result = seg.copy() seg_result["tts_audio_path"] = None seg_result["tts_duration"] = 0 results.append(seg_result) continue tts_duration = _get_audio_duration(output_path) original_duration = seg["end"] - seg["start"] # Speed-match if original_duration > 0 and tts_duration > 0: speed_ratio = tts_duration / original_duration if speed_ratio > 1.05: target_speed = min(speed_ratio, TTS_MAX_SPEED_FACTOR) adjusted_path = tts_dir / f"seg_{idx:06d}_adj.mp3" _adjust_speed(output_path, adjusted_path, target_speed) if adjusted_path.exists(): output_path = adjusted_path tts_duration = _get_audio_duration(output_path) seg_result = seg.copy() seg_result["tts_audio_path"] = str(output_path) seg_result["tts_duration"] = round(tts_duration, 3) seg_result["voice_used"] = voice seg_result["gender_matched"] = "male" results.append(seg_result) if progress_callback and idx % 10 == 0: progress_callback(int((idx + 1) / total * 100)) if progress_callback: progress_callback(100) valid = len([r for r in results if r.get("tts_audio_path")]) logger.info(f"TTS complete: {valid}/{total} segments (MALE voice: {voice})") return results async def _generate_tts_segment(text: str, voice: str, output_path: Path): import edge_tts communicate = edge_tts.Communicate(text, voice) await communicate.save(str(output_path)) def _get_audio_duration(audio_path: Path) -> float: cmd = ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", str(audio_path)] result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) try: return float(result.stdout.strip()) except (ValueError, AttributeError): return 0.0 def _adjust_speed(input_path: Path, output_path: Path, speed_factor: float): filters = [] remaining = speed_factor while remaining > 2.0: filters.append("atempo=2.0") remaining /= 2.0 while remaining < 0.5: filters.append("atempo=0.5") remaining /= 0.5 filters.append(f"atempo={remaining:.4f}") filter_chain = ",".join(filters) cmd = ["ffmpeg", "-y", "-i", str(input_path), "-filter:a", filter_chain, str(output_path)] subprocess.run(cmd, capture_output=True, text=True, timeout=30)