Files
VoiceStudio/backend/tests/test_asr_oom_fallback.py
T
Palash DebnathandClaude Opus 4.8 208719a555 fix: dub OOM fallback, watermark on /generate, Settings responsiveness (#241 follow-up) (#244)
* fix(dub): whisperx CUDA OOM → CPU fallback instead of a bare 500

Found while exercising the dub pipeline on an 8 GB RTX 4070 Laptop GPU: with
the TTS model + GPU worker pool resident, whisperx's CTranslate2 load of
large-v3 dies with "CUDA failed with error out of memory", and
POST /dub/transcribe surfaced it as an unhandled 500 with no guidance.

WhisperXBackend now catches a CUDA OOM at load and retries on CPU (int8, same
model + accuracy, just slower) after clearing the CUDA cache. Dubbing keeps
working on small/laptop GPUs instead of dead-ending. Only triggers on a CUDA
OOM, so the MPS/CPU paths are untouched (cross-platform parity).

Verified: /dub/transcribe on the prepped job went 500 → 200 with correct
segments. Added a deterministic unit test (forces the OOM, asserts the device
switches cuda→cpu; a non-OOM RuntimeError still propagates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(watermark): embed invisible watermark on /generate output, not just dubs

embed_watermark was wired only into the dub pipeline (dub_generate.py), so
plain TTS from /generate came out unmarked even with invisible watermarking
enabled — i.e. the setting silently did nothing for the main generate path.
Embed it on the final audio in the generate handler too. embed_watermark
self-gates on the setting + AudioSeal availability and passes audio through
unchanged on failure, so it's a no-op when off and never breaks generation.

Verified: detector on a fresh /generate clip went is_watermarked:false →
true, confidence 1.0, message OMNI ("OM"), is_omnivoice:true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(settings): wrap the settings sub-nav so tabs don't clip out of view

The settings sub-nav has 10 tabs (General…Privacy) but the shared .ui-tabs
primitive is a non-wrapping inline-flex row, so on a narrow Settings pane the
later tabs (Credentials/Logs/About/Privacy) overflowed the right edge and were
unreachable. Scope flex-wrap to `.ui-tabs.settings-tabs-ui` only — the bar now
grows to 2–3 rows instead of running off-screen. The shared primitive (used by
the models role tabs, log-source tabs, etc.) is unchanged.

Verified at 900px (2 rows) and 700px (3 rows): all 10 tabs visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): connect active tab to content via accent + tighten spacing

Make the active settings tab read as connected to the panel below: each tab
carries its own semantic accent (already in TAB_DEFS — Models pink, Engines
purple, …) instead of a uniform pink, and that accent is threaded down as
--settings-accent to paint a matching hairline along the top of the content
panel. The shared colour ties tab→content subtly and wrap-proof (no fragile
positional connector). Content wrapped in .settings-content with deliberate
margin/padding so it breathes under the bar; the bar's own bottom margin is
dropped so the bridge owns that gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(engines): make the compatibility matrix responsive (scroll, don't overlap)

On a narrow Settings pane the matrix's fixed-width columns (status/gpu/
isolation/actions ≈ 630px) plus the flexible name column couldn't fit, so the
cells collapsed and OVERLAPPED — name text rendered under the AVAILABLE/ACTIVE
badges and GPU chips. Give the table a horizontal-scroll container with a
shared header/body min-width (840px) and stop the fixed cells from shrinking,
so columns keep their shape and stay legible at any width (scroll for the
overflow) — the same data-table treatment used elsewhere. Fills normally on
wide panes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): border-connect the active tab to its content panel

Refine the tab→content connection from a single accent hairline to a
"border-connect": the pill bar opens at its bottom (flat corners, no bottom
border) into a 3-sided panel (.settings-content) framed in the active tab's
accent, with a 2px full-accent top edge at the seam. The bar + panel read as
one outlined container, and the active tab's colour visibly feeds into the
panel it opens. Accent is threaded per-tab via --settings-accent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 10:19:54 +05:30

65 lines
2.2 KiB
Python

"""WhisperX CUDA-OOM → CPU fallback (api/services parity for small GPUs).
On an 8 GB laptop GPU with the TTS model resident, whisperx's CTranslate2
load of large-v3 dies with `RuntimeError: CUDA failed with error out of
memory`, which previously surfaced as a bare 500 from /dub/transcribe. The
backend now retries on CPU (slower, same model/accuracy). This test forces the
OOM deterministically (no GPU needed) and asserts the device switch.
"""
from __future__ import annotations
import os
import sys
import tempfile
import types
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
_config = types.ModuleType("core.config")
_config.DATA_DIR = tempfile.mkdtemp(prefix="omnivoice_asr_oom_")
_config.VOICES_DIR = _config.DATA_DIR
_config.OUTPUTS_DIR = _config.DATA_DIR
sys.modules["core.config"] = _config
whisperx = pytest.importorskip("whisperx")
from services.asr_backend import WhisperXBackend # noqa: E402
def test_cuda_oom_falls_back_to_cpu(monkeypatch):
calls = []
def fake_load_model(name, device, compute_type, **kw):
calls.append((device, compute_type))
if device == "cuda":
raise RuntimeError("CUDA failed with error out of memory")
return object() # CPU load succeeds
monkeypatch.setattr(whisperx, "load_model", fake_load_model)
be = WhisperXBackend()
# Force the CUDA starting point regardless of the CI host's hardware.
be._device, be._compute_type = "cuda", "float16"
be._allow_vad_pickle_globals = lambda: None # skip torch pickle allowlist
be._ensure_asr()
assert be._asr is not None # didn't raise — recovered
assert be._device == "cpu" and be._compute_type == "int8"
assert [d for d, _ in calls] == ["cuda", "cpu"] # tried CUDA, then CPU
def test_non_oom_runtime_error_still_raises(monkeypatch):
def fake_load_model(name, device, compute_type, **kw):
raise RuntimeError("some other failure") # not an OOM → must propagate
monkeypatch.setattr(whisperx, "load_model", fake_load_model)
be = WhisperXBackend()
be._device, be._compute_type = "cuda", "float16"
be._allow_vad_pickle_globals = lambda: None
with pytest.raises(RuntimeError, match="some other failure"):
be._ensure_asr()