Files
voicebox/backend/services/captures.py
T
Jamie Pine b434db22f6 chore(backend): repair test suite and bring ruff to green
The suite hadn't run green since the routes refactor:
- test_profile_duplicate_names.py imported the pre-refactor module
  layout and broke collection; now imports backend.services.profiles
- tests/conftest.py puts the repo root and backend dir on sys.path so
  files collect standalone instead of depending on run order
- test_cors.py tested a hand-copied mirror of the origin list that had
  drifted from app.py (missing http://tauri.localhost); it now builds
  the app via the real create_app() factory
- test_progress.py simulated a 1KB download, below the tracker's 1MB
  reporting threshold; simulation raised to 5MB
- slow/timeout markers registered in pyproject

Ruff: ~900 violations auto-fixed (typing modernization, import
sorting, unused imports, whitespace). The remaining rules are baselined
in pyproject.toml with per-rule counts to burn down, plus per-file
carve-outs for deliberate env-before-import ordering. ruff check is
now clean; suite is 134 passed, 2 skipped.
2026-07-26 23:16:09 -07:00

236 lines
7.5 KiB
Python

"""
Captures service — persists raw audio alongside its STT transcript and,
optionally, an LLM-refined version.
A capture is a single voice input event (dictation, long-form recording, or
uploaded file). Storage mirrors the generations flow: audio lives under
``data/captures/<id>.wav`` and rows live in the ``captures`` table.
"""
import contextlib
import json
import logging
import uuid
from pathlib import Path
import soundfile as sf
from sqlalchemy.orm import Session
from .. import config
from ..database import Capture as DBCapture
from ..models import CaptureResponse, RefinementFlagsModel
from ..utils.audio import load_audio
from .refinement import RefinementFlags, refine_transcript
from .transcribe import get_whisper_model
logger = logging.getLogger(__name__)
VALID_SOURCES = {"dictation", "recording", "file"}
# Suffixes whisper's miniaudio loader can read directly. Anything outside
# this set has to go through librosa for decode + a soundfile transcode
# before whisper sees it.
WHISPER_NATIVE_FORMATS = (".wav", ".mp3", ".flac", ".ogg")
def _to_response(row: DBCapture) -> CaptureResponse:
flags_model: RefinementFlagsModel | None = None
if row.refinement_flags:
try:
flags_model = RefinementFlagsModel(**json.loads(row.refinement_flags))
except (ValueError, TypeError):
flags_model = None
return CaptureResponse(
id=row.id,
audio_path=row.audio_path,
source=row.source,
language=row.language,
duration_ms=row.duration_ms,
transcript_raw=row.transcript_raw or "",
transcript_refined=row.transcript_refined,
stt_model=row.stt_model,
llm_model=row.llm_model,
refinement_flags=flags_model,
created_at=row.created_at,
)
async def create_capture(
*,
audio_bytes: bytes,
filename: str,
source: str,
language: str | None,
stt_model: str | None,
db: Session,
) -> CaptureResponse:
"""Persist raw audio, run STT, store the row."""
if source not in VALID_SOURCES:
raise ValueError(f"Invalid source '{source}'. Must be one of {sorted(VALID_SOURCES)}")
capture_id = str(uuid.uuid4())
suffix = Path(filename).suffix.lower() or ".wav"
if suffix not in (".wav", ".mp3", ".m4a", ".flac", ".ogg", ".webm"):
suffix = ".wav"
raw_path = config.get_captures_dir() / f"{capture_id}{suffix}"
written_files: list[Path] = []
try:
raw_path.write_bytes(audio_bytes)
written_files.append(raw_path)
# Decode once with librosa — its audioread fallback handles webm/opus
# via ffmpeg, which miniaudio (used inside mlx-audio's whisper) can't.
# The decoded array gives us an accurate duration and becomes the
# canonical WAV we hand to whisper.
try:
audio, sr = load_audio(str(raw_path))
duration_ms = int((len(audio) / sr) * 1000) if sr else None
except Exception as decode_err:
logger.warning(
"Could not decode capture %s (%s): %r", capture_id, suffix, decode_err
)
audio, sr = None, None
duration_ms = None
if audio is None or sr is None:
# Decode failed. Only pass the file straight to whisper if the
# source is a format its miniaudio loader can still read — webm,
# m4a, etc. would just 500 later. Surface a clean error instead.
if suffix not in WHISPER_NATIVE_FORMATS:
raise ValueError(
f"Could not decode {suffix} audio — the recording may be empty or corrupt"
)
audio_path = raw_path
elif suffix == ".wav":
audio_path = raw_path
else:
# Transcode to WAV so downstream loaders (miniaudio, soundfile) work
# regardless of what format the client shipped.
audio_path = config.get_captures_dir() / f"{capture_id}.wav"
sf.write(str(audio_path), audio, sr, format="WAV")
written_files.append(audio_path)
with contextlib.suppress(OSError):
raw_path.unlink()
written_files.remove(raw_path)
whisper = get_whisper_model()
resolved_stt = stt_model or whisper.model_size
transcript = await whisper.transcribe(str(audio_path), language, resolved_stt)
row = DBCapture(
id=capture_id,
audio_path=config.to_storage_path(audio_path),
source=source,
language=language,
duration_ms=duration_ms,
transcript_raw=transcript,
stt_model=resolved_stt,
)
db.add(row)
db.commit()
db.refresh(row)
except Exception:
# Anything between the first write and the commit means the audio on
# disk has no row pointing at it — clean up so data/captures doesn't
# accumulate orphan blobs across failed transcribes.
for path in written_files:
try:
path.unlink()
except OSError:
pass
raise
return _to_response(row)
def list_captures(db: Session, limit: int = 50, offset: int = 0) -> tuple[list[CaptureResponse], int]:
total = db.query(DBCapture).count()
rows = (
db.query(DBCapture)
.order_by(DBCapture.created_at.desc())
.limit(limit)
.offset(offset)
.all()
)
return [_to_response(r) for r in rows], total
def get_capture(capture_id: str, db: Session) -> CaptureResponse | None:
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
return _to_response(row) if row else None
def delete_capture(capture_id: str, db: Session) -> bool:
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
if not row:
return False
resolved = config.resolve_storage_path(row.audio_path)
if resolved and resolved.exists():
try:
resolved.unlink()
except OSError:
logger.exception("Failed to remove capture audio %s", resolved)
db.delete(row)
db.commit()
return True
async def refine_capture(
capture_id: str,
flags: RefinementFlags,
model_size: str | None,
db: Session,
) -> CaptureResponse | None:
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
if not row:
return None
refined, llm_size = await refine_transcript(
row.transcript_raw or "",
flags,
model_size=model_size,
)
row.transcript_refined = refined
row.llm_model = llm_size
row.refinement_flags = json.dumps(flags.to_dict())
db.commit()
db.refresh(row)
return _to_response(row)
async def retranscribe_capture(
capture_id: str,
stt_model: str | None,
language: str | None,
db: Session,
) -> CaptureResponse | None:
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
if not row:
return None
resolved = config.resolve_storage_path(row.audio_path)
if not resolved or not resolved.exists():
raise FileNotFoundError(f"Audio for capture {capture_id} is missing")
whisper = get_whisper_model()
resolved_stt = stt_model or whisper.model_size
transcript = await whisper.transcribe(str(resolved), language, resolved_stt)
row.transcript_raw = transcript
row.stt_model = resolved_stt
if language:
row.language = language
# Refined text is stale after a fresh STT pass — force a re-refine.
row.transcript_refined = None
row.llm_model = None
row.refinement_flags = None
db.commit()
db.refresh(row)
return _to_response(row)