Files changed (1) hide show
  1. app.py +86 -43
app.py CHANGED
@@ -1,15 +1,10 @@
1
- """Piper TTS HTTP server for the Under-The-Palm-Tree games.
 
2
 
3
- Exposes a tiny REST API that the games' piper-client.js can call directly
4
- from the browser. CORS is wide open so any origin (Vercel, GitHub Pages,
5
- Squarespace iframes) can use it.
6
-
7
- POST /synthesize {"text": "...", "voice": "en_GB-alan-low" | "ar_JO-kareem-medium"}
8
- POST /tts {"text": "...", "lang": "en" | "ar"}
9
  GET /healthz -> "ok"
10
- GET / -> {"status": "ready", "voices": [...]}
11
-
12
- Each POST returns audio/wav (16-bit PCM).
13
  """
14
  import io
15
  import os
@@ -24,50 +19,97 @@ from piper import PiperVoice
24
 
25
  VOICES_DIR = Path(os.environ.get("VOICES_DIR", "/voices"))
26
  DEFAULT_VOICE = "en_GB-alan-low"
27
-
28
- LANG_VOICE_MAP = {
29
- "ar": "ar_JO-kareem-medium",
30
- "en": "en_GB-alan-low",
31
- }
32
 
33
  app = FastAPI(title="Palm Tree TTS — Piper")
 
 
34
 
35
- app.add_middleware(
36
- CORSMiddleware,
37
- allow_origins=["*"],
38
- allow_methods=["GET", "POST", "OPTIONS"],
39
- allow_headers=["*"],
40
- )
41
 
42
- _voice_cache: dict = {}
43
 
44
-
45
- def load_voice(name: str):
46
- if name in _voice_cache:
47
- return _voice_cache[name]
48
  onnx = VOICES_DIR / f"{name}.onnx"
49
  cfg = VOICES_DIR / f"{name}.onnx.json"
50
- if not onnx.exists() or not cfg.exists():
51
- raise HTTPException(status_code=404, detail=f"voice '{name}' not found")
52
- _voice_cache[name] = PiperVoice.load(str(onnx), config_path=str(cfg))
53
- return _voice_cache[name]
 
 
 
 
54
 
55
 
56
  def available_voices():
57
  return sorted(p.stem for p in VOICES_DIR.glob("*.onnx"))
58
 
59
 
60
- def synth_wav(text: str, voice_name: str) -> bytes:
 
61
  voice = load_voice(voice_name)
62
  buf = io.BytesIO()
63
- with wave.open(buf, "wb") as wf:
64
- # Newer piper-tts uses synthesize_wav; fall back to synthesize for older builds.
65
- synth = getattr(voice, "synthesize_wav", None) or voice.synthesize
66
- synth(text, wf)
67
- return buf.getvalue()
68
-
69
 
70
- class SynthRequest(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  text: str
72
  voice: str | None = None
73
  lang: str | None = None
@@ -83,7 +125,7 @@ def healthz():
83
  return Response("ok", media_type="text/plain")
84
 
85
 
86
- def resolve_voice(req: SynthRequest) -> str:
87
  if req.voice:
88
  return req.voice
89
  if req.lang:
@@ -92,14 +134,15 @@ def resolve_voice(req: SynthRequest) -> str:
92
 
93
 
94
  @app.post("/synthesize")
95
- def synthesize(req: SynthRequest):
96
  text = (req.text or "").strip()
97
  if not text:
98
  raise HTTPException(status_code=400, detail="text is required")
99
- audio = synth_wav(text, resolve_voice(req))
100
- return Response(audio, media_type="audio/wav")
 
101
 
102
 
103
  @app.post("/tts")
104
- def tts(req: SynthRequest):
105
  return synthesize(req)
 
1
+ """Piper TTS HTTP server Under The Palm Tree.
2
+ Robust against piper-tts API differences across versions.
3
 
4
+ POST /tts {"text":"...", "lang":"en"|"ar"} -> audio/wav
5
+ POST /synthesize {"text":"...", "voice":"..."} -> audio/wav
 
 
 
 
6
  GET /healthz -> "ok"
7
+ GET / -> {"status":"ready","voices":[...]}
 
 
8
  """
9
  import io
10
  import os
 
19
 
20
  VOICES_DIR = Path(os.environ.get("VOICES_DIR", "/voices"))
21
  DEFAULT_VOICE = "en_GB-alan-low"
22
+ LANG_VOICE_MAP = {"ar": "ar_JO-kareem-medium", "en": "en_GB-alan-low"}
 
 
 
 
23
 
24
  app = FastAPI(title="Palm Tree TTS — Piper")
25
+ app.add_middleware(CORSMiddleware, allow_origins=["*"],
26
+ allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"])
27
 
28
+ _cache = {}
 
 
 
 
 
29
 
 
30
 
31
+ def load_voice(name):
32
+ if name in _cache:
33
+ return _cache[name]
 
34
  onnx = VOICES_DIR / f"{name}.onnx"
35
  cfg = VOICES_DIR / f"{name}.onnx.json"
36
+ if not onnx.exists():
37
+ raise HTTPException(status_code=404, detail=f"voice '{name}' not found at {onnx}")
38
+ try:
39
+ v = PiperVoice.load(str(onnx), config_path=str(cfg))
40
+ except TypeError:
41
+ v = PiperVoice.load(str(onnx))
42
+ _cache[name] = v
43
+ return v
44
 
45
 
46
  def available_voices():
47
  return sorted(p.stem for p in VOICES_DIR.glob("*.onnx"))
48
 
49
 
50
+ def synth_wav(text, voice_name):
51
+ """Generate a WAV byte string, handling several piper-tts API shapes."""
52
  voice = load_voice(voice_name)
53
  buf = io.BytesIO()
 
 
 
 
 
 
54
 
55
+ # --- Strategy 1: synthesize_wav(text, wav_file) writes a full WAV ---
56
+ if hasattr(voice, "synthesize_wav"):
57
+ try:
58
+ with wave.open(buf, "wb") as wf:
59
+ voice.synthesize_wav(text, wf)
60
+ data = buf.getvalue()
61
+ if len(data) > 44: # more than just a header
62
+ return data
63
+ except Exception:
64
+ buf = io.BytesIO()
65
+
66
+ # --- Strategy 2: synthesize() yields AudioChunk objects ---
67
+ if hasattr(voice, "synthesize"):
68
+ try:
69
+ chunks = list(voice.synthesize(text))
70
+ if chunks:
71
+ first = chunks[0]
72
+ rate = getattr(first, "sample_rate", 22050)
73
+ width = getattr(first, "sample_width", 2)
74
+ channels = getattr(first, "sample_channels", 1)
75
+ buf = io.BytesIO()
76
+ with wave.open(buf, "wb") as wf:
77
+ wf.setnchannels(channels)
78
+ wf.setsampwidth(width)
79
+ wf.setframerate(rate)
80
+ for ch in chunks:
81
+ pcm = getattr(ch, "audio_int16_bytes", None)
82
+ if pcm is None:
83
+ pcm = getattr(ch, "audio", b"")
84
+ wf.writeframes(pcm)
85
+ data = buf.getvalue()
86
+ if len(data) > 44:
87
+ return data
88
+ except Exception as e:
89
+ raise HTTPException(status_code=500, detail=f"synthesize failed: {e}")
90
+
91
+ # --- Strategy 3: older synthesize_stream_raw -> raw int16 PCM ---
92
+ if hasattr(voice, "synthesize_stream_raw"):
93
+ try:
94
+ cfg = getattr(voice, "config", None)
95
+ rate = getattr(cfg, "sample_rate", 22050) if cfg else 22050
96
+ buf = io.BytesIO()
97
+ with wave.open(buf, "wb") as wf:
98
+ wf.setnchannels(1)
99
+ wf.setsampwidth(2)
100
+ wf.setframerate(rate)
101
+ for pcm in voice.synthesize_stream_raw(text):
102
+ wf.writeframes(pcm)
103
+ data = buf.getvalue()
104
+ if len(data) > 44:
105
+ return data
106
+ except Exception as e:
107
+ raise HTTPException(status_code=500, detail=f"stream synth failed: {e}")
108
+
109
+ raise HTTPException(status_code=500, detail="no usable piper synth method")
110
+
111
+
112
+ class Req(BaseModel):
113
  text: str
114
  voice: str | None = None
115
  lang: str | None = None
 
125
  return Response("ok", media_type="text/plain")
126
 
127
 
128
+ def resolve(req):
129
  if req.voice:
130
  return req.voice
131
  if req.lang:
 
134
 
135
 
136
  @app.post("/synthesize")
137
+ def synthesize(req: Req):
138
  text = (req.text or "").strip()
139
  if not text:
140
  raise HTTPException(status_code=400, detail="text is required")
141
+ audio = synth_wav(text, resolve(req))
142
+ return Response(audio, media_type="audio/wav",
143
+ headers={"Access-Control-Allow-Origin": "*"})
144
 
145
 
146
  @app.post("/tts")
147
+ def tts(req: Req):
148
  return synthesize(req)