Spaces:
Running
Running
| """ | |
| 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) | |