first commit

This commit is contained in:
2025-12-04 11:58:36 +01:00
commit 21cdc65ade
38 changed files with 8176 additions and 0 deletions

163
engines/piper.py Normal file
View 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())