""" 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"}, )