ibrahimgulbutt Claude Fable 5 commited on
Commit
96a2a78
·
1 Parent(s): 7aba3a7

Two-level TTS cache: raw samples + encoded per format

Browse files

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

Files changed (1) hide show
  1. services/tts_service.py +40 -11
services/tts_service.py CHANGED
@@ -49,6 +49,13 @@ _cache: "OrderedDict[tuple, tuple[bytes, str]]" = OrderedDict()
49
  _cache_lock = threading.Lock()
50
  _CACHE_MAX = int(os.getenv("TTS_CACHE_SIZE", "256"))
51
 
 
 
 
 
 
 
 
52
 
53
  def _onnx_paths() -> tuple[Path, Path]:
54
  model = Path(os.getenv("KOKORO_ONNX_MODEL", "models/kokoro-v1.0.onnx"))
@@ -218,6 +225,22 @@ def _cache_put(key: tuple, value: tuple[bytes, str]) -> None:
218
  _cache.popitem(last=False)
219
 
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  def _encode(samples, fmt: str) -> tuple[bytes, str]:
222
  """Encode float32 samples → (bytes, mime). OGG/Opus is ~10x smaller than
223
  WAV for speech; falls back to WAV if libsndfile lacks Opus support."""
@@ -244,22 +267,28 @@ def synthesize(text: str, voice: str | None = None, fmt: str = "wav") -> tuple[b
244
  voice = voice or DEFAULT_VOICE
245
  fmt = "ogg" if str(fmt).lower() in ("ogg", "opus") else "wav"
246
  key = (text, voice, fmt)
 
247
 
248
  cached = _cache_get(key)
249
  if cached is not None:
250
  return cached
251
 
252
- with _synth_sem:
253
- cached = _cache_get(key) # may have been filled while we waited
254
- if cached is not None:
255
- return cached
256
- backend = _get_backend()
257
- chunks = list(backend.segments(text, voice))
258
- if not chunks:
259
- raise RuntimeError("Kokoro produced no audio")
260
- samples = np.concatenate(chunks) if len(chunks) > 1 else chunks[0]
261
- result = _encode(samples, fmt)
262
-
 
 
 
 
 
263
  _cache_put(key, result)
264
  return result
265
 
 
49
  _cache_lock = threading.Lock()
50
  _CACHE_MAX = int(os.getenv("TTS_CACHE_SIZE", "256"))
51
 
52
+ # Second cache level: raw float32 samples per (text, voice). Synthesis is the
53
+ # expensive step (seconds); encoding is milliseconds — so a request for the
54
+ # same text in a different container (e.g. Safari asking for wav after the
55
+ # cache was warmed with ogg) re-encodes instead of re-synthesizing.
56
+ _sample_cache: "OrderedDict[tuple, object]" = OrderedDict()
57
+ _SAMPLE_CACHE_MAX = int(os.getenv("TTS_SAMPLE_CACHE_SIZE", "128"))
58
+
59
 
60
  def _onnx_paths() -> tuple[Path, Path]:
61
  model = Path(os.getenv("KOKORO_ONNX_MODEL", "models/kokoro-v1.0.onnx"))
 
225
  _cache.popitem(last=False)
226
 
227
 
228
+ def _samples_get(key: tuple):
229
+ with _cache_lock:
230
+ hit = _sample_cache.get(key)
231
+ if hit is not None:
232
+ _sample_cache.move_to_end(key)
233
+ return hit
234
+
235
+
236
+ def _samples_put(key: tuple, samples) -> None:
237
+ with _cache_lock:
238
+ _sample_cache[key] = samples
239
+ _sample_cache.move_to_end(key)
240
+ while len(_sample_cache) > _SAMPLE_CACHE_MAX:
241
+ _sample_cache.popitem(last=False)
242
+
243
+
244
  def _encode(samples, fmt: str) -> tuple[bytes, str]:
245
  """Encode float32 samples → (bytes, mime). OGG/Opus is ~10x smaller than
246
  WAV for speech; falls back to WAV if libsndfile lacks Opus support."""
 
267
  voice = voice or DEFAULT_VOICE
268
  fmt = "ogg" if str(fmt).lower() in ("ogg", "opus") else "wav"
269
  key = (text, voice, fmt)
270
+ skey = (text, voice)
271
 
272
  cached = _cache_get(key)
273
  if cached is not None:
274
  return cached
275
 
276
+ samples = _samples_get(skey)
277
+ if samples is None:
278
+ with _synth_sem:
279
+ cached = _cache_get(key) # may have been filled while we waited
280
+ if cached is not None:
281
+ return cached
282
+ samples = _samples_get(skey)
283
+ if samples is None:
284
+ backend = _get_backend()
285
+ chunks = list(backend.segments(text, voice))
286
+ if not chunks:
287
+ raise RuntimeError("Kokoro produced no audio")
288
+ samples = np.concatenate(chunks) if len(chunks) > 1 else chunks[0]
289
+ _samples_put(skey, samples)
290
+
291
+ result = _encode(samples, fmt)
292
  _cache_put(key, result)
293
  return result
294