154 lines
5.4 KiB
Python
154 lines
5.4 KiB
Python
#!/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()
|