Files
VoiceStudio/backend/services/asr_backend.py
T
Palash DebnathandClaude Opus 4.8 63a0d00f09 fix(asr): PyTorch-Whisper fallback works without cuDNN 8 or preload (#255) (#268)
Windows + NVIDIA users hit `Could not locate cudnn_ops_infer64_8.dll`:
WhisperX/faster-whisper run on CTranslate2, which needs cuDNN 8, but PyTorch
2.8 ships cuDNN 9 and the side-loaded `cudnn8_compat` libs were missing from
the venv. The PyTorch-Whisper backend should have been the fallback, but it
errored "set OMNIVOICE_PRELOAD_TTS_ASR=1" because it only worked when the TTS
model preloaded an ASR head.

- `PyTorchWhisperBackend._ensure_pipe()` now builds its OWN transformers ASR
  pipeline on demand (PyTorch stack → cuDNN 9, no CTranslate2/cuDNN-8), without
  loading the full TTS model and without the preload env var. A constructor-
  passed pipe (when the TTS model already has one) is still reused. Model is
  overridable via OMNIVOICE_PYTORCH_ASR_MODEL.
- dub_core transcribe preflight no longer hard-rejects pytorch-whisper when no
  pipe is preloaded — it lazy-loads; any failure surfaces per-chunk with the
  real cause.

So a Windows box without cuDNN 8 can switch ASR backend to "PyTorch Whisper"
in Settings → Models and transcription works. Docs: troubleshooting entry.

Tests: tests/test_pytorch_whisper_fallback.py (lazy standalone build, reuse of
a passed pipe, no get_model() call, env override). Full tests/ suite: 700 pass.

Does NOT close #255 — pending the reporter confirming the fallback works on
their machine; the cuDNN-8 install gap (faster-whisper path) is a follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:23:58 +05:30

1057 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 logging
import os
import re
from abc import ABC, abstractmethod
logger = logging.getLogger("omnivoice.asr")
# ── Protocol ────────────────────────────────────────────────────────────────
class ASRBackend(ABC):
id: str = "base"
display_name: str = "Base ASR"
@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 unload(self) -> None:
"""Release the model from memory."""
pass
# ── WhisperX (cross-platform default — forced-alignment word timing) ────────
class WhisperXBackend(ASRBackend):
id = "whisperx"
display_name = "WhisperX (faster-whisper + wav2vec2 forced alignment)"
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}"
def _ensure_asr(self):
if self._asr is not None:
return
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 RuntimeError as e:
# 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
self._ensure_asr()
logger.info("whisperx transcribing %s (word_timestamps=%s)", audio_path, word_timestamps)
audio = whisperx.load_audio(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)"
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
@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}"
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,
)
self._model = WhisperModel(
self._model_name, device=device, compute_type=compute_type
)
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)"
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]:
try:
import torch
if not (hasattr(torch.backends, "mps") and torch.backends.mps.is_available()):
return False, "Apple Silicon (MPS) not available."
import mlx_whisper # noqa: F401
return True, "ready"
except ImportError as e:
return False, f"mlx-whisper not installed: {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)"
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,
)
self._pipe = hf_pipeline(
"automatic-speech-recognition",
model=model_name,
dtype=asr_dtype,
device_map=device,
)
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"
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"
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
# ── 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"
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
_REGISTRY: dict[str, type[ASRBackend]] = {
"whisperx": WhisperXBackend,
"faster-whisper": FasterWhisperBackend,
"mlx-whisper": MLXWhisperBackend,
"pytorch-whisper": PyTorchWhisperBackend,
"nemo-parakeet": NeMoASRBackend,
"moonshine": MoonshineASRBackend,
"funasr": FunASRBackend,
}
def list_backends() -> list[dict]:
out = []
for bid, cls in _REGISTRY.items():
ok, msg = cls.is_available()
out.append({
"id": bid,
"display_name": cls.display_name,
"available": ok,
"reason": None if ok else msg,
})
return out
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`.
"""
ok, _ = WhisperXBackend.is_available()
if ok:
return "whisperx"
ok, _ = FasterWhisperBackend.is_available()
if ok:
return "faster-whisper"
try:
import torch
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
ok, _ = MLXWhisperBackend.is_available()
if ok:
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]()
_capture_backend: ASRBackend | None = None
def get_capture_asr_backend() -> ASRBackend:
"""Pick the fastest ASR engine for capture / dictation.
Priority order (speed-first — word alignment is unnecessary for
dictation, so we skip WhisperX's forced-alignment overhead):
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.
"""
global _capture_backend
if _capture_backend is not None:
return _capture_backend
# Prefer MLX Turbo on Apple Silicon
ok, _ = MLXWhisperBackend.is_available()
if ok:
_capture_backend = MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
return _capture_backend
# Fall back to faster-whisper (CPU int8 on non-Apple)
ok, _ = FasterWhisperBackend.is_available()
if ok:
_capture_backend = FasterWhisperBackend()
return _capture_backend
# Last resort
_capture_backend = PyTorchWhisperBackend()
return _capture_backend