Add reproducible Piper TTS service
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small, private Piper worker for the Mike AI profile router.
|
||||
|
||||
The public OpenAI-compatible endpoint remains in the router. This worker only
|
||||
accepts the narrow internal /status and /tts protocol and never logs input text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import wave
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from piper import PiperVoice, SynthesisConfig
|
||||
|
||||
|
||||
DATA_DIR = Path(os.getenv("PIPER_DATA_DIR", "/data"))
|
||||
VOICE_NAME = os.getenv("PIPER_VOICE", "de_DE-thorsten-high")
|
||||
VOICE_ALIAS = os.getenv("PIPER_VOICE_ALIAS", "alloy")
|
||||
HOST = os.getenv("PIPER_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PIPER_PORT", "8085"))
|
||||
MAX_TEXT_CHARS = int(os.getenv("PIPER_MAX_TEXT_CHARS", "8000"))
|
||||
MAX_REQUEST_BYTES = int(os.getenv("PIPER_MAX_REQUEST_BYTES", "65536"))
|
||||
|
||||
VOICE_PATH = DATA_DIR / f"{VOICE_NAME}.onnx"
|
||||
VOICE = PiperVoice.load(str(VOICE_PATH))
|
||||
SYNTHESIS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def synthesize_wav(text: str, speed: float) -> bytes:
|
||||
"""Synthesize a complete WAV in memory without retaining the text."""
|
||||
output = io.BytesIO()
|
||||
config = SynthesisConfig(length_scale=1.0 / speed)
|
||||
with SYNTHESIS_LOCK, wave.open(output, "wb") as wav_file:
|
||||
VOICE.synthesize_wav(text, wav_file, syn_config=config)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def wav_to_mp3(wav_bytes: bytes) -> bytes:
|
||||
"""Convert Piper's WAV to the MP3 format Open WebUI requests by default."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-f", "wav", "-i", "pipe:0",
|
||||
"-codec:a", "libmp3lame", "-b:a", "96k",
|
||||
"-f", "mp3", "pipe:1",
|
||||
],
|
||||
input=wav_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("ffmpeg conversion failed")
|
||||
return result.stdout
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
# Deliberately omit URLs and request bodies from the log.
|
||||
print(f"piper-worker: {self.command} -> {args[1] if len(args) > 1 else '-'}")
|
||||
|
||||
def send_bytes(self, status: int, body: bytes, content_type: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_json(self, status: int, payload: dict) -> None:
|
||||
self.send_bytes(
|
||||
status,
|
||||
json.dumps(payload, separators=(",", ":")).encode(),
|
||||
"application/json",
|
||||
)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path != "/status":
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||
return
|
||||
self.send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"ready": True,
|
||||
"engine": "piper",
|
||||
"model": VOICE_NAME,
|
||||
"voices": [VOICE_ALIAS],
|
||||
},
|
||||
)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path != "/tts":
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||
return
|
||||
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
if content_length <= 0 or content_length > MAX_REQUEST_BYTES:
|
||||
self.send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "invalid request size"})
|
||||
return
|
||||
|
||||
try:
|
||||
request = json.loads(self.rfile.read(content_length))
|
||||
text = request.get("text", "")
|
||||
voice = request.get("voice", VOICE_ALIAS)
|
||||
output_format = request.get("format", "mp3")
|
||||
speed = float(request.get("speed", 1.0))
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON request"})
|
||||
return
|
||||
|
||||
if not isinstance(text, str) or not text.strip() or len(text) > MAX_TEXT_CHARS:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid text"})
|
||||
return
|
||||
if voice != VOICE_ALIAS:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "unknown voice"})
|
||||
return
|
||||
if output_format not in {"wav", "mp3"}:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "unsupported format"})
|
||||
return
|
||||
if not 0.5 <= speed <= 2.0:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid speed"})
|
||||
return
|
||||
|
||||
try:
|
||||
audio = synthesize_wav(text.strip(), speed)
|
||||
if output_format == "mp3":
|
||||
audio = wav_to_mp3(audio)
|
||||
content_type = "audio/mpeg"
|
||||
else:
|
||||
content_type = "audio/wav"
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError):
|
||||
self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "synthesis failed"})
|
||||
return
|
||||
|
||||
self.send_bytes(HTTPStatus.OK, audio, content_type)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Piper worker ready: {VOICE_NAME} as {VOICE_ALIAS} on {HOST}:{PORT}")
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
Reference in New Issue
Block a user