Files
audio-engine-hub/engines/piper.py
2025-12-04 11:58:36 +01:00

164 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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())