"""Text-to-speech engine with multiple backends and async playback. Backends (tried in order): 1. pyttsx3 — offline, cross-platform 2. edge-tts — Microsoft Edge TTS (async, high quality, needs internet) 3. espeak — CLI fallback (Linux) Install one: pip install pyttsx3 OR pip install edge-tts """ import threading import queue import time import os import tempfile class TTSEngine: """Non-blocking TTS that speaks in a background thread.""" def __init__(self, enabled=True): self.enabled = enabled self._backend = None self._queue = queue.Queue(maxsize=5) # drop old messages if backed up self._thread = None self._stop = threading.Event() if enabled: self._backend = self._detect_backend() if self._backend: self._thread = threading.Thread(target=self._worker, daemon=True) self._thread.start() else: print("[TTS] No backend available. Install: pip install pyttsx3") self.enabled = False def _detect_backend(self): # Try edge-tts first — natural assistant voice try: import edge_tts print("[TTS] Using edge-tts (GuyNeural)") return ('edge_tts', None) except ImportError: pass # Try pyttsx3 try: import pyttsx3 engine = pyttsx3.init() engine.setProperty('rate', 170) engine.setProperty('volume', 1.0) print("[TTS] Using pyttsx3") return ('pyttsx3', engine) except Exception: pass # Try espeak CLI if os.system("which espeak > /dev/null 2>&1") == 0: print("[TTS] Using espeak CLI") return ('espeak', None) return None def speak(self, text): """Queue text for speaking. Non-blocking. Drops if queue is full.""" if not self.enabled or not text: return try: self._queue.put_nowait(text) except queue.Full: # Drop oldest, add new try: self._queue.get_nowait() except queue.Empty: pass try: self._queue.put_nowait(text) except queue.Full: pass def _worker(self): """Background thread that processes the speech queue.""" while not self._stop.is_set(): try: text = self._queue.get(timeout=0.5) except queue.Empty: continue # Drain queue — only speak the latest message latest = text while not self._queue.empty(): try: latest = self._queue.get_nowait() except queue.Empty: break self._speak_sync(latest) def _speak_sync(self, text): name, engine = self._backend try: if name == 'pyttsx3': engine.say(text) engine.runAndWait() elif name == 'edge_tts': self._speak_edge(text) elif name == 'espeak': # -s = speed (words per minute), -a = amplitude safe = text.replace('"', '\\"').replace("'", "\\'") os.system(f'espeak -s 170 -a 200 "{safe}" 2>/dev/null') except Exception as e: print(f"[TTS] Error: {e}") def _speak_edge(self, text): """edge-tts is async, run in a sync wrapper.""" import asyncio import edge_tts tmp = os.path.join(tempfile.gettempdir(), "nav_tts.mp3") async def _gen(): comm = edge_tts.Communicate(text, "en-US-GuyNeural", rate="+15%") await comm.save(tmp) asyncio.run(_gen()) # Play with ffplay or aplay if os.system(f"which ffplay > /dev/null 2>&1") == 0: os.system(f"ffplay -nodisp -autoexit -loglevel error {tmp}") elif os.system(f"which aplay > /dev/null 2>&1") == 0: # Convert to wav first wav = tmp.replace('.mp3', '.wav') os.system(f"ffmpeg -y -i {tmp} {wav} -loglevel error 2>/dev/null") os.system(f"aplay {wav} 2>/dev/null") def shutdown(self): self._stop.set() if self._thread: self._thread.join(timeout=2) if self._backend and self._backend[0] == 'pyttsx3': try: self._backend[1].stop() except Exception: pass