"""Local speech-to-text via faster-whisper. Deliberately torch-free: faster-whisper runs on CTranslate2 (CPU, int8), so it fits the "no ML stack in the venv" rule that keeps Promethean small. Replaces the browser's Web Speech API, which in Chrome ships audio to Google — the whole point is to keep dictation on-device. Model size via NEXUS_STT_MODEL (default "base"); downloaded and cached on first use. If faster-whisper isn't installed, `available()` is False and the backend reports it so the UI can fall back. """ from __future__ import annotations import base64 import logging import os import tempfile _log = logging.getLogger("nexus.stt") _MODEL = None _MODEL_SIZE = os.getenv("NEXUS_STT_MODEL", "base") def available() -> bool: try: import faster_whisper # noqa: F401 return True except Exception: return False def _get_model(): global _MODEL if _MODEL is None: from faster_whisper import WhisperModel _log.info("loading whisper model %s (cpu/int8)", _MODEL_SIZE) _MODEL = WhisperModel(_MODEL_SIZE, device="cpu", compute_type="int8") return _MODEL def transcribe_b64(audio_b64: str) -> str: """Transcribe a base64 audio blob (any container PyAV can decode — the browser's MediaRecorder produces webm/opus).""" raw = base64.b64decode(audio_b64) with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f: f.write(raw) path = f.name try: segments, _info = _get_model().transcribe(path, vad_filter=True) return " ".join(s.text for s in segments).strip() finally: try: os.unlink(path) except OSError: pass