first commit
This commit is contained in:
163
engines/piper.py
Normal file
163
engines/piper.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""
|
||||
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())
|
||||
Reference in New Issue
Block a user