- Projects/workspaces: documents grouped into projects; chat RAG scopes to the active project. Switcher in the Documents page. - Agentic action tools: web_search, fetch_url, and remember (first write tool), allowlist-gated per playbook. - Local Whisper STT (faster-whisper, no torch): on-device dictation replacing the browser Web Speech API. POST /stt + GET /stt/status; browser fallback. - Vector index extended to conversation recall (message_vectors), with the brute-force cosine scan kept as the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
56 lines
1.7 KiB
Python
56 lines
1.7 KiB
Python
"""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
|