Files
voicebox/backend/routes/captures.py
T
James PineandClaude Opus 4.7 87c582ad54 feat(capture): dictation, personalities, 0.5.0
Ships the Capture release end to end. Global-hotkey dictation with
synthetic paste into the focused app on macOS and Windows, an on-screen
pill across recording / transcribing / refining, customizable push-to-
talk and toggle chords, and an accessibility-permission prompt scoped to
Settings → Captures with inline re-check feedback.

Voice profiles gain optional personalities that power compose / rewrite /
respond actions via a local Qwen3 LLM — shared with refinement, so there
is one local LLM in the app, not two.

Refinement hardened with deterministic Whisper-loop collapse before the
LLM sees the transcript, per-capture flag snapshots for re-runs, and a
ten-transcript evaluation harness across every bundled refinement size.

Version bump 0.4.5 → 0.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-22 18:49:16 -07:00

184 lines
6.2 KiB
Python

"""Capture (voice input) endpoints."""
import logging
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from .. import config, models
from ..database import Capture as DBCapture, get_db
from ..services import captures as captures_service
from ..services import settings as settings_service
from ..services.refinement import RefinementFlags
logger = logging.getLogger(__name__)
router = APIRouter()
UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB
@router.post("/captures", response_model=models.CaptureCreateResponse)
async def create_capture_endpoint(
file: UploadFile = File(...),
source: str = Form("file"),
language: str | None = Form(None),
stt_model: str | None = Form(None),
db: Session = Depends(get_db),
):
"""Upload audio, run STT, persist the capture."""
chunks = []
while chunk := await file.read(UPLOAD_CHUNK_SIZE):
chunks.append(chunk)
audio_bytes = b"".join(chunks)
if not audio_bytes:
raise HTTPException(status_code=400, detail="Uploaded file is empty")
saved = settings_service.get_capture_settings(db)
resolved_stt = stt_model or saved.stt_model
if language is None:
resolved_language = None if saved.language == "auto" else saved.language
else:
resolved_language = None if language == "auto" else language
try:
capture = await captures_service.create_capture(
audio_bytes=audio_bytes,
filename=file.filename or "capture.wav",
source=source,
language=resolved_language,
stt_model=resolved_stt,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("Failed to create capture")
raise HTTPException(status_code=500, detail=str(e))
return models.CaptureCreateResponse(
**capture.model_dump(),
auto_refine=bool(saved.auto_refine),
allow_auto_paste=bool(saved.allow_auto_paste),
)
@router.get("/captures", response_model=models.CaptureListResponse)
async def list_captures_endpoint(
limit: int = 50,
offset: int = 0,
db: Session = Depends(get_db),
):
if limit < 1 or limit > 200:
raise HTTPException(status_code=400, detail="limit must be between 1 and 200")
if offset < 0:
raise HTTPException(status_code=400, detail="offset must be >= 0")
items, total = captures_service.list_captures(db, limit=limit, offset=offset)
return models.CaptureListResponse(items=items, total=total)
@router.get("/captures/{capture_id}", response_model=models.CaptureResponse)
async def get_capture_endpoint(capture_id: str, db: Session = Depends(get_db)):
capture = captures_service.get_capture(capture_id, db)
if not capture:
raise HTTPException(status_code=404, detail="Capture not found")
return capture
@router.get("/captures/{capture_id}/audio")
async def get_capture_audio_endpoint(capture_id: str, db: Session = Depends(get_db)):
"""Stream the original capture audio file."""
row = db.query(DBCapture).filter(DBCapture.id == capture_id).first()
if not row:
raise HTTPException(status_code=404, detail="Capture not found")
audio_path = config.resolve_storage_path(row.audio_path)
if audio_path is None or not audio_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
audio_path,
media_type="audio/wav",
filename=f"capture_{capture_id}.wav",
)
@router.delete("/captures/{capture_id}")
async def delete_capture_endpoint(capture_id: str, db: Session = Depends(get_db)):
deleted = captures_service.delete_capture(capture_id, db)
if not deleted:
raise HTTPException(status_code=404, detail="Capture not found")
return {"message": f"Capture {capture_id} deleted"}
@router.post("/captures/{capture_id}/refine", response_model=models.CaptureResponse)
async def refine_capture_endpoint(
capture_id: str,
request: models.CaptureRefineRequest,
db: Session = Depends(get_db),
):
saved = settings_service.get_capture_settings(db)
if request.flags is not None:
flags = RefinementFlags(
smart_cleanup=request.flags.smart_cleanup,
self_correction=request.flags.self_correction,
preserve_technical=request.flags.preserve_technical,
)
else:
flags = RefinementFlags(
smart_cleanup=saved.smart_cleanup,
self_correction=saved.self_correction,
preserve_technical=saved.preserve_technical,
)
resolved_model = request.model_size or saved.llm_model
try:
capture = await captures_service.refine_capture(
capture_id=capture_id,
flags=flags,
model_size=resolved_model,
db=db,
)
except Exception as e:
logger.exception("Refinement failed for capture %s", capture_id)
raise HTTPException(status_code=500, detail=str(e))
if not capture:
raise HTTPException(status_code=404, detail="Capture not found")
return capture
@router.post("/captures/{capture_id}/retranscribe", response_model=models.CaptureResponse)
async def retranscribe_capture_endpoint(
capture_id: str,
request: models.CaptureRetranscribeRequest,
db: Session = Depends(get_db),
):
saved = settings_service.get_capture_settings(db)
resolved_stt = request.model or saved.stt_model
if request.language is None:
resolved_language = None if saved.language == "auto" else saved.language
else:
resolved_language = request.language
try:
capture = await captures_service.retranscribe_capture(
capture_id=capture_id,
stt_model=resolved_stt,
language=resolved_language,
db=db,
)
except FileNotFoundError as e:
raise HTTPException(status_code=410, detail=str(e))
except Exception as e:
logger.exception("Retranscribe failed for capture %s", capture_id)
raise HTTPException(status_code=500, detail=str(e))
if not capture:
raise HTTPException(status_code=404, detail="Capture not found")
return capture