ibrahimgulbutt Claude Fable 5 commited on
Commit
bf6e45c
·
1 Parent(s): 7b7e2c1

Add Kokoro TTS (fp32 ONNX + misaki, cached, warmed) — /tts endpoint

Browse files

Syncs the Space with the GitHub feat/kokoro-tts branch: Kokoro-82M via
kokoro-onnx (fp32 = same audio quality as torch, 2-4x faster CPU, no
torch in the image), misaki G2P, LRU response cache, startup warmup,
concurrency guard, and optional Ogg/Opus output. The Dockerfile now
downloads the fp32 model files at build. /tts degrades to 503 when
unavailable so the web client falls back to browser speech.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (7) hide show
  1. .gitignore +3 -1
  2. Dockerfile +17 -1
  3. README.md +84 -2
  4. main.py +19 -2
  5. requirements.txt +12 -0
  6. routers/tts.py +37 -0
  7. services/tts_service.py +273 -0
.gitignore CHANGED
@@ -6,4 +6,6 @@ venv/
6
  *.egg-info/
7
  .env
8
  *.log
9
- .DS_Store
 
 
 
6
  *.egg-info/
7
  .env
8
  *.log
9
+ .DS_Store
10
+ # Kokoro ONNX model files (downloaded in Dockerfile / locally, never committed)
11
+ models/
Dockerfile CHANGED
@@ -2,14 +2,30 @@ FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
5
- # Install system dependencies for pdf extraction if needed
 
6
  RUN apt-get update && apt-get install -y \
7
  build-essential \
 
 
8
  && rm -rf /var/lib/apt/lists/*
9
 
10
  COPY requirements.txt .
11
  RUN pip install --no-cache-dir -r requirements.txt
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  COPY . .
14
 
15
  EXPOSE 7860
 
2
 
3
  WORKDIR /app
4
 
5
+ # System deps: build-essential (pdf extraction), espeak-ng (Kokoro/misaki g2p
6
+ # fallback for out-of-dictionary words), curl (TTS model download below)
7
  RUN apt-get update && apt-get install -y \
8
  build-essential \
9
+ curl \
10
+ espeak-ng \
11
  && rm -rf /var/lib/apt/lists/*
12
 
13
  COPY requirements.txt .
14
  RUN pip install --no-cache-dir -r requirements.txt
15
 
16
+ # Kokoro ONNX model files — the fp32 export (same weights as the torch release,
17
+ # so audio quality is unchanged; do NOT swap in the int8/fp16 variants unless a
18
+ # quality drop is acceptable). ~340MB total; skip with
19
+ # --build-arg DOWNLOAD_TTS_MODELS=false (TTS then 503s and clients fall back).
20
+ ARG DOWNLOAD_TTS_MODELS=true
21
+ RUN if [ "$DOWNLOAD_TTS_MODELS" = "true" ]; then \
22
+ mkdir -p models && \
23
+ curl -fL -o models/kokoro-v1.0.onnx \
24
+ https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx && \
25
+ curl -fL -o models/voices-v1.0.bin \
26
+ https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin ; \
27
+ fi
28
+
29
  COPY . .
30
 
31
  EXPOSE 7860
README.md CHANGED
@@ -8,6 +8,88 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # Smartfolio Python Service
12
 
13
- FastAPI service for resume extraction.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  pinned: false
9
  ---
10
 
11
+ # Smartfolio Python AI Service
12
 
13
+ The Smartfolio Python Service acts as the dedicated AI and Web Scraping engine for the Smartfolio platform. It is completely decoupled from the main NestJS backend and runs as a separate **FastAPI** microservice.
14
+
15
+ ## 🚀 Live Deployment
16
+ - **Python API**: [https://mahrooshishaq-smartfolio-python.hf.space](https://mahrooshishaq-smartfolio-python.hf.space)
17
+ - **API Documentation**: [https://mahrooshishaq-smartfolio-python.hf.space/docs](https://mahrooshishaq-smartfolio-python.hf.space/docs) (Interactive Swagger UI)
18
+
19
+ ## 🏗 Architecture & Technologies
20
+
21
+ 1. **FastAPI**: Provides a high-performance asynchronous API layer.
22
+ 2. **Web Scraping (Selenium)**: Automates headless browsers to fetch real-time Job postings and Course catalogs.
23
+ 3. **Resume Parsing**: Uses `pdfminer.six` and specialized NLP pipelines to extract raw text and structured data from uploaded PDF resumes.
24
+ 4. **LLM Integration (Groq)**: Connects to the `llama-3.3-70b-versatile` model to provide intelligent insights, resume formatting tips, and skill gap analyses.
25
+ 5. **Security**: Validates an `API_KEY` passed from the NestJS backend to ensure only authorized traffic reaches the AI engine.
26
+
27
+ ---
28
+
29
+ ## 🛠 Features
30
+
31
+ ### 1. Resume Analysis API
32
+ Receives a raw PDF file as `multipart/form-data` from the NestJS backend, extracts the text content safely in memory, and passes it to the Llama3 model to generate:
33
+ - A tailored summary of the candidate.
34
+ - Hard/Soft skills extraction.
35
+ - A score representing the resume's ATS-readiness.
36
+ - Actionable improvements for formatting and impact.
37
+
38
+ ### 2. Jobs Scraper
39
+ Accepts a search query and location to dynamically launch a headless Chromium instance, scraping the latest relevant roles across various job boards.
40
+
41
+ ### 3. Courses Scraper
42
+ Fetches highly relevant upskilling courses and certifications based on the user's identified skill gaps from the resume analysis.
43
+
44
+ ### 4. Text-to-Speech (`POST /tts`)
45
+ Synthesizes interviewer speech for the mock-interview feature using **Kokoro-82M**.
46
+
47
+ - **Backend**: runs the fp32 ONNX export via `kokoro-onnx` + `misaki` G2P — identical audio to the torch pipeline, 2–4x faster on CPU, no torch in the image. `TTS_BACKEND=torch` switches to the original `kokoro` KPipeline if installed; `auto` (default) prefers ONNX and falls back to torch.
48
+ - **Model files**: downloaded in the `Dockerfile` (fp32 `kokoro-v1.0.onnx` + `voices-v1.0.bin` — keep fp32; the int8/fp16 variants trade quality for size). Override paths with `KOKORO_ONNX_MODEL` / `KOKORO_ONNX_VOICES`.
49
+ - **Request**: `{ text, voice?, format? }` — `format: "ogg"` returns Ogg/Opus (~10x smaller than WAV; clients should check `canPlayType` — Safari needs `wav`).
50
+ - **Performance**: results are LRU-cached per `(text, voice, format)` (`TTS_CACHE_SIZE`, default 256); syntheses are serialized (`TTS_MAX_CONCURRENCY`, default 1) so parallel requests don't thrash CPU threads; the model is warmed at startup (`TTS_WARMUP=0` to disable).
51
+ - **Degradation**: any failure returns 503 and the web client falls back to browser speech synthesis — TTS being absent never breaks the interview.
52
+
53
+ ---
54
+
55
+ ## ⚙️ Environment Configuration
56
+
57
+ To run the Python service locally, set up the following environment variables in a `.env` file:
58
+
59
+ ```env
60
+ # Server
61
+ PORT=7860
62
+
63
+ # Security
64
+ # This key must match the PYTHON_API_KEY set in the NestJS backend
65
+ API_KEY=your-secure-secret-key
66
+
67
+ # LLM Integrations
68
+ GROQ_API_KEY=your-groq-api-key
69
+ ```
70
+
71
+ ---
72
+
73
+ ## 📦 Deployment (Hugging Face Docker)
74
+
75
+ This application uses a custom `Dockerfile` because it requires a complex environment with **Chromium** and **Selenium webdriver** dependencies installed natively on the Linux host.
76
+
77
+ 1. Create a new Space on Hugging Face and select the **Docker** template.
78
+ 2. Link your GitHub repository or push the Python code directly.
79
+ 3. Hugging Face will read the provided `Dockerfile`, which:
80
+ - Installs `python3.9`, `chromium`, and `chromium-driver`.
81
+ - Installs the Python requirements (`pip install -r requirements.txt`).
82
+ - Starts the Uvicorn server on port `7860` using host `0.0.0.0`.
83
+ 4. Go to **Settings -> Variables and secrets** and add `API_KEY` and `GROQ_API_KEY`.
84
+ 5. Your service is now live!
85
+
86
+ ### Running Locally
87
+ To avoid installing Chromium natively on your local machine, running via Docker is recommended.
88
+
89
+ ```bash
90
+ # Build the Docker image
91
+ docker build -t smartfolio-python .
92
+
93
+ # Run the container
94
+ docker run -p 7860:7860 --env-file .env smartfolio-python
95
+ ```
main.py CHANGED
@@ -1,9 +1,26 @@
 
 
 
 
1
  from fastapi import FastAPI
2
- from routers import extraction
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
- app = FastAPI(title="Smartfolio Python Service", version="1.0.0")
5
 
6
  app.include_router(extraction.router)
 
7
 
8
 
9
  @app.get("/health")
 
1
+ import os
2
+ import threading
3
+ from contextlib import asynccontextmanager
4
+
5
  from fastapi import FastAPI
6
+ from routers import extraction, tts
7
+ from services import tts_service
8
+
9
+
10
+ @asynccontextmanager
11
+ async def lifespan(app: FastAPI):
12
+ # Warm the TTS model off the request path so the first /tts call doesn't
13
+ # pay model-load cost. Runs in a daemon thread; if TTS deps are missing it
14
+ # quietly no-ops and /tts keeps degrading to 503. Disable with TTS_WARMUP=0.
15
+ if os.getenv("TTS_WARMUP", "1") != "0":
16
+ threading.Thread(target=tts_service.warmup, daemon=True).start()
17
+ yield
18
+
19
 
20
+ app = FastAPI(title="Smartfolio Python Service", version="1.0.0", lifespan=lifespan)
21
 
22
  app.include_router(extraction.router)
23
+ app.include_router(tts.router)
24
 
25
 
26
  @app.get("/health")
requirements.txt CHANGED
@@ -3,3 +3,15 @@ uvicorn==0.30.6
3
  pdfplumber==0.11.4
4
  python-multipart==0.0.9
5
  pydantic==2.8.2
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  pdfplumber==0.11.4
4
  python-multipart==0.0.9
5
  pydantic==2.8.2
6
+
7
+ # Text-to-speech: Kokoro-82M via ONNX Runtime (fp32 export — identical weights
8
+ # to the torch release, so no quality loss) with misaki providing the same
9
+ # English G2P the torch pipeline uses. 2-4x faster than torch on CPU and keeps
10
+ # torch (~2GB) out of the image. Model files are fetched in the Dockerfile
11
+ # (or point KOKORO_ONNX_MODEL / KOKORO_ONNX_VOICES at them).
12
+ # The /tts route still degrades gracefully to 503 if anything is missing.
13
+ kokoro-onnx>=0.4.9
14
+ misaki[en]>=0.9.4
15
+ soundfile>=0.12.1
16
+ # Optional torch fallback (heavy): `pip install kokoro>=0.9.2` — picked up
17
+ # automatically by TTS_BACKEND=auto when the ONNX model files are absent.
routers/tts.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Literal
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from fastapi.responses import Response
5
+ from pydantic import BaseModel, Field
6
+
7
+ from services.tts_service import synthesize, is_available, backend_status
8
+
9
+ router = APIRouter(prefix="/tts", tags=["tts"])
10
+
11
+
12
+ class TtsRequest(BaseModel):
13
+ text: str = Field(..., min_length=1, max_length=1200)
14
+ voice: str | None = None
15
+ # "ogg" = Ogg/Opus (~10x smaller than WAV; not playable in Safari, so the
16
+ # client negotiates via canPlayType and asks for "wav" when needed).
17
+ format: Literal["wav", "ogg"] = "wav"
18
+
19
+
20
+ @router.get("/health")
21
+ def tts_health():
22
+ """Reports whether Kokoro TTS is installed/usable and which backend runs it."""
23
+ return {"available": is_available(), "backend": backend_status()}
24
+
25
+
26
+ @router.post("")
27
+ def tts(req: TtsRequest):
28
+ """
29
+ Synthesize interviewer speech with Kokoro. Returns audio (WAV or Ogg/Opus)
30
+ on success, or 503 when TTS is unavailable so the caller falls back to
31
+ browser speech. Responses are LRU-cached per (text, voice, format).
32
+ """
33
+ try:
34
+ audio, mime = synthesize(req.text, req.voice, req.format)
35
+ return Response(content=audio, media_type=mime)
36
+ except Exception as e: # noqa: BLE001 — TTS is optional; never hard-fail
37
+ raise HTTPException(status_code=503, detail=f"TTS unavailable: {e}")
services/tts_service.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Text-to-speech using Kokoro-82M (https://huggingface.co/hexgrad/Kokoro-82M).
3
+
4
+ Two interchangeable backends, selected by TTS_BACKEND (auto | onnx | torch):
5
+
6
+ - "onnx" — kokoro-onnx running the **fp32** ONNX export (bit-identical weights
7
+ to the torch release, so no quality loss) + misaki for English G2P (the same
8
+ phonemizer the torch pipeline uses, so pronunciation is unchanged). 2-4x
9
+ faster than torch on CPU and keeps torch out of the image entirely.
10
+ Needs the model files (see Dockerfile):
11
+ KOKORO_ONNX_MODEL (default models/kokoro-v1.0.onnx)
12
+ KOKORO_ONNX_VOICES (default models/voices-v1.0.bin)
13
+ - "torch" — the original `kokoro` KPipeline (pip install kokoro soundfile).
14
+
15
+ "auto" prefers ONNX when its deps + model files are present, else torch, else
16
+ the service reports unavailable and callers fall back to browser speech.
17
+
18
+ Everything loads lazily on first use (call `warmup()` at startup to pre-pay
19
+ that cost off the request path). Synthesis results are LRU-cached per
20
+ (text, voice, format) — interview questions are static text, so replays and
21
+ re-entered rounds are served instantly. A semaphore keeps concurrent syntheses
22
+ from thrashing each other's CPU threads.
23
+ """
24
+ import io
25
+ import os
26
+ import re
27
+ import threading
28
+ from collections import OrderedDict
29
+ from pathlib import Path
30
+
31
+ # American English by default. Kokoro voices: af_heart, af_bella, am_michael, ...
32
+ DEFAULT_VOICE = "af_heart"
33
+ SAMPLE_RATE = 24000
34
+
35
+ _SERVICE_ROOT = Path(__file__).resolve().parent.parent
36
+
37
+ _backend = None
38
+ _backend_error: str | None = None
39
+ _init_lock = threading.Lock()
40
+
41
+ # Only TTS_MAX_CONCURRENCY syntheses run at once; both backends parallelize a
42
+ # single utterance across cores, so more concurrency usually means all requests
43
+ # get slower, not faster.
44
+ _synth_sem = threading.BoundedSemaphore(int(os.getenv("TTS_MAX_CONCURRENCY", "1")))
45
+
46
+ _cache: "OrderedDict[tuple, tuple[bytes, str]]" = OrderedDict()
47
+ _cache_lock = threading.Lock()
48
+ _CACHE_MAX = int(os.getenv("TTS_CACHE_SIZE", "256"))
49
+
50
+
51
+ def _onnx_paths() -> tuple[Path, Path]:
52
+ model = Path(os.getenv("KOKORO_ONNX_MODEL", "models/kokoro-v1.0.onnx"))
53
+ voices = Path(os.getenv("KOKORO_ONNX_VOICES", "models/voices-v1.0.bin"))
54
+ if not model.is_absolute():
55
+ model = _SERVICE_ROOT / model
56
+ if not voices.is_absolute():
57
+ voices = _SERVICE_ROOT / voices
58
+ return model, voices
59
+
60
+
61
+ def _split_chunks(text: str, max_len: int = 300) -> list[str]:
62
+ """Sentence-ish chunks under max_len chars (stays well inside Kokoro's
63
+ phoneme-sequence limit). The torch pipeline splits internally; the ONNX
64
+ path needs us to do it."""
65
+ sentences = re.findall(r"[^.?!]+[.?!]+[\"')\]]*\s*|[^.?!]+$", text)
66
+ chunks: list[str] = []
67
+ current = ""
68
+ for s in sentences:
69
+ s = s.strip()
70
+ if not s:
71
+ continue
72
+ if current and len(current) + len(s) + 1 > max_len:
73
+ chunks.append(current)
74
+ current = s
75
+ else:
76
+ current = f"{current} {s}".strip()
77
+ while len(current) > max_len: # single overlong sentence: hard split
78
+ chunks.append(current[:max_len])
79
+ current = current[max_len:].strip()
80
+ if current:
81
+ chunks.append(current)
82
+ return chunks or [text]
83
+
84
+
85
+ class _OnnxBackend:
86
+ """kokoro-onnx (fp32 weights) + misaki G2P — torch-free, CPU-fast."""
87
+
88
+ name = "onnx"
89
+
90
+ def __init__(self):
91
+ from kokoro_onnx import Kokoro
92
+ from misaki import en
93
+
94
+ model, voices = _onnx_paths()
95
+ if not model.exists() or not voices.exists():
96
+ raise FileNotFoundError(f"Kokoro ONNX model files missing ({model}, {voices})")
97
+ # Same G2P stack as the torch pipeline: misaki dictionary first,
98
+ # espeak-ng for out-of-dictionary words when available.
99
+ fallback = None
100
+ try:
101
+ from misaki import espeak
102
+ fallback = espeak.EspeakFallback(british=False)
103
+ except Exception:
104
+ pass # espeak-ng not installed — dictionary-only G2P still works
105
+ self._g2p = en.G2P(trf=False, british=False, fallback=fallback)
106
+ self._kokoro = Kokoro(str(model), str(voices))
107
+
108
+ def segments(self, text: str, voice: str):
109
+ import numpy as np
110
+
111
+ for chunk in _split_chunks(text):
112
+ phonemes, _ = self._g2p(chunk)
113
+ if not phonemes:
114
+ continue
115
+ samples, _sr = self._kokoro.create(phonemes, voice=voice, is_phonemes=True)
116
+ yield np.asarray(samples, dtype="float32")
117
+
118
+
119
+ class _TorchBackend:
120
+ """The original kokoro KPipeline (heavy: pulls in torch)."""
121
+
122
+ name = "torch"
123
+
124
+ def __init__(self):
125
+ from kokoro import KPipeline
126
+
127
+ threads = os.getenv("TTS_TORCH_THREADS")
128
+ if threads:
129
+ import torch
130
+ torch.set_num_threads(max(1, int(threads)))
131
+ self._pipeline = KPipeline(lang_code="a") # 'a' = American English
132
+
133
+ def segments(self, text: str, voice: str):
134
+ import numpy as np
135
+
136
+ for _, _, audio in self._pipeline(text, voice=voice):
137
+ # Kokoro yields a torch tensor (or ndarray) of float samples per segment.
138
+ if hasattr(audio, "detach"):
139
+ audio = audio.detach().cpu().numpy()
140
+ yield np.asarray(audio, dtype="float32")
141
+
142
+
143
+ def _get_backend():
144
+ """Lazily build (and cache) the configured backend."""
145
+ global _backend, _backend_error
146
+ if _backend is not None:
147
+ return _backend
148
+ with _init_lock:
149
+ if _backend is not None:
150
+ return _backend
151
+ choice = os.getenv("TTS_BACKEND", "auto").lower()
152
+ errors = []
153
+ if choice in ("auto", "onnx"):
154
+ try:
155
+ _backend = _OnnxBackend()
156
+ except Exception as e:
157
+ errors.append(f"onnx: {e}")
158
+ if choice == "onnx":
159
+ _backend_error = "; ".join(errors)
160
+ raise
161
+ if _backend is None and choice in ("auto", "torch"):
162
+ try:
163
+ _backend = _TorchBackend()
164
+ except Exception as e:
165
+ errors.append(f"torch: {e}")
166
+ if _backend is None:
167
+ _backend_error = "; ".join(errors) or f"unknown TTS_BACKEND {choice!r}"
168
+ raise RuntimeError(f"No TTS backend available ({_backend_error})")
169
+ _backend_error = None
170
+ return _backend
171
+
172
+
173
+ def is_available() -> bool:
174
+ """True if some backend can plausibly run (cheap check, no model load)."""
175
+ if _backend is not None:
176
+ return True
177
+ try:
178
+ import kokoro_onnx # noqa: F401
179
+ import misaki # noqa: F401
180
+ import soundfile # noqa: F401
181
+ model, voices = _onnx_paths()
182
+ if model.exists() and voices.exists():
183
+ return True
184
+ except Exception:
185
+ pass
186
+ try:
187
+ import kokoro # noqa: F401
188
+ import soundfile # noqa: F401
189
+ return True
190
+ except Exception:
191
+ return False
192
+
193
+
194
+ def backend_status() -> dict:
195
+ """For /tts/health: which backend is configured/loaded."""
196
+ return {
197
+ "configured": os.getenv("TTS_BACKEND", "auto"),
198
+ "loaded": _backend.name if _backend is not None else None,
199
+ "error": _backend_error,
200
+ }
201
+
202
+
203
+ def _cache_get(key: tuple) -> tuple[bytes, str] | None:
204
+ with _cache_lock:
205
+ hit = _cache.get(key)
206
+ if hit is not None:
207
+ _cache.move_to_end(key)
208
+ return hit
209
+
210
+
211
+ def _cache_put(key: tuple, value: tuple[bytes, str]) -> None:
212
+ with _cache_lock:
213
+ _cache[key] = value
214
+ _cache.move_to_end(key)
215
+ while len(_cache) > _CACHE_MAX:
216
+ _cache.popitem(last=False)
217
+
218
+
219
+ def _encode(samples, fmt: str) -> tuple[bytes, str]:
220
+ """Encode float32 samples → (bytes, mime). OGG/Opus is ~10x smaller than
221
+ WAV for speech; falls back to WAV if libsndfile lacks Opus support."""
222
+ import soundfile as sf
223
+
224
+ buf = io.BytesIO()
225
+ if fmt == "ogg":
226
+ try:
227
+ sf.write(buf, samples, SAMPLE_RATE, format="OGG", subtype="OPUS")
228
+ return buf.getvalue(), "audio/ogg"
229
+ except Exception:
230
+ buf = io.BytesIO()
231
+ sf.write(buf, samples, SAMPLE_RATE, format="WAV")
232
+ return buf.getvalue(), "audio/wav"
233
+
234
+
235
+ def synthesize(text: str, voice: str | None = None, fmt: str = "wav") -> tuple[bytes, str]:
236
+ """
237
+ Synthesize `text` → (audio bytes, mime type). Raises on any failure so the
238
+ router can return 503 and the client can fall back to browser TTS.
239
+ """
240
+ import numpy as np
241
+
242
+ voice = voice or DEFAULT_VOICE
243
+ fmt = "ogg" if str(fmt).lower() in ("ogg", "opus") else "wav"
244
+ key = (text, voice, fmt)
245
+
246
+ cached = _cache_get(key)
247
+ if cached is not None:
248
+ return cached
249
+
250
+ with _synth_sem:
251
+ cached = _cache_get(key) # may have been filled while we waited
252
+ if cached is not None:
253
+ return cached
254
+ backend = _get_backend()
255
+ chunks = list(backend.segments(text, voice))
256
+ if not chunks:
257
+ raise RuntimeError("Kokoro produced no audio")
258
+ samples = np.concatenate(chunks) if len(chunks) > 1 else chunks[0]
259
+ result = _encode(samples, fmt)
260
+
261
+ _cache_put(key, result)
262
+ return result
263
+
264
+
265
+ def warmup() -> bool:
266
+ """Build the backend and run a tiny synthesis so the first real request
267
+ doesn't pay model-load + kernel-JIT cost. Safe to call in a thread; never
268
+ raises (unavailability is already handled per-request)."""
269
+ try:
270
+ synthesize("Hi.", fmt="wav")
271
+ return True
272
+ except Exception:
273
+ return False