Files
jonandClaude Opus 4.8 ba6a4ac4e4 feat: workspaces, agentic action tools, local Whisper STT, vec recall
- 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>
2026-07-23 14:22:46 -05:00

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