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>
83 lines
2.6 KiB
Python
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)
|