palmtts / app.py
Thursday88's picture
Upload 3 files
9e763fd verified
Raw
History Blame Contribute Delete
2.88 kB
"""Piper TTS HTTP server for the Under-The-Palm-Tree games.
Exposes a tiny REST API that the games' piper-client.js can call directly
from the browser. CORS is wide open so any origin (Vercel, GitHub Pages,
Squarespace iframes) can use it.
POST /synthesize {"text": "...", "voice": "en_GB-alan-low" | "ar_JO-kareem-medium"}
POST /tts {"text": "...", "lang": "en" | "ar"}
GET /healthz -> "ok"
GET / -> {"status": "ready", "voices": [...]}
Each POST returns audio/wav (16-bit PCM).
"""
import io
import os
import wave
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response, JSONResponse
from pydantic import BaseModel
from piper import PiperVoice
VOICES_DIR = Path(os.environ.get("VOICES_DIR", "/voices"))
DEFAULT_VOICE = "en_GB-alan-medium"
LANG_VOICE_MAP = {
"ar": "ar_JO-kareem-medium",
"en": "en_GB-alan-medium",
}
app = FastAPI(title="Palm Tree TTS — Piper")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
_voice_cache: dict = {}
def load_voice(name: str):
if name in _voice_cache:
return _voice_cache[name]
onnx = VOICES_DIR / f"{name}.onnx"
cfg = VOICES_DIR / f"{name}.onnx.json"
if not onnx.exists() or not cfg.exists():
raise HTTPException(status_code=404, detail=f"voice '{name}' not found")
_voice_cache[name] = PiperVoice.load(str(onnx), config_path=str(cfg))
return _voice_cache[name]
def available_voices():
return sorted(p.stem for p in VOICES_DIR.glob("*.onnx"))
def synth_wav(text: str, voice_name: str) -> bytes:
voice = load_voice(voice_name)
buf = io.BytesIO()
with wave.open(buf, "wb") as wf:
# Newer piper-tts uses synthesize_wav; fall back to synthesize for older builds.
synth = getattr(voice, "synthesize_wav", None) or voice.synthesize
synth(text, wf)
return buf.getvalue()
class SynthRequest(BaseModel):
text: str
voice: str | None = None
lang: str | None = None
@app.get("/")
def root():
return JSONResponse({"status": "ready", "voices": available_voices()})
@app.get("/healthz")
def healthz():
return Response("ok", media_type="text/plain")
def resolve_voice(req: SynthRequest) -> str:
if req.voice:
return req.voice
if req.lang:
return LANG_VOICE_MAP.get(req.lang.lower(), DEFAULT_VOICE)
return DEFAULT_VOICE
@app.post("/synthesize")
def synthesize(req: SynthRequest):
text = (req.text or "").strip()
if not text:
raise HTTPException(status_code=400, detail="text is required")
audio = synth_wav(text, resolve_voice(req))
return Response(audio, media_type="audio/wav")
@app.post("/tts")
def tts(req: SynthRequest):
return synthesize(req)