From 6c75acbf7240461792117ff80d6a26f9c1880f6b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 07:57:04 +0200 Subject: [PATCH] perf: bound stt concurrency --- CHANGELOG.md | 2 ++ TODO.md | 2 +- routes/stt.py | 19 ++++++++++++++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 911308b..1c18f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi 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. +- **STT Queue Bound.** Transcription and STT benchmark calls now share a single bounded process-local slot, preventing + concurrent recognition requests from overcommitting the inference device. - **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 4cdc397..da06367 100644 --- a/TODO.md +++ b/TODO.md @@ -122,7 +122,7 @@ the first block before investing in architecture or polish. - [ ] 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 and preview/style/seed-finder synthesis are capped at two active jobs; extend the - cap to full speech-proxy and STT paths. + cap to full speech-proxy paths; STT transcription and benchmarks are capped at one active job. - [ ] Use bounded server queues. ## P0 — Security diff --git a/routes/stt.py b/routes/stt.py index 3858325..e754793 100644 --- a/routes/stt.py +++ b/routes/stt.py @@ -24,6 +24,19 @@ from core.audio import _to_wav_16k from core.voice import _AUDIO_EXTS, _UPLOAD_EXTS router = APIRouter() +_STT_GENERATION_SLOT = asyncio.Semaphore(1) + + +async def _bounded_stt_call(fn, *args): + """Serialize heavyweight STT work so one process cannot overcommit a GPU.""" + try: + await asyncio.wait_for(_STT_GENERATION_SLOT.acquire(), timeout=30) + except asyncio.TimeoutError: + raise HTTPException(429, "STT generation queue is busy; retry shortly") + try: + return await asyncio.to_thread(fn, *args) + finally: + _STT_GENERATION_SLOT.release() # ── STT backend definitions ─────────────────────────────────────────────────── @@ -481,7 +494,7 @@ async def transcribe(request: Request): raw_backend = data.get("backend") or data.get("stt_backend") or settings.get("stt_preferred_backend") or "configured" backend = _clean_stt_backend(str(raw_backend)) try: - text, used_backend = await asyncio.to_thread(_transcribe_audio, src, settings, backend) + text, used_backend = await _bounded_stt_call(_transcribe_audio, src, settings, backend) return {"text": text, "backend": used_backend} except Exception as e: raise HTTPException(502, f"STT error ({backend}): {e}") @@ -505,7 +518,7 @@ async def transcribe_bytes( wav_tmp = _to_wav_16k(tmp) settings = _load_settings() stt_backend = _clean_stt_backend(backend) - text, used_backend = await asyncio.to_thread(_transcribe_audio, wav_tmp, settings, stt_backend) + text, used_backend = await _bounded_stt_call(_transcribe_audio, wav_tmp, settings, stt_backend) return {"text": text, "backend": used_backend} except HTTPException: raise @@ -571,7 +584,7 @@ async def stt_benchmark( for engine in engines: if not isinstance(engine, dict): continue - results.append(await asyncio.to_thread(_benchmark_stt_engine, wav_tmp, engine, settings, reference)) + results.append(await _bounded_stt_call(_benchmark_stt_engine, wav_tmp, engine, settings, reference)) ok_rows = [r for r in results if r.get("ok")] best_time = min((r.get("time_sec") for r in ok_rows if isinstance(r.get("time_sec"), (int, float))), default=None) best_accuracy = max((r.get("accuracy") for r in ok_rows if isinstance(r.get("accuracy"), (int, float))), default=None)