Files
VoiceStudio/backend/services/asr_backend.py
T
Palash Debnath 8d11e19494 feat: dictation maturity + batch TTS pipeline + tests (#32)
Global Hotkey:
- Register ⌘+⇧+Space system-wide via tauri-plugin-global-shortcut
- Shows/focuses window and emits tray-dictate event from any app

Auto-Paste:
- enigo crate simulates ⌘V/Ctrl+V after transcription
- Text auto-pastes into whatever app was active before dictation

Streaming ASR:
- WebSocket endpoint /ws/transcribe for live partial transcription
- 2s buffer interval, configurable via OMNIVOICE_STREAM_INTERVAL
- CaptureButton streams audio chunks, shows italic partial text
- Falls back to HTTP POST if WebSocket unavailable

Batch TTS Pipeline:
- Replace stub worker with full pipeline:
  extract → transcribe → translate → generate → mix → export
- Per-job progress tracking (stage, percent, current_lang, segment)
- GoogleTranslator integration via deep_translator
- Download endpoint GET /batch/download/{id}/{lang}
- BatchQueue UI rewritten: progress bars, cancel/delete, downloads
- Type-safe API client (api/batch.ts)

Tests:
- 23 tests for batch endpoints + streaming ASR helpers
- Lightweight fixtures that stub GPU deps

UX (earlier sessions):
- Dual-mode ASR (Turbo MLX + WhisperX Accurate)
- Enhanced download progress (speed, ETA, bytes)
- Status bar black flash fix
- Cold-start model preloading
- Full accessibility audit (ARIA, focus-visible)
- Compact UI layout improvements
- README updated with new features
2026-04-28 18:54:26 +05:30

585 lines
22 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
from abc import ABC, abstractmethod
from typing import Optional
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
import torch
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 VAD file ships inside the whisperx wheel,
# so it's as trusted as whisperx itself. Two-layer defence:
# (a) allowlist the known pickle globals so the secure load path
# actually succeeds, and
# (b) monkey-patch `torch.load` to force `weights_only=False` as
# a belt-and-braces fallback for anything we missed.
self._allow_vad_pickle_globals()
import torch.serialization as _ts
_orig_top = torch.load
_orig_inner = _ts.load
def _patched(*args, **kwargs):
# Force — Lightning explicitly passes weights_only=True, so a
# setdefault wouldn't override it. The VAD pickle ships in the
# whisperx wheel; trust is the same as trusting whisperx itself.
kwargs["weights_only"] = False
return _orig_inner(*args, **kwargs)
torch.load = _patched
_ts.load = _patched
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.
)
finally:
torch.load = _orig_top
_ts.load = _orig_inner
@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 — the immediate cause of the error
# pyannote's VAD emits (`GLOBAL omegaconf.listconfig.ListConfig`).
try:
from omegaconf.listconfig import ListConfig
from omegaconf.dictconfig import DictConfig
from omegaconf.base import ContainerMetadata, Metadata
allow += [ListConfig, DictConfig, ContainerMetadata, Metadata]
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
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)
result = self._asr.transcribe(audio)
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
# ── 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
# Fall back to grabbing the TTS model's ASR head.
import asyncio
from services.model_manager import get_model
try:
loop = asyncio.get_event_loop()
if loop.is_running():
raise RuntimeError(
"PyTorchWhisperBackend needs the ASR pipe — pass it via constructor "
"when calling from an async context."
)
model = loop.run_until_complete(get_model())
except RuntimeError:
model = asyncio.run(get_model())
self._pipe = getattr(model, "_asr_pipe", None)
if self._pipe is None:
raise RuntimeError("Loaded TTS model has no `_asr_pipe` attribute.")
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}
# ── Registry ────────────────────────────────────────────────────────────────
_REGISTRY: dict[str, type[ASRBackend]] = {
"whisperx": WhisperXBackend,
"faster-whisper": FasterWhisperBackend,
"mlx-whisper": MLXWhisperBackend,
"pytorch-whisper": PyTorchWhisperBackend,
}
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]()
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.
"""
# Prefer MLX Turbo on Apple Silicon
ok, _ = MLXWhisperBackend.is_available()
if ok:
# Use Turbo model for maximum speed
return MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
# Fall back to faster-whisper (CPU int8 on non-Apple)
ok, _ = FasterWhisperBackend.is_available()
if ok:
return FasterWhisperBackend()
# Last resort
return PyTorchWhisperBackend()