perf: bound preview tts concurrency

This commit is contained in:
mARTin-B78 2026-09-04 07:56:30 +02:00
parent 742b43d860
commit c1ad0cdc46
3 changed files with 20 additions and 4 deletions

View File

@ -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.

View File

@ -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

View File

@ -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}")