"""Text-to-speech service using Piper.""" import subprocess import tempfile import os import logging from pathlib import Path from typing import Optional, Tuple from .config import config logger = logging.getLogger(__name__) class TTSService: """Text-to-speech service using Piper.""" def __init__(self): self.config = config self._validate_setup() def _validate_setup(self): """Validate that piper and voice models are available.""" # Check if piper-tts is available try: result = subprocess.run( [self.config.piper_executable, "--help"], capture_output=True, timeout=10 ) if result.returncode != 0: raise RuntimeError(f"Piper TTS not working: {result.stderr.decode()}") except (subprocess.TimeoutExpired, FileNotFoundError) as e: raise RuntimeError(f"Piper TTS not found or not working: {e}") # Check if default voice model exists if not self.config.validate_voice_files(): default_voice = self.config.default_voice model_path = self.config.get_voice_model_path() raise RuntimeError( f"Default voice '{default_voice}' model not found at {model_path}. " f"Please download the voice model files." ) logger.info(f"TTS service initialized with voice: {self.config.default_voice}") def synthesize( self, text: str, voice: Optional[str] = None, speed: float = 1.0 ) -> Tuple[bytes, str]: """ Synthesize text to speech. Args: text: Text to synthesize voice: Voice to use (defaults to configured default) speed: Speech speed multiplier Returns: Tuple of (audio_data, audio_format) Raises: ValueError: If voice is not available RuntimeError: If synthesis fails """ voice = voice or self.config.default_voice if not self.config.validate_voice_files(voice): available_voices = list(self.config.available_voices.keys()) raise ValueError( f"Voice '{voice}' not available. Available voices: {available_voices}" ) model_path = self.config.get_voice_model_path(voice) # Create temporary file for output with tempfile.NamedTemporaryFile(suffix=f".{self.config.audio_format}", delete=False) as temp_file: temp_path = temp_file.name try: # Build piper command cmd = [ self.config.piper_executable, "-m", str(model_path), "-f", temp_path ] # Add speed if different from default if speed != 1.0: cmd.extend(["--length-scale", str(1.0 / speed)]) logger.debug(f"Running piper command: {' '.join(cmd)}") # Run piper-tts process = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = process.communicate(input=text, timeout=30) if process.returncode != 0: raise RuntimeError(f"TTS synthesis failed: {stderr}") # Read the generated audio file with open(temp_path, "rb") as f: audio_data = f.read() if not audio_data: raise RuntimeError("Generated audio file is empty") logger.info(f"Successfully synthesized {len(text)} characters with voice '{voice}'") return audio_data, self.config.audio_format except subprocess.TimeoutExpired: process.kill() raise RuntimeError("TTS synthesis timed out") except Exception as e: logger.error(f"TTS synthesis error: {e}") raise finally: # Clean up temp file try: os.unlink(temp_path) except OSError: pass def list_voices(self) -> dict: """List available voices with their information.""" voices = {} for voice_name, voice_config in self.config.available_voices.items(): voices[voice_name] = { "name": voice_name, "language": voice_config["language"], "gender": voice_config["gender"], "description": voice_config["description"], "available": self.config.validate_voice_files(voice_name) } return voices def get_voice_info(self, voice_name: str) -> dict: """Get information about a specific voice.""" if voice_name not in self.config.available_voices: raise ValueError(f"Voice '{voice_name}' not found") voice_config = self.config.available_voices[voice_name] return { "name": voice_name, "language": voice_config["language"], "gender": voice_config["gender"], "description": voice_config["description"], "available": self.config.validate_voice_files(voice_name), "model_path": str(self.config.get_voice_model_path(voice_name)), "config_path": str(self.config.get_voice_config_path(voice_name)) }