A whisperx/CTranslate2 transcribe can hang hard on some Windows+CUDA setups and never return. ASR shares the small (1-2 worker) _gpu_pool with TTS, so one stuck worker starved every other request — the next TTS generate then surfaced as "Can't reach the local backend" though the process was alive (#720/#721/#723). Two parts: - Bound the three remaining unguarded whole-file transcribe paths (dub whole-file dub_core.py, batch.py, live-dictation capture_ws.py) with run_transcribe_guarded, matching the dub-QC/dictation/OpenAI paths that were already bounded by #656. - On timeout, run_transcribe_guarded now calls executor.reset() when the pool supports it (_ResilientGpuPool, already built for the model-load-timeout case in #589/#599): the wedged worker is abandoned and the next submit gets a fresh one, restoring capacity without an app restart. Best-effort — a plain ThreadPoolExecutor (tests) just gets the bound + actionable error. Regression tests: pool.reset() is invoked on timeout; a non-reset pool still bounds cleanly. Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1778 lines
75 KiB
Python
1778 lines
75 KiB
Python
"""
|
||
ASR adapter interface — Phase 3.3 (ROADMAP.md).
|
||
|
||
One protocol, multiple engines. Today we ship:
|
||
|
||
• FasterWhisperBackend — CTranslate2-based (the engine WhisperX uses).
|
||
Default on Linux, Windows, mac-Intel. Also fast
|
||
on mac-ARM so we use it as the cross-platform
|
||
baseline and only prefer MLX on mac-ARM when
|
||
explicitly installed.
|
||
• MLXWhisperBackend — mlx-whisper on Apple Silicon. Optional speedup,
|
||
only available when mlx wheels install (mac-ARM).
|
||
• PyTorchWhisperBackend — last-resort fallback using the existing
|
||
`_asr_pipe` on the TTS model.
|
||
|
||
Both return the raw Whisper output dict so `services.segmentation.
|
||
segment_transcript(...)` can keep working unchanged — new backends normalise
|
||
their output to the `{"chunks": [{"text", "timestamp": (start, end)}]}`
|
||
shape the segmenter expects.
|
||
|
||
Selection via `OMNIVOICE_ASR_BACKEND` (default: auto-detect, prefers
|
||
faster-whisper because it's available on every platform we ship to).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import re
|
||
from abc import ABC, abstractmethod
|
||
|
||
logger = logging.getLogger("omnivoice.asr")
|
||
|
||
# A single ASR transcribe must never block a request indefinitely. The chunked
|
||
# dub pipeline already bounds each chunk (OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S);
|
||
# the *whole-file* paths (dub QC re-transcribe, dictation, OpenAI-compat) ran
|
||
# unbounded, so a slow/stuck transcribe — e.g. large-v3 on a VRAM-starved GPU
|
||
# where the resident TTS model contends for memory — hung the request *and* tied
|
||
# up a GPU-pool worker, surfacing in the UI as the misleading "can't reach the
|
||
# local backend" (TamKieu / Vietnam report). Bound them so a hang becomes a fast,
|
||
# actionable error instead. Generous default (whole-file large-v3 on CPU is slow
|
||
# but valid); override with the env var for very long single files.
|
||
ASR_TRANSCRIBE_TIMEOUT_S = float(os.environ.get("OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S", "300.0"))
|
||
|
||
|
||
class ASRTimeoutError(TimeoutError):
|
||
"""Raised when a whole-file transcribe exceeds ASR_TRANSCRIBE_TIMEOUT_S.
|
||
|
||
Carries a user-actionable message: the backend is alive (this is not a
|
||
connection failure) — the ASR model is too heavy for the available compute.
|
||
"""
|
||
|
||
|
||
async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
|
||
timeout: float = ASR_TRANSCRIBE_TIMEOUT_S):
|
||
"""Run a blocking transcribe ``fn`` in ``executor`` with a hard wall-clock
|
||
bound. On timeout, raise :class:`ASRTimeoutError` with guidance instead of
|
||
letting the request hang forever.
|
||
|
||
``run_in_executor`` cannot cancel the underlying thread, so a wedged
|
||
transcribe (a CTranslate2 / whisperx / VAD hang seen on some Windows + CUDA
|
||
setups, #730) keeps occupying its GPU-pool worker. With a 1–2 worker pool
|
||
that starves every *other* request — including TTS generate — and the next
|
||
thing the user does surfaces as "Can't reach the local backend" even though
|
||
the process is alive. So on timeout we also ``reset()`` the pool when it
|
||
supports it (``_ResilientGpuPool``): the wedged thread is abandoned and the
|
||
next submit gets a fresh worker, restoring capacity without an app restart.
|
||
The orphaned thread still holds its VRAM until the process exits, which is
|
||
why the message still recommends a smaller ASR model / Flush as the durable
|
||
fix. Executors without ``reset`` (a plain ThreadPoolExecutor in tests) just
|
||
get the bound + actionable error.
|
||
"""
|
||
loop = asyncio.get_running_loop()
|
||
fut = loop.run_in_executor(executor, fn)
|
||
try:
|
||
return await asyncio.wait_for(fut, timeout=timeout)
|
||
except asyncio.TimeoutError:
|
||
# Free the poisoned pool so a hung transcribe can't keep starving TTS /
|
||
# other ASR work (the "can't reach backend" symptom, #730).
|
||
_reset = getattr(executor, "reset", None)
|
||
if callable(_reset):
|
||
try:
|
||
_reset()
|
||
logger.warning(
|
||
"%s transcription exceeded %.0fs — abandoned the GPU-pool "
|
||
"worker to restore capacity (#730).", what, timeout,
|
||
)
|
||
except Exception:
|
||
logger.exception("GPU pool reset after ASR timeout failed")
|
||
raise ASRTimeoutError(
|
||
f"{what} transcription exceeded {timeout:.0f}s and was abandoned — "
|
||
"the backend is running, but the ASR model is too heavy for the "
|
||
"available compute. Most often the GPU is VRAM-starved: the resident "
|
||
"TTS model and a large ASR model (large-v3) contend for memory. "
|
||
"Capacity was restored automatically, but for a durable fix Flush the "
|
||
"TTS model to free VRAM, pick a smaller ASR model in Settings → "
|
||
"Models, or set ASR to CPU. (Raise OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S "
|
||
"for very long single files.)"
|
||
)
|
||
|
||
|
||
def _compute_type_candidates(device: str) -> list[str]:
|
||
"""Per-device compute_type fallback chain. int8 is supported by every
|
||
CTranslate2 CUDA+CPU build; float16/int8_float16 only on GPUs with efficient
|
||
fp16 — so degrade rather than crash (#551). Honors an ASR_COMPUTE_TYPE env
|
||
override (power users on exotic hardware can pin int8/float32)."""
|
||
import os
|
||
override = os.environ.get("ASR_COMPUTE_TYPE")
|
||
if override:
|
||
return [override]
|
||
return ["float16", "int8_float16", "int8"] if device == "cuda" else ["int8", "float32"]
|
||
|
||
|
||
def _is_compute_type_error(msg: str) -> bool:
|
||
low = msg.lower()
|
||
return "compute type" in low or "efficient float16" in low
|
||
|
||
|
||
def _decode_audio_16k_mono(audio_path: str):
|
||
"""Decode `audio_path` to a 16 kHz mono float32 waveform using OmniVoice's
|
||
*validated* ffmpeg, instead of whisperx.load_audio's bare ``"ffmpeg"`` PATH
|
||
lookup.
|
||
|
||
whisperx (and openai-whisper) shell out to a literal ``"ffmpeg"`` resolved
|
||
against the OS PATH. On Windows that resolves to whatever the system finds
|
||
first — a WindowsApps alias stub or a corrupt/wrong-arch download — which
|
||
passes `which` but explodes at spawn with ``[WinError 193] %1 is not a valid
|
||
Win32 application``. whisperx only catches `CalledProcessError`, so the
|
||
spawn-time `OSError` escapes and the dub/batch path reports the opaque
|
||
"Transcription produced no segments" (#479). ``find_ffmpeg()`` probes each
|
||
candidate with ``-version`` and returns a runnable binary (the bundled
|
||
imageio-ffmpeg / Tauri sidecar) — or None, so we can raise an actionable
|
||
error. This also fixes the imageio case a PATH-prepend can't: its binary is
|
||
named ``ffmpeg-<plat>-vN.exe``, not ``ffmpeg``, so bare lookup never finds
|
||
it. Mirrors whisperx.audio.load_audio's command exactly (16 kHz, mono, s16le).
|
||
"""
|
||
import subprocess
|
||
|
||
import numpy as np
|
||
|
||
from services.ffmpeg_utils import find_ffmpeg
|
||
|
||
ffmpeg = find_ffmpeg()
|
||
if not ffmpeg:
|
||
raise RuntimeError(
|
||
"Cannot transcribe: ffmpeg is missing or not runnable. Install "
|
||
"ffmpeg (or let OmniVoice's bundled binary download), then retry. "
|
||
"On Windows a '[WinError 193]' here means the ffmpeg binary is "
|
||
"corrupt or the wrong architecture — reinstall it or clear the "
|
||
"imageio-ffmpeg cache."
|
||
)
|
||
cmd = [
|
||
ffmpeg, "-nostdin", "-threads", "0", "-i", audio_path,
|
||
"-f", "s16le", "-ac", "1", "-acodec", "pcm_s16le", "-ar", "16000", "-",
|
||
]
|
||
try:
|
||
out = subprocess.run(cmd, capture_output=True, check=True).stdout
|
||
except OSError as e:
|
||
# Belt-and-suspenders: find_ffmpeg() already -version-validated this
|
||
# binary, so a WinError 193 here is unexpected — surface it clearly
|
||
# rather than letting it become "no segments".
|
||
raise RuntimeError(
|
||
f"ffmpeg at {ffmpeg!r} could not be executed ({e}). Reinstall "
|
||
"ffmpeg or clear the imageio-ffmpeg cache."
|
||
) from e
|
||
except subprocess.CalledProcessError as e:
|
||
stderr = (e.stderr or b"").decode(errors="replace")[:500]
|
||
raise RuntimeError(f"Failed to decode audio for transcription: {stderr}") from e
|
||
return np.frombuffer(out, np.int16).flatten().astype(np.float32) / 32768.0
|
||
|
||
|
||
# ── Protocol ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class ASRBackend(ABC):
|
||
id: str = "base"
|
||
display_name: str = "Base ASR"
|
||
# Accelerator families this backend can use, in preference order; always
|
||
# includes a fallback. Subset of {cuda, rocm, mps, xpu, cpu}. Mirrors the
|
||
# TTSBackend.gpu_compat contract so engine_routing.resolve_routing() can
|
||
# surface the effective device per host (no silent CPU fallback). The
|
||
# conservative default is CPU-only; subclasses declare what they really run
|
||
# on. (ROCm is intentionally NOT claimed yet for any ASR engine — see the
|
||
# per-engine notes; an unverified `rocm` claim would route ROCm hosts to a
|
||
# broken GPU path, strictly worse than the honest `cpu_fallback`.)
|
||
gpu_compat: tuple[str, ...] = ("cpu",)
|
||
|
||
@classmethod
|
||
@abstractmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
...
|
||
|
||
@abstractmethod
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
"""Return the raw Whisper output dict. Callers (`segment_transcript`)
|
||
know how to read it — this stays deliberately untyped so new engines
|
||
that already speak the shape plug in with zero adapter work.
|
||
"""
|
||
|
||
def ensure_loaded(self) -> None:
|
||
"""Eagerly load the model weights, raising the real cause on failure.
|
||
|
||
Backends load lazily inside ``transcribe()`` by default, so a load
|
||
failure (missing weights, CUDA/cuDNN mismatch, torch-2.6 weights-only
|
||
VAD regression, import error) first surfaces buried in per-chunk
|
||
errors — and is retried on *every* chunk. The transcribe preflight
|
||
calls this so the genuine cause is surfaced once, up front, as a clean
|
||
terminal error event instead of N cryptic per-chunk failures (#578).
|
||
|
||
Default is a no-op; backends that hold a heavy model override it to
|
||
trigger their lazy loader. It MUST raise the underlying exception (not
|
||
swallow it) so the caller can classify and surface it.
|
||
"""
|
||
pass
|
||
|
||
def unload(self) -> None:
|
||
"""Release the model from memory."""
|
||
pass
|
||
|
||
|
||
# ── WhisperX (cross-platform default — forced-alignment word timing) ────────
|
||
|
||
|
||
def _harden_speechbrain_lazy_imports() -> None:
|
||
"""Make speechbrain 1.x's lazy-import guard fire on Windows too (#630/#611/#647).
|
||
|
||
speechbrain 1.x exposes optional integrations (``k2_fsa``, ``numba`` losses,
|
||
``spacy``/``flair`` nlp) as ``LazyModule`` redirects living in ``sys.modules``.
|
||
Stray introspection — PyTorch's op-registration machinery, pickling, a
|
||
``dir()``/``hasattr`` walk — touches one of these during ``whisperx.load_model``
|
||
(pyannote → speechbrain), which would *actually* import the optional package.
|
||
speechbrain guards against that by suppressing the import when the triggering
|
||
frame is the stdlib ``inspect`` module — but the check is
|
||
``filename.endswith("/inspect.py")``, a hardcoded POSIX separator. On Windows
|
||
the frame filename uses backslashes (``...\\Lib\\inspect.py``), so the guard
|
||
misses, the redirect imports ``speechbrain.integrations.k2_fsa`` → ``import k2``
|
||
→ k2 isn't installed → ``ImportError: Lazy import of LazyModule(...k2_fsa...)
|
||
failed``. That bubbles out of WhisperX and aborts transcription with zero
|
||
segments. WhisperX is the *default* ASR, so this is a Windows-only break of a
|
||
cross-platform-default feature (P0 parity).
|
||
|
||
Fix the whole class — every optional-integration redirect, not just k2 — by
|
||
re-implementing ``LazyModule.ensure_module`` with an ``os.sep``-agnostic
|
||
basename check. Idempotent and a no-op on macOS/Linux (basename match is a
|
||
strict superset of the old forward-slash check) and when speechbrain is
|
||
absent. A genuine access from real user code with k2 missing still raises
|
||
ImportError unchanged — only inspect-triggered spurious imports are
|
||
suppressed, on every platform.
|
||
"""
|
||
try:
|
||
from speechbrain.utils import importutils as _iu
|
||
except Exception: # speechbrain not installed / import side-effect — nothing to harden
|
||
return
|
||
if getattr(_iu.LazyModule, "_omnivoice_xplat_guard", False):
|
||
return
|
||
import importlib as _importlib
|
||
import inspect as _inspect
|
||
import sys as _sys
|
||
import warnings as _warnings
|
||
|
||
def ensure_module(self, stacklevel):
|
||
importer_frame = None
|
||
try:
|
||
importer_frame = _inspect.getframeinfo(_sys._getframe(stacklevel + 1))
|
||
except AttributeError:
|
||
_warnings.warn(
|
||
"Failed to inspect frame to check if we should ignore importing a "
|
||
"module lazily (OmniVoice cross-platform guard)."
|
||
)
|
||
if importer_frame is not None:
|
||
# Normalise BOTH separators explicitly (not os.path.basename, which is
|
||
# host-dependent) so the guard is correct regardless of which os.path
|
||
# flavour is active. Upstream's `.endswith("/inspect.py")` matched only
|
||
# POSIX paths — that is the Windows-only bug (#630/#611/#647).
|
||
base = importer_frame.filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||
if base == "inspect.py":
|
||
raise AttributeError()
|
||
if self.lazy_module is None:
|
||
try:
|
||
if self.package is None:
|
||
self.lazy_module = _importlib.import_module(self.target)
|
||
else:
|
||
self.lazy_module = _importlib.import_module(f".{self.target}", self.package)
|
||
except Exception as e: # noqa: BLE001 — match upstream: wrap as ImportError
|
||
raise ImportError(f"Lazy import of {repr(self)} failed") from e
|
||
return self.lazy_module
|
||
|
||
_iu.LazyModule.ensure_module = ensure_module
|
||
_iu.LazyModule._omnivoice_xplat_guard = True
|
||
logger.debug("speechbrain LazyModule guard hardened for cross-platform inspect.py check")
|
||
|
||
|
||
class WhisperXBackend(ASRBackend):
|
||
id = "whisperx"
|
||
display_name = "WhisperX (faster-whisper + wav2vec2 forced alignment)"
|
||
# CTranslate2 backend: CUDA fp16 or CPU int8 (see _pick_device). ROCm not
|
||
# claimed — CTranslate2 has no upstream HIP build, so a ROCm host honestly
|
||
# gets cpu_fallback rather than a false GPU promise.
|
||
gpu_compat = ("cuda", "cpu")
|
||
|
||
def __init__(self):
|
||
self._model_name = os.environ.get("ASR_MODEL_WHISPERX", "large-v3")
|
||
self._asr = None
|
||
self._align_cache = {} # language_code → (align_model, metadata)
|
||
self._device, self._compute_type = self._pick_device()
|
||
|
||
@staticmethod
|
||
def _pick_device() -> tuple[str, str]:
|
||
# CUDA fp16 when available; otherwise CPU int8 (fastest CPU path,
|
||
# negligible WER regression vs fp32 for whisper-large-v3).
|
||
try:
|
||
import torch
|
||
if torch.cuda.is_available():
|
||
return "cuda", "float16"
|
||
except Exception:
|
||
pass
|
||
return "cpu", "int8"
|
||
|
||
@classmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
try:
|
||
import whisperx # noqa: F401
|
||
return True, "ready"
|
||
except ImportError as e:
|
||
return False, f"whisperx not installed: {e}"
|
||
except Exception as e: # noqa: BLE001
|
||
# The import can fail while loading a native dep — CTranslate2's .so
|
||
# is rejected by hardened kernels / newer glibc with "cannot enable
|
||
# executable stack" (#692), an OSError, not an ImportError. An
|
||
# availability probe must REPORT 'unusable here', never raise, so
|
||
# engine selection falls back instead of crashing the ASR preflight.
|
||
return False, f"whisperx failed to load ({type(e).__name__}): {e}"
|
||
|
||
def ensure_loaded(self) -> None:
|
||
# Surface a whisperx/CTranslate2/torch load failure at preflight (once,
|
||
# with the real cause) instead of buried per-chunk and retried N times
|
||
# (#578). Re-raises whatever `_ensure_asr` raises after its fp16→int8
|
||
# and OOM→CPU fallbacks are exhausted.
|
||
self._ensure_asr()
|
||
|
||
def _ensure_asr(self):
|
||
if self._asr is not None:
|
||
return
|
||
# Patch speechbrain's lazy-import guard BEFORE whisperx pulls in pyannote
|
||
# → speechbrain, or a stray k2_fsa redirect import aborts ASR on Windows
|
||
# (#630/#611/#647). No-op on macOS/Linux and when speechbrain is absent.
|
||
_harden_speechbrain_lazy_imports()
|
||
import whisperx
|
||
logger.info(
|
||
"whisperx loading ASR %s on %s (%s)",
|
||
self._model_name, self._device, self._compute_type,
|
||
)
|
||
# PyTorch 2.6 flipped `torch.load(weights_only=True)` to default,
|
||
# which breaks pyannote 3.x's VAD checkpoint (that whisperx ships):
|
||
# each load surfaces a different missing global — `omegaconf.*`,
|
||
# `typing.Any`, etc. The fix is to allowlist the pickle globals the
|
||
# VAD file contains via `torch.serialization.add_safe_globals` so
|
||
# the secure `weights_only=True` load path succeeds *without* us
|
||
# disabling it.
|
||
#
|
||
# An earlier defensive layer (monkey-patching `torch.load` to force
|
||
# `weights_only=False` for the duration of `whisperx.load_model`)
|
||
# was removed in P0 Wave 1: it defeated PyTorch's secure unpickler
|
||
# globally for any code that ran during that window, which is the
|
||
# opposite of what the surrounding comment claimed. If a downstream
|
||
# callee deserialised an attacker-controlled pickle in that window
|
||
# it would have executed arbitrary code with no warning. The
|
||
# allowlist below is the only correct mitigation; if pyannote ever
|
||
# ships a checkpoint with a new pickle class, the load fails loudly
|
||
# and we extend `_allow_vad_pickle_globals()`.
|
||
self._allow_vad_pickle_globals()
|
||
try:
|
||
self._asr = whisperx.load_model(
|
||
self._model_name,
|
||
device=self._device,
|
||
compute_type=self._compute_type,
|
||
# vad_method="silero" is the default; keep it so short gaps
|
||
# get cleaned up before transcription.
|
||
)
|
||
except (ValueError, RuntimeError) as e:
|
||
# #551: GPUs without efficient fp16 (older Maxwell/Pascal, GTX 16xx)
|
||
# or a CTranslate2/cuDNN binary mismatch raise a *ValueError*
|
||
# ("Requested float16 compute type, but the target device or backend
|
||
# do not support efficient float16 computation") at load — not an
|
||
# OOM, not a RuntimeError. Retry on the SAME device with the next
|
||
# compute_type candidate (cuda: int8_float16 → int8) before touching
|
||
# the OOM→CPU path, so we degrade rather than crash every chunk.
|
||
if _is_compute_type_error(str(e)):
|
||
candidates = _compute_type_candidates(self._device)
|
||
try:
|
||
nxt = candidates[candidates.index(self._compute_type) + 1:]
|
||
except ValueError:
|
||
nxt = [c for c in candidates if c != self._compute_type]
|
||
for ct in nxt:
|
||
logger.warning(
|
||
"whisperx %s unsupported on %s — retrying with %s. Detail: %s",
|
||
self._compute_type, self._device, ct, e,
|
||
)
|
||
self._compute_type = ct
|
||
try:
|
||
self._asr = whisperx.load_model(
|
||
self._model_name,
|
||
device=self._device,
|
||
compute_type=self._compute_type,
|
||
)
|
||
return
|
||
except (ValueError, RuntimeError) as e2:
|
||
if _is_compute_type_error(str(e2)):
|
||
e = e2
|
||
continue
|
||
raise
|
||
# Exhausted compute-type candidates on this device — re-raise.
|
||
raise
|
||
# CUDA OOM: a resident TTS model + the GPU worker pool can starve
|
||
# VRAM on small (e.g. 8 GB laptop) GPUs, so loading large-v3 on
|
||
# CUDA dies here — which previously surfaced as a bare 500 from
|
||
# /dub/transcribe with no guidance. Fall back to CPU (slower, but
|
||
# dubbing still works and keeps the same model/accuracy) instead.
|
||
# Only triggers on a CUDA OOM, so the MPS/CPU paths are untouched.
|
||
if self._device == "cuda" and "out of memory" in str(e).lower():
|
||
logger.warning(
|
||
"whisperx CUDA OOM loading %s — retrying on CPU (slower). "
|
||
"Free VRAM (Flush the TTS model) for GPU-speed ASR. Detail: %s",
|
||
self._model_name, e,
|
||
)
|
||
try:
|
||
import torch
|
||
torch.cuda.empty_cache()
|
||
except Exception: # noqa: BLE001 — cache clear is best-effort
|
||
pass
|
||
self._device, self._compute_type = "cpu", "int8"
|
||
self._asr = whisperx.load_model(
|
||
self._model_name,
|
||
device=self._device,
|
||
compute_type=self._compute_type,
|
||
)
|
||
else:
|
||
raise
|
||
|
||
@staticmethod
|
||
def _allow_vad_pickle_globals():
|
||
"""Register the pickle classes that pyannote's VAD checkpoint contains.
|
||
|
||
Without this, PyTorch 2.6's secure unpickler refuses to load the file
|
||
even if the call explicitly passes `weights_only=False` later — the
|
||
allowlist is per-process and harmless to re-apply. Each class we add
|
||
is one that has surfaced in the wild from pyannote/omegaconf/pytorch-
|
||
lightning pickles; extending the list is safe.
|
||
"""
|
||
try:
|
||
import torch.serialization as _ts
|
||
except Exception:
|
||
return
|
||
add = getattr(_ts, "add_safe_globals", None)
|
||
if add is None:
|
||
return # older torch — secure unpickler didn't exist
|
||
|
||
allow = []
|
||
# omegaconf config containers + every node wrapper type the library
|
||
# exposes. pyannote's VAD checkpoint pickles `ListConfig` /
|
||
# `DictConfig` trees whose leaves are `AnyNode`/`ValueNode`/etc., so
|
||
# allowlist the whole family in one pass rather than waiting for
|
||
# users to hit each one in turn. All of these are pure metadata
|
||
# containers — no executable side effects.
|
||
try:
|
||
import omegaconf.nodes as _ocn
|
||
import omegaconf.base as _ocb
|
||
from omegaconf.listconfig import ListConfig
|
||
from omegaconf.dictconfig import DictConfig
|
||
allow += [ListConfig, DictConfig]
|
||
for _modname in ("nodes", "base"):
|
||
_mod = _ocn if _modname == "nodes" else _ocb
|
||
for _name in dir(_mod):
|
||
_obj = getattr(_mod, _name, None)
|
||
if isinstance(_obj, type) and _obj.__module__ == f"omegaconf.{_modname}":
|
||
allow.append(_obj)
|
||
except Exception:
|
||
pass
|
||
# `EnumNode` references real enum classes at unpickle time; allow
|
||
# the base Enum/IntEnum/Flag types so configs using enums load.
|
||
try:
|
||
import enum
|
||
allow += [enum.Enum, enum.IntEnum, enum.Flag, enum.IntFlag]
|
||
except Exception:
|
||
pass
|
||
# torch utility types that aren't in the secure unpickler's
|
||
# default allowlist. `TorchVersion` is a `str` subclass that
|
||
# pyannote/lightning serialise as metadata; `Size` is the shape
|
||
# tuple type used in tensor metadata. Both are inert data.
|
||
try:
|
||
from torch.torch_version import TorchVersion
|
||
import torch as _torch
|
||
allow += [TorchVersion, _torch.Size]
|
||
except Exception:
|
||
pass
|
||
# PyTorch Lightning serialises `hyper_parameters` as
|
||
# `argparse.Namespace` (or an AttributeDict subclass thereof) so
|
||
# configs roundtrip. Allowlist the Namespace constructor — it is
|
||
# just an attribute bag with no executable side effects.
|
||
try:
|
||
import argparse
|
||
allow += [argparse.Namespace]
|
||
except Exception:
|
||
pass
|
||
# pyannote-specific metadata classes that travel with the VAD
|
||
# checkpoint. Only the inert data-only types are allowlisted —
|
||
# the `Model` / `Task` / `Dataset` classes from the same modules
|
||
# do real work in `__init__` and stay off the allowlist.
|
||
try:
|
||
from pyannote.audio.core.model import Introspection, Output
|
||
from pyannote.audio.core.task import Problem, Resolution, Specifications
|
||
allow += [Introspection, Output, Problem, Resolution, Specifications]
|
||
except Exception:
|
||
pass
|
||
# Python typing primitives that show up in config annotations.
|
||
try:
|
||
import typing
|
||
allow += [typing.Any]
|
||
except Exception:
|
||
pass
|
||
# pytorch-lightning's OrderedDict-backed state dict helpers.
|
||
try:
|
||
from collections import OrderedDict, defaultdict
|
||
allow += [OrderedDict, defaultdict]
|
||
except Exception:
|
||
pass
|
||
# Plain-data builtins. pyannote's VAD checkpoint pickles config
|
||
# entries that resolve to bare builtin constructors (`GLOBAL list`,
|
||
# `GLOBAL int`, …) and the secure unpickler refuses each one
|
||
# without an explicit allowlist. These constructors only build
|
||
# inert data primitives — no side effects, no code paths — so the
|
||
# full set is safe to allowlist together, which avoids users
|
||
# hitting them one-at-a-time as the checkpoint deserialises.
|
||
allow += [
|
||
list, dict, tuple, set, frozenset,
|
||
int, float, bool, str, bytes, bytearray, complex,
|
||
type(None), slice, range,
|
||
]
|
||
# numpy scalar/array constructors that show up in pyannote configs
|
||
# (sample rates, hop sizes saved as numpy ints/floats). Each is a
|
||
# pure data type — safe to allowlist.
|
||
try:
|
||
import numpy as _np
|
||
allow += [
|
||
_np.ndarray, _np.dtype,
|
||
_np.int8, _np.int16, _np.int32, _np.int64,
|
||
_np.uint8, _np.uint16, _np.uint32, _np.uint64,
|
||
_np.float16, _np.float32, _np.float64,
|
||
_np.bool_, _np.complex64, _np.complex128,
|
||
]
|
||
# numpy.core was renamed to numpy._core in 1.25+. Both modules
|
||
# expose the same reconstruct helpers; allowlist whichever ships.
|
||
for _modname in ("numpy._core.multiarray", "numpy.core.multiarray"):
|
||
try:
|
||
_mod = __import__(_modname, fromlist=["_reconstruct", "scalar"])
|
||
for _attr in ("_reconstruct", "scalar"):
|
||
_fn = getattr(_mod, _attr, None)
|
||
if _fn is not None:
|
||
allow.append(_fn)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
# pathlib types — config files sometimes save cache directories as
|
||
# Path objects so the checkpoint can be relocated.
|
||
try:
|
||
import pathlib
|
||
allow += [
|
||
pathlib.PurePath, pathlib.PurePosixPath, pathlib.PureWindowsPath,
|
||
pathlib.Path, pathlib.PosixPath, pathlib.WindowsPath,
|
||
]
|
||
except Exception:
|
||
pass
|
||
if allow:
|
||
try:
|
||
add(allow)
|
||
except Exception as e:
|
||
logger.debug("add_safe_globals failed (harmless): %s", e)
|
||
|
||
def _get_align(self, language_code: str):
|
||
"""Lazy-load the wav2vec2 alignment model for this language. WhisperX
|
||
bundles aligners for ~20 major languages; for the others we fall back
|
||
to faster-whisper's native word timestamps (already in result)."""
|
||
if language_code in self._align_cache:
|
||
return self._align_cache[language_code]
|
||
import whisperx
|
||
try:
|
||
model, metadata = whisperx.load_align_model(
|
||
language_code=language_code, device=self._device,
|
||
)
|
||
self._align_cache[language_code] = (model, metadata)
|
||
return model, metadata
|
||
except Exception as e:
|
||
logger.info(
|
||
"whisperx: no alignment model for language=%r (%s); "
|
||
"falling back to Whisper's native word timestamps",
|
||
language_code, e,
|
||
)
|
||
self._align_cache[language_code] = None
|
||
return None
|
||
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
import whisperx # used for whisperx.align() below
|
||
self._ensure_asr()
|
||
logger.info("whisperx transcribing %s (word_timestamps=%s)", audio_path, word_timestamps)
|
||
# Decode via OmniVoice's validated ffmpeg, NOT whisperx.load_audio's bare
|
||
# "ffmpeg" PATH lookup which yields [WinError 193] -> "no segments" on
|
||
# Windows (#479). Same 16 kHz mono s16le array whisperx expects.
|
||
audio = _decode_audio_16k_mono(audio_path)
|
||
try:
|
||
result = self._asr.transcribe(audio)
|
||
except IndexError:
|
||
# WhisperX pipeline crashes with IndexError if VAD produces 0 segments
|
||
logger.info("whisperx transcribe threw IndexError (likely 0 VAD segments). Returning empty result.")
|
||
result = {"segments": [], "language": "en"}
|
||
|
||
lang = result.get("language", "en")
|
||
|
||
# Forced alignment when available — drastically improves word boundary
|
||
# accuracy (±10-30 ms vs Whisper's ±100-300 ms). Skip for rare-language
|
||
# audio where no wav2vec2 aligner exists.
|
||
if word_timestamps:
|
||
align = self._get_align(lang)
|
||
if align is not None:
|
||
model_a, metadata = align
|
||
try:
|
||
result = whisperx.align(
|
||
result["segments"], model_a, metadata, audio,
|
||
self._device, return_char_alignments=False,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("whisperx alignment failed: %s — using raw timestamps", e)
|
||
|
||
# Normalise to the shape segment_transcript(...) expects: chunks +
|
||
# segments + language metadata. whisperx's post-align result has
|
||
# `segments` with `words: [{word, start, end, score}]`.
|
||
segments = result.get("segments", [])
|
||
chunks = [
|
||
{"text": seg.get("text", ""),
|
||
"timestamp": (seg.get("start"), seg.get("end"))}
|
||
for seg in segments
|
||
]
|
||
return {
|
||
"chunks": chunks,
|
||
"segments": [
|
||
{
|
||
"text": seg.get("text", ""),
|
||
"start": seg.get("start"),
|
||
"end": seg.get("end"),
|
||
"words": seg.get("words", []) if word_timestamps else [],
|
||
}
|
||
for seg in segments
|
||
],
|
||
"language": lang,
|
||
}
|
||
|
||
def unload(self) -> None:
|
||
self._asr = None
|
||
self._align_cache.clear()
|
||
import gc
|
||
gc.collect()
|
||
try:
|
||
import torch
|
||
if torch.cuda.is_available():
|
||
torch.cuda.empty_cache()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Faster-Whisper (cross-platform fallback) ────────────────────────────────
|
||
|
||
|
||
class FasterWhisperBackend(ASRBackend):
|
||
id = "faster-whisper"
|
||
display_name = "Faster-Whisper (CTranslate2 — Linux/Windows/macOS)"
|
||
# CTranslate2: CUDA or CPU (no upstream ROCm/HIP build — see WhisperX note).
|
||
gpu_compat = ("cuda", "cpu")
|
||
|
||
def __init__(self):
|
||
# Defaulting to the CTranslate2-converted large-v3 repo. Matches
|
||
# KNOWN_MODELS in api/routers/setup.py so the first-run wizard
|
||
# downloads what the backend will actually load.
|
||
self._model_name = os.environ.get(
|
||
"ASR_MODEL_FASTER", "Systran/faster-whisper-large-v3"
|
||
)
|
||
self._model = None # lazy — first transcribe() loads weights
|
||
# Set by _ensure_model() to the device/compute_type that actually loaded
|
||
# (after the #551 compute_type / #255 OOM→CPU fallback chain).
|
||
self._device: str | None = None
|
||
self._compute_type: str | None = None
|
||
|
||
@classmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
try:
|
||
import faster_whisper # noqa: F401
|
||
return True, "ready"
|
||
except ImportError as e:
|
||
return False, f"faster-whisper not installed: {e}"
|
||
except Exception as e: # noqa: BLE001
|
||
# faster-whisper pulls in CTranslate2, whose .so is rejected by
|
||
# hardened kernels / newer glibc ("cannot enable executable stack",
|
||
# #692) — an OSError. Report unavailable so we fall back, not crash.
|
||
return False, f"faster-whisper failed to load ({type(e).__name__}): {e}"
|
||
|
||
def _ensure_model(self):
|
||
if self._model is not None:
|
||
return
|
||
from faster_whisper import WhisperModel
|
||
# Device / compute-type auto-pick:
|
||
# - CUDA present → GPU fp16
|
||
# - Apple Silicon / CPU → CPU int8 (fastest on CPU, negligible
|
||
# WER regression vs fp32 for whisper-large-v3)
|
||
device, compute_type = "cpu", "int8"
|
||
try:
|
||
import torch
|
||
if torch.cuda.is_available():
|
||
device, compute_type = "cuda", "float16"
|
||
except Exception:
|
||
pass
|
||
logger.info(
|
||
"faster-whisper loading %s on %s (%s)",
|
||
self._model_name, device, compute_type,
|
||
)
|
||
# Try the per-device compute_type chain (cuda: float16 → int8_float16 →
|
||
# int8; cpu: int8 → float32). A GPU without efficient fp16 (older
|
||
# Maxwell/Pascal, GTX 16xx, or a CTranslate2/cuDNN mismatch) raises a
|
||
# *ValueError* at construction (#551) — degrade to the next candidate
|
||
# instead of failing every chunk. A genuine CUDA OOM falls back to CPU
|
||
# (slower, same model/accuracy), preserving the existing #255 behaviour.
|
||
candidates = _compute_type_candidates(device)
|
||
if compute_type in candidates:
|
||
candidates = candidates[candidates.index(compute_type):]
|
||
last_err: Exception | None = None
|
||
while True:
|
||
for ct in candidates:
|
||
try:
|
||
self._model = WhisperModel(
|
||
self._model_name, device=device, compute_type=ct
|
||
)
|
||
self._device, self._compute_type = device, ct
|
||
return
|
||
except (ValueError, RuntimeError) as e:
|
||
last_err = e
|
||
if _is_compute_type_error(str(e)):
|
||
logger.warning(
|
||
"faster-whisper %s unsupported on %s — trying next "
|
||
"compute_type. Detail: %s", ct, device, e,
|
||
)
|
||
continue
|
||
if device == "cuda" and "out of memory" in str(e).lower():
|
||
# Stop scanning GPU candidates; fall back to CPU below.
|
||
break
|
||
raise
|
||
# Exhausted candidates for this device. If we were on CUDA and the
|
||
# last failure was an OOM, retry on CPU with its candidates (#255).
|
||
if device == "cuda" and last_err is not None and (
|
||
"out of memory" in str(last_err).lower()
|
||
):
|
||
logger.warning(
|
||
"faster-whisper CUDA OOM loading %s — retrying on CPU "
|
||
"(slower). Free VRAM (Flush the TTS model) for GPU-speed "
|
||
"ASR. Detail: %s", self._model_name, last_err,
|
||
)
|
||
try:
|
||
import torch
|
||
torch.cuda.empty_cache()
|
||
except Exception: # noqa: BLE001 — cache clear is best-effort
|
||
pass
|
||
device = "cpu"
|
||
candidates = _compute_type_candidates(device)
|
||
compute_type = candidates[0]
|
||
continue
|
||
# All candidates exhausted (and no OOM→CPU retry available) — surface
|
||
# the last error.
|
||
raise last_err
|
||
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
self._ensure_model()
|
||
logger.info(
|
||
"faster-whisper transcribing %s (word_timestamps=%s)",
|
||
audio_path, word_timestamps,
|
||
)
|
||
# faster-whisper returns a generator of Segment objects + an Info
|
||
# struct. Materialise the generator so downstream consumers can
|
||
# index / re-iterate.
|
||
segments_iter, info = self._model.transcribe(
|
||
audio_path,
|
||
word_timestamps=word_timestamps,
|
||
vad_filter=True, # built-in Silero VAD — cleaner segment starts
|
||
)
|
||
segments = list(segments_iter)
|
||
# Normalise to the shape segment_transcript(...) expects: a dict with
|
||
# `chunks` (for backwards compat with mlx output) AND `segments` +
|
||
# `language` (so callers that peek at language metadata keep working).
|
||
chunks = [
|
||
{"text": seg.text, "timestamp": (seg.start, seg.end)}
|
||
for seg in segments
|
||
]
|
||
out = {
|
||
"chunks": chunks,
|
||
"segments": [
|
||
{
|
||
"text": seg.text,
|
||
"start": seg.start,
|
||
"end": seg.end,
|
||
"words": (
|
||
[
|
||
{
|
||
"word": w.word,
|
||
"start": w.start,
|
||
"end": w.end,
|
||
"probability": w.probability,
|
||
}
|
||
for w in (seg.words or [])
|
||
]
|
||
if word_timestamps
|
||
else []
|
||
),
|
||
}
|
||
for seg in segments
|
||
],
|
||
"language": info.language,
|
||
"language_probability": info.language_probability,
|
||
"duration": info.duration,
|
||
}
|
||
return out
|
||
|
||
def unload(self) -> None:
|
||
self._asr = None
|
||
import gc
|
||
gc.collect()
|
||
try:
|
||
import torch
|
||
if torch.cuda.is_available():
|
||
torch.cuda.empty_cache()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ── MLX Whisper (Apple Silicon optional) ────────────────────────────────────
|
||
|
||
# Default model for general transcription (dub pipeline etc.)
|
||
_MLX_MODEL_DEFAULT = "mlx-community/whisper-large-v3-mlx"
|
||
# Turbo model for dictation / capture — 5× faster, 0.8B params vs 1.5B.
|
||
_MLX_MODEL_TURBO = "mlx-community/whisper-large-v3-turbo"
|
||
|
||
|
||
class MLXWhisperBackend(ASRBackend):
|
||
id = "mlx-whisper"
|
||
display_name = "MLX Whisper (Apple Silicon CoreML)"
|
||
gpu_compat = ("mps", "cpu")
|
||
|
||
def __init__(self, model_name: str | None = None):
|
||
self._model_name = model_name or os.environ.get(
|
||
"ASR_MODEL", _MLX_MODEL_DEFAULT,
|
||
)
|
||
|
||
@classmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
# #390: shared platform gate FIRST — one rule for MLX-Audio + MLX-Whisper.
|
||
# Returns False on Linux/Windows/mac-Intel before any package import, so
|
||
# a stray mlx-whisper wheel never reports available or advertises `mps`.
|
||
from core.device_caps import mlx_supported
|
||
ok, why = mlx_supported()
|
||
if not ok:
|
||
return False, why
|
||
try:
|
||
import mlx_whisper # noqa: F401
|
||
return True, "ready"
|
||
# Catch OSError/RuntimeError too, not just ImportError: in a
|
||
# PyInstaller bundle mlx's native dylib/metallib can fail to load
|
||
# even when the package imports, raising OSError/RuntimeError. We must
|
||
# report unavailable (so the picker falls back) rather than crash the
|
||
# registry scan (Wave 4.4).
|
||
except (ImportError, OSError, RuntimeError) as e:
|
||
return False, f"mlx-whisper unavailable: {e}"
|
||
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
import mlx_whisper
|
||
logger.info(
|
||
"MLX Whisper transcribing %s (model=%s, word_timestamps=%s)",
|
||
audio_path, self._model_name, word_timestamps,
|
||
)
|
||
result = mlx_whisper.transcribe(
|
||
audio_path,
|
||
path_or_hf_repo=self._model_name,
|
||
word_timestamps=word_timestamps,
|
||
)
|
||
# Normalise to the `chunks` shape the rest of the pipeline expects.
|
||
if "segments" in result and "chunks" not in result:
|
||
result["chunks"] = [
|
||
{"text": seg["text"], "timestamp": (seg["start"], seg["end"])}
|
||
for seg in result["segments"]
|
||
]
|
||
return result
|
||
|
||
def warmup(self) -> None:
|
||
"""Eagerly load model weights into memory so first transcribe is instant.
|
||
|
||
mlx_whisper internally caches via a class-level ModelHolder singleton.
|
||
Calling ``load_model`` triggers the download (if needed) and loads
|
||
weights onto the GPU — subsequent transcribe() calls hit the warm cache.
|
||
"""
|
||
import time
|
||
t0 = time.perf_counter()
|
||
try:
|
||
from mlx_whisper.transcribe import ModelHolder
|
||
import mlx.core as mx
|
||
# load_model populates the class-level singleton; after this call
|
||
# the model is resident in unified memory.
|
||
ModelHolder.get_model(self._model_name, dtype=mx.float16)
|
||
dt = time.perf_counter() - t0
|
||
logger.info("MLX Whisper model '%s' warmed up in %.1fs", self._model_name, dt)
|
||
except Exception as e:
|
||
dt = time.perf_counter() - t0
|
||
logger.warning("MLX Whisper warmup failed after %.1fs: %s", dt, e)
|
||
|
||
|
||
# ── PyTorch Whisper fallback (CUDA / CPU via pipeline) ─────────────────────
|
||
|
||
|
||
class PyTorchWhisperBackend(ASRBackend):
|
||
id = "pytorch-whisper"
|
||
display_name = "PyTorch Whisper (CUDA / CPU via transformers pipeline)"
|
||
# Pure transformers pipeline → runs wherever torch does (CUDA, MPS, CPU).
|
||
# ROCm-via-HIP would also work but is left unclaimed pending verification.
|
||
gpu_compat = ("cuda", "mps", "cpu")
|
||
|
||
def __init__(self, asr_pipe=None):
|
||
# Reuses the `_asr_pipe` attached to the TTS model when available.
|
||
self._pipe = asr_pipe
|
||
|
||
@classmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
try:
|
||
import transformers # noqa: F401
|
||
return True, "ready"
|
||
except ImportError as e:
|
||
return False, f"transformers not installed: {e}"
|
||
|
||
def _ensure_pipe(self):
|
||
if self._pipe is not None:
|
||
return
|
||
# Build a standalone transformers Whisper pipeline on demand. This runs
|
||
# on PyTorch's own stack (cuDNN 9 ships with torch), so it works as a
|
||
# fallback on machines where WhisperX / faster-whisper can't load
|
||
# cuDNN 8 (the `cudnn_ops_infer64_8.dll` failure, issue #255) — and it
|
||
# needs neither OMNIVOICE_PRELOAD_TTS_ASR=1 nor a loaded TTS model.
|
||
# When the TTS model already has an ASR head, dub_core passes it via the
|
||
# constructor and this path is skipped.
|
||
import torch
|
||
from transformers import pipeline as hf_pipeline
|
||
from services.model_manager import get_best_device
|
||
|
||
model_name = os.environ.get(
|
||
"OMNIVOICE_PYTORCH_ASR_MODEL", "openai/whisper-large-v3-turbo"
|
||
)
|
||
device = get_best_device()
|
||
asr_dtype = torch.float16 if str(device).startswith("cuda") else torch.float32
|
||
logger.info(
|
||
"PyTorchWhisperBackend: loading standalone ASR pipeline %s on %s",
|
||
model_name, device,
|
||
)
|
||
try:
|
||
self._pipe = hf_pipeline(
|
||
"automatic-speech-recognition",
|
||
model=model_name,
|
||
dtype=asr_dtype,
|
||
device_map=device,
|
||
)
|
||
except Exception as e:
|
||
# #549: an incomplete transformers install fails to build the ASR
|
||
# pipeline (e.g. "Could not import module 'AutoFeatureExtractor'").
|
||
# The raw error is opaque; re-raise with an actionable next step so
|
||
# the toast tells the user how to recover instead of "no segments".
|
||
raise RuntimeError(
|
||
"transformers ASR pipeline failed to import (AutoFeatureExtractor) "
|
||
"— your transformers install is incomplete; reinstall with "
|
||
"`uv pip install --reinstall transformers`, or use faster-whisper "
|
||
"(OmniVoice's default ASR) which avoids the transformers pipeline. "
|
||
f"Underlying: {e}"
|
||
) from e
|
||
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
import soundfile as sf
|
||
import torch
|
||
self._ensure_pipe()
|
||
audio_np, sr = sf.read(audio_path, dtype="float32")
|
||
if audio_np.ndim > 1:
|
||
audio_np = audio_np.mean(axis=1)
|
||
bs = 16 if torch.cuda.is_available() else 2
|
||
result = self._pipe(
|
||
{"array": audio_np, "sampling_rate": sr},
|
||
return_timestamps="word" if word_timestamps else True,
|
||
chunk_length_s=15,
|
||
batch_size=bs,
|
||
)
|
||
return result if isinstance(result, dict) else {"chunks": [], "raw": result}
|
||
|
||
|
||
# ── NeMo Parakeet TDT (NVIDIA — English SOTA from ASR Leaderboard) ─────────
|
||
|
||
|
||
class NeMoASRBackend(ASRBackend):
|
||
"""NVIDIA Parakeet TDT via NeMo toolkit.
|
||
|
||
FastConformer encoder + Token-and-Duration Transducer decoder.
|
||
Beats Whisper large-v3 on English benchmarks (~6% WER).
|
||
Supports 25+ European languages with auto language detection.
|
||
Requires NVIDIA GPU.
|
||
"""
|
||
id = "nemo-parakeet"
|
||
# CUDA-only: is_available() hard-fails without a GPU ("Parakeet TDT requires
|
||
# NVIDIA GPU (CUDA)"), so declaring a CPU path would be a false claim. On a
|
||
# CPU host this correctly resolves to routing_status="unavailable", matching
|
||
# is_available()=False (the matrix suppresses the routing badge there).
|
||
gpu_compat = ("cuda",)
|
||
display_name = "Parakeet TDT (NVIDIA NeMo — English SOTA)"
|
||
|
||
def __init__(self):
|
||
self._model_name = os.environ.get(
|
||
"ASR_MODEL_NEMO", "nvidia/parakeet-tdt-0.6b-v3"
|
||
)
|
||
self._model = None
|
||
|
||
@classmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
try:
|
||
import torch
|
||
if not torch.cuda.is_available():
|
||
return False, "Parakeet TDT requires NVIDIA GPU (CUDA)"
|
||
except ImportError:
|
||
return False, "PyTorch not installed"
|
||
try:
|
||
import nemo.collections.asr # noqa: F401
|
||
return True, "ready"
|
||
except ImportError as e:
|
||
return False, f"nemo_toolkit[asr] not installed: {e}"
|
||
|
||
def _ensure_model(self):
|
||
if self._model is not None:
|
||
return
|
||
import nemo.collections.asr as nemo_asr
|
||
logger.info("NeMo loading %s", self._model_name)
|
||
self._model = nemo_asr.models.ASRModel.from_pretrained(
|
||
model_name=self._model_name
|
||
)
|
||
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
self._ensure_model()
|
||
logger.info(
|
||
"NeMo Parakeet transcribing %s (word_timestamps=%s)",
|
||
audio_path, word_timestamps,
|
||
)
|
||
outputs = self._model.transcribe(
|
||
[audio_path], timestamps=word_timestamps
|
||
)
|
||
# NeMo returns a list of Hypothesis objects with .text and optional
|
||
# .timestep / .alignments. Normalise to OmniVoice's expected shape.
|
||
hyp = outputs[0] if outputs else None
|
||
if hyp is None:
|
||
return {"chunks": [], "segments": [], "language": "en"}
|
||
|
||
text = hyp.text if hasattr(hyp, "text") else str(hyp)
|
||
|
||
# Extract word-level timestamps if available
|
||
words = []
|
||
segments_out = []
|
||
if word_timestamps and hasattr(hyp, "timestep") and hyp.timestep:
|
||
try:
|
||
# NeMo timestep format varies by model version
|
||
ts = hyp.timestep
|
||
if isinstance(ts, dict) and "word" in ts:
|
||
for w in ts["word"]:
|
||
words.append({
|
||
"word": w.get("char", w.get("word", "")),
|
||
"start": w.get("start_offset", 0),
|
||
"end": w.get("end_offset", 0),
|
||
})
|
||
except Exception as e:
|
||
logger.debug("NeMo timestamp extraction: %s", e)
|
||
|
||
# Build a single segment from the full transcription
|
||
# (NeMo doesn't natively split into VAD segments like Whisper)
|
||
if text.strip():
|
||
segments_out.append({
|
||
"text": text,
|
||
"start": words[0]["start"] if words else 0.0,
|
||
"end": words[-1]["end"] if words else None,
|
||
"words": words,
|
||
})
|
||
|
||
chunks = [
|
||
{"text": seg["text"], "timestamp": (seg["start"], seg["end"])}
|
||
for seg in segments_out
|
||
]
|
||
return {
|
||
"chunks": chunks,
|
||
"segments": segments_out,
|
||
"language": "en", # Parakeet v3 auto-detects but doesn't expose it cleanly
|
||
}
|
||
|
||
def unload(self) -> None:
|
||
self._model = None
|
||
import gc
|
||
gc.collect()
|
||
try:
|
||
import torch
|
||
if torch.cuda.is_available():
|
||
torch.cuda.empty_cache()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ── Moonshine (edge-optimized, variable-length — from ASR Leaderboard) ─────
|
||
|
||
|
||
class MoonshineASRBackend(ASRBackend):
|
||
"""Moonshine ASR via moonshine-voice or ONNX runtime.
|
||
|
||
Optimized for edge/CPU deployment. Variable-length processing
|
||
(no 30s padding waste like Whisper). Sub-200ms latency.
|
||
Great for live capture and CPU-only environments.
|
||
"""
|
||
id = "moonshine"
|
||
gpu_compat = ("cpu",) # edge/CPU-optimized by design
|
||
display_name = "Moonshine (edge-optimized, ONNX)"
|
||
|
||
def __init__(self):
|
||
self._model_name = os.environ.get(
|
||
"ASR_MODEL_MOONSHINE", "moonshine/base"
|
||
)
|
||
self._transcriber = None
|
||
|
||
@classmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
try:
|
||
import moonshine_onnx # noqa: F401
|
||
return True, "ready (moonshine_onnx)"
|
||
except ImportError:
|
||
pass
|
||
try:
|
||
from moonshine_voice import Transcriber # noqa: F401
|
||
return True, "ready (moonshine_voice)"
|
||
except ImportError:
|
||
pass
|
||
return False, (
|
||
"moonshine not installed. Install with: "
|
||
"uv pip install moonshine-onnx (or moonshine-voice)"
|
||
)
|
||
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
logger.info(
|
||
"Moonshine transcribing %s (model=%s)",
|
||
audio_path, self._model_name,
|
||
)
|
||
# Try moonshine_onnx first (lighter), then moonshine_voice
|
||
try:
|
||
import moonshine_onnx
|
||
text = moonshine_onnx.transcribe(audio_path, model=self._model_name)
|
||
if isinstance(text, list):
|
||
text = " ".join(text)
|
||
except ImportError:
|
||
from moonshine_voice import Transcriber
|
||
if self._transcriber is None:
|
||
self._transcriber = Transcriber(model=self._model_name)
|
||
text = self._transcriber.transcribe_file(audio_path)
|
||
if isinstance(text, list):
|
||
text = " ".join(text)
|
||
|
||
# Moonshine returns plain text without timestamps in basic mode.
|
||
# Build minimal segments structure.
|
||
segments_out = []
|
||
if text and text.strip():
|
||
# Get audio duration for rough segment bounds
|
||
try:
|
||
import soundfile as sf
|
||
info = sf.info(audio_path)
|
||
duration = info.duration
|
||
except Exception:
|
||
duration = None
|
||
|
||
segments_out.append({
|
||
"text": text.strip(),
|
||
"start": 0.0,
|
||
"end": duration,
|
||
"words": [],
|
||
})
|
||
|
||
chunks = [
|
||
{"text": seg["text"], "timestamp": (seg["start"], seg["end"])}
|
||
for seg in segments_out
|
||
]
|
||
return {
|
||
"chunks": chunks,
|
||
"segments": segments_out,
|
||
"language": "en",
|
||
}
|
||
|
||
def unload(self) -> None:
|
||
self._transcriber = None
|
||
|
||
|
||
# ── sherpa-onnx live dictation (ONNX, CPU, streaming + offline) ─────────────
|
||
|
||
|
||
def _load_audio_16k_mono_f32(audio_path: str):
|
||
"""Decode any audio file to 16 kHz mono float32 in [-1, 1] for sherpa.
|
||
|
||
Prefers soundfile (WAV/FLAC — the dictation buffers are already WAV) and
|
||
resamples to 16 kHz when needed; falls back to OmniVoice's validated ffmpeg
|
||
for containers soundfile can't read (WebM/Opus). 16 kHz is sherpa's cheapest
|
||
feed; it resamples internally too, but doing it here keeps the contract tight.
|
||
"""
|
||
import numpy as np
|
||
try:
|
||
import soundfile as sf
|
||
data, sr = sf.read(audio_path, dtype="float32", always_2d=False)
|
||
if getattr(data, "ndim", 1) > 1:
|
||
data = data.mean(axis=1)
|
||
data = np.ascontiguousarray(data, dtype=np.float32)
|
||
if sr != 16000:
|
||
# Lightweight linear resample — adequate for ASR features.
|
||
n = int(round(len(data) * 16000 / sr))
|
||
if n > 0:
|
||
xp = np.linspace(0.0, 1.0, num=len(data), endpoint=False)
|
||
x = np.linspace(0.0, 1.0, num=n, endpoint=False)
|
||
data = np.interp(x, xp, data).astype(np.float32)
|
||
sr = 16000
|
||
return data, sr
|
||
except Exception:
|
||
# Container soundfile can't read (WebM/Opus) — use the validated ffmpeg
|
||
# path, which already yields 16 kHz mono float32.
|
||
return _decode_audio_16k_mono(audio_path), 16000
|
||
|
||
|
||
class SherpaDictationBackend(ASRBackend):
|
||
"""k2-fsa/sherpa-onnx ONNX dictation engine (CPU, live + offline).
|
||
|
||
One :class:`ASRBackend` instance is bound to one of the seven sherpa
|
||
dictation models (see :mod:`services.sherpa_dictation`). For the offline
|
||
``transcribe(path)`` contract it runs an ``OfflineRecognizer`` for offline
|
||
models and a one-shot ``OnlineRecognizer`` decode for streaming models
|
||
(so ``POST /transcribe`` works for every sherpa model). The *live* WS path
|
||
drives the streaming recognizer incrementally — see ``capture_ws.py``.
|
||
|
||
CPU provider only (cross-platform default-parity rule); no CUDA dep.
|
||
"""
|
||
id = "sherpa-onnx-asr"
|
||
display_name = "Sherpa-ONNX dictation (live, CPU — streaming + offline)"
|
||
gpu_compat = ("cpu",)
|
||
|
||
def __init__(self, model_id: str | None = None):
|
||
from services import sherpa_dictation as _sd
|
||
mid = model_id or os.environ.get(
|
||
"OMNIVOICE_SHERPA_ASR_MODEL", _sd.DEFAULT_MODEL_ID
|
||
)
|
||
spec = _sd.get_spec(mid)
|
||
if spec is None:
|
||
raise ValueError(
|
||
f"Unknown sherpa dictation model {mid!r}. Known: "
|
||
f"{[s.id for s in _sd.list_specs()]}"
|
||
)
|
||
self._spec = spec
|
||
self._rec = None # lazy OfflineRecognizer / OnlineRecognizer
|
||
|
||
@property
|
||
def spec(self):
|
||
return self._spec
|
||
|
||
@property
|
||
def streaming(self) -> bool:
|
||
return self._spec.streaming
|
||
|
||
@classmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
from services.sherpa_dictation import sherpa_available
|
||
return sherpa_available()
|
||
|
||
def ensure_loaded(self) -> None:
|
||
self._ensure_rec()
|
||
|
||
def _ensure_rec(self):
|
||
if self._rec is not None:
|
||
return
|
||
from services import sherpa_dictation as _sd
|
||
if self._spec.streaming:
|
||
self._rec = _sd.build_online_recognizer(self._spec)
|
||
else:
|
||
self._rec = _sd.build_offline_recognizer(self._spec)
|
||
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
self._ensure_rec()
|
||
logger.info(
|
||
"sherpa-onnx dictation transcribing %s (model=%s, kind=%s)",
|
||
audio_path, self._spec.id, self._spec.kind,
|
||
)
|
||
samples, sr = _load_audio_16k_mono_f32(audio_path)
|
||
if self._spec.streaming:
|
||
text = self._decode_online_oneshot(samples, sr)
|
||
else:
|
||
text = self._decode_offline(samples, sr)
|
||
return _sherpa_result(text, samples, sr)
|
||
|
||
def _decode_offline(self, samples, sr) -> str:
|
||
s = self._rec.create_stream()
|
||
s.accept_waveform(sr, samples)
|
||
self._rec.decode_stream(s)
|
||
return (s.result.text or "").strip()
|
||
|
||
def _decode_online_oneshot(self, samples, sr) -> str:
|
||
"""One-shot decode of a whole buffer through the streaming recognizer
|
||
(for the non-streaming ``transcribe()`` / partial re-decode path)."""
|
||
import numpy as np
|
||
s = self._rec.create_stream()
|
||
s.accept_waveform(sr, samples)
|
||
tail = np.zeros(int(0.5 * sr), dtype=np.float32)
|
||
s.accept_waveform(sr, tail)
|
||
s.input_finished()
|
||
while self._rec.is_ready(s):
|
||
self._rec.decode_stream(s)
|
||
return (self._rec.get_result(s) or "").strip()
|
||
|
||
def unload(self) -> None:
|
||
self._rec = None
|
||
import gc
|
||
gc.collect()
|
||
|
||
|
||
def _sherpa_result(text: str, samples, sr) -> dict:
|
||
"""Normalise a sherpa decode to OmniVoice's ``{chunks, segments, language,
|
||
text}`` contract. sherpa gives plain text (no VAD split), so emit a single
|
||
segment spanning the buffer — same shape Moonshine uses."""
|
||
text = (text or "").strip()
|
||
try:
|
||
duration = round(len(samples) / float(sr), 3)
|
||
except Exception:
|
||
duration = None
|
||
segments = []
|
||
if text:
|
||
segments.append({"text": text, "start": 0.0, "end": duration, "words": []})
|
||
chunks = [{"text": s["text"], "timestamp": (s["start"], s["end"])} for s in segments]
|
||
return {"chunks": chunks, "segments": segments, "language": "auto", "text": text}
|
||
|
||
|
||
# ── Registry ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
# ── FunASR (SenseVoice — all-in-one multilingual, opt-in alternative, #182) ──
|
||
|
||
# SenseVoice emits rich tokens like `<|en|><|NEUTRAL|><|Speech|>` around the
|
||
# text; strip them when no postprocessor is applied.
|
||
_FUNASR_TAG_RE = re.compile(r"<\|[^|>]*\|>")
|
||
|
||
|
||
def _ms_to_s(value):
|
||
"""Milliseconds → seconds (FunASR reports ms). None on bad input."""
|
||
try:
|
||
return round(float(value) / 1000.0, 3)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _clean_funasr_text(text):
|
||
return _FUNASR_TAG_RE.sub("", str(text or "")).strip()
|
||
|
||
|
||
def _normalize_funasr(res) -> dict:
|
||
"""Normalise FunASR ``generate()`` output → OmniVoice's
|
||
``{chunks, segments, language}`` shape (the same one the Whisper backends
|
||
return, consumed by ``services.segmentation``). Defensive about FunASR's
|
||
output variations: prefers VAD ``sentence_info`` (ms timestamps + optional
|
||
``spk`` speaker id) and falls back to a single utterance from ``text``.
|
||
Pure — testable without funasr installed.
|
||
"""
|
||
item = (res[0] if isinstance(res, (list, tuple)) and res else res) or {}
|
||
if not isinstance(item, dict):
|
||
item = {"text": str(item)}
|
||
language = item.get("language") or item.get("lang") or None
|
||
|
||
segments = []
|
||
for s in item.get("sentence_info") or []:
|
||
if not isinstance(s, dict):
|
||
continue
|
||
txt = _clean_funasr_text(s.get("text", ""))
|
||
if not txt:
|
||
continue
|
||
seg = {"text": txt, "start": _ms_to_s(s.get("start", 0)) or 0.0, "end": _ms_to_s(s.get("end"))}
|
||
spk = s.get("spk")
|
||
if spk is not None:
|
||
seg["speaker"] = f"Speaker {int(spk) + 1}" if isinstance(spk, (int, float)) else str(spk)
|
||
segments.append(seg)
|
||
|
||
if not segments:
|
||
txt = _clean_funasr_text(item.get("text", ""))
|
||
if txt:
|
||
ts = item.get("timestamp") or [] # [[start_ms, end_ms], ...]
|
||
start = _ms_to_s(ts[0][0]) if ts else 0.0
|
||
end = _ms_to_s(ts[-1][1]) if ts else None
|
||
segments.append({"text": txt, "start": start or 0.0, "end": end})
|
||
|
||
chunks = [{"text": seg["text"], "timestamp": (seg["start"], seg.get("end"))} for seg in segments]
|
||
return {"chunks": chunks, "segments": segments, "language": language}
|
||
|
||
|
||
class FunASRBackend(ASRBackend):
|
||
"""FunASR — SenseVoiceSmall + FSMN-VAD. All-in-one multilingual ASR:
|
||
transcription + punctuation across 50+ languages, with optional speaker
|
||
diarization via the cam++ model. Opt-in alternative to WhisperX (issue
|
||
#182); WhisperX remains the cross-platform default.
|
||
"""
|
||
id = "funasr"
|
||
gpu_compat = ("cuda", "cpu") # FunASR: CUDA or CPU
|
||
display_name = "FunASR (SenseVoice — 50+ languages, all-in-one)"
|
||
|
||
def __init__(self):
|
||
self._model_name = os.environ.get("ASR_MODEL_FUNASR", "iic/SenseVoiceSmall")
|
||
self._vad_model = os.environ.get("ASR_FUNASR_VAD", "fsmn-vad")
|
||
# cam++ speaker model → inline diarization (Phase 2). Set ASR_FUNASR_SPK=""
|
||
# to disable and fall back to the dub pipeline's pyannote/heuristic path.
|
||
self._spk_model = os.environ.get("ASR_FUNASR_SPK", "cam++")
|
||
self._model = None
|
||
|
||
@classmethod
|
||
def is_available(cls) -> tuple[bool, str]:
|
||
try:
|
||
import funasr # noqa: F401
|
||
return True, "ready"
|
||
except ImportError:
|
||
return False, "funasr not installed. Install with: uv pip install funasr"
|
||
|
||
def _ensure_model(self):
|
||
if self._model is not None:
|
||
return
|
||
from funasr import AutoModel
|
||
kwargs = {"model": self._model_name, "vad_model": self._vad_model, "disable_update": True}
|
||
if self._spk_model:
|
||
kwargs["spk_model"] = self._spk_model
|
||
logger.info("FunASR loading %s (vad=%s, spk=%s)", self._model_name, self._vad_model, self._spk_model or "off")
|
||
self._model = AutoModel(**kwargs)
|
||
|
||
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
|
||
self._ensure_model()
|
||
logger.info("FunASR transcribing %s", audio_path)
|
||
res = self._model.generate(input=audio_path, cache={}, language="auto", use_itn=True)
|
||
return _normalize_funasr(res)
|
||
|
||
def unload(self) -> None:
|
||
self._model = None
|
||
import gc
|
||
gc.collect()
|
||
try:
|
||
import torch
|
||
if torch.cuda.is_available():
|
||
torch.cuda.empty_cache()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _isolated_faster_whisper():
|
||
"""Lazy import so the subprocess_asr → subprocess_backend chain isn't
|
||
pulled in at registry definition time."""
|
||
from services.subprocess_asr import IsolatedFasterWhisperBackend
|
||
return IsolatedFasterWhisperBackend
|
||
|
||
|
||
class _LazyASRRegistry(dict):
|
||
"""Registry with one lazily-resolved entry (Wave 4.2). Mirrors the TTS
|
||
registry's lazy pattern so listing/selecting the crash-isolated ASR
|
||
backend doesn't import the subprocess stack unless it's used."""
|
||
|
||
_LAZY = {"faster-whisper-isolated": _isolated_faster_whisper}
|
||
|
||
def __contains__(self, key):
|
||
return dict.__contains__(self, key) or key in self._LAZY
|
||
|
||
def __getitem__(self, key):
|
||
if dict.__contains__(self, key):
|
||
return dict.__getitem__(self, key)
|
||
if key in self._LAZY:
|
||
cls = self._LAZY[key]()
|
||
self[key] = cls
|
||
return cls
|
||
raise KeyError(key)
|
||
|
||
def __iter__(self):
|
||
seen = set()
|
||
for k in dict.__iter__(self):
|
||
seen.add(k)
|
||
yield k
|
||
for k in self._LAZY:
|
||
if k not in seen:
|
||
yield k
|
||
|
||
def items(self):
|
||
for k in self:
|
||
yield k, self[k]
|
||
|
||
|
||
_REGISTRY: dict[str, type[ASRBackend]] = _LazyASRRegistry({
|
||
"whisperx": WhisperXBackend,
|
||
"faster-whisper": FasterWhisperBackend,
|
||
"mlx-whisper": MLXWhisperBackend,
|
||
"pytorch-whisper": PyTorchWhisperBackend,
|
||
"nemo-parakeet": NeMoASRBackend,
|
||
"moonshine": MoonshineASRBackend,
|
||
"funasr": FunASRBackend,
|
||
"sherpa-onnx-asr": SherpaDictationBackend,
|
||
# "faster-whisper-isolated": resolved lazily (crash-isolated subprocess).
|
||
})
|
||
|
||
|
||
# Short install hints surfaced as tooltips on the Settings → Engines UI
|
||
# (parity with tts_backend._INSTALL_HINTS).
|
||
_INSTALL_HINTS: dict[str, str] = {
|
||
"whisperx": "pip install whisperx (CTranslate2 + wav2vec2 alignment; CUDA or CPU)",
|
||
"faster-whisper": "pip install faster-whisper (CTranslate2; cross-platform, CUDA or CPU)",
|
||
"mlx-whisper": "pip install mlx-whisper (Apple Silicon only)",
|
||
"pytorch-whisper": "Bundled with transformers — no extra install (CUDA/MPS/CPU)",
|
||
"nemo-parakeet": "pip install nemo_toolkit[asr] (NVIDIA Parakeet; CUDA or CPU)",
|
||
"moonshine": "pip install useful-moonshine (edge/CPU-optimized ASR)",
|
||
"funasr": "pip install funasr (SenseVoiceSmall + FSMN-VAD; CUDA or CPU)",
|
||
"sherpa-onnx-asr": "uv add sherpa-onnx (ONNX live dictation; CPU, cross-platform)",
|
||
}
|
||
|
||
# Most-recent failure per backend, so a transient probe error survives between
|
||
# Settings refreshes (parity with tts_backend._LAST_ERRORS).
|
||
_LAST_ERRORS: dict[str, str] = {}
|
||
|
||
|
||
def list_backends() -> list[dict]:
|
||
"""Enumerate every ASR backend with the **same 11-key shape as TTS** so the
|
||
Engine Compatibility Matrix renders all families uniformly.
|
||
|
||
Per-entry: id, display_name, available, reason (scrubbed), install_hint,
|
||
last_error, isolation_mode, gpu_compat, effective_device, routing_status,
|
||
routing_reason. A backend whose ``is_available()`` raises is reported
|
||
``available: false`` (never a 500), exactly like TTS.
|
||
"""
|
||
from core.device_caps import detect_host_caps
|
||
from core.scrub import scrub_text
|
||
from services.engine_routing import routing_fields
|
||
caps = detect_host_caps()
|
||
|
||
out: list[dict] = []
|
||
for bid, cls in _REGISTRY.items():
|
||
try:
|
||
ok, msg = cls.is_available()
|
||
except Exception as exc:
|
||
ok = False
|
||
msg = f"{type(exc).__name__}: {exc}"
|
||
logger.warning(
|
||
"asr list_backends: %s.is_available() raised — degrading "
|
||
"gracefully so the picker still renders: %s", bid, msg,
|
||
)
|
||
if ok:
|
||
_LAST_ERRORS.pop(bid, None)
|
||
else:
|
||
_LAST_ERRORS[bid] = scrub_text(msg)
|
||
isolation = "subprocess" if getattr(cls, "_is_subprocess_isolated", False) else "in-process"
|
||
gpu_compat = getattr(cls, "gpu_compat", ("cpu",))
|
||
out.append({
|
||
"id": bid,
|
||
"display_name": cls.display_name,
|
||
"available": ok,
|
||
# ASR previously emitted `reason` UNMASKED — scrub it now (closes a
|
||
# pre-existing token-leak gap, matching TTS's redaction guarantee).
|
||
"reason": None if ok else scrub_text(msg),
|
||
"install_hint": _INSTALL_HINTS.get(bid),
|
||
"last_error": _LAST_ERRORS.get(bid),
|
||
"isolation_mode": isolation,
|
||
"gpu_compat": list(gpu_compat),
|
||
**routing_fields(gpu_compat, caps),
|
||
})
|
||
return out
|
||
|
||
|
||
def _probe_available(cls) -> bool:
|
||
"""``is_available()`` that never raises. A probe that explodes (e.g. a native
|
||
lib that refuses to load — CTranslate2's exec-stack rejection, #692) means the
|
||
engine is unusable on this host, so treat it as unavailable and fall through
|
||
to the next candidate rather than crash engine selection."""
|
||
try:
|
||
ok, _ = cls.is_available()
|
||
return bool(ok)
|
||
except Exception: # noqa: BLE001
|
||
logger.warning(
|
||
"ASR auto-detect: %s.is_available() raised — treating as unavailable",
|
||
cls.__name__, exc_info=True,
|
||
)
|
||
return False
|
||
|
||
|
||
def _auto_detect() -> str:
|
||
"""Pick the best available ASR engine for the current hardware.
|
||
|
||
Preference order:
|
||
1. whisperx — faster-whisper transcription + wav2vec2 forced
|
||
alignment (±10-30 ms word timing). Best for the
|
||
dub pipeline because lip-sync quality depends on
|
||
word-boundary accuracy.
|
||
2. faster-whisper — transcription only (no forced alignment). Slightly
|
||
looser word boundaries but strictly faster; safe
|
||
fallback when whisperx isn't installed.
|
||
3. mlx-whisper — mac-ARM speedup if installed (~10-20% latency win
|
||
vs faster-whisper int8 on Apple Silicon for
|
||
large-v3). Optional; faster-whisper remains the
|
||
baseline so we don't diverge mac-only behaviour.
|
||
4. pytorch-whisper — last resort; requires the TTS model to be loaded
|
||
so it can reuse `_asr_pipe`.
|
||
"""
|
||
if _probe_available(WhisperXBackend):
|
||
return "whisperx"
|
||
if _probe_available(FasterWhisperBackend):
|
||
return "faster-whisper"
|
||
try:
|
||
import torch
|
||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||
if _probe_available(MLXWhisperBackend):
|
||
return "mlx-whisper"
|
||
except Exception:
|
||
pass
|
||
return "pytorch-whisper"
|
||
|
||
|
||
def active_backend_id() -> str:
|
||
explicit = os.environ.get("OMNIVOICE_ASR_BACKEND")
|
||
if explicit:
|
||
return explicit
|
||
from core import prefs
|
||
picked = prefs.get("asr_backend")
|
||
if picked:
|
||
return picked
|
||
return _auto_detect()
|
||
|
||
|
||
def get_active_asr_backend(*, asr_pipe=None) -> ASRBackend:
|
||
bid = active_backend_id()
|
||
if bid == "pytorch-whisper":
|
||
return PyTorchWhisperBackend(asr_pipe=asr_pipe)
|
||
if bid == "mlx-whisper":
|
||
return MLXWhisperBackend()
|
||
if bid == "faster-whisper":
|
||
return FasterWhisperBackend()
|
||
if bid == "whisperx":
|
||
return WhisperXBackend()
|
||
if bid not in _REGISTRY:
|
||
raise ValueError(f"Unknown ASR backend: {bid!r}. Known: {list(_REGISTRY)}")
|
||
return _REGISTRY[bid]()
|
||
|
||
|
||
def transcribe_reference(audio_path: str) -> str | None:
|
||
"""Transcribe a voice-clone reference clip with the active ASR backend.
|
||
|
||
Voice cloning without a user-supplied transcript used to fall through to
|
||
``OmniVoice.load_asr_model()`` — a transformers ``pipeline()`` load of
|
||
whisper-large-v3-turbo that fails outright on transformers 5.3 (#308),
|
||
even when whisperx / faster-whisper / mlx-whisper are installed and
|
||
working. Route the reference transcript through the registry instead, so
|
||
the model-attached pipeline is only reached when it is genuinely the last
|
||
resort. Returns ``None`` on any failure — callers pass ``ref_text=None``
|
||
through and the model's built-in fallback still gets its chance.
|
||
"""
|
||
try:
|
||
backend = get_active_asr_backend()
|
||
except Exception as e: # noqa: BLE001 — never let ASR break generation
|
||
logger.warning("transcribe_reference: no ASR backend available (%s)", e)
|
||
return None
|
||
if isinstance(backend, PyTorchWhisperBackend):
|
||
# The registry fell through to the model-attached pipeline; let the
|
||
# model load it lazily rather than constructing a second copy here.
|
||
return None
|
||
try:
|
||
result = backend.transcribe(audio_path, word_timestamps=False)
|
||
except Exception as e: # noqa: BLE001 — degrade to the model fallback
|
||
logger.warning(
|
||
"transcribe_reference: %s failed (%s) — deferring to the model's "
|
||
"built-in ASR fallback",
|
||
backend.id, e,
|
||
)
|
||
return None
|
||
result = result or {}
|
||
text = result.get("text") or " ".join(
|
||
(seg.get("text") or "").strip() for seg in result.get("segments", [])
|
||
)
|
||
text = (text or "").strip()
|
||
return text or None
|
||
|
||
|
||
_capture_backend: ASRBackend | None = None
|
||
# The sherpa model id the cached capture backend was built for, so a model
|
||
# switch in Settings rebuilds the singleton instead of serving the old model.
|
||
_capture_backend_key: str | None = None
|
||
|
||
|
||
def dictation_model_id() -> str | None:
|
||
"""The selected sherpa dictation model id, or None when dictation is off /
|
||
no sherpa model is chosen. Env var wins (power-user pin), then prefs."""
|
||
explicit = os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL")
|
||
if explicit:
|
||
return explicit
|
||
try:
|
||
from core import prefs
|
||
if not prefs.get("dictation.enabled", True):
|
||
return None
|
||
mid = prefs.get("dictation.model_id")
|
||
except Exception:
|
||
return None
|
||
from services.sherpa_dictation import is_sherpa_model
|
||
return mid if is_sherpa_model(mid) else None
|
||
|
||
|
||
def get_capture_asr_backend() -> ASRBackend:
|
||
"""Pick the fastest ASR engine for capture / dictation.
|
||
|
||
Selection order:
|
||
|
||
0. sherpa-onnx dictation — when ``dictation.model_id`` names one of the
|
||
seven sherpa models (live/CPU; the new live-dictation path).
|
||
1. mlx-whisper Turbo — Apple Silicon, ~5× faster than large-v3
|
||
2. mlx-whisper large — still native Metal, faster than CPU int8
|
||
3. faster-whisper — cross-platform CTranslate2 fallback
|
||
4. pytorch-whisper — last resort
|
||
|
||
The caller should also pass ``word_timestamps=False`` to the returned
|
||
backend to skip per-word timing and shave another ~30% latency.
|
||
|
||
Returns a cached singleton so the model stays warm between calls; the
|
||
singleton is rebuilt if the selected sherpa model changes.
|
||
"""
|
||
global _capture_backend, _capture_backend_key
|
||
|
||
# 0. Honor an explicit sherpa dictation model selection.
|
||
sherpa_id = dictation_model_id()
|
||
if sherpa_id:
|
||
ok, _ = SherpaDictationBackend.is_available()
|
||
if ok:
|
||
if not (isinstance(_capture_backend, SherpaDictationBackend)
|
||
and _capture_backend_key == sherpa_id):
|
||
try:
|
||
_capture_backend = SherpaDictationBackend(model_id=sherpa_id)
|
||
_capture_backend_key = sherpa_id
|
||
except Exception as e: # noqa: BLE001 — fall through to Whisper
|
||
logger.warning(
|
||
"sherpa dictation model %r unavailable (%s) — falling "
|
||
"back to Whisper capture engine", sherpa_id, e,
|
||
)
|
||
_capture_backend = None
|
||
_capture_backend_key = None
|
||
if _capture_backend is not None:
|
||
return _capture_backend
|
||
else:
|
||
logger.info(
|
||
"dictation.model_id=%r selected but sherpa-onnx not installed — "
|
||
"falling back to Whisper capture engine", sherpa_id,
|
||
)
|
||
|
||
if _capture_backend is not None and _capture_backend_key is None:
|
||
return _capture_backend
|
||
|
||
# Prefer MLX Turbo on Apple Silicon
|
||
ok, _ = MLXWhisperBackend.is_available()
|
||
if ok:
|
||
_capture_backend = MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
|
||
_capture_backend_key = None
|
||
return _capture_backend
|
||
|
||
# Fall back to faster-whisper (CPU int8 on non-Apple)
|
||
ok, _ = FasterWhisperBackend.is_available()
|
||
if ok:
|
||
_capture_backend = FasterWhisperBackend()
|
||
_capture_backend_key = None
|
||
return _capture_backend
|
||
|
||
# Last resort
|
||
_capture_backend = PyTorchWhisperBackend()
|
||
_capture_backend_key = None
|
||
return _capture_backend
|