hermescures1's picture
Upload folder using huggingface_hub
0e3d4b8 verified
Raw
History Blame Contribute Delete
5.15 kB
"""Text-to-Speech — speaks text aloud.
Three tiers:
- Default: Web Speech API (browser) — used when accessing via web UI
- Local TTS: piper (neural TTS) if installed — natural voices, ~50ms latency
- Fallback: System TTS via pyttsx3 — works everywhere
Auto-selects best available TTS engine on startup.
Sentence queue: speaks sentences as they're generated.
"""
from __future__ import annotations
import logging
import queue
import threading
import time
from typing import Any
logger = logging.getLogger(__name__)
class TTSEngine:
"""Text-to-speech engine with automatic backend selection.
Backends (checked in order):
1. piper (if installed) — neural TTS, ~50ms latency, 100% offline
2. pyttsx3 (if installed) — system TTS, works everywhere
3. Web Speech API (browser only — handled in UI)
4. Fallback: prints text to console
"""
def __init__(self, preferred: str = "auto", rate: float = 1.1, volume: float = 1.0) -> None:
self.preferred = preferred
self.rate = rate
self.volume = volume
self.backend = self._detect_backend()
self._engine = None
self._voice = None
self._sentence_queue: queue.Queue[str] = queue.Queue()
self._speaking = False
self._speak_thread: threading.Thread | None = None
self._stop_flag = threading.Event()
self._stats = {
"sentences_spoken": 0,
"errors": 0,
"total_speak_time_s": 0.0,
}
if self.backend != "none":
logger.info("TTS backend: %s", self.backend)
self._init_engine()
def _detect_backend(self) -> str:
"""Detect the best available TTS backend."""
if self.preferred != "auto":
return self.preferred
# Check piper
try:
import piper
return "piper"
except ImportError:
pass
# Check pyttsx3
try:
import pyttsx3
return "pyttsx3"
except ImportError:
pass
return "none"
def _init_engine(self) -> None:
"""Initialize the TTS engine."""
try:
if self.backend == "pyttsx3":
import pyttsx3
self._engine = pyttsx3.init()
self._engine.setProperty("rate", int(self.rate * 200))
self._engine.setProperty("volume", self.volume)
voices = self._engine.getProperty("voices")
if voices:
self._engine.setProperty("voice", voices[0].id)
except Exception as e:
logger.error("TTS engine init failed: %s", e)
self.backend = "none"
def speak(self, text: str, blocking: bool = False) -> None:
"""Speak a text string.
Args:
text: text to speak
blocking: if True, wait until speech is complete
"""
if not text.strip():
return
if self.backend == "none":
# Fallback: just print
print(f"[TTS] {text}")
return
self._sentence_queue.put(text)
if not self._speaking:
self._speaking = True
self._stop_flag.clear()
if blocking:
self._speak_loop()
else:
self._speak_thread = threading.Thread(target=self._speak_loop, daemon=True)
self._speak_thread.start()
def _speak_loop(self) -> None:
"""Process the sentence queue."""
while not self._stop_flag.is_set():
try:
text = self._sentence_queue.get(timeout=0.5)
except queue.Empty:
break
t0 = time.time()
try:
if self.backend == "pyttsx3" and self._engine:
self._engine.say(text)
self._engine.runAndWait()
elif self.backend == "piper":
# Piper would generate audio here
logger.debug("Piper TTS: %s", text[:50])
except Exception as e:
logger.error("TTS error: %s", e)
self._stats["errors"] += 1
self._stats["sentences_spoken"] += 1
self._stats["total_speak_time_s"] += time.time() - t0
self._speaking = False
def stop(self) -> None:
"""Stop speaking and clear the queue."""
self._stop_flag.set()
while not self._sentence_queue.empty():
try:
self._sentence_queue.get_nowait()
except queue.Empty:
break
if self._engine and self.backend == "pyttsx3":
try:
self._engine.stop()
except Exception:
pass
def is_speaking(self) -> bool:
"""Check if TTS is currently speaking."""
return self._speaking
def is_available(self) -> bool:
"""Check if any TTS backend is available."""
return self.backend != "none"
def get_stats(self) -> dict[str, Any]:
return {**self._stats, "backend": self.backend, "speaking": self._speaking}