video-dubbing-agent / services /tts_generator.py
dashhdata's picture
Upload folder using huggingface_hub
4ec3855 verified
Raw
History Blame Contribute Delete
4.65 kB
"""
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)