266ea11447
FRIDAY Voice (speaches GPU STT/TTS + hermes agent + push-to-talk console) on .150 Docker Desktop, fronted by coach.hobbs.farm. Also captures the VoiceCoach compose/agent trees and deploy notes it replaced, which had never been committed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
73 lines
1.8 KiB
Python
73 lines
1.8 KiB
Python
"""
|
|
Piper TTS HTTP service.
|
|
Self-hosted text-to-speech, internal network only.
|
|
"""
|
|
import io
|
|
import logging
|
|
import os
|
|
import wave
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import Response
|
|
from pydantic import BaseModel
|
|
import piper
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MODEL_PATH = os.getenv("PIPER_MODEL", "/app/models/en_US-lessac-medium.onnx")
|
|
voice = None
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global voice
|
|
logger.info(f"Loading Piper model: {MODEL_PATH}")
|
|
voice = piper.PiperVoice.load(MODEL_PATH)
|
|
logger.info("Piper TTS ready.")
|
|
yield
|
|
logger.info("Shutting down.")
|
|
|
|
|
|
app = FastAPI(title="Piper TTS Service", lifespan=lifespan)
|
|
|
|
|
|
class TTSRequest(BaseModel):
|
|
text: str
|
|
speaker_id: int = None
|
|
length_scale: float = 1.0
|
|
sentence_silence: float = 0.2
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
if voice is None:
|
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
|
return {"status": "ok", "model": os.path.basename(MODEL_PATH)}
|
|
|
|
|
|
@app.post("/synthesize")
|
|
async def synthesize(req: TTSRequest):
|
|
if voice is None:
|
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
|
|
|
if not req.text.strip():
|
|
raise HTTPException(status_code=400, detail="Empty text")
|
|
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as wf:
|
|
synth_kwargs = {
|
|
"length_scale": req.length_scale,
|
|
"sentence_silence": req.sentence_silence,
|
|
}
|
|
if req.speaker_id is not None:
|
|
synth_kwargs["speaker_id"] = req.speaker_id
|
|
voice.synthesize(req.text, wf, **synth_kwargs)
|
|
|
|
return Response(
|
|
content=buf.getvalue(),
|
|
media_type="audio/wav",
|
|
headers={"Content-Disposition": "inline; filename=speech.wav"},
|
|
)
|