"""Speech-to-text service using faster-whisper (in-process, CPU/GPU).""" import asyncio import logging import tempfile import time from typing import TYPE_CHECKING if TYPE_CHECKING: from faster_whisper import WhisperModel logger = logging.getLogger(__name__) _model: "WhisperModel | None" = None _model_lock = asyncio.Lock() _load_error: str | None = None async def load_stt_model() -> None: """Load the Whisper model. Called once at startup when voice is enabled.""" global _model, _load_error from fabledassistant.services.voice_config import get_stt_model, is_voice_enabled if not await is_voice_enabled(): return async with _model_lock: if _model is not None: return try: from faster_whisper import WhisperModel model_name = await get_stt_model() logger.info("Loading Whisper STT model '%s'...", model_name) loop = asyncio.get_running_loop() _model = await loop.run_in_executor( None, lambda: WhisperModel(model_name, device="cpu", compute_type="int8"), ) logger.info("Whisper STT model '%s' loaded", model_name) except Exception: _load_error = "Failed to load Whisper STT model" logger.exception(_load_error) async def reload_stt_model() -> None: """Unload the current model and reload with the current config. Safe to call at runtime.""" global _model, _load_error async with _model_lock: _model = None _load_error = None await load_stt_model() def stt_available() -> bool: return _model is not None async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm", initial_prompt: str | None = None) -> str: """Transcribe audio bytes to text. Runs the model in a thread executor. initial_prompt: optional text to bias the model toward domain-specific vocabulary (e.g. recent conversation context). Reduces mishearings like "gold" for "cold". """ if _model is None: raise RuntimeError("STT model not loaded") suffix = ".webm" if "ogg" in mime_type: suffix = ".ogg" elif "wav" in mime_type: suffix = ".wav" elif "mp4" in mime_type or "m4a" in mime_type: suffix = ".mp4" def _run() -> str: with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as f: f.write(audio_bytes) f.flush() t0 = time.monotonic() segments, _ = _model.transcribe( # type: ignore[union-attr] f.name, beam_size=5, initial_prompt=initial_prompt or None, ) text = " ".join(seg.text.strip() for seg in segments).strip() logger.debug("STT transcription took %.2fs", time.monotonic() - t0) return text loop = asyncio.get_running_loop() return await loop.run_in_executor(None, _run)