This commit fixes several critical issues that prevented the service from deploying correctly and loading the TTS models. - **Fix Model Loading:** Corrected the volume mount path in to point to the correct source directory. The engine code was also updated to use absolute paths () inside the container, making the model loading mechanism robust. - **Update Session Resume:** The has been updated to reflect the final debugging steps and the successful resolution of all issues.
164 lines
5.9 KiB
Python
164 lines
5.9 KiB
Python
"""
|
||
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())
|