Mercurial
diff dictation/transcriber.py @ 275:78699f810817
Add Qwen3-VL and WebRTC dictation services
Add Bazel targets for the CUDA-backed Qwen3-VL server and a local WebRTC faster-whisper dictation service.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: e3d8cb06-6c95-4ae0-9757-651d3796ab00
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 10:58:47 -0700 |
| parents | |
| children | 49e9e591c9bb |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/dictation/transcriber.py Mon Aug 17 10:58:47 2026 -0700 @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +import numpy as np + + +@dataclass(frozen=True) +class Transcript: + text: str + language: str + probability: float + + +class Transcriber(Protocol): + async def warmup(self) -> None: + ... + + async def transcribe( + self, + samples: np.ndarray, + *, + final: bool, + ) -> Transcript: + ... + + +class FasterWhisperTranscriber: + def __init__(self, model_dir: Path, compute_type: str) -> None: + self._model_dir = model_dir + self._compute_type = compute_type + self._executor = ThreadPoolExecutor( + max_workers=1, + thread_name_prefix="dictation", + ) + self._model = None + self._load_lock = asyncio.Lock() + + async def warmup(self) -> None: + if self._model is not None: + return + async with self._load_lock: + if self._model is not None: + return + loop = asyncio.get_running_loop() + self._model = await loop.run_in_executor( + self._executor, + self._load, + ) + + def _load(self): + from faster_whisper import WhisperModel + + if not (self._model_dir / "model.bin").is_file(): + raise FileNotFoundError( + f"Model not found at {self._model_dir}. " + "Run: bazel run //dictation:download_model" + ) + return WhisperModel( + str(self._model_dir), + device="cuda", + compute_type=self._compute_type, + local_files_only=True, + ) + + async def transcribe( + self, + samples: np.ndarray, + *, + final: bool, + ) -> Transcript: + await self.warmup() + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + self._executor, + self._transcribe_sync, + np.asarray(samples, dtype=np.float32), + final, + ) + + def _transcribe_sync( + self, + samples: np.ndarray, + final: bool, + ) -> Transcript: + segments, info = self._model.transcribe( + samples, + beam_size=5 if final else 1, + best_of=5 if final else 1, + condition_on_previous_text=False, + vad_filter=False, + ) + text = "".join(segment.text for segment in segments).strip() + return Transcript( + text=text, + language=info.language or "", + probability=float(info.language_probability or 0.0), + ) + + def close(self) -> None: + self._executor.shutdown(wait=False, cancel_futures=True)