* 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>
65 lines
2.2 KiB
Python
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()
|