Files
VoiceStudio/backend/services/model_manager.py
T
debpalashandClaude Fable 5 63fd497caf feat: TTS-only first run, platform-curated ASR, guided OS permissions, parakeet-mlx
Only the TTS model (~2.4 GB) is required on first run; ASR models are
per-platform curated picks (curated_on in models.yaml) installed on demand.
Every transcription surface returns a typed asr_model_missing error with a
one-click download CTA instead of silently pulling multi-GB Whisper weights.
Settings -> Models is a grouped, platform-aware catalog. New guided
permissions UX (wizard System Check + Settings -> Permissions + mic
pre-flight) with native mic-state checks and OS settings deep-links. New
parakeet-mlx engine brings Parakeet TDT v3 to Apple Silicon (language-gated
capture preference so multilingual dictation never regresses). Docs:
expressive-speech page, Flush/Unload + CPU-fallback triage, clone-length FAQ.
Hardening: preflight fails open for custom model pins, ROCm curation no
longer inherits NVIDIA picks, Windows mic probe reads the NonPackaged
consent key, CaptureWidget setup race fixed, offline-cache CI simulation
fixes so empty-cache runners stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:20:54 +05:30

1636 lines
74 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 sys
import time
import asyncio
import logging
import threading
from concurrent.futures import ThreadPoolExecutor, Executor
from utils.containment import contain_system_exit
# ── 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, contain_system_exit(fn, what))
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(_timeout_guidance(what, timeout))
def _timeout_guidance(what: str, timeout: float) -> str:
"""Device-aware timeout message (#896): a CPU-only host must never be told
to "set the engine to CPU" or blamed on VRAM — on CPU the job is simply
compute-bound. GPU hosts keep the VRAM-contention guidance."""
family = "cuda" # conservative default: GPU wording if the probe fails
try:
from core.device_caps import detect_host_caps
family = detect_host_caps().family
except Exception: # noqa: BLE001 — guidance must never mask the timeout
pass
common = (
f"{what} exceeded {timeout:.0f}s and was abandoned — the backend is "
"running, but the job was too heavy for the available compute. "
"Capacity was restored automatically; "
)
if family == "cpu":
return common + (
"this machine renders on CPU, where long generations are "
"compute-bound. For a durable fix try shorter text or a lighter "
"engine (OmniVoice GGUF and Supertonic-3 are CPU-tuned). If you "
"expect very long single generations, raise "
"OMNIVOICE_GENERATE_TIMEOUT_S."
)
return common + (
"most often the GPU is VRAM-starved (a resident model and this job "
"contend for memory). For a durable fix, Flush caches / Unload the "
"resident model (top toolbar or Settings → Models) before retrying, "
"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")
# ── Broken-snapshot-link self-heal ───────────────────────────────────
# A sibling of the incomplete-cache class above: the blobs are FULLY
# downloaded, but the snapshots/<rev>/ entries pointing at them are dangling
# symlinks (0 KB) or zero-byte stand-ins — blob-naming mismatches between
# download modes, interrupted renames, or antivirus interference all produce
# this state (reported on Windows, where the NTFS links show as 0 KB, but the
# heal is generic). os.path.isfile() on a dangling link is False, so
# transformers raises the same "does not appear to have a file named …"
# signature even though the bytes are on disk. The resume repair below can't
# fix it (snapshot_download may trust/short-circuit on the existing broken
# entry), so rung 0 of the recovery ladder deletes exactly the broken entries
# and restores them — see services.hf_cache_repair.
# Repos this process already attempted the link self-heal for — the retry
# after a repair may only happen ONCE per repo per process, so a cache that
# stays broken can't loop repair↔retry.
_LINK_REPAIR_ATTEMPTED: set[str] = set()
def _selfheal_broken_snapshot_links(checkpoint: str) -> bool:
"""Rung 0 of cache recovery: delete-and-restore broken snapshot entries.
Returns True only when broken entries were found, removed AND restored —
i.e. retrying the load is worth it. At most one attempt per repo per
process. Never raises; when it returns False the legacy resume/force
ladder still runs."""
if checkpoint in _LINK_REPAIR_ATTEMPTED:
return False
_LINK_REPAIR_ATTEMPTED.add(checkpoint)
if os.path.isdir(checkpoint):
return False # a local-directory checkpoint doesn't use the hub cache
try:
from services.hf_cache_repair import repair_repo_cache
summary = repair_repo_cache(checkpoint)
except Exception as repair_err: # repair must never break the ladder
logger.warning("Snapshot-link self-heal for %s errored: %s",
checkpoint, repair_err)
return False
if summary.get("removed") and summary.get("ok"):
logger.warning(
"Model cache for %s had %d broken file link(s) — repaired "
"automatically (%s), retrying the load.",
checkpoint, summary["removed"],
summary.get("outcome") or "healed",
)
return True
if summary.get("found"):
logger.warning(
"Model cache for %s has %d broken file link(s) that could not be "
"auto-repaired (%s).",
checkpoint, summary["found"], summary.get("error") or "unknown",
)
return False
def _manual_cache_delete_hint(checkpoint: str) -> str:
"""Names the exact on-disk folder to delete when every auto-repair rung
failed — "delete the model" is only actionable if the user can find it.
Empty for local-directory checkpoints (they don't live in the hub cache)."""
try:
if os.path.isdir(checkpoint):
return ""
from services.hf_cache_repair import repo_cache_dir
return (
f" If the problem persists, quit OmniVoice, delete "
f"{repo_cache_dir(checkpoint)} and restart — the model "
"re-downloads automatically."
)
except Exception:
return ""
# Why the LAST _repair_model_cache run failed ("" when it succeeded / hasn't
# run). #886: the "could not be auto-repaired" message used to drop the cause
# entirely, so a mirror outage, offline mode, or a full disk all read the same.
_last_repair_error: str = ""
def _repair_failure_detail() -> str:
"""One sanitized clause naming why auto-repair failed, or "" (#886).
Feeds user-facing messages (the generate 500 detail / model status), so it
goes through core.failure.sanitize — and because the cause text is now part
of the surfaced error, the shared HF-mirror hint (#874) fires on it when
the repair failed against an unreachable configured mirror."""
if not _last_repair_error:
return ""
try:
from core.failure import sanitize
cause = sanitize(_last_repair_error)
except Exception:
cause = _last_repair_error
return f" Auto-repair failed with: {cause}."
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."""
global _last_repair_error
_last_repair_error = ""
if _hf_offline():
logger.warning(
"Model cache for %s is incomplete but HF offline mode is set — "
"cannot auto-repair.", checkpoint,
)
_last_repair_error = (
"Hugging Face offline mode is enabled (HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE)"
)
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)
_last_repair_error = f"{type(imp_err).__name__}: {imp_err}"
return False
dl_kwargs: dict = {"repo_id": checkpoint}
# Explicit endpoint (HF_ENDPOINT / pref) wins; otherwise the automatic
# endpoint selection's cached pick applies (services.endpoint_race).
try:
from services import endpoint_race
endpoint = endpoint_race.effective_endpoint()
except Exception: # endpoint resolution must never break the repair
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,
)
_last_repair_error = f"{type(e).__name__}: {e}"
if attempt < retries:
# Endpoint failover (auto mode only, once per repo per
# process — same guard pattern as the snapshot-link rung): a
# network-classified repair failure re-races the endpoints so
# the next attempt retries on the winner instead of burning
# every retry on a dead host. Explicit user endpoints are
# never switched.
try:
from services import endpoint_race
if endpoint_race.reselect_after_failure(checkpoint, str(e)):
new_ep = endpoint_race.effective_endpoint()
if new_ep:
dl_kwargs["endpoint"] = new_ep
else:
dl_kwargs.pop("endpoint", None)
logger.info(
"Auto-repair of %s: endpoint failover — retrying on %s",
checkpoint, new_ep or "https://huggingface.co",
)
except Exception: # failover must never break the ladder
pass
if 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
if checkpoint == "test":
# Test-suite sentinel (tests/conftest.py sets OMNIVOICE_MODEL=test):
# return it verbatim. Self-healing it to the real default — "test"
# is a bare token like the #693 engine-id leak — would hand every
# app-booting test the real 2.3 GB k2-fsa/OmniVoice checkpoint,
# which is exactly the download the sentinel exists to prevent. A
# real load against "test" fails fast with a clear HF error instead.
return 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
# Rung 0: broken snapshot links — the blobs are on disk but the
# snapshot entries don't resolve (dangling symlinks / zero-byte
# stand-ins). Delete exactly the broken entries, restore, and
# retry the load ONCE (guarded per repo per process). A cache
# without broken links falls straight through to the resume
# ladder below.
_model = None
if _selfheal_broken_snapshot_links(checkpoint):
_set_loading(
"loading_weights",
"Model cache had broken file links — repaired "
"automatically, retrying…",
)
try:
_model = _load()
except OSError as e_link:
if not _is_incomplete_cache_error(e_link):
raise
logger.warning(
"Load still failing after snapshot-link repair of %s — "
"falling back to resume repair.", checkpoint,
)
e = e_link
_model = None
if _model is None:
_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)."
f"{_repair_failure_detail()} "
"Open Settings → Models, delete the OmniVoice TTS model, "
f"and install it again.{_manual_cache_delete_hint(checkpoint)}"
) 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 "
f"it again.{_manual_cache_delete_hint(checkpoint)}"
) from e3
else:
raise RuntimeError(
f"The TTS model cache for {checkpoint} is incomplete and "
f"could not be auto-repaired.{_repair_failure_detail()} "
"Open Settings → Models, delete the OmniVoice TTS model, "
f"and install it again.{_manual_cache_delete_hint(checkpoint)}"
) 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."
f"{_manual_cache_delete_hint(checkpoint)}"
) 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)
# #1000 class: transformers' lazy-import machinery wraps ANY disruption
# to an inner import (including one interrupted by process teardown)
# in a generic "Could not import module X. Are this object's
# requirements defined correctly?" — logging only str(exc) discarded
# the real cause in __cause__/__context__ and made a shutdown race
# look like a broken install. exc_info surfaces the full chain.
logger.error("Model loading failed: %s", str(exc), exc_info=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.
This is the shared load boundary for BOTH get_model() and the startup
preload_model() — the memory reclaim must live here, or a memory-tight
machine gets protected on demand loads but OS-killed during the startup
preload (review finding on the original placement in get_model()).
"""
_make_room_before_tts_load()
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:
# Crash forensics (#1164): a cold TTS model load is where memory
# exhaustion (OS OOM kill) most often lands — record that one
# started so an unclean death is attributable by the next run.
from core.run_sentinel import touch_activity
touch_activity("model_load", "omnivoice-tts")
model = await _load_model_with_timeout()
return model
def _make_room_before_tts_load() -> None:
"""Evict-then-load: free what we already own before a tight TTS load.
The audit's top gap: on a 16 GB unified-memory box a plain TTS load could
still be OS-killed — the dub path frees memory before *ASR* loads
(offload_tts_for_asr, #1119), but nothing freed memory before a *TTS*
load, and a warm dictation model (~2 GB) is routinely the difference.
Deliberately NOT admission control: refusing a load on an estimate would
brick machines that would actually cope (the #1111 decision — advisory
only). This only releases things the app already reclaims on idle anyway
(the capture-ASR model, engine instances, allocator caches), just *now*
instead of after the idle timeout — and only when free memory is actually
tight, so a roomy machine pays nothing.
"""
try:
from services.memory_budget import available_memory
free_gb = (available_memory() or {}).get("ram_available_gb")
if free_gb is None or free_gb >= _UNIFIED_OFFLOAD_HEADROOM_GB:
return
logger.info(
"Memory tight before TTS load (%.1f GB free) — releasing idle "
"models first.", free_gb,
)
try:
from services.asr_backend import release_idle_capture_backend
release_idle_capture_backend(0.0) # 0s idle = release if unleased
except Exception: # noqa: BLE001 — best-effort, never block the load
logger.debug("capture-ASR pre-load release failed", exc_info=True)
release_tts_side_caches()
free_vram()
except Exception: # noqa: BLE001 — making room must never break loading
logger.debug("pre-load memory reclaim skipped", exc_info=True)
def _checkpoint_in_local_cache(checkpoint: str) -> bool:
"""True when ``checkpoint`` is loadable with NO network: an existing local
directory, or a COMPLETE HF cache snapshot. ``snapshot_download(...,
local_files_only=True)`` never constructs an HTTP session, so a broken
proxy env (#959: ``ALL_PROXY``/``HTTPS_PROXY=socks5://`` without socksio)
can't false-negative this probe. Never raises."""
if os.path.isdir(checkpoint):
return True
try:
from huggingface_hub import snapshot_download
snapshot_download(checkpoint, local_files_only=True)
return True
except Exception:
return False
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:
# Warm-up is gated on LOCAL availability only — never a Hub API
# probe. The old `model_info(checkpoint)` probe proved the repo
# exists on huggingface.co, NOT that this machine has it installed,
# so on any networked machine with an uninstalled model (fresh
# install, empty-cache CI/test run) every app boot silently pulled
# the multi-GB checkpoint in a background thread the moment lifespan
# started — violating this function's "if models aren't installed
# yet, silently exits" contract. The cache-only check also never
# constructs an HTTP session, so the #959 class (broken
# ALL_PROXY/HTTPS_PROXY=socks5:// env raising at client
# construction) can't false-negative it, and startup stays free of
# network calls (local-first). Uses the same resolver as the load
# path (#693) so a leaked engine id can't skew the probe.
checkpoint = resolve_omnivoice_checkpoint()
if not _checkpoint_in_local_cache(checkpoint):
logger.info(
"Preload skipped: %s is not installed locally — the model "
"will load (and download if requested) on first use.",
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:
# See the matching exc_info note on the _load_model_sync handler above
# (#1000 class) — the full chain, not just str(e), is what actually
# distinguishes a real dependency problem from a shutdown-interrupted
# import.
logger.warning("Model preload failed (non-fatal): %s", e, exc_info=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)
idle_timeout = _resolve_idle_timeout()
async with _model_lock:
if model is not None and time.time() - _last_used > idle_timeout:
logger.info("Idle timeout reached. Unloading OmniVoice model to free VRAM.")
model = None
release_tts_side_caches()
free_vram()
# The capture/dictation ASR was never idle-released — so once a user
# dictated, its model stayed resident for the life of the process while
# the TTS model dutifully freed its 3.8 GB. On a 16 GB Mac that left the
# backend sitting at ~6.2 GB idle, which is what tipped it into the
# memory pressure that gets it killed mid-generate (#1076/#1092/#1093/
# #1101). Give it the same bargain the TTS model already makes. Held
# off while a live dictation stream has a lease, so nothing is unloaded
# mid-sentence.
try:
from services.asr_backend import release_idle_capture_backend
if release_idle_capture_backend(idle_timeout):
free_vram()
except Exception: # noqa: BLE001 — the reaper must never kill idle_worker
logger.warning("idle capture-ASR release failed", exc_info=True)
def release_tts_side_caches():
"""Drop caches keyed to the TTS model, for when the model itself is released.
The voice-clone prompt cache (services.tts_backend) holds encoded reference
tensors belonging to *this* model instance. If the model is unloaded but the
prompts survive, an "unload" no longer means unload (#1119) — they sit in the
very memory the unload was reclaiming (``_offload_unified_memory`` drops the
model precisely to hand that RAM to the ASR model).
Previously only ``OmniVoiceBackend.unload()`` cleared them, which sufficed
while the cache was adapter-only. The native ``/generate`` path now populates
it too, and that path unloads through *here*, never through the adapter.
Reached through ``sys.modules`` rather than an import, deliberately:
``tts_backend`` already imports this module, so importing it back would close
a real cycle — and doing it at *import* time (e.g. a registration hook) drags
``core.config`` in earlier than it is today, which perturbs DATA_DIR binding.
A plain lookup has neither problem, and is exactly right besides: if the module
was never imported, it has no cache to clear.
Best-effort by construction — cache hygiene must never be able to break an
unload, because a failed unload is how the backend gets OOM-killed.
"""
mod = sys.modules.get("services.tts_backend")
if mod is None:
return
try:
mod.clear_clone_prompt_cache()
except Exception: # noqa: BLE001
logger.debug("clone-prompt cache clear failed during unload", exc_info=True)
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
# Free RAM below which the TTS model is released before ASR loads on a
# unified-memory machine. WhisperX large-v3 needs ~3 GB plus VAD and overhead,
# so a box with less than this much headroom cannot hold both — and on a Mac the
# loser is the whole backend process (the OS kills it). Tunable for bigger boxes.
_UNIFIED_OFFLOAD_HEADROOM_GB = float(
os.environ.get("OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB", "6.0")
)
def _offload_unified_memory() -> bool:
"""Release the TTS model on a unified-memory host when RAM is tight.
Returns True when the model was actually released. Never raises — a failure
to make room must not abort the transcription that asked for it."""
global model
try:
from services.memory_budget import available_memory
free_gb = available_memory().get("ram_available_gb")
if free_gb is not None and free_gb > _UNIFIED_OFFLOAD_HEADROOM_GB:
return False # plenty of room — keep the model warm, pay no reload
logger.info(
"Unified memory tight (%s GB free) — releasing the TTS model so ASR has room "
"(it reloads on the next generation).",
"unknown" if free_gb is None else f"{free_gb:.1f}",
)
model = None
release_tts_side_caches()
free_vram()
return True
except Exception as e: # noqa: BLE001
logger.warning("unified-memory TTS offload failed (continuing): %s", e)
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():
# UNIFIED MEMORY (Apple Silicon / CPU). Moving the model "to CPU" frees
# nothing here — it is the same physical RAM — which is why this used to
# bail out entirely. But the conclusion was wrong: the fix on unified
# memory isn't to MOVE the model, it's to RELEASE it.
#
# Holding the ~3.8 GB TTS model resident while WhisperX large-v3 (~3 GB)
# loads on top of it is what gets the backend OOM-killed mid-dub on a
# 16 GB Mac (#1119) — the transcribe stream just dies. Unload it and the
# room is real. get_model() lazily reloads on the next TTS use, so the
# only cost is that reload, and only when memory was actually tight.
_offload_unified_memory()
return
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():
# Nothing to restore on unified memory: offload UNLOADED the model, and
# get_model() reloads it lazily on the next TTS call. Reloading it here
# would just re-occupy the RAM we freed, right when the dub still has
# translation and synthesis ahead of it.
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.exception(
"Failed to load Pyannote pipeline (class=%s)", err_class,
)
return (None, err_class) if return_error else None