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 engines.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())
|