"""Render TTS voice guidance as audio segments and merge into video. Generates .wav clips for each unique voice message, then composites them onto the video timeline using ffmpeg. """ import os import tempfile import json def render_voice_track(voice_events, output_audio_path, total_duration): """Generate a single audio track from timestamped voice events.""" tmpdir = tempfile.mkdtemp(prefix="nav_tts_") backend = _detect_backend() if not backend: print("[TTS-Render] No TTS backend. Install: pip install edge-tts") return False # Deduplicate and synthesize unique texts unique_texts = list(set(t for _, t in voice_events)) clip_map = {} for i, text in enumerate(unique_texts): clip_path = os.path.join(tmpdir, f"clip_{i}.wav") _synth(backend, text, clip_path) if os.path.exists(clip_path) and os.path.getsize(clip_path) > 100: clip_map[text] = clip_path if not clip_map: return False # Build a concat file: silence gaps + voice clips at correct timestamps segments = [] cursor = 0.0 for ts, text in sorted(voice_events, key=lambda x: x[0]): if text not in clip_map: continue # Add silence gap before this clip gap = ts - cursor if gap > 0.05: silence_path = os.path.join(tmpdir, f"silence_{len(segments)}.wav") os.system(f'ffmpeg -y -f lavfi -i anullsrc=r=22050:cl=mono -t {gap:.3f} {silence_path} -loglevel error') if os.path.exists(silence_path): segments.append(silence_path) cursor = ts # Get clip duration dur_str = os.popen( f'ffprobe -i {clip_map[text]} -show_entries format=duration -v error -of csv=p=0' ).read().strip() clip_dur = float(dur_str) if dur_str else 2.0 segments.append(clip_map[text]) cursor = ts + clip_dur # Add trailing silence to match video duration if cursor < total_duration: trail = os.path.join(tmpdir, "silence_trail.wav") os.system(f'ffmpeg -y -f lavfi -i anullsrc=r=22050:cl=mono -t {total_duration - cursor:.3f} {trail} -loglevel error') if os.path.exists(trail): segments.append(trail) if not segments: return False # Write concat list concat_file = os.path.join(tmpdir, "concat.txt") with open(concat_file, 'w') as f: for seg in segments: f.write(f"file '{seg}'\n") # Concatenate all segments os.system(f'ffmpeg -y -f concat -safe 0 -i {concat_file} -c:a pcm_s16le -ar 22050 -ac 1 {output_audio_path} -loglevel error') # Cleanup for f_path in os.listdir(tmpdir): try: os.remove(os.path.join(tmpdir, f_path)) except Exception: pass try: os.rmdir(tmpdir) except Exception: pass return os.path.exists(output_audio_path) and os.path.getsize(output_audio_path) > 100 # Cleanup for f in os.listdir(tmpdir): os.remove(os.path.join(tmpdir, f)) os.rmdir(tmpdir) return os.path.exists(output_audio_path) def merge_voice_into_video(video_path, voice_events, total_duration): """Add TTS voice track to an existing video file. Returns new path.""" tmpdir = tempfile.gettempdir() voice_track = os.path.join(tmpdir, "nav_voice_track.wav") if not render_voice_track(voice_events, voice_track, total_duration): return video_path # fallback: return original output = video_path.replace('.mp4', '_voiced.mp4') # Check if video already has audio has_audio = os.popen( f'ffprobe -i {video_path} -show_streams -select_streams a -loglevel error 2>&1' ).read().strip() if has_audio: # Mix TTS with existing audio, use longest duration os.system( f'ffmpeg -y -i {video_path} -i {voice_track} ' f'-filter_complex "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=0[a]" ' f'-map 0:v -map "[a]" -c:v copy -c:a aac -shortest ' f'{output} -loglevel error' ) else: # Add TTS as the only audio os.system( f'ffmpeg -y -i {video_path} -i {voice_track} ' f'-map 0:v -map 1:a -c:v copy -c:a aac -shortest ' f'{output} -loglevel error' ) if os.path.exists(output) and os.path.getsize(output) > 0: return output return video_path def _detect_backend(): # Prefer edge-tts — natural assistant voice try: import edge_tts return "edge_tts" except ImportError: pass if os.system("which espeak > /dev/null 2>&1") == 0: return "espeak" try: import pyttsx3 return "pyttsx3" except ImportError: pass return None def _synth(backend, text, out_path): """Synthesize text to a .wav file.""" try: if backend == "edge_tts": import asyncio, edge_tts mp3 = out_path.replace('.wav', '.mp3') async def _gen(): # en-US-GuyNeural: clear male assistant voice # rate=+15% for snappy navigation feel c = edge_tts.Communicate(text, "en-US-GuyNeural", rate="+15%") await c.save(mp3) asyncio.run(_gen()) os.system(f'ffmpeg -y -i {mp3} -ar 22050 -ac 1 {out_path} -loglevel error') if os.path.exists(mp3): os.remove(mp3) elif backend == "espeak": safe = text.replace('"', '\\"').replace("'", "\\'") os.system(f'espeak -s 170 -w {out_path} "{safe}" 2>/dev/null') elif backend == "pyttsx3": import pyttsx3 engine = pyttsx3.init() engine.setProperty('rate', 170) engine.save_to_filename(out_path) engine.say(text) engine.runAndWait() except Exception as e: print(f"[TTS-Render] Synth error: {e}")