Files
FabledScribe/src/fabledassistant/services/stt.py
T

91 lines
2.9 KiB
Python

"""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)