| | """ |
| | tts_engine.py |
| | ───────────── |
| | Text-to-Speech engine. |
| | |
| | On Hugging Face Spaces (headless server): |
| | - pyttsx3 is skipped (needs audio hardware) |
| | - gTTS saves an MP3 that Gradio can play back via gr.Audio |
| | - Falls back to silent mode gracefully |
| | |
| | Locally: pyttsx3 works offline, gTTS needs internet. |
| | """ |
| |
|
| | import logging |
| | import threading |
| | import os |
| | import io |
| |
|
| | logger = logging.getLogger(__name__) |
| |
|
| |
|
| | class TTSEngine: |
| | def __init__(self, rate: int = 160, volume: float = 1.0): |
| | self._rate = rate |
| | self._volume = volume |
| | self._backend = "silent" |
| | self._init() |
| |
|
| | def _init(self): |
| | |
| | if os.environ.get("GRADIO_SERVER_NAME") is None: |
| | try: |
| | import pyttsx3 |
| | e = pyttsx3.init() |
| | e.setProperty("rate", self._rate) |
| | e.setProperty("volume", self._volume) |
| | self._engine = e |
| | self._backend = "pyttsx3" |
| | logger.info("TTS backend: pyttsx3 (offline)") |
| | return |
| | except Exception as exc: |
| | logger.debug(f"pyttsx3 unavailable: {exc}") |
| |
|
| | |
| | try: |
| | import gtts |
| | self._backend = "gtts" |
| | logger.info("TTS backend: gTTS (online)") |
| | return |
| | except ImportError: |
| | pass |
| |
|
| | logger.warning("No TTS backend available — speech output disabled.") |
| |
|
| | |
| |
|
| | def speak(self, text: str): |
| | """Blocking speech.""" |
| | if not text: |
| | return |
| | if self._backend == "pyttsx3": |
| | self._engine.say(text) |
| | self._engine.runAndWait() |
| | elif self._backend == "gtts": |
| | self._gtts_speak(text) |
| | else: |
| | logger.info(f"[TTS silent]: {text[:80]}") |
| |
|
| | def speak_async(self, text: str): |
| | """Non-blocking TTS in a daemon thread.""" |
| | threading.Thread(target=self.speak, args=(text,), daemon=True).start() |
| |
|
| | def to_audio_bytes(self, text: str) -> bytes | None: |
| | """ |
| | Returns MP3 bytes (for Gradio gr.Audio playback). |
| | Returns None if TTS unavailable. |
| | """ |
| | if self._backend == "gtts": |
| | try: |
| | from gtts import gTTS |
| | buf = io.BytesIO() |
| | gTTS(text=text, lang="en", slow=False).write_to_fp(buf) |
| | return buf.getvalue() |
| | except Exception as exc: |
| | logger.error(f"gTTS error: {exc}") |
| | return None |
| |
|
| | |
| |
|
| | def _gtts_speak(self, text: str): |
| | try: |
| | from gtts import gTTS |
| | import tempfile |
| | tts = gTTS(text=text, lang="en", slow=False) |
| | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as f: |
| | tts.save(f.name) |
| | tmp = f.name |
| | for player in ("mpg123", "mpg321", "ffplay -nodisp -autoexit"): |
| | if os.system(f"which {player.split()[0]} > /dev/null 2>&1") == 0: |
| | os.system(f"{player} {tmp} > /dev/null 2>&1") |
| | break |
| | os.unlink(tmp) |
| | except Exception as exc: |
| | logger.error(f"gTTS playback error: {exc}") |