Spaces:
Running
Running
| """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 | |
| def root(): | |
| return JSONResponse({"status": "ready", "voices": available_voices()}) | |
| 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 | |
| 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") | |
| def tts(req: SynthRequest): | |
| return synthesize(req) | |