Files
VoiceStudio/backend/services/model_manager.py
T
c5c57508b3 fix(device): fall back to CPU when the GPU arch is unsupported, not 500 every generate (#756) (#757)
* fix(settings): contain + tighten the whole Settings surface (measure cap, container-query stacking, wrap the shared rows)

Two systemic issues drove 'too spread out' + 'elements go out of view' across
many Settings pages:

1. Spread — .settings-content capped at 1280px, so on wide windows every
   label-left/control-right row left a huge void. Introduce a --settings-measure
   token (720px, macOS-like) + --settings-rail, and cap the content to it,
   left-aligned under the nav. One token now controls the reading width.

2. Overflow + bad responsiveness — the row stack break was a *viewport* media
   query (560px), but the 168px nav rail means a 760px-viewport window only has
   ~530px of content, so rows went side-by-side in a cramped box. Make
   .settings-content a container (container-type: inline-size) and stack on the
   CONTENT width via @container, keeping the viewport @media as a fallback for
   the .st-row instances used outside Settings (Splash/FirstRun/Dub/SetupWizard).

3. The shared .perfpanel__row (button/badge row reused by 6+ panels:
   RemoteBackend, HFMirror, LLMEndpoint, Pronunciation, MCPBindings, …) was an
   inline-flex with no wrap and no max-width, so it ran off the right edge —
   add flex-wrap + max-width:100% + min-width:0. Plus two rigid-width fixes that
   escaped the row cap: ApiKeys input min-width:220→0, Appearance scale floor.

Frontend builds clean; tokens, @container query, and the wrap all verified in the
emitted CSS bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(settings): center the settings block + tighten measure (kill the lopsided right void)

The capped content was left-aligned, so on a wide window everything jammed to the
left with a dead empty third on the right (screenshot). Center the whole settings
block (nav rail + content) as a unit via max-width + margin-inline:auto, and drop
the measure 720→660 so label→control rows read denser. The cap is computed from
the tokens (rail + gap + measure + page padding) so the content track lands
exactly at --settings-measure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(device): fall back to CPU when the GPU arch is unsupported, instead of 500-ing every generate (#756)

get_best_device() called check_device_compatibility() and, on an unsupported
compute capability, only LOGGED a warning then still returned 'cuda' — so the
model loaded on a GPU whose kernels can't launch and every generate 500'd with
'CUDA error: no kernel image is available for execution'. Both a too-old card
(Pascal sm_61, GTX 10-series) and a too-new one (Blackwell sm_120 on pre-cu128
wheels) hit this.

Now an unsupported arch falls back to CPU (works, just slower) with a clear
warning; OMNIVOICE_FORCE_CUDA=1 overrides. Belt-and-suspenders: _oom_friendly_reraise
classifies a raw 'no kernel image is available' as an unsupported-GPU error
(switch to CPU / install matching torch) rather than the OOM/Flush message.

Tests: get_best_device → cpu on incompatible, stays cuda on compatible, honors
the force override; reraise gives the actionable GPU message, not OOM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(device): patch detect_host_caps via string path so the #756 fallback test is full-suite robust

The first version aliased the import + inserted backend on sys.path, which patched
a module copy get_best_device's local 'from core.device_caps import detect_host_caps'
didn't resolve in the full suite (passed alone, failed in CI). Use the string-form
monkeypatch target; verified passing alongside the other device/model tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): fold #757 device-fallback entry into [0.3.8]; drop the merge's stale [Unreleased] dupe

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:59:49 +05:30

1263 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import time
import asyncio
import logging
import threading
from concurrent.futures import ThreadPoolExecutor, Executor
# ── Lazy imports ─────────────────────────────────────────────────────
# torch and OmniVoice are heavy (~2-3s import on Apple Silicon).
# Deferring them until first use cuts cold start from ~4s to ~1.5s,
# so health/status endpoints respond immediately on boot.
_torch = None
_OmniVoice = None
def _lazy_torch():
global _torch
if _torch is None:
import torch as _t
_torch = _t
return _torch
def _lazy_omnivoice():
global _OmniVoice
if _OmniVoice is None:
try:
from omnivoice.models.omnivoice import OmniVoice as _OV
except ModuleNotFoundError:
# The venv's editable install is missing/broken (#564). main.py wires
# the source fallback at startup, but resolve it here too so the
# model-load path self-heals and logs the paths it searched.
from core.omnivoice_path import ensure_omnivoice_importable
_backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ensure_omnivoice_importable(_backend_dir, logger)
from omnivoice.models.omnivoice import OmniVoice as _OV
_OmniVoice = _OV
return _OmniVoice
from core.config import IDLE_TIMEOUT_SECONDS, CPU_POOL_WORKERS
logger = logging.getLogger("omnivoice.model")
# Per-TTS-job VRAM headroom estimate. OmniVoice's forward + autoregressive
# decode peaks around 1.6 GB, but the interactive clone path co-loads WhisperX
# large-v3 ASR (~3 GB) to transcribe the reference, so a *concurrent* clone job
# is realistically ~5 GB. The old 2.5 GB budget over-committed: an 8 GB card
# (~7 GB free) got 2 workers, and two concurrent clone jobs blew past VRAM into
# a sticky CUDA "illegal memory access" that aborts the whole backend process —
# the wave of "Can't reach the local backend" crash reports on 8 GB GPUs
# (#567/#570/#571/#580/#582/#583/#584). Budgeting 5 GB serializes to 1 worker on
# ≤10 GB cards (no contention → no crash) while 16/24 GB cards still parallelize.
# Power users override with OMNIVOICE_GPU_WORKERS.
_GPU_VRAM_PER_JOB_GB = 5.0
_GPU_WORKER_CAP = 4
_gpu_pool_singleton: "_ResilientGpuPool | None" = None
_cpu_pool = ThreadPoolExecutor(max_workers=CPU_POOL_WORKERS)
def _workers_for_free_vram(free_gb: float) -> int:
"""GPU worker count for a given free-VRAM figure: free // per-job budget,
floored at 1 and capped at _GPU_WORKER_CAP. Pure so the sizing policy is
unit-tested without a GPU (the #567 crash hinged on this returning >1 on
8 GB cards)."""
return max(1, min(_GPU_WORKER_CAP, int(free_gb // _GPU_VRAM_PER_JOB_GB)))
def _pick_gpu_workers() -> int:
"""Pick a sensible GPU worker count from the runtime environment.
Resolution order:
1. OMNIVOICE_GPU_WORKERS env var (explicit user override, clamped 1..16).
2. CUDA / ROCm: free VRAM // per-job budget, capped at 4.
3. MPS / CPU / unknown: 1.
Designed to fail safe — any exception → 1 worker, never propagated.
"""
override = os.environ.get("OMNIVOICE_GPU_WORKERS")
if override:
try:
n = int(override)
return max(1, min(16, n))
except ValueError:
logger.warning("OMNIVOICE_GPU_WORKERS=%r is not an integer; ignoring", override)
try:
torch = _lazy_torch()
if hasattr(torch, "cuda") and torch.cuda.is_available():
free_bytes, _total = torch.cuda.mem_get_info()
free_gb = free_bytes / (1024 ** 3)
workers = _workers_for_free_vram(free_gb)
logger.info(
"GPU pool sized to %d worker(s) — %.1f GB free / %.1f GB per job (cap %d)",
workers, free_gb, _GPU_VRAM_PER_JOB_GB, _GPU_WORKER_CAP,
)
return workers
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
logger.info("GPU pool: MPS detected, using 1 worker (shared system memory)")
return 1
except Exception as e:
logger.warning("GPU worker probe failed (%s); defaulting to 1", e)
return 1
def _build_gpu_pool() -> ThreadPoolExecutor:
workers = _pick_gpu_workers()
return ThreadPoolExecutor(max_workers=workers, thread_name_prefix="gpu-pool")
class _ResilientGpuPool(Executor):
"""A stable, self-healing wrapper around the GPU `ThreadPoolExecutor`.
The crash this fixes (#589 #599): `_reset_gpu_pool()` shuts the pool down on
a model-load timeout, but consumers that captured the executor *object* at
import time (`from services.model_manager import _gpu_pool` at module level —
generation, dub_generate, dub_core, dub_translate, openai_compat) kept
submitting to the dead pool and got `RuntimeError: cannot schedule new
futures after shutdown` on the next generate/dub/translate.
Making `_gpu_pool` a single long-lived wrapper whose *inner* pool is swapped
means those references never go stale: every `submit()` resolves the live
pool, and a submit that races a shutdown rebuilds once and retries. Building
the inner pool stays lazy so we still size workers after torch's device
probe (the reason for the original `__getattr__` indirection).
"""
def __init__(self):
self._pool: "ThreadPoolExecutor | None" = None
self._lock = threading.Lock()
def _live_pool(self) -> ThreadPoolExecutor:
pool = self._pool
if pool is None:
with self._lock:
if self._pool is None:
self._pool = _build_gpu_pool()
pool = self._pool
return pool
def submit(self, fn, /, *args, **kwargs):
try:
return self._live_pool().submit(fn, *args, **kwargs)
except RuntimeError as e:
# "cannot schedule new futures after shutdown": the inner pool was
# reset (or torn down) under us. Rebuild once and retry so a stale
# caller self-heals instead of 500-ing. (Interpreter-shutdown races
# re-raise on the retry — we don't loop.)
if "shutdown" not in str(e).lower():
raise
with self._lock:
self._pool = _build_gpu_pool()
pool = self._pool
return pool.submit(fn, *args, **kwargs)
def reset(self) -> None:
"""Abandon the current worker pool; the next submit builds a fresh one.
Python can't kill a thread wedged in a timed-out load, but dropping the
poisoned pool means a retry gets a clean worker instead of queueing
behind the wedged one. The wrapper identity is preserved, so references
held by importers stay valid.
"""
with self._lock:
pool, self._pool = self._pool, None
if pool is not None:
try:
pool.shutdown(wait=False, cancel_futures=True)
except Exception:
pass
def shutdown(self, wait=True, *, cancel_futures=False):
with self._lock:
pool, self._pool = self._pool, None
if pool is not None:
pool.shutdown(wait=wait, cancel_futures=cancel_futures)
def _get_gpu_pool() -> "_ResilientGpuPool":
"""Internal accessor for the GPU pool singleton. Same object as the
module-level `_gpu_pool` attribute, but resolvable from inside this module
(Python's module `__getattr__` only fires for lookups from *outside*).
"""
global _gpu_pool_singleton
if _gpu_pool_singleton is None:
_gpu_pool_singleton = _ResilientGpuPool()
return _gpu_pool_singleton
def __getattr__(name: str):
"""Lazy module attribute — initialises `_gpu_pool` on first access so we
can probe the device after torch finishes its lazy import. Without this
we'd be forced to commit to max_workers=1 at module import time, before
knowing whether CUDA is even available.
"""
if name == "_gpu_pool":
return _get_gpu_pool()
raise AttributeError(f"module 'services.model_manager' has no attribute {name!r}")
# ── GPU-job timeout guard (#730 class; residual #850/#802/#755 …) ─────
# A blocking GPU job that wedges on a Windows+CUDA hang keeps occupying its
# worker forever — run_in_executor can't cancel the thread. With a 12 worker
# pool that starves *every* other request, so the next user action surfaces as
# the misleading "Can't reach the local backend" even though the process is
# alive. ASR/dub/model-load already bound+reset on hang (run_transcribe_guarded,
# _reset_pool_on_wedge, _load_model_with_timeout); the TTS **generate** paths
# (generation.py, tts_stream.py) were the last unguarded dispatch — and the
# residual on-main reports all fail on generate:start (audio). This is the same
# guard generalised so every GPU dispatch shares one recovery path.
GPU_JOB_TIMEOUT_S = float(os.environ.get("OMNIVOICE_GENERATE_TIMEOUT_S", "300.0"))
class GpuJobTimeoutError(TimeoutError):
"""A GPU-pool job exceeded its wall-clock bound and was abandoned.
The backend is alive — the job was too heavy for the available compute
(most often a VRAM-starved GPU). Pool capacity is restored automatically by
resetting the pool; the message carries the durable fix.
"""
async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
timeout: float = GPU_JOB_TIMEOUT_S,
executor=None):
"""Run blocking ``fn`` on the GPU pool with a hard wall-clock bound.
On timeout, ``reset()`` the pool (abandon the wedged worker so the next
submit gets a fresh one) and raise :class:`GpuJobTimeoutError`. ``fn`` must
be a zero-arg callable — wrap args with ``functools.partial`` at the call
site. Deliberately mirrors ``asr_backend.run_transcribe_guarded`` so every
GPU dispatch shares one bound+recover path (#730 class). Executors without
``reset`` (a plain ThreadPoolExecutor in tests) still get the bound + error.
"""
loop = asyncio.get_running_loop()
ex = executor if executor is not None else _get_gpu_pool()
fut = loop.run_in_executor(ex, fn)
try:
return await asyncio.wait_for(fut, timeout=timeout)
except asyncio.TimeoutError:
_reset = getattr(ex, "reset", None)
if callable(_reset):
try:
_reset()
logger.warning(
"%s exceeded %.0fs — abandoned the GPU-pool worker to "
"restore capacity (#730).", what, timeout,
)
except Exception:
logger.exception("GPU pool reset after %s timeout failed", what)
raise GpuJobTimeoutError(
f"{what} exceeded {timeout:.0f}s and was abandoned — the backend is "
"running, but the job was too heavy for the available compute. Most "
"often the GPU is VRAM-starved (a resident model and this job contend "
"for memory). Capacity was restored automatically; for a durable fix "
"try shorter text, a lighter engine, or set the engine to CPU in "
"Settings → Models. (Raise OMNIVOICE_GENERATE_TIMEOUT_S for very long "
"single generations.)"
)
model = None # type: ignore
_model_lock = asyncio.Lock()
_last_used = time.time()
# Idle timeout is resolved per-tick in _resolve_idle_timeout() (MM2-05) from
# prefs/env/core.config — no module-level duplicate of IDLE_TIMEOUT_SECONDS.
# ── Loading sub-stage tracker ────────────────────────────────────────
# Updated by _load_model_sync() so get_model_status() can report
# granular progress to the frontend pill.
_loading_detail: dict = {
"sub_stage": None, # importing | loading_weights | loading_asr | compiling | ready | error
"detail": "", # human-readable description
"error": None, # error message string if failed
"progress": None, # 0-100 percentage (None = indeterminate)
}
# ── ROCm GFX version overrides ───────────────────────────────────────
# AMD GPUs on ROCm report through torch.cuda but may need
# HSA_OVERRIDE_GFX_VERSION for unsupported GFX IDs.
_ROCM_GFX_OVERRIDES = {
# RDNA 3 (RX 7000 series) — override to gfx1100
"gfx1101": "11.0.0", "gfx1102": "11.0.0", "gfx1103": "11.0.0",
# RDNA 2 (RX 6000 series) — override to gfx1030
"gfx1031": "10.3.0", "gfx1032": "10.3.0", "gfx1034": "10.3.0",
# Vega (RX Vega / Radeon VII) — override to gfx900
"gfx902": "9.0.0", "gfx906": "9.0.6",
}
def _configure_rocm_if_needed(torch):
"""Auto-set HSA_OVERRIDE_GFX_VERSION for AMD GPUs on ROCm.
ROCm-enabled PyTorch reports `torch.cuda.is_available() == True` but
some consumer AMD GPUs have GFX IDs not in the official support matrix.
Setting HSA_OVERRIDE_GFX_VERSION lets them run with the closest
supported architecture.
"""
if os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
return # User already set it manually
try:
device_name = torch.cuda.get_device_name(0).lower()
# Only AMD GPUs need this — skip NVIDIA
if not any(kw in device_name for kw in ("amd", "radeon", "instinct")):
return
# Try to read the GFX version from the device properties
props = torch.cuda.get_device_properties(0)
gcn_arch = getattr(props, "gcnArchName", "") or ""
gfx_id = gcn_arch.split(":")[0].strip().lower()
if gfx_id in _ROCM_GFX_OVERRIDES:
override = _ROCM_GFX_OVERRIDES[gfx_id]
os.environ["HSA_OVERRIDE_GFX_VERSION"] = override
logger.info("ROCm: auto-set HSA_OVERRIDE_GFX_VERSION=%s for %s (%s)",
override, device_name, gfx_id)
except Exception as e:
logger.debug("ROCm GFX auto-config skipped: %s", e)
def check_device_compatibility():
"""Check if PyTorch supports the current GPU's compute capability.
Returns (compatible, warning_message). Compatible is True if OK or
no discrete GPU is present.
"""
torch = _lazy_torch()
if not torch.cuda.is_available():
return True, None
try:
major, minor = torch.cuda.get_device_capability(0)
device_name = torch.cuda.get_device_name(0)
sm_tag = f"sm_{major}{minor}"
arch_list = getattr(torch.cuda, "_get_arch_list", lambda: [])()
if arch_list:
compute_tag = f"compute_{major}{minor}"
if sm_tag not in arch_list and compute_tag not in arch_list:
return False, (
f"{device_name} (compute capability {major}.{minor} / {sm_tag}) "
f"is not supported by this PyTorch build. "
f"Supported architectures: {', '.join(arch_list)}. "
f"Try: pip install torch --index-url https://download.pytorch.org/whl/nightly/cu128"
)
except Exception:
pass
return True, None
def get_best_device():
"""Detect the best available compute device.
Priority: CUDA/ROCm > Intel XPU > DirectML > MPS > CPU
The *family* decision delegates to ``core.device_caps.detect_host_caps()``
(the single source of truth) so the probe and this loader can never
disagree. This function keeps the side-effects the probe deliberately
avoids: the ROCm ``HSA_OVERRIDE_GFX_VERSION`` env override and the
DirectML device-string return (DirectML is not a torch device family, so
the probe reports it as ``cpu`` — we still resolve the real device string
here for Windows DirectML users). The string contract is unchanged:
``"cuda"`` / ``"xpu"`` / a DirectML device string / ``"mps"`` / ``"cpu"``.
"""
from core.device_caps import detect_host_caps
torch = _lazy_torch()
family = detect_host_caps().family
# ── NVIDIA CUDA or AMD ROCm (both present through torch.cuda) ─────
if family in ("cuda", "rocm"):
_configure_rocm_if_needed(torch)
compatible, warning = check_device_compatibility()
if not compatible:
logger.warning(warning)
# #756: the GPU's compute capability isn't in this torch build's arch
# list, so CUDA kernels can't launch ("no kernel image is available
# for execution") — every generate would 500. Too-old (Pascal sm_61)
# and too-new (Blackwell sm_120 on pre-cu128 wheels) both land here.
# Fall back to CPU so the app WORKS (slowly) instead of dead-ending;
# OMNIVOICE_FORCE_CUDA=1 overrides for users who installed a matching
# torch and know the arch_list probe is wrong for their setup.
if not _env_flag("OMNIVOICE_FORCE_CUDA"):
logger.warning(
"Falling back to CPU: this GPU is unsupported by the installed "
"PyTorch build (set OMNIVOICE_FORCE_CUDA=1 to force CUDA anyway)."
)
return "cpu"
return "cuda"
# ── Intel Arc / discrete GPU via IPEX ────────────────────────────
if family == "xpu":
try:
logger.info("Using Intel XPU device: %s", torch.xpu.get_device_name(0))
except Exception:
logger.info("Using Intel XPU device")
return "xpu"
# ── Apple Silicon MPS ────────────────────────────────────────────
# Checked BEFORE DirectML to mirror the probe's family-priority order
# (cuda > rocm > xpu > mps; DirectML is not a torch family) so the loader
# and detect_host_caps() never disagree on a host that somehow exposes both.
if family == "mps":
return "mps"
# ── DirectML — universal Windows GPU (probe reports this as "cpu") ─
# Reached only when no torch family was detected (family == "cpu"), which is
# exactly the DirectML case — the probe classifies DirectML hosts as cpu.
try:
import torch_directml
if torch_directml.device_count() > 0:
logger.info("Using DirectML device (GPU %d)", 0)
return str(torch_directml.device(0))
except ImportError:
pass
return "cpu"
_COMPILE_ERR_MODULE_PREFIXES = ("torch._dynamo", "torch._inductor", "torch.fx", "triton")
_COMPILE_ERR_TB_MARKERS = ("/_dynamo/", "/_inductor/", "/triton/", "torch/fx/")
_COMPILE_ERR_MSG_MARKERS = (
"dynamo", "inductor", "triton", "cudagraph",
"symbolically trace", "torch.compile", "fx graph",
)
def _is_compile_runtime_failure(exc: BaseException) -> bool:
"""True when an exception originates in the torch.compile stack (Dynamo /
Inductor / Triton / FX / CUDA-graph trees) rather than in the model itself.
#278: on GPU architectures Triton doesn't support yet (e.g. Blackwell
sm_120), the compiled model dies mid-generation with errors like
"Detected that you are using FX to symbolically trace a dynamo-optimized
function" or an AssertionError out of torch/_inductor/cudagraph_trees.py.
Walks the exception chain and checks (a) the exception type's module,
(b) the message, (c) the traceback file paths — the cudagraph case is a
bare AssertionError, so the traceback check is load-bearing.
"""
import traceback as _tb
seen: set[int] = set()
cur: BaseException | None = exc
while cur is not None and id(cur) not in seen:
seen.add(id(cur))
mod = type(cur).__module__ or ""
if mod.startswith(_COMPILE_ERR_MODULE_PREFIXES):
return True
msg = str(cur).lower()
if any(marker in msg for marker in _COMPILE_ERR_MSG_MARKERS):
return True
try:
for frame in _tb.extract_tb(cur.__traceback__):
filename = (frame.filename or "").replace("\\", "/")
if any(marker in filename for marker in _COMPILE_ERR_TB_MARKERS):
return True
except Exception as traceback_scan_error:
logging.debug(
"Skipping traceback marker scan while classifying compile runtime failure: %s",
traceback_scan_error,
)
# Follow the chain, honoring `raise ... from None` (the eager-retry
# path suppresses the original compile error so a genuine eager
# failure isn't misclassified as a compile failure).
if cur.__cause__ is not None:
cur = cur.__cause__
elif not cur.__suppress_context__:
cur = cur.__context__
else:
cur = None
return False
def _install_compile_fallback(_model) -> None:
"""Wrap ``model.generate`` so a torch.compile failure at inference time
falls back to the eager (uncompiled) model instead of failing the
generation (#278).
All TTS paths (generate, archetype previews, dub, stream, batch) funnel
through ``model.generate``, so this is the single choke point. On a
compile-stack failure we: log a clear warning, restore the eager module
(``OptimizedModule._orig_mod``), disable compile for the rest of the
session via ``engine_env.mark_compile_runtime_failure``, reset dynamo
state, and retry the call once eagerly. Non-compile errors (real OOM,
validation, …) propagate unchanged — fully backward compatible for users
whose torch.compile works.
"""
orig_generate = _model.generate
def _generate_with_compile_fallback(*args, **kwargs):
try:
return orig_generate(*args, **kwargs)
except Exception as exc:
compiled = getattr(_model, "llm", None)
eager = getattr(compiled, "_orig_mod", None)
if eager is None or not _is_compile_runtime_failure(exc):
raise
logger.warning(
"torch.compile runtime failure during generation (%s: %s) — "
"falling back to the eager model and disabling torch.compile "
"for this session. Generation is being retried without it.",
type(exc).__name__, exc,
)
from services import engine_env
engine_env.mark_compile_runtime_failure(f"{type(exc).__name__}: {exc}")
_model.llm = eager
try:
torch = _lazy_torch()
torch._dynamo.reset()
except Exception as reset_exc:
logger.debug(
"Non-fatal: failed to reset torch._dynamo state after compile failure (%s: %s). "
"Continuing with eager fallback.",
type(reset_exc).__name__,
reset_exc,
)
try:
return orig_generate(*args, **kwargs)
except Exception as eager_exc:
# `from None` so a genuine eager failure (e.g. a real OOM)
# isn't chained to — and misclassified as — the compile error.
raise eager_exc from None
_model.generate = _generate_with_compile_fallback
# ── #315: thread affinity for cudagraph-compiled models ─────────────────────
# `torch.compile(mode="reduce-overhead")` captures CUDA graphs, and captured
# graph state is **thread-local** (torch/_inductor/cudagraph_trees keys its
# tree manager off the capturing thread). The `_gpu_pool` runs up to
# `_GPU_WORKER_CAP` threads, so render #1 captures the graph on worker A and a
# later render dispatched to worker B replays against mismatched cudagraph
# state — silently corrupting the audio (static / slowed playback, no
# exception, so the #278 eager fallback never fires). Fix: every call into a
# cudagraph-compiled model executes on ONE dedicated thread; uncompiled
# models (CPU / MPS / Windows-no-Triton / compile-disabled) keep the full pool.
_TORCH_COMPILE_MODE = "reduce-overhead"
# Compile modes that enable CUDA graphs under the hood — these need the
# single-thread affinity below. "default" / "max-autotune-no-cudagraphs"
# would not.
_CUDAGRAPH_COMPILE_MODES = frozenset({"reduce-overhead", "max-autotune"})
_compiled_inference_executor: "ThreadPoolExecutor | None" = None
_compiled_inference_thread_ident: "int | None" = None
def _get_compiled_inference_executor() -> ThreadPoolExecutor:
"""The single-thread executor that owns ALL inference on a compiled model.
Created lazily the first time a model is compiled with a cudagraph mode;
reused across model reloads (idle unload → reload keeps the same thread,
which is fine — a fresh compile simply captures its graphs there too).
The worker is spun up eagerly so its thread ident is known for the
re-entrancy guard in `_install_compile_thread_affinity`.
"""
global _compiled_inference_executor, _compiled_inference_thread_ident
if _compiled_inference_executor is None:
_compiled_inference_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="compiled-infer",
)
_compiled_inference_thread_ident = _compiled_inference_executor.submit(
threading.get_ident
).result()
return _compiled_inference_executor
def _install_compile_thread_affinity(_model) -> None:
"""Pin every ``model.generate`` call to the dedicated compile thread (#315).
Wraps ``model.generate`` (the single choke point all TTS paths funnel
through — generate, archetype previews, dub, stream, batch) so the call
body always runs on `_get_compiled_inference_executor()`'s one thread.
That makes the thread that *captures* the CUDA graph on the first render
and the thread that *replays* it on every later render the same thread,
deterministically, regardless of which `_gpu_pool` worker dispatched it.
Installed AFTER `_install_compile_fallback`, so the call-time order is:
caller thread → hop to the dedicated thread → eager-fallback wrapper →
real generate (the #278 classification/retry also runs on the dedicated
thread, with native tracebacks). The hop is a no-op when already on the
dedicated thread — a 1-worker executor submitting to itself would
deadlock, so the re-entrancy guard is load-bearing.
"""
executor = _get_compiled_inference_executor()
inner_generate = _model.generate
def _generate_on_compile_thread(*args, **kwargs):
if threading.get_ident() == _compiled_inference_thread_ident:
return inner_generate(*args, **kwargs)
return executor.submit(inner_generate, *args, **kwargs).result()
_model.generate = _generate_on_compile_thread
def _set_loading(sub_stage: str, detail: str = "", error: str | None = None, progress: float | None = None):
"""Update the loading detail dict atomically."""
_loading_detail["sub_stage"] = sub_stage
_loading_detail["detail"] = detail
_loading_detail["error"] = error
_loading_detail["progress"] = progress
def _env_flag(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def should_preload_tts_asr() -> bool:
"""Whether OmniVoice.from_pretrained should attach PyTorch Whisper.
The default is intentionally false. On Apple Silicon, eager TTS + ASR
loading can overcommit unified memory and leave desktop startup stuck
at the model-loading stage. ASR backends still load on demand.
"""
return _env_flag("OMNIVOICE_PRELOAD_TTS_ASR")
def _is_incomplete_cache_error(exc: BaseException) -> bool:
"""True when `exc` is the truncated-HF-cache class (#352 / #581).
transformers raises an OSError whose message contains "does not appear to
have a file named …" when the on-disk snapshot has config/tokenizer files
but no weight shard — the signature of an interrupted download. We match on
that phrase (stable across transformers 4.x/5.x) rather than the error type,
since the same OSError type covers unrelated I/O failures."""
return "does not appear to have a file named" in str(exc)
def _hf_offline() -> bool:
"""Respect HF's offline switches so repair never makes a network call the
user opted out of. `snapshot_download` would itself raise offline, but
checking up front lets us skip straight to the actionable message."""
return _env_flag("HF_HUB_OFFLINE") or _env_flag("TRANSFORMERS_OFFLINE")
def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
"""Re-fetch a checkpoint's missing files in place and report success.
An interrupted download leaves the cache missing only some files;
`snapshot_download` resumes/fills exactly those (already-present, correctly
sized blobs are skipped by hash, so a near-complete cache repairs in
seconds and a complete one would no-op). Returns False — leaving the caller
to surface the actionable delete-and-reinstall message — when repair is
impossible (offline) or the re-fetch itself fails (no network, gated repo,
full disk). Never raises; repair is best-effort.
``force=True`` passes ``force_download`` so the re-fetch replaces files that
are *present but corrupt* — a truncated/garbled blob that still has the right
size won't be re-fetched by the default resume (#739). It re-downloads the
whole snapshot, so it's the last resort the load path only reaches after a
plain resume-repair didn't fix the cache."""
if _hf_offline():
logger.warning(
"Model cache for %s is incomplete but HF offline mode is set — "
"cannot auto-repair.", checkpoint,
)
return False
try:
from huggingface_hub import snapshot_download
except Exception as imp_err: # pragma: no cover - huggingface_hub is a hard dep
logger.warning("Cannot import snapshot_download to repair cache: %s", imp_err)
return False
dl_kwargs: dict = {"repo_id": checkpoint}
endpoint = os.environ.get("HF_ENDPOINT")
if endpoint:
dl_kwargs["endpoint"] = endpoint
if force:
# Replace present-but-corrupt blobs that resume would trust by size.
dl_kwargs["force_download"] = True
if os.name == "nt":
# Match the install path (download.py): avoid symlinks on Windows.
dl_kwargs["local_dir_use_symlinks"] = False
def _attempt() -> None:
"""One snapshot_download, tolerating an hf_hub that rejects the optional
symlink knob. Lets real failures (network, gated repo, disk) propagate."""
try:
snapshot_download(**dl_kwargs)
except TypeError:
# Older/newer huggingface_hub may not accept local_dir_use_symlinks
# on a cache-only call — retry without the optional knob.
dl_kwargs.pop("local_dir_use_symlinks", None)
snapshot_download(**dl_kwargs)
# Bounded retries (#739): an incomplete cache *is* an interrupted download, so
# a single transient blip mid-repair shouldn't drop the user back to a manual
# delete-and-reinstall. snapshot_download resumes between attempts (present,
# correctly-sized blobs are skipped by hash), so each retry continues where
# the last left off — cheap and idempotent. Counts/backoff are env-tunable
# for restricted networks and kept fast (backoff=0) in tests.
try:
retries = max(1, int(os.environ.get("OMNIVOICE_MODEL_REPAIR_RETRIES", "3")))
except ValueError:
retries = 3
try:
backoff = max(0.0, float(os.environ.get("OMNIVOICE_MODEL_REPAIR_BACKOFF_S", "2")))
except ValueError:
backoff = 2.0
logger.info(
"Auto-repairing incomplete model cache for %s (up to %d attempt(s)) …",
checkpoint, retries,
)
for attempt in range(1, retries + 1):
try:
_attempt()
logger.info("Auto-repair of %s completed; retrying model load.", checkpoint)
return True
except Exception as e:
logger.warning(
"Auto-repair of %s attempt %d/%d failed: %s",
checkpoint, attempt, retries, e,
)
if attempt < retries and backoff:
time.sleep(backoff * attempt)
return False
_DEFAULT_OMNIVOICE_CHECKPOINT = "k2-fsa/OmniVoice"
def resolve_omnivoice_checkpoint() -> str:
"""Resolve the OmniVoice TTS checkpoint from ``OMNIVOICE_MODEL``, self-healing
a misconfigured value.
A valid checkpoint is either a HuggingFace repo id (``org/repo`` — contains a
``/``) or an existing local directory. A bare token like ``"omnivoice"`` — a
TTS *engine id* that leaked into ``OMNIVOICE_MODEL`` (e.g. a stale pref/env) —
is neither, and would crash model load with *"omnivoice is not a local folder
and is not a valid model identifier listed on huggingface.co/models"* (#693).
Fall back to the default rather than 500 on every launch.
"""
checkpoint = os.environ.get("OMNIVOICE_MODEL", _DEFAULT_OMNIVOICE_CHECKPOINT).strip()
if not checkpoint:
return _DEFAULT_OMNIVOICE_CHECKPOINT
# Honor a HF repo id (org/repo) or an EXPLICIT local path (absolute, or with
# a path separator). A bare token like "omnivoice" must NOT be treated as a
# local dir even if a cwd-relative folder happens to share its name — that
# is exactly the engine-id leak (#693), so self-heal to the default.
if "/" in checkpoint or "\\" in checkpoint or os.path.isabs(checkpoint):
return checkpoint
logger.warning(
"OMNIVOICE_MODEL=%r is not a HuggingFace repo id (org/repo) or a local "
"path — falling back to %s (#693).",
checkpoint, _DEFAULT_OMNIVOICE_CHECKPOINT,
)
return _DEFAULT_OMNIVOICE_CHECKPOINT
def _load_model_sync():
global model
from utils.hf_progress import register_listener, unregister_listener
# Register a listener that updates _loading_detail with real-time
# download/weight-loading percentages from hf_hub_download tqdm bars.
def _on_hf_progress(ev):
pct = ev.get("pct", 0.0)
filename = ev.get("filename", "")
phase = ev.get("phase", "")
if pct > 0:
pct_int = min(round(pct * 100), 99) # cap at 99 until fully done
detail = _loading_detail.get("detail", "")
# Append percentage to the existing detail label
base = detail.split(" —")[0].split(" (")[0] # strip old suffix
_loading_detail["progress"] = pct_int
_loading_detail["detail"] = f"{base}{pct_int}%"
lid = register_listener(_on_hf_progress)
try:
_set_loading("importing", "Importing PyTorch & OmniVoice runtime…")
logger.info("Importing PyTorch & OmniVoice runtime…")
torch = _lazy_torch()
OmniVoice = _lazy_omnivoice()
device = get_best_device()
checkpoint = resolve_omnivoice_checkpoint()
_set_loading("loading_weights", f"Loading TTS weights on {device}…")
logger.info("Loading OmniVoice model on device: %s", device)
preload_asr = should_preload_tts_asr()
if preload_asr:
logger.info("Preloading PyTorch Whisper with TTS model.")
else:
logger.info("Skipping PyTorch Whisper preload; ASR will load on demand.")
def _load():
return OmniVoice.from_pretrained(
checkpoint, device_map=device, dtype=torch.float16, load_asr=preload_asr,
)
try:
_model = _load()
except OSError as e:
# #352 / #581: a truncated HF cache surfaces here as "does not
# appear to have a file named pytorch_model.bin or
# model.safetensors". Instead of dead-ending the user with a
# manual delete-and-reinstall instruction, try to self-repair: an
# interrupted download leaves the cache missing only some files,
# and snapshot_download() resumes/fills exactly those (a complete
# cache never reaches this branch, so the fast path is untouched).
if not _is_incomplete_cache_error(e):
raise
_set_loading("loading_weights", "Repairing incomplete model cache…")
if not _repair_model_cache(checkpoint):
raise RuntimeError(
f"The TTS model cache for {checkpoint} is incomplete "
"(weights missing — usually an interrupted download). "
"Open Settings → Models, delete the OmniVoice TTS model, "
"and install it again."
) from e
_set_loading("loading_weights", f"Loading TTS weights on {device}…")
try:
_model = _load()
except OSError as e2:
# Resume-repair ran but the cache is still unusable. The usual
# cause beyond "repo genuinely lacks weights" is a blob that's
# present with the right size but corrupt — snapshot_download's
# resume trusts it and never re-fetches it (#739). Force a full
# re-download (replaces corrupt blobs) and retry once more before
# falling back to the manual delete-and-reinstall message.
if _is_incomplete_cache_error(e2):
_set_loading("loading_weights", "Re-downloading model files…")
if _repair_model_cache(checkpoint, force=True):
try:
_model = _load()
except OSError as e3:
raise RuntimeError(
f"The TTS model cache for {checkpoint} is incomplete "
"and could not be auto-repaired. Open Settings → "
"Models, delete the OmniVoice TTS model, and install "
"it again."
) from e3
else:
raise RuntimeError(
f"The TTS model cache for {checkpoint} is incomplete and "
"could not be auto-repaired. Open Settings → Models, "
"delete the OmniVoice TTS model, and install it again."
) from e2
else:
raise RuntimeError(
f"The TTS model cache for {checkpoint} is incomplete and "
"could not be auto-repaired. Open Settings → Models, delete "
"the OmniVoice TTS model, and install it again."
) from e2
try:
# plan-02 (#65): gate on Triton availability (+ user setting), not
# just device==cuda. Triton has no Windows wheel, so the old
# cuda-only check OOM'd on Windows+CUDA; should_torch_compile()
# falls back to eager there.
from services.engine_env import should_torch_compile
if should_torch_compile(device):
_set_loading("compiling", "Compiling model (torch.compile)…")
try:
_model.llm = torch.compile(_model.llm, mode=_TORCH_COMPILE_MODE)
except Exception as compile_exc:
# #278: compile is an optimization, never a point of
# failure — keep the eager model and remember the failure
# so later loads this session skip compile up front.
from services.engine_env import mark_compile_runtime_failure
mark_compile_runtime_failure(f"{type(compile_exc).__name__}: {compile_exc}")
logger.warning(
"torch.compile failed (%s) — continuing with the eager model.",
compile_exc,
)
else:
# Compilation is lazy: Dynamo/Inductor/Triton can still
# blow up on the first *forward* (e.g. unsupported new GPU
# archs, #278). Wrap generate so that falls back to eager
# instead of failing the generation.
_install_compile_fallback(_model)
if _TORCH_COMPILE_MODE in _CUDAGRAPH_COMPILE_MODES:
# #315: reduce-overhead uses CUDA graphs, whose
# captured state is thread-local. Pin all inference to
# one dedicated thread so a later render dispatched to
# a different _gpu_pool worker can't replay a graph it
# didn't capture (static / slowed audio from the 2nd
# render onward).
_install_compile_thread_affinity(_model)
logger.info(
"torch.compile mode %r uses CUDA graphs — compiled-model "
"inference pinned to a single dedicated thread (#315).",
_TORCH_COMPILE_MODE,
)
logger.info("torch.compile applied.")
except Exception as e:
logger.info("torch.compile skipped: %s", e)
_set_loading("ready", "Model ready", progress=100)
logger.info("OmniVoice model loaded successfully.")
return _model
except Exception as exc:
# Surface an ACTIONABLE, sanitized error in /model/status (it's shown in
# the first-run System Check). build_failure classifies the cause and
# attaches a fix hint — e.g. a corrupted transformers install
# ([Errno 2] … modeling_*.py) now says "reinstall transformers" instead
# of an unhelpful raw path + "try restarting" — and strips the home dir.
try:
from core.failure import build_failure
_f = build_failure(exc, stage="model-load", include_diagnostic=False)
err_msg = _f["reason"] + (f" — {_f['hint']}" if _f.get("hint") else "")
except Exception: # never let failure-formatting mask the real error
err_msg = str(exc)
_set_loading("error", "Model loading failed", error=err_msg)
logger.error("Model loading failed: %s", str(exc))
raise
finally:
unregister_listener(lid)
def _model_load_timeout() -> float:
"""Overall ceiling (seconds) for a single model load/download attempt.
Backstop for any hang the HF per-read socket timeouts don't catch
(a wedged torch.compile, a deadlock, etc.). Generous by default so a
legitimate cold multi-GB download on a slow link still completes;
overridable via OMNIVOICE_MODEL_LOAD_TIMEOUT for very slow networks.
"""
try:
return max(30.0, float(os.environ.get("OMNIVOICE_MODEL_LOAD_TIMEOUT", "1200")))
except (ValueError, TypeError):
return 1200.0
def _reset_gpu_pool() -> None:
"""Recover from a wedged/timed-out load by abandoning the GPU worker pool.
The resilient wrapper is kept (its identity is shared by every importer);
only its inner `ThreadPoolExecutor` is dropped, so the next submit builds a
fresh worker. This is what stops stale references from raising "cannot
schedule new futures after shutdown" after a reset (#589 #599).
"""
if _gpu_pool_singleton is not None:
_gpu_pool_singleton.reset()
async def _load_model_with_timeout():
"""Run the blocking model load on the GPU pool, bounded by a deadline.
Raises RuntimeError on timeout (and resets the poisoned pool) so callers
surface an actionable error instead of hanging indefinitely.
"""
loop = asyncio.get_running_loop()
timeout = _model_load_timeout()
try:
return await asyncio.wait_for(
loop.run_in_executor(_get_gpu_pool(), _load_model_sync),
timeout=timeout,
)
except asyncio.TimeoutError as exc:
_set_loading("error", "Model load timed out", error="timeout")
_reset_gpu_pool()
logger.error("Model load exceeded %ss; resetting GPU pool.", timeout)
raise RuntimeError(
f"Model loading timed out after {int(timeout)}s — usually a network "
"stall downloading the model (proxy, firewall, or antivirus). Check "
"your connection or set a Hugging Face mirror in Settings, then retry."
) from exc
async def get_model():
global model, _last_used
_last_used = time.time()
if model is not None:
return model
async with _model_lock:
if model is None:
model = await _load_model_with_timeout()
return model
async def preload_model():
"""Background model warm-up — call from lifespan startup.
Loads the TTS model on the GPU pool thread so the first /generate
call is near-instant instead of waiting 4-6s for weight loading.
Non-blocking: if models aren't installed yet, silently exits.
"""
global model, _last_used
if model is not None:
return # already loaded
try:
# Check if the required model checkpoint exists before attempting
# a heavy load that would fail and pollute startup logs. Use the same
# resolver as the load path (#693) so a leaked engine id in
# OMNIVOICE_MODEL can't make this model_info() probe fail and silently
# disable warm-up (then the first /generate eats the full load).
checkpoint = resolve_omnivoice_checkpoint()
try:
from huggingface_hub import model_info
model_info(checkpoint, timeout=5)
except Exception:
# Model not downloaded yet — skip preload
logger.info("Preload skipped: %s not available locally.", checkpoint)
return
logger.info("Preloading TTS model in background…")
_last_used = time.time()
async with _model_lock:
if model is None:
model = await _load_model_with_timeout()
logger.info("Preload complete — model ready.")
except Exception as e:
logger.warning("Model preload failed (non-fatal): %s", e)
def get_model_status():
is_loaded = model is not None
# asyncio.Lock exposes .locked() on all supported Python versions; wrap in try for safety.
try:
is_loading = (not is_loaded) and _model_lock.locked()
except Exception:
is_loading = False
status = "loading" if is_loading else ("ready" if is_loaded else "idle")
result = {
"loaded": is_loaded,
"loading": is_loading,
"status": status,
}
# Attach sub-stage detail when loading or after an error
sub = _loading_detail.get("sub_stage")
if sub:
result["sub_stage"] = sub
result["detail"] = _loading_detail.get("detail", "")
progress = _loading_detail.get("progress")
if progress is not None:
result["progress"] = progress
err = _loading_detail.get("error")
if err:
result["error"] = err
return result
def _resolve_idle_timeout() -> float:
"""In-process model idle timeout in seconds (MM2-05): prefs store → env →
core.config default, env winning. Resolved per-tick so a settings change
takes effect without a restart."""
try:
from core import prefs
return float(prefs.resolve(
"idle_timeout_seconds",
env="OMNIVOICE_IDLE_TIMEOUT_S",
default=IDLE_TIMEOUT_SECONDS,
))
except (TypeError, ValueError, ImportError):
return float(IDLE_TIMEOUT_SECONDS)
async def idle_worker():
global model
torch = _lazy_torch()
while True:
await asyncio.sleep(30)
async with _model_lock:
if model is not None and time.time() - _last_used > _resolve_idle_timeout():
logger.info("Idle timeout reached. Unloading OmniVoice model to free VRAM.")
model = None
free_vram()
def free_vram():
"""Release cached GPU memory on any accelerator (CUDA, MPS, XPU)."""
torch = _lazy_torch()
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
torch.mps.empty_cache()
elif hasattr(torch, "xpu") and torch.xpu.is_available():
torch.xpu.empty_cache()
def _has_dedicated_vram():
"""Check if the current device has limited dedicated VRAM that needs offloading."""
torch = _lazy_torch()
if torch.cuda.is_available():
return True
if hasattr(torch, "xpu") and torch.xpu.is_available():
return True
return False
def offload_tts_for_asr():
"""Move TTS model to CPU to free VRAM for ASR (WhisperX large-v3).
On a 7-8 GB laptop GPU the TTS model (~2.4 GB) and WhisperX large-v3
(~3 GB) plus the VAD model can't coexist. Offloading the TTS model to
CPU before transcription prevents CUDA OOM, then restore_tts_after_asr()
moves it back.
Works on CUDA (NVIDIA + ROCm) and Intel XPU.
"""
global model
torch = _lazy_torch()
if model is None:
return
if not _has_dedicated_vram():
return # MPS / CPU / DirectML don't benefit from manual offloading
try:
# Check if there's enough free VRAM to skip offloading
if torch.cuda.is_available():
free_mem = torch.cuda.mem_get_info()[0]
if free_mem > 8 * 1024 ** 3: # > 8 GB free → skip offload
return
except Exception:
pass
try:
logger.info("Offloading TTS model to CPU to free VRAM for ASR...")
model.to("cpu")
free_vram()
logger.info("TTS model offloaded. VRAM freed for ASR.")
except Exception as e:
logger.warning("TTS offload failed: %s", e)
def restore_tts_after_asr():
"""Move TTS model back to the GPU after ASR completes."""
global model
torch = _lazy_torch()
if model is None:
return
if not _has_dedicated_vram():
return
try:
device = get_best_device()
if device in ("cuda", "xpu"):
logger.info("Restoring TTS model to %s...", device)
model.to(device)
free_vram()
except Exception as e:
logger.warning("TTS restore to %s failed: %s", get_best_device(), e)
_diar_pipeline = None
# Sentinel error classes used by callers (dub_core) to decide whether to
# emit a structured SSE warning with a docs deeplink. Kept as module-level
# constants so tests can pin them — they cross the SSE wire and the
# frontend's errorDocsMap classifies on the same strings.
DIARIZATION_ERR_NO_TOKEN = "NO_TOKEN"
DIARIZATION_ERR_LICENSE = "PYANNOTE_LICENSE_REQUIRED"
DIARIZATION_ERR_LOAD = "LOAD_FAILED"
def _classify_diarization_error(exc: BaseException) -> str:
"""Map a pyannote/HF-hub exception to one of the diarization error
sentinels above.
The 401/403 path is the canonical "user hasn't accepted the model
license on huggingface.co" symptom — both `Pipeline.from_pretrained`
and `huggingface_hub` raise distinct exception classes for it
depending on the installed versions, so we sniff on both the class
name and the stringified message rather than importing the
`HfHubHTTPError` symbol directly (which is not stable across
huggingface_hub majors).
"""
name = type(exc).__name__.lower()
msg = str(exc).lower()
if (
"401" in msg
or "403" in msg
or "unauthorized" in msg
or "gated" in msg
or "accept" in msg and ("license" in msg or "terms" in msg or "user conditions" in msg)
or "hfhubhttperror" in name
or "gatedrepoerror" in name
or "repositorynotfounderror" in name and "gated" in msg
):
return DIARIZATION_ERR_LICENSE
return DIARIZATION_ERR_LOAD
def _ensure_pyannote_hf_token_compat():
"""pyannote-audio 3.x calls huggingface_hub.hf_hub_download / snapshot_download
with the ``use_auth_token`` kwarg, which huggingface_hub 1.x removed (only
``token`` remains) — raising ``hf_hub_download() got an unexpected keyword
argument 'use_auth_token'`` and breaking diarization (#167).
Wrap those functions to translate the deprecated kwarg. We patch
huggingface_hub itself BEFORE pyannote is imported, so pyannote's
``from huggingface_hub import hf_hub_download`` binds the wrapped fn; we
also patch any already-imported pyannote submodule that bound it directly.
Idempotent (guarded by an attribute marker).
"""
import functools
import sys as _sys
import huggingface_hub as _hf
def _wrap(orig):
if orig is None or getattr(orig, "_ov_uat_shim", False):
return orig
@functools.wraps(orig)
def _wrapped(*args, **kwargs):
if "use_auth_token" in kwargs:
kwargs.setdefault("token", kwargs.pop("use_auth_token"))
return orig(*args, **kwargs)
_wrapped._ov_uat_shim = True
return _wrapped
for _name in ("hf_hub_download", "snapshot_download"):
if hasattr(_hf, _name):
setattr(_hf, _name, _wrap(getattr(_hf, _name)))
for _modname, _mod in list(_sys.modules.items()):
if _modname.startswith("pyannote.") and _mod is not None:
for _name in ("hf_hub_download", "snapshot_download"):
if hasattr(_mod, _name):
setattr(_mod, _name, _wrap(getattr(_mod, _name)))
def get_diarization_pipeline(return_error: bool = False):
"""Load (or return the cached) pyannote speaker-diarization-3.1 pipeline.
Default return: the pipeline instance, or `None` if anything went
wrong (no token, license not accepted, model load crashed). Existing
callers (dub_core legacy `_transcribe`) rely on the `None` sentinel.
When `return_error=True`, returns a 2-tuple
`(pipeline | None, error_sentinel | None)` where `error_sentinel` is
one of the `DIARIZATION_ERR_*` constants. This shape is what the
streaming `_diarize` path uses to emit a structured SSE warning with
a docs deeplink — issue #78.
"""
global _diar_pipeline
if _diar_pipeline is not None:
return (_diar_pipeline, None) if return_error else _diar_pipeline
# Phase 1 AUTH-01: 3-source resolver (App → Env → HF-CLI). Per
# Pitfall #1 in 01-RESEARCH.md — exactly one place in the backend
# reads HF tokens, and that place is `token_resolver.resolve()`.
from services import token_resolver
resolved = token_resolver.resolve()
if not resolved:
return (None, DIARIZATION_ERR_NO_TOKEN) if return_error else None
hf_token = resolved.token
try:
torch = _lazy_torch()
_ensure_pyannote_hf_token_compat() # #167: use_auth_token -> token
# PyTorch 2.6 flipped torch.load's default to weights_only=True, whose
# secure unpickler rejects the pyannote checkpoint's metadata globals
# (torch_version.TorchVersion, omegaconf nodes, …) — surfacing as
# "Weights only load failed / Unsupported global" and breaking
# diarization on torch>=2.6 even after the license is accepted (#270).
# Reuse the exact allowlist the WhisperX VAD load registers so the
# secure load path succeeds; it is idempotent and per-process.
try:
from services.asr_backend import WhisperXBackend
WhisperXBackend._allow_vad_pickle_globals()
except Exception as _glob_e:
logger.debug("pyannote safe-globals allowlist skipped: %s", _glob_e)
from pyannote.audio import Pipeline
logger.info("Loading Pyannote Diarization Pipeline...")
_diar_pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", use_auth_token=hf_token)
device = get_best_device()
# Pyannote supports CUDA and CPU; route XPU/DirectML to CPU
if device in ("cuda",):
_diar_pipeline.to(torch.device(device))
logger.info("Pyannote Diarization Pipeline loaded on %s.", device)
return (_diar_pipeline, None) if return_error else _diar_pipeline
except Exception as e:
err_class = _classify_diarization_error(e)
logger.error(
"Failed to load Pyannote pipeline (class=%s): %s", err_class, e,
)
return (None, err_class) if return_error else None