""" NovaAi – TTS-Engine-Hub engines/piper.py Version: v0.1.1 Description: Piper TTS engine adapter: real CLI invocation + output as WAV, OGG, or MP3. Synthesizes WAV via Piper, converts to OGG/MP3 via ffmpeg-python if needed. Uses dynamic model path: ./models/piper/[model]/model.onnx Author: Abby (ChatGPT) Date: 2025-07-23 Canvas: piper.py """ import asyncio import subprocess import tempfile import os import shutil import json from .engine_base import TTSEngineBase import ffmpeg class PiperEngine(TTSEngineBase): def __init__(self): self.piper_executable = shutil.which("piper") self.ffmpeg_executable = shutil.which("ffmpeg") def _load_config(self, model: str): """Load the model config JSON file to get speaker mappings.""" model_dir = f"/models/piper/{model}" config_file = os.path.join(model_dir, f"{model}.onnx.json") if os.path.isfile(config_file): with open(config_file, 'r') as f: return json.load(f) return {} def _get_speaker_id(self, speaker: str, model: str): """Convert speaker name to speaker ID using the model's config.""" if not speaker or speaker == "default": return None if speaker.isdigit(): return speaker config = self._load_config(model) speaker_id_map = config.get('speaker_id_map', {}) return str(speaker_id_map.get(speaker)) def _run_ffmpeg_blocking(self, input_path, output_path): """Wrapper for the blocking ffmpeg call.""" ( ffmpeg .input(input_path) .output(output_path) .run(overwrite_output=True, quiet=True) ) async def synthesize(self, text: str, speaker: str = None, model: str = None, fmt: str = "ogg"): if not self.piper_executable: raise RuntimeError("Piper executable not found. Please install it and ensure it's in your PATH.") if not model: raise ValueError("Model must be specified for Piper.") model_dir = f"/models/piper/{model}" model_file = os.path.join(model_dir, f"{model}.onnx") if not os.path.isfile(model_file): raise FileNotFoundError(f"Piper model not found: {model_file}") with tempfile.NamedTemporaryFile(suffix=".wav", prefix="piper_", delete=False) as wav_file: output_wav_path = wav_file.name cmd = [self.piper_executable, "--model", model_file, "--output_file", output_wav_path, "--stdin_text"] if speaker: speaker_id = self._get_speaker_id(speaker, model) if speaker_id: cmd += ["--speaker", speaker_id] process = await asyncio.create_subprocess_exec( *cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await process.communicate(input=text.encode('utf-8')) if process.returncode != 0: os.remove(output_wav_path) raise RuntimeError(f"Piper synth failed: {stderr.decode()}") fmt = (fmt or "ogg").lower() if fmt == "wav": return output_wav_path if not self.ffmpeg_executable: os.remove(output_wav_path) raise RuntimeError("ffmpeg not found, cannot convert audio format.") with tempfile.NamedTemporaryFile(suffix=f'.{fmt}', prefix="piper_conv_", delete=False) as converted_file: output_other_path = converted_file.name try: await asyncio.to_thread(self._run_ffmpeg_blocking, output_wav_path, output_other_path) except Exception as e: raise RuntimeError(f"ffmpeg conversion failed: {e}") finally: os.remove(output_wav_path) return output_other_path def list_models(self): models_dir = "/models/piper/" if not os.path.isdir(models_dir): return [] return [name for name in os.listdir(models_dir) if os.path.isdir(os.path.join(models_dir, name))] def list_voices(self, model: str = None): if not model: return ["default"] config = self._load_config(model) speaker_id_map = config.get('speaker_id_map', {}) if speaker_id_map: return ["default"] + sorted(speaker_id_map.keys()) return ["default"] def healthcheck(self): status = "ok" if not self.piper_executable: status = "missing_piper_executable" return {"status": status, "engine": "piper"} async def selftest(self): if not self.piper_executable: return {"selftest": False, "error": "Piper executable not found.", "engine": "piper"} try: models = self.list_models() if not models: return {"selftest": False, "error": "No Piper models found.", "engine": "piper"} test_text = "This is a selftest." first_model = models[0] voices = self.list_voices(first_model) test_voice = voices[0] if voices else None audio_file = await self.synthesize(test_text, speaker=test_voice, model=first_model, fmt="wav") selftest_passed = os.path.exists(audio_file) and os.path.getsize(audio_file) > 0 if selftest_passed: os.remove(audio_file) return {"selftest": selftest_passed, "models": models, "engine": "piper"} except Exception as e: return {"selftest": False, "error": str(e), "engine": "piper"} if __name__ == "__main__": async def main(): engine = PiperEngine() print("Selftest:", await engine.selftest()) print("Models:", engine.list_models()) print("Voices:", engine.list_voices(engine.list_models()[0])) print("Healthcheck:", engine.healthcheck()) asyncio.run(main())