* fix(windows): gate torch.compile on Triton availability (#129, closes #65) plan-02. torch.compile(mode="reduce-overhead") needs Triton at runtime; Triton has no Windows wheel, so the old `device=="cuda"`-only guard in model_manager.py failed on Windows+CUDA and surfaced as a confusing "OOM" (#65). Inference-time, hard to diagnose. - engine_env.should_torch_compile(device): requires CUDA + find_spec("triton") + the existing perf.torch_compile_disabled setting being off; logs the skip reason at INFO and falls back to eager. - model_manager.py call site uses it instead of the bare cuda check. - smoke-test.sh INST-02: import torch + ctranslate2 + whisperx (full ASR path) so a missing transitive dep fails the build instead of crashing mid- transcription (#116). Runs in the CI smoke-matrix on Win/macOS/Linux. setuptools>=75.0 (fix-sequence step 1) already pinned (#58). Linux/CUDA+Triton behaviour unchanged. Tests (TDD): tests/test_torch_compile_gate.py (4). Closes #65; addresses #129/#116. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(windows): also gate subprocess torch.compile on Triton (Greptile #138) Greptile flagged that the in-process gate left a parallel gap: engine subprocesses honour TORCH_COMPILE_DISABLE, but build_engine_env() only set it on the user's Performance toggle — so a Triton-absent host (Windows, or macOS) still exposed subprocess engines to the same crash this PR fixes in-process. - build_engine_env(): set TORCH_COMPILE_DISABLE=1 when the user disabled compile OR Triton is unavailable (find_spec), cross-platform — mirrors should_torch_compile(). Drops the Windows-only scoping (and the now-unused `import sys`). - Refreshed the stale module docstring. - 3 new tests cover the subprocess gate (triton-missing, triton-present, user-opt-out). 7/7 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * revert(engine_env): keep subprocess TORCH_COMPILE_DISABLE user-driven Reverts the build_engine_env() broadening from the previous commit. Auto- disabling subprocess torch.compile on Triton-absence conflicts with a deliberate, tested contract (test_perf_settings: Windows + flag-off ⇒ no injection; non-Windows ⇒ never inject) — the subprocess var is intentionally under the user's explicit control. The #65 fix is the in-process should_torch_compile() gate (unchanged here), which IS automatic and fully tested. Pushing back on the subprocess auto-gate as a separate, deliberate contract change rather than forcing it through by rewriting established tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
560 lines
22 KiB
Python
560 lines
22 KiB
Python
import os
|
|
import time
|
|
import asyncio
|
|
import logging
|
|
from typing import Optional
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
# ── 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:
|
|
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 on a 24 kHz 8-second utterance; we budget 2.5 GB
|
|
# to leave room for the ASR/diarization pipelines that run concurrently in
|
|
# the same process. Tuned empirically — bumps to 3 GB if anyone reports OOM
|
|
# at 16 GB on a multi-segment dub.
|
|
_GPU_VRAM_PER_JOB_GB = 2.5
|
|
_GPU_WORKER_CAP = 4
|
|
|
|
_gpu_pool_singleton: "ThreadPoolExecutor | None" = None
|
|
_cpu_pool = ThreadPoolExecutor(max_workers=CPU_POOL_WORKERS)
|
|
|
|
|
|
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 = max(1, min(_GPU_WORKER_CAP, int(free_gb // _GPU_VRAM_PER_JOB_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")
|
|
|
|
|
|
def _get_gpu_pool() -> ThreadPoolExecutor:
|
|
"""Internal accessor. Same singleton as the module-level `_gpu_pool`
|
|
attribute, but resolvable from inside this module (Python's module
|
|
`__getattr__` only fires for unresolved lookups from *outside*).
|
|
"""
|
|
global _gpu_pool_singleton
|
|
if _gpu_pool_singleton is None:
|
|
_gpu_pool_singleton = _build_gpu_pool()
|
|
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}")
|
|
|
|
model = None # type: ignore
|
|
_model_lock = asyncio.Lock()
|
|
_last_used = time.time()
|
|
_IDLE_TIMEOUT_SECONDS = 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
|
|
"""
|
|
torch = _lazy_torch()
|
|
|
|
# ── NVIDIA CUDA or AMD ROCm ──────────────────────────────────────
|
|
# ROCm-enabled PyTorch reports through torch.cuda, so this covers both.
|
|
if torch.cuda.is_available():
|
|
_configure_rocm_if_needed(torch)
|
|
compatible, warning = check_device_compatibility()
|
|
if not compatible:
|
|
logger.warning(warning)
|
|
return "cuda"
|
|
|
|
# ── Intel Arc / discrete GPU via IPEX ────────────────────────────
|
|
try:
|
|
import intel_extension_for_pytorch # noqa: F401
|
|
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
|
logger.info("Using Intel XPU device: %s", torch.xpu.get_device_name(0))
|
|
return "xpu"
|
|
except ImportError:
|
|
pass
|
|
|
|
# ── DirectML — universal Windows GPU (AMD, Intel, NVIDIA fallback)
|
|
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
|
|
|
|
# ── Apple Silicon MPS ────────────────────────────────────────────
|
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
return "mps"
|
|
|
|
return "cpu"
|
|
|
|
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 _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 = os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice")
|
|
_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.")
|
|
_model = OmniVoice.from_pretrained(
|
|
checkpoint, device_map=device, dtype=torch.float16, load_asr=preload_asr,
|
|
)
|
|
|
|
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)…")
|
|
_model.llm = torch.compile(_model.llm, mode="reduce-overhead")
|
|
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:
|
|
err_msg = str(exc)
|
|
_set_loading("error", "Model loading failed", error=err_msg)
|
|
logger.error("Model loading failed: %s", err_msg)
|
|
raise
|
|
finally:
|
|
unregister_listener(lid)
|
|
|
|
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:
|
|
loop = asyncio.get_running_loop()
|
|
model = await loop.run_in_executor(_get_gpu_pool(), _load_model_sync)
|
|
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.
|
|
checkpoint = os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice")
|
|
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:
|
|
loop = asyncio.get_running_loop()
|
|
model = await loop.run_in_executor(_get_gpu_pool(), _load_model_sync)
|
|
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
|
|
|
|
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 > _IDLE_TIMEOUT_SECONDS:
|
|
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 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()
|
|
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
|