Files
FabledGrimoire/src/fabledassistant/services/stt.py
T
bvandeusenandClaude Sonnet 4.6 f146485df3 feat: hot-reload voice models without server restart
Voice enabled/STT model are now DB-backed (admin settings), not env
vars. Added reload_stt_model()/reload_tts_model() that clear singletons
under lock and re-trigger loading. POST /api/admin/voice/reload triggers
both in background tasks. Settings UI polls /api/voice/status every 2.5s
until models are ready, with spinner feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:52:07 -04:00

83 lines
2.6 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") -> str:
"""Transcribe audio bytes to text. Runs the model in a thread executor."""
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(f.name, beam_size=5) # type: ignore[union-attr]
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)