diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a08c2..911308b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **Docker Socket Isolation.** Compose deployments now place the host Docker socket behind a dedicated network proxy; the web container no longer mounts the socket or joins the host Docker group. The client retains direct Unix-socket compatibility for deployments that intentionally configure it. +- **TTS Queue Bound.** Preview, style-variation, and seed-finder synthesis now share a two-slot process-local queue; + excess requests receive a retryable 429 after a bounded wait instead of creating unbounded worker threads. - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL rejection and oversized JSON request rejection. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. diff --git a/TODO.md b/TODO.md index 57a085b..4cdc397 100644 --- a/TODO.md +++ b/TODO.md @@ -121,7 +121,8 @@ the first block before investing in architecture or polish. - [ ] Prevent duplicate generation when a user clicks twice. - [ ] Centralize provider health checks and error handling. - [ ] Limit concurrency to 1–2 image generations, 1–2 voice generations, and 1 STT request per user. Consistent - portrait/reference rendering is now capped at two active jobs; extend the cap to voice and STT paths. + portrait/reference rendering and preview/style/seed-finder synthesis are capped at two active jobs; extend the + cap to full speech-proxy and STT paths. - [ ] Use bounded server queues. ## P0 — Security diff --git a/routes/tts.py b/routes/tts.py index 8c8000f..da3ae1a 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -54,6 +54,19 @@ logger = logging.getLogger("uvicorn.error") router = APIRouter() _SEED_FINDER_VOICE_LOCK = threading.Lock() +_TTS_GENERATION_SLOTS = asyncio.Semaphore(2) + + +async def _bounded_tts_call(fn, *args): + """Run one blocking synthesis through the process-local voice queue.""" + try: + await asyncio.wait_for(_TTS_GENERATION_SLOTS.acquire(), timeout=30) + except asyncio.TimeoutError: + raise HTTPException(429, "TTS generation queue is full; retry shortly") + try: + return await asyncio.to_thread(fn, *args) + finally: + _TTS_GENERATION_SLOTS.release() # ── TTS backend helpers ─────────────────────────────────────────────────────── @@ -678,11 +691,11 @@ async def tts_preview(request: Request): try: if backend == "voice_clone" and data.get("seed_finder") and "seed" in _overrides: - audio, media_type = await asyncio.to_thread( + audio, media_type = await _bounded_tts_call( _seed_finder_request_audio, text, voice, settings, int(_overrides["seed"]) ) else: - audio, media_type = await asyncio.to_thread(_preview_request_audio, text, voice, settings, instruct, backend) + audio, media_type = await _bounded_tts_call(_preview_request_audio, text, voice, settings, instruct, backend) except Exception as e: raise HTTPException(502, f"TTS error: {e}") audio, media_type, ext, duration_sec, clipped = _prepare_proxy_audio(audio, media_type, response_format) @@ -718,7 +731,7 @@ async def tts_style_variation(request: Request): settings = _load_settings() try: - audio, media_type = await asyncio.to_thread(_preview_request_audio, text, source_voice, settings, instruct, backend) + audio, media_type = await _bounded_tts_call(_preview_request_audio, text, source_voice, settings, instruct, backend) audio, media_type, _ext, duration, _clipped = _prepare_proxy_audio(audio, media_type, "wav") except Exception as e: raise HTTPException(502, f"Style variation synthesis failed: {e}")