sidmazak commited on
Commit
f3c1c56
·
verified ·
1 Parent(s): 743ede5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +654 -286
app.py CHANGED
@@ -1,305 +1,673 @@
1
- """
2
- ╔══════════════════════════════════════════════════════════════╗
3
- ║ Indic TTS API — Production Grade ║
4
- ║ Models: MMS-TTS (1107 langs) · Indic Parler-TTS · IndicF5 ║
5
- ║ Docs: /docs | Redoc: /redoc | Health: /health ║
6
- ╚══════════════════════════════════════════════════════════════╝
7
- """
8
  from __future__ import annotations
9
 
10
  import io
11
- import gc
12
- import re
13
- import base64
14
  import logging
15
- import time
16
  import threading
17
- from typing import Optional, List, Dict
 
18
  from collections import OrderedDict
 
 
 
19
 
20
- import numpy as np
21
- import soundfile as sf
22
- import torch
23
  import uvicorn
24
- from fastapi import FastAPI, HTTPException, UploadFile, File, Form
25
- from fastapi.middleware.cors import CORSMiddleware
26
- from fastapi.responses import StreamingResponse, RedirectResponse
27
- from pydantic import BaseModel, Field, field_validator
28
-
29
- # ──────────────────────────────────────────────────────────────
30
- # Logging
31
- # ──────────────────────────────────────────────────────────────
32
- logging.basicConfig(
33
- level=logging.INFO,
34
- format="%(asctime)s │ %(levelname)-8s │ %(name)s │ %(message)s",
35
- datefmt="%H:%M:%S",
36
- )
37
- logger = logging.getLogger("indic-tts")
38
 
39
- # ──────────────────────────────────────────────────────────────
40
  # Config
41
- # ──────────────────────────────────────────────────────────────
42
- VERSION = "1.0.0"
43
- MAX_TEXT_CHARS = 5000
44
- CHUNK_SIZE = 250
45
- MODEL_CACHE_SLOTS = 3
46
- SILENCE_BETWEEN_CHUNKS = 0.25
47
-
48
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
49
- logger.info(f"Running on device: {DEVICE}")
50
-
51
- # ──────────────────────────────────────────────────────────────
52
- # Language Tables
53
- # ──────────────────────────────────────────────────────────────
54
- MMS_FEATURED = {
55
- "hin": "Hindi", "eng": "English", "ben": "Bengali", "tel": "Telugu",
56
- "tam": "Tamil", "mar": "Marathi", "guj": "Gujarati", "kan": "Kannada",
57
- "mal": "Malayalam", "pan": "Punjabi", "ori": "Odia", "asm": "Assamese",
58
- }
59
-
60
- PARLER_LANGS = {
61
- "hin": "Hindi", "eng": "English", "ben": "Bengali", "mar": "Marathi",
62
- }
63
-
64
- PARLER_VOICES = {
65
- "neutral_female": "A female speaker delivers neutral, clear speech at a moderate pace with high audio quality.",
66
- "neutral_male": "A male speaker delivers neutral, clear speech at a moderate pace with high audio quality.",
67
- }
68
-
69
- # ──────────────────────────────────────────────────────────────
70
- # Model Cache
71
- # ──────────────────────────────────────────────────────────────
72
- class ModelCache:
73
- def __init__(self, max_slots: int = MODEL_CACHE_SLOTS):
74
- self._cache: OrderedDict = OrderedDict()
75
- self._max = max_slots
76
- self._lock = threading.Lock()
77
-
78
- def get(self, key: str):
79
- with self._lock:
80
- if key in self._cache:
81
- self._cache.move_to_end(key)
82
- return self._cache[key]
83
- return None
84
-
85
- def put(self, key: str, value):
86
- with self._lock:
87
- if key in self._cache:
88
- self._cache.move_to_end(key)
89
- self._cache[key] = value
90
- return
91
- while len(self._cache) >= self._max:
92
- evicted, _ = self._cache.popitem(last=False)
93
- logger.info(f"🗑️ Evicted model from cache: {evicted}")
94
- gc.collect()
95
- if torch.cuda.is_available():
96
- torch.cuda.empty_cache()
97
- self._cache[key] = value
98
- logger.info(f"✅ Cached model: {key} ({len(self._cache)}/{self._max} slots used)")
99
-
100
- def loaded(self) -> List[str]:
101
- with self._lock:
102
- return list(self._cache.keys())
103
-
104
- cache = ModelCache()
105
-
106
- # ──────────────────────────────────────────────────────────────
107
- # Model Loaders
108
- # ──────────────────────────────────────────────────────────────
109
- def load_mms(lang_code: str) -> dict:
110
- from transformers import VitsModel, AutoTokenizer
111
- model_id = f"facebook/mms-tts-{lang_code}"
112
- logger.info(f"⬇️ Loading MMS-TTS model: {model_id}")
113
-
114
- # Explicit tokenizer initialization
115
- tok = AutoTokenizer.from_pretrained(model_id)
116
- mdl = VitsModel.from_pretrained(model_id).to(DEVICE)
117
- mdl.eval()
118
- return {"model": mdl, "tokenizer": tok, "sr": mdl.config.sampling_rate}
119
-
120
- def get_mms(lang_code: str) -> dict:
121
- key = f"mms-{lang_code}"
122
- hit = cache.get(key)
123
- if hit:
124
- return hit
125
- obj = load_mms(lang_code)
126
- cache.put(key, obj)
127
- return obj
128
-
129
- # ──────────────────────────────────────────────────────────────
130
- # Text Processing
131
- # ──────────────────────────────────────────────────────────────
132
- _SENT_SPLIT = re.compile(r'(?<=[।.!?؟\n])\s*')
133
-
134
- def contains_content(text: str) -> bool:
135
- """
136
- Checks if text contains actual alphanumeric characters.
137
- Filters out strings like "???", " .", or emojis-only.
138
- """
139
- return bool(re.search(r'[A-Za-z0-9\u0900-\u097F]', text))
140
-
141
- def chunk_text(text: str, max_chars: int = CHUNK_SIZE) -> List[str]:
142
- text = text.strip()
143
- if not text:
144
- return []
145
-
146
- sentences = [s.strip() for s in _SENT_SPLIT.split(text) if s.strip()]
147
- chunks, current = [], ""
148
- for sent in sentences:
149
- if not current:
150
- current = sent
151
- elif len(current) + 1 + len(sent) <= max_chars:
152
- current += " " + sent
153
- else:
154
- chunks.append(current)
155
- current = sent
156
- if current:
157
- chunks.append(current)
158
-
159
- # Post-process: Remove chunks that have no actual letters/numbers
160
- valid_chunks = [c for c in chunks if contains_content(c)]
161
-
162
- # Word-split fallback for very long valid chunks
163
- final = []
164
- for chunk in valid_chunks:
165
- if len(chunk) <= max_chars:
166
- final.append(chunk)
167
- else:
168
- words, acc = chunk.split(), ""
169
- for w in words:
170
- if len(acc) + 1 + len(w) <= max_chars:
171
- acc = (acc + " " + w).strip()
172
- else:
173
- if acc: final.append(acc)
174
- acc = w
175
- if acc: final.append(acc)
176
-
177
- return final
178
-
179
- def concat_audio(arrays: List[np.ndarray], sr: int) -> np.ndarray:
180
- if not arrays:
181
- return np.array([], dtype=np.float32)
182
- gap = np.zeros(int(sr * SILENCE_BETWEEN_CHUNKS), dtype=np.float32)
183
- parts = []
184
- for i, a in enumerate(arrays):
185
- parts.append(a.astype(np.float32))
186
- if i < len(arrays) - 1:
187
- parts.append(gap)
188
- return np.concatenate(parts)
189
-
190
- def resample_speed(audio: np.ndarray, speed: float) -> np.ndarray:
191
- if abs(speed - 1.0) < 0.01:
192
- return audio
193
- from scipy.signal import resample
194
- new_len = max(1, int(len(audio) / speed))
195
- return resample(audio, new_len).astype(np.float32)
196
-
197
- def encode_wav(audio: np.ndarray, sr: int) -> bytes:
198
- buf = io.BytesIO()
199
- sf.write(buf, audio, sr, format="WAV", subtype="PCM_16")
200
- buf.seek(0)
201
- return buf.read()
202
-
203
- # ──────────────────────────────────────────────────────────────
204
- # Synthesis Backends
205
- # ──────────────────────────────────────────────────────────────
206
- def synth_mms(text: str, lang: str, speed: float) -> tuple[np.ndarray, int]:
207
- c = get_mms(lang)
208
- inputs = c["tokenizer"](text, return_tensors="pt").to(DEVICE)
209
-
210
- if "input_ids" in inputs:
211
- inputs["input_ids"] = inputs["input_ids"].long()
212
-
213
- with torch.no_grad():
214
- wav = c["model"](**inputs).waveform.squeeze().cpu().numpy()
215
- return resample_speed(wav, speed), c["sr"]
216
-
217
- # ─────────────────────���────────────────────────────────────────
218
- # Pydantic Schemas
219
- # ──────────────────────────────────────────────────────────────
220
  class SynthRequest(BaseModel):
221
- text: str = Field(..., description="Text to synthesize", max_length=MAX_TEXT_CHARS)
222
- model: str = Field("mms-tts", description="TTS model name")
223
- language: str = Field("hin", description="ISO 639-3 language code")
224
- speed: float = Field(1.0, description="Speed multiplier", ge=0.5, le=2.0)
225
-
226
- @field_validator("text")
227
- @classmethod
228
- def validate_text(cls, v):
229
- if not v or not v.strip():
230
- raise ValueError("text cannot be empty")
231
- return v
232
-
233
- # ──────────────────────────────────────────────────────────────
234
- # FastAPI App
235
- # ──────────────────────────────────────────────────────────────
236
- app = FastAPI(title="Indic TTS API", version=VERSION)
237
- app.add_middleware(
238
- CORSMiddleware,
239
- allow_origins=["*"],
240
- allow_credentials=True,
241
- allow_methods=["*"],
242
- allow_headers=["*"],
243
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- def _run_synthesis(req: SynthRequest) -> tuple[np.ndarray, int, int]:
246
- # This logic now relies on chunk_text filtering out empty/punctuation-only chunks
247
- chunks = chunk_text(req.text, CHUNK_SIZE)
248
-
249
- if not chunks:
250
- # If after filtering we have nothing, the input was likely only punctuation
251
- raise HTTPException(
252
- status_code=400,
253
- detail="Input text contains no valid content to synthesize (e.g., only punctuation or spaces)."
254
- )
255
 
256
- audios, sr = [], 16000
257
- if req.model == "mms-tts":
258
- for ch in chunks:
259
- try:
260
- a, sr = synth_mms(ch, req.language, req.speed)
261
- if a.size > 0:
262
- audios.append(a)
263
- except Exception as e:
264
- logger.error(f"Error synthesizing chunk: {e}")
265
- continue
266
-
267
- if not audios:
268
- raise HTTPException(500, detail="Synthesis failed: No audio generated.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
- final = concat_audio(audios, sr) if len(audios) > 1 else audios[0]
271
- return final, sr, len(chunks)
272
-
273
- @app.get("/", include_in_schema=False)
274
- async def root():
275
- return RedirectResponse("/docs")
276
-
277
- @app.post("/tts", response_class=StreamingResponse)
278
- async def text_to_speech(req: SynthRequest):
279
- """
280
- Synthesize text to speech.
281
- Example: {"text": "नमस्ते दुनिया", "model": "mms-tts", "language": "hin"}
282
- """
283
- t0 = time.perf_counter()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  try:
285
- audio, sr, n_chunks = _run_synthesis(req)
286
- except HTTPException:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  raise
288
- except Exception as exc:
289
- logger.exception("Synthesis error")
290
- raise HTTPException(500, detail=str(exc)) from exc
291
-
292
- wav_bytes = encode_wav(audio, sr)
293
- elapsed = time.perf_counter() - t0
294
-
295
- return StreamingResponse(
296
- io.BytesIO(wav_bytes),
297
- media_type="audio/wav",
298
- headers={
299
- "Content-Disposition": 'attachment; filename="speech.wav"',
300
- "X-Processing-Time": str(round(elapsed, 3)),
301
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  )
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  if __name__ == "__main__":
305
- uvicorn.run("app:app", host="0.0.0.0", port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import io
4
+ import inspect
5
+ import json
 
6
  import logging
7
+ import os
8
  import threading
9
+ import traceback
10
+ import wave
11
  from collections import OrderedDict
12
+ from contextlib import asynccontextmanager
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
 
16
+ import anyio
 
 
17
  import uvicorn
18
+ import numpy as np
19
+ from fastapi import Body, FastAPI, HTTPException, Query, Request
20
+ from fastapi.responses import JSONResponse, Response
21
+ from huggingface_hub import hf_hub_download
22
+ from pydantic import BaseModel, Field
23
+ from piper import PiperVoice, SynthesisConfig
 
 
 
 
 
 
 
 
24
 
25
+ # -----------------------------------------------------------------------------
26
  # Config
27
+ # -----------------------------------------------------------------------------
28
+
29
+ LOG = logging.getLogger("piper_api")
30
+ logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format='%(levelname)s:\t%(message)s')
31
+
32
+ HF_REPO_ID = os.getenv("PIPER_VOICE_REPO", "rhasspy/piper-voices")
33
+ MODELS_DIR = Path(os.getenv("PIPER_MODELS_DIR", "./piper_models")).resolve()
34
+ MODELS_DIR.mkdir(parents=True, exist_ok=True)
35
+
36
+ DEFAULT_VOICE = os.getenv("PIPER_DEFAULT_VOICE", "en_US-lessac-medium")
37
+ PRELOAD_VOICES = [
38
+ v.strip()
39
+ for v in os.getenv("PIPER_PRELOAD_VOICES", "en_US-lessac-medium,hi_IN-rohan-medium").split(",")
40
+ if v.strip()
41
+ ]
42
+ CACHE_SIZE = max(1, int(os.getenv("PIPER_CACHE_SIZE", "2")))
43
+ USE_CUDA = os.getenv("PIPER_USE_CUDA", "0").strip().lower() in {"1", "true", "yes", "on"}
44
+ DEFAULT_SAMPLE_RATE = int(os.getenv("PIPER_SAMPLE_RATE", "22050"))
45
+
46
+ # -----------------------------------------------------------------------------
47
+ # Schemas
48
+ # -----------------------------------------------------------------------------
49
+
50
+
51
+ class VoiceFile(BaseModel):
52
+ path: str
53
+ local_path: Optional[str] = None
54
+ size_bytes: Optional[int] = None
55
+ downloaded: bool = False
56
+
57
+
58
+ class VoiceInfo(BaseModel):
59
+ key: str
60
+ name: str
61
+ language: Dict[str, Any]
62
+ quality: str
63
+ num_speakers: int
64
+ aliases: List[str] = Field(default_factory=list)
65
+ files: List[VoiceFile] = Field(default_factory=list)
66
+ loaded: bool = False
67
+
68
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  class SynthRequest(BaseModel):
70
+ text: str = Field(..., min_length=1)
71
+ voice: Optional[str] = None
72
+ speaker: Optional[str] = None
73
+ speaker_id: Optional[int] = None
74
+ length_scale: Optional[float] = Field(None, gt=0)
75
+ noise_scale: Optional[float] = Field(None, ge=0)
76
+ noise_w_scale: Optional[float] = Field(None, ge=0)
77
+ volume: Optional[float] = Field(None, gt=0)
78
+ normalize_audio: Optional[bool] = None
79
+ download: bool = False
80
+
81
+
82
+ class DownloadRequest(BaseModel):
83
+ voice: str
84
+
85
+
86
+ class CatalogStats(BaseModel):
87
+ total_voices: int
88
+ total_languages: int
89
+ cached_voices: int
90
+ cache_size: int
91
+ default_voice: str
92
+ cuda_enabled: bool
93
+
94
+
95
+ # -----------------------------------------------------------------------------
96
+ # Runtime state
97
+ # -----------------------------------------------------------------------------
98
+
99
+
100
+ class RuntimeState:
101
+ def __init__(self) -> None:
102
+ self.catalog: Dict[str, Dict[str, Any]] = {}
103
+ self.alias_map: Dict[str, str] = {}
104
+ self.loaded_voices: "OrderedDict[str, PiperVoice]" = OrderedDict()
105
+ self.ready: bool = False
106
+
107
+ def canon(self, voice_name: str) -> str:
108
+ if voice_name in self.catalog:
109
+ return voice_name
110
+ if voice_name in self.alias_map:
111
+ return self.alias_map[voice_name]
112
+ raise KeyError(voice_name)
113
+
114
+ def list_languages(self) -> int:
115
+ langs = set()
116
+ for entry in self.catalog.values():
117
+ lang = entry.get("language", {}) or {}
118
+ code = lang.get("code")
119
+ if code:
120
+ langs.add(code)
121
+ return len(langs)
122
+
123
+
124
+ STATE = RuntimeState()
125
+ _VOICE_LOAD_LOCK = threading.Lock()
126
+
127
+ # -----------------------------------------------------------------------------
128
+ # Helpers
129
+ # -----------------------------------------------------------------------------
130
+
131
+
132
+ def _iter_catalog_files(entry: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]:
133
+ files = entry.get("files", {})
134
+
135
+ if isinstance(files, dict):
136
+ return [(str(rel_path), meta if isinstance(meta, dict) else {}) for rel_path, meta in files.items()]
137
+
138
+ if isinstance(files, list):
139
+ out: List[Tuple[str, Dict[str, Any]]] = []
140
+ for item in files:
141
+ if isinstance(item, dict) and item.get("path"):
142
+ out.append((str(item["path"]), item))
143
+ return out
144
+
145
+ return []
146
+
147
+
148
+ def _download_catalog() -> Dict[str, Dict[str, Any]]:
149
+ LOG.info(f"Downloading catalog from {HF_REPO_ID}...")
150
+ catalog_path = hf_hub_download(
151
+ repo_id=HF_REPO_ID,
152
+ filename="voices.json",
153
+ repo_type="model",
154
+ )
155
+ with open(catalog_path, "r", encoding="utf-8") as f:
156
+ raw = json.load(f)
157
 
158
+ if not isinstance(raw, dict):
159
+ raise RuntimeError("Unexpected voices.json structure")
 
 
 
 
 
 
 
 
160
 
161
+ catalog: Dict[str, Dict[str, Any]] = {}
162
+ alias_map: Dict[str, str] = {}
163
+
164
+ for key, entry in raw.items():
165
+ if not isinstance(entry, dict):
166
+ continue
167
+ catalog[key] = entry
168
+ alias_map[key] = key
169
+ for alias in entry.get("aliases", []) or []:
170
+ alias_map[str(alias)] = key
171
+
172
+ STATE.catalog = catalog
173
+ STATE.alias_map = alias_map
174
+ return catalog
175
+
176
+
177
+ def _resolve_voice_name(name: str) -> str:
178
+ try:
179
+ return STATE.canon(name)
180
+ except KeyError as exc:
181
+ raise HTTPException(status_code=404, detail=f"Unknown voice: {name}") from exc
182
+
183
+
184
+ def _ensure_voice_downloaded(voice_key: str) -> Dict[str, str]:
185
+ entry = STATE.catalog.get(voice_key)
186
+ if entry is None:
187
+ raise HTTPException(status_code=404, detail=f"Unknown voice: {voice_key}")
188
+
189
+ files = _iter_catalog_files(entry)
190
+ if not files:
191
+ raise HTTPException(status_code=500, detail=f"Malformed file list for voice: {voice_key}")
192
+
193
+ local_files: Dict[str, str] = {}
194
+ for rel_path, _meta in files:
195
+ try:
196
+ local_path = hf_hub_download(
197
+ repo_id=HF_REPO_ID,
198
+ filename=rel_path,
199
+ repo_type="model",
200
+ local_dir=str(MODELS_DIR),
201
+ local_dir_use_symlinks=False,
202
+ )
203
+ local_files[rel_path] = local_path
204
+ except Exception as e:
205
+ # CRITICAL FIX: Don't hide download errors
206
+ LOG.error(f"Failed to download {rel_path} for {voice_key}: {e}")
207
+ raise HTTPException(status_code=500, detail=f"Failed to download model file {rel_path}: {str(e)}")
208
+
209
+ return local_files
210
+
211
+
212
+ def _voice_model_path(voice_key: str) -> str:
213
+ local_files = _ensure_voice_downloaded(voice_key)
214
+ for rel_path, local_path in local_files.items():
215
+ if rel_path.endswith(".onnx"):
216
+ return local_path
217
+ raise HTTPException(status_code=500, detail=f"No ONNX file found for voice: {voice_key}")
218
+
219
+
220
+ def _load_voice(voice_key: str) -> PiperVoice:
221
+ voice_key = _resolve_voice_name(voice_key)
222
+
223
+ cached = STATE.loaded_voices.get(voice_key)
224
+ if cached is not None:
225
+ STATE.loaded_voices.move_to_end(voice_key)
226
+ return cached
227
+
228
+ with _VOICE_LOAD_LOCK:
229
+ cached = STATE.loaded_voices.get(voice_key)
230
+ if cached is not None:
231
+ return cached
232
+
233
+ LOG.info(f"Loading voice: {voice_key}")
234
 
235
+ # 1. Download/Verify files
236
+ try:
237
+ model_path = _voice_model_path(voice_key)
238
+ except HTTPException:
239
+ # Re-raise HTTP exceptions (download errors)
240
+ raise
241
+ except Exception as e:
242
+ LOG.error(f"Error finding model path for {voice_key}: {e}")
243
+ raise HTTPException(status_code=500, detail=f"Error finding model: {str(e)}")
244
+
245
+ # 2. Load the Model
246
+ try:
247
+ voice = PiperVoice.load(model_path, use_cuda=USE_CUDA)
248
+ except Exception as e:
249
+ LOG.error(f"Error loading PiperVoice from {model_path}: {e}")
250
+ LOG.error(traceback.format_exc())
251
+ raise HTTPException(status_code=500, detail=f"Failed to load voice model: {str(e)}")
252
+
253
+ # 3. Sanity Check: Does the voice actually have a config?
254
+ # If not, it might be a dummy/corrupted load.
255
+ if not hasattr(voice, 'config') or voice.config is None:
256
+ LOG.error(f"Voice {voice_key} loaded but has no config. This usually means files are missing or corrupted.")
257
+ raise HTTPException(status_code=500, detail=f"Voice {voice_key} is invalid (missing config).")
258
+
259
+ STATE.loaded_voices[voice_key] = voice
260
+ STATE.loaded_voices.move_to_end(voice_key)
261
+
262
+ while len(STATE.loaded_voices) > CACHE_SIZE:
263
+ removed_key, _ = STATE.loaded_voices.popitem(last=False)
264
+ LOG.info(f"Evicted voice from cache: {removed_key}")
265
+
266
+ return voice
267
+
268
+
269
+ def _make_synthesis_config(payload: SynthRequest) -> SynthesisConfig:
270
+ kwargs: Dict[str, Any] = {}
271
+ sig = inspect.signature(SynthesisConfig)
272
+ for field_name in ("volume", "length_scale", "noise_scale", "noise_w_scale", "normalize_audio"):
273
+ value = getattr(payload, field_name)
274
+ if value is not None and field_name in sig.parameters:
275
+ kwargs[field_name] = value
276
+ return SynthesisConfig(**kwargs)
277
+
278
+
279
+ def _voice_to_info(voice_key: str) -> VoiceInfo:
280
+ entry = STATE.catalog[voice_key]
281
+ files_meta = _iter_catalog_files(entry)
282
+
283
+ files: List[VoiceFile] = []
284
+ for rel_path, meta in files_meta:
285
+ local_path = (MODELS_DIR / rel_path).resolve()
286
+ downloaded = local_path.exists()
287
+
288
+ size_bytes = meta.get("size_bytes", meta.get("size"))
289
+ if isinstance(size_bytes, str) and size_bytes.isdigit():
290
+ size_bytes = int(size_bytes)
291
+ elif not isinstance(size_bytes, int):
292
+ size_bytes = None
293
+
294
+ files.append(
295
+ VoiceFile(
296
+ path=rel_path,
297
+ local_path=str(local_path) if downloaded else None,
298
+ size_bytes=size_bytes,
299
+ downloaded=downloaded,
300
+ )
301
+ )
302
+
303
+ return VoiceInfo(
304
+ key=entry.get("key", voice_key),
305
+ name=entry.get("name", ""),
306
+ language=entry.get("language", {}),
307
+ quality=entry.get("quality", ""),
308
+ num_speakers=int(entry.get("num_speakers", 1)),
309
+ aliases=list(entry.get("aliases", []) or []),
310
+ files=files,
311
+ loaded=voice_key in STATE.loaded_voices,
312
+ )
313
+
314
+
315
+ def _guess_sample_rate(voice: Any, entry: Optional[Dict[str, Any]] = None) -> int:
316
+ if entry:
317
+ for key in ("sample_rate", "audio_sample_rate", "rate"):
318
+ val = entry.get(key)
319
+ if isinstance(val, (int, float)) and val > 0:
320
+ return int(val)
321
+
322
+ for attr in ("sample_rate",):
323
+ val = getattr(voice, attr, None)
324
+ if isinstance(val, (int, float)) and val > 0:
325
+ return int(val)
326
+
327
+ cfg = getattr(voice, "config", None)
328
+ if cfg is not None:
329
+ val = getattr(cfg, "sample_rate", None)
330
+ if isinstance(val, (int, float)) and val > 0:
331
+ return int(val)
332
+
333
+ return DEFAULT_SAMPLE_RATE
334
+
335
+
336
+ def _choose_speaker_kwargs(payload: SynthRequest, synth_sig: inspect.Signature) -> Dict[str, Any]:
337
+ kwargs: Dict[str, Any] = {}
338
+ if payload.speaker_id is not None and "speaker_id" in synth_sig.parameters:
339
+ kwargs["speaker_id"] = payload.speaker_id
340
+ elif payload.speaker is not None and "speaker" in synth_sig.parameters:
341
+ kwargs["speaker"] = payload.speaker
342
+ return kwargs
343
+
344
+
345
+ def _synthesize_wav_bytes(voice_key: str, payload: SynthRequest) -> bytes:
346
+ voice = _load_voice(voice_key)
347
+ syn_config = _make_synthesis_config(payload)
348
+
349
+ synth_sig = inspect.signature(voice.synthesize)
350
+ synth_kwargs = _choose_speaker_kwargs(payload, synth_sig)
351
+
352
+ gen = None
353
+ if "syn_config" in synth_sig.parameters:
354
+ gen = voice.synthesize(payload.text, syn_config=syn_config, **synth_kwargs)
355
+ elif "synthesis_config" in synth_sig.parameters:
356
+ gen = voice.synthesize(payload.text, synthesis_config=syn_config, **synth_kwargs)
357
+ else:
358
+ gen = voice.synthesize(payload.text, **synth_kwargs)
359
+
360
+ chunks = []
361
+ try:
362
+ for chunk in gen:
363
+ chunks.append(chunk)
364
+ except Exception as e:
365
+ LOG.error(f"Error during synthesis generation: {e}")
366
+ LOG.error(traceback.format_exc())
367
+ raise HTTPException(status_code=500, detail=f"Synthesis generator failed: {str(e)}")
368
+
369
+ if not chunks:
370
+ # This specific error usually means the voice loaded but is invalid
371
+ LOG.error(f"Synthesis produced no audio for voice {voice_key}. Text: '{payload.text}'")
372
+ raise HTTPException(status_code=500, detail="Piper returned no audio chunks. The voice model may be missing or corrupted.")
373
+
374
+ first = chunks[0]
375
+ sample_rate = int(getattr(first, "sample_rate", None) or _guess_sample_rate(voice, STATE.catalog.get(voice_key, {})))
376
+ sample_width = int(getattr(first, "sample_width", None) or 2)
377
+ sample_channels = int(getattr(first, "sample_channels", None) or 1)
378
+
379
+ for chunk in chunks:
380
+ if int(getattr(chunk, "sample_rate", sample_rate) or sample_rate) != sample_rate:
381
+ raise HTTPException(status_code=500, detail="Inconsistent sample_rate across Piper chunks.")
382
+ if int(getattr(chunk, "sample_width", sample_width) or sample_width) != sample_width:
383
+ raise HTTPException(status_code=500, detail="Inconsistent sample_width across Piper chunks.")
384
+ if int(getattr(chunk, "sample_channels", sample_channels) or sample_channels) != sample_channels:
385
+ raise HTTPException(status_code=500, detail="Inconsistent sample_channels across Piper chunks.")
386
+
387
+ buf = io.BytesIO()
388
+ try:
389
+ with wave.open(buf, "wb") as wav_file:
390
+ wav_file.setnchannels(sample_channels)
391
+ wav_file.setsampwidth(sample_width)
392
+ wav_file.setframerate(sample_rate)
393
+ wav_file.setcomptype("NONE", "not compressed")
394
+
395
+ for chunk in chunks:
396
+ audio_bytes = getattr(chunk, "audio_int16_bytes", None)
397
+ if audio_bytes is None:
398
+ raw_audio = getattr(chunk, "audio", None)
399
+ if raw_audio is not None:
400
+ if hasattr(raw_audio, 'astype'):
401
+ if raw_audio.dtype != np.int16:
402
+ raw_audio = (raw_audio * 32767).astype(np.int16)
403
+ audio_bytes = raw_audio.tobytes()
404
+ else:
405
+ audio_bytes = bytes(raw_audio)
406
+ else:
407
+ raise HTTPException(status_code=500, detail="Audio chunk missing audio data")
408
+
409
+ wav_file.writeframes(audio_bytes)
410
+ except Exception as e:
411
+ LOG.error(f"Error writing WAV: {e}")
412
+ LOG.error(traceback.format_exc())
413
+ raise HTTPException(status_code=500, detail=f"WAV encoding failed: {str(e)}")
414
+
415
+ wav_bytes = buf.getvalue()
416
+ if len(wav_bytes) < 44 or not wav_bytes.startswith(b"RIFF"):
417
+ raise HTTPException(status_code=500, detail="Synthesis produced an invalid WAV payload.")
418
+ return wav_bytes
419
+
420
+
421
+ async def _startup() -> None:
422
  try:
423
+ LOG.info("Starting application...")
424
+ await anyio.to_thread.run_sync(_download_catalog, limiter=None)
425
+
426
+ preload_candidates = [DEFAULT_VOICE] + [v for v in PRELOAD_VOICES if v != DEFAULT_VOICE]
427
+ seen = set()
428
+
429
+ for voice_name in preload_candidates:
430
+ if not voice_name or voice_name in seen:
431
+ continue
432
+ seen.add(voice_name)
433
+ try:
434
+ await anyio.to_thread.run_sync(_load_voice, voice_name, limiter=None)
435
+ except HTTPException as exc:
436
+ # CRITICAL FIX: Log the full detail of why it failed
437
+ LOG.error(f"FATAL: Failed to preload voice '{voice_name}'. Reason: {exc.detail}")
438
+ # Depending on your preference, you might want to raise here to stop the app
439
+ # raise exc
440
+ except Exception as exc:
441
+ LOG.error(f"FATAL: Unexpected error preloading voice '{voice_name}': {exc}")
442
+ LOG.error(traceback.format_exc())
443
+ # raise exc
444
+
445
+ STATE.ready = True
446
+ LOG.info(f"Piper API ready with {len(STATE.catalog)} voices ({STATE.list_languages()} languages).")
447
+ except Exception:
448
+ LOG.exception("Startup failed")
449
  raise
450
+
451
+
452
+ async def _shutdown() -> None:
453
+ STATE.loaded_voices.clear()
454
+ LOG.info("Application shutdown.")
455
+
456
+
457
+ @asynccontextmanager
458
+ async def lifespan(_: FastAPI):
459
+ await _startup()
460
+ try:
461
+ yield
462
+ finally:
463
+ await _shutdown()
464
+
465
+
466
+ app = FastAPI(
467
+ title="Piper TTS API",
468
+ version="1.0.0",
469
+ description="FastAPI wrapper for Piper TTS with automatic Hugging Face voice download and OpenAPI docs.",
470
+ lifespan=lifespan,
471
+ )
472
+
473
+ # -----------------------------------------------------------------------------
474
+ # Routes
475
+ # -----------------------------------------------------------------------------
476
+
477
+
478
+ @app.get("/")
479
+ async def root() -> Dict[str, Any]:
480
+ return {
481
+ "name": "Piper TTS API",
482
+ "ready": STATE.ready,
483
+ "default_voice": DEFAULT_VOICE,
484
+ "docs": "/docs",
485
+ "health": "/health",
486
+ "voices": "/voices",
487
+ "synthesize": "/synthesize",
488
+ }
489
+
490
+
491
+ @app.get("/health", tags=["system"])
492
+ async def health() -> Dict[str, Any]:
493
+ return {
494
+ "ready": STATE.ready,
495
+ "cached_voices": list(STATE.loaded_voices.keys()),
496
+ "cache_size": CACHE_SIZE,
497
+ "default_voice": DEFAULT_VOICE,
498
+ "cuda_enabled": USE_CUDA,
499
+ }
500
+
501
+
502
+ @app.get("/stats", response_model=CatalogStats, tags=["system"])
503
+ async def stats() -> CatalogStats:
504
+ return CatalogStats(
505
+ total_voices=len(STATE.catalog),
506
+ total_languages=STATE.list_languages(),
507
+ cached_voices=len(STATE.loaded_voices),
508
+ cache_size=CACHE_SIZE,
509
+ default_voice=DEFAULT_VOICE,
510
+ cuda_enabled=USE_CUDA,
511
+ )
512
+
513
+
514
+ @app.get("/voices", response_model=List[VoiceInfo], tags=["voices"])
515
+ async def list_voices(
516
+ language: Optional[str] = Query(None),
517
+ quality: Optional[str] = Query(None),
518
+ loaded_only: bool = Query(False),
519
+ ) -> List[VoiceInfo]:
520
+ voices: List[VoiceInfo] = []
521
+ for key, entry in STATE.catalog.items():
522
+ if loaded_only and key not in STATE.loaded_voices:
523
+ continue
524
+ lang = entry.get("language", {}) or {}
525
+ if language and lang.get("code") != language:
526
+ continue
527
+ if quality and entry.get("quality") != quality:
528
+ continue
529
+ voices.append(_voice_to_info(key))
530
+ return voices
531
+
532
+
533
+ @app.get("/voices/{voice_name}", response_model=VoiceInfo, tags=["voices"])
534
+ async def get_voice(voice_name: str) -> VoiceInfo:
535
+ return _voice_to_info(_resolve_voice_name(voice_name))
536
+
537
+
538
+ @app.post("/voices/download", response_model=VoiceInfo, tags=["voices"])
539
+ async def download_voice(payload: DownloadRequest = Body(...)) -> VoiceInfo:
540
+ voice_key = _resolve_voice_name(payload.voice)
541
+ await anyio.to_thread.run_sync(_ensure_voice_downloaded, voice_key, limiter=None)
542
+ return _voice_to_info(voice_key)
543
+
544
+
545
+ @app.post("/synthesize", tags=["synthesis"], responses={200: {"content": {"audio/wav": {}}}})
546
+ async def synthesize(payload: SynthRequest = Body(...)) -> Response:
547
+ voice_key = _resolve_voice_name(payload.voice or DEFAULT_VOICE)
548
+ wav_bytes = await anyio.to_thread.run_sync(_synthesize_wav_bytes, voice_key, payload, limiter=None)
549
+
550
+ headers = {}
551
+ if payload.download:
552
+ headers["Content-Disposition"] = f'attachment; filename="{voice_key}.wav"'
553
+ return Response(content=wav_bytes, media_type="audio/wav", headers=headers)
554
+
555
+
556
+ @app.get("/synthesize", tags=["synthesis"], responses={200: {"content": {"audio/wav": {}}}})
557
+ async def synthesize_get(
558
+ text: str = Query(..., min_length=1),
559
+ voice: Optional[str] = Query(None),
560
+ speaker: Optional[str] = Query(None),
561
+ speaker_id: Optional[int] = Query(None),
562
+ length_scale: Optional[float] = Query(None, gt=0),
563
+ noise_scale: Optional[float] = Query(None, ge=0),
564
+ noise_w_scale: Optional[float] = Query(None, ge=0),
565
+ volume: Optional[float] = Query(None, gt=0),
566
+ normalize_audio: Optional[bool] = Query(None),
567
+ ) -> Response:
568
+ payload = SynthRequest(
569
+ text=text,
570
+ voice=voice,
571
+ speaker=speaker,
572
+ speaker_id=speaker_id,
573
+ length_scale=length_scale,
574
+ noise_scale=noise_scale,
575
+ noise_w_scale=noise_w_scale,
576
+ volume=volume,
577
+ normalize_audio=normalize_audio,
578
+ download=False,
579
+ )
580
+ return await synthesize(payload)
581
+
582
+
583
+ @app.post("/tts", tags=["synthesis"], responses={200: {"content": {"audio/wav": {}}}})
584
+ async def tts(payload: SynthRequest = Body(...)) -> Response:
585
+ return await synthesize(payload)
586
+
587
+
588
+ @app.get("/tts", tags=["synthesis"], responses={200: {"content": {"audio/wav": {}}}})
589
+ async def tts_get(
590
+ text: str = Query(..., min_length=1),
591
+ voice: Optional[str] = Query(None),
592
+ speaker: Optional[str] = Query(None),
593
+ speaker_id: Optional[int] = Query(None),
594
+ length_scale: Optional[float] = Query(None, gt=0),
595
+ noise_scale: Optional[float] = Query(None, ge=0),
596
+ noise_w_scale: Optional[float] = Query(None, ge=0),
597
+ volume: Optional[float] = Query(None, gt=0),
598
+ normalize_audio: Optional[bool] = Query(None),
599
+ ) -> Response:
600
+ return await synthesize_get(
601
+ text=text,
602
+ voice=voice,
603
+ speaker=speaker,
604
+ speaker_id=speaker_id,
605
+ length_scale=length_scale,
606
+ noise_scale=noise_speed,
607
+ noise_w_scale=noise_w_scale,
608
+ volume=volume,
609
+ normalize_audio=normalize_audio,
610
+ )
611
+
612
+
613
+ @app.post("/tts/stream", tags=["synthesis"], responses={200: {"content": {"audio/wav": {}}}})
614
+ async def tts_stream(payload: SynthRequest = Body(...)) -> Response:
615
+ return await synthesize(payload)
616
+
617
+
618
+ @app.get("/tts/stream", tags=["synthesis"], responses={200: {"content": {"audio/wav": {}}}})
619
+ async def tts_stream_get(
620
+ text: str = Query(..., min_length=1),
621
+ voice: Optional[str] = Query(None),
622
+ speaker: Optional[str] = Query(None),
623
+ speaker_id: Optional[int] = Query(None),
624
+ length_scale: Optional[float] = Query(None, gt=0),
625
+ noise_scale: Optional[float] = Query(None, ge=0),
626
+ noise_w_scale: Optional[float] = Query(None, ge=0),
627
+ volume: Optional[float] = Query(None, gt=0),
628
+ normalize_audio: Optional[bool] = Query(None),
629
+ ) -> Response:
630
+ return await synthesize_get(
631
+ text=text,
632
+ voice=voice,
633
+ speaker=speaker,
634
+ speaker_id=speaker_id,
635
+ length_scale=length_scale,
636
+ noise_scale=noise_scale,
637
+ noise_w_scale=noise_w_scale,
638
+ volume=volume,
639
+ normalize_audio=normalize_audio,
640
  )
641
 
642
+
643
+ @app.post("/reload", tags=["system"])
644
+ async def reload_catalog() -> Dict[str, Any]:
645
+ await anyio.to_thread.run_sync(_download_catalog, limiter=None)
646
+ return {
647
+ "ok": True,
648
+ "total_voices": len(STATE.catalog),
649
+ "total_languages": STATE.list_languages(),
650
+ }
651
+
652
+
653
+ @app.exception_handler(HTTPException)
654
+ async def http_exception_handler(_request: Request, exc: HTTPException):
655
+ LOG.warning(f"HTTP Exception: {exc.status_code} - {exc.detail}")
656
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
657
+
658
+
659
+ @app.exception_handler(Exception)
660
+ async def unhandled_exception_handler(_request: Request, exc: Exception):
661
+ LOG.error("Unhandled Exception occurred")
662
+ LOG.error(traceback.format_exc())
663
+ return JSONResponse(status_code=500, content={"detail": f"{type(exc).__name__}: {exc}"})
664
+
665
+
666
  if __name__ == "__main__":
667
+ uvicorn.run(
668
+ "app:app",
669
+ host=os.getenv("HOST", "0.0.0.0"),
670
+ port=int(os.getenv("PORT", "7860")),
671
+ reload=False,
672
+ log_level=os.getenv("LOG_LEVEL", "info").lower(),
673
+ )