""" Transcription endpoints — faster-whisper audio-to-text for IVR analysis. Two surfaces: POST /transcribe — raw audio upload (internal / testing use) POST /transcribe/recording — Twilio webhook; downloads RecordingUrl and returns transcribed text Model is lazy-loaded on first request and cached for the process lifetime. Set OSPREY_WHISPER_MODEL to override the default model path. Set OSPREY_WHISPER_DEVICE to "cpu" if no GPU is available (slower). """ from __future__ import annotations import os import tempfile from pathlib import Path import httpx from fastapi import APIRouter, Form, HTTPException, UploadFile from fastapi.responses import JSONResponse router = APIRouter(prefix="/transcribe", tags=["transcription"]) # Default: the faster-whisper-medium model migrated to Assets _DEFAULT_MODEL_PATH = ( "/Library/Assets/LLM/whisper/models/Whisper/faster-whisper" "/models--Systran--faster-whisper-medium/snapshots" ) _model = None # lazy-loaded WhisperModel singleton def _get_model(): """Load and cache the WhisperModel. Raises RuntimeError if faster-whisper is not installed.""" global _model if _model is not None: return _model try: from faster_whisper import WhisperModel # type: ignore except ImportError as exc: raise RuntimeError( "faster-whisper is not installed. " "Add faster-whisper to requirements.txt and reinstall." ) from exc model_path = os.environ.get("OSPREY_WHISPER_MODEL", _DEFAULT_MODEL_PATH) device = os.environ.get("OSPREY_WHISPER_DEVICE", "cuda") compute_type = "float16" if device == "cuda" else "int8" # Resolve snapshot hash dir if the path points to the snapshots parent path = Path(model_path) if path.is_dir(): snapshots = list(path.iterdir()) if path.name == "snapshots" else [] if snapshots: path = snapshots[0] # use the first (and typically only) snapshot _model = WhisperModel(str(path), device=device, compute_type=compute_type) return _model def _transcribe_file(audio_path: str) -> dict: """Run transcription on a local file. Returns segments and full text.""" model = _get_model() segments, info = model.transcribe(audio_path, beam_size=5) segment_list = [ {"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text.strip()} for s in segments ] full_text = " ".join(s["text"] for s in segment_list).strip() return { "text": full_text, "language": info.language, "language_probability": round(info.language_probability, 3), "segments": segment_list, } @router.post("") async def transcribe_upload(file: UploadFile) -> JSONResponse: """ Accept a raw audio file upload and return the transcription. Supports any format faster-whisper accepts (wav, mp3, ogg, flac, m4a). """ suffix = Path(file.filename or "audio.wav").suffix or ".wav" with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: tmp.write(await file.read()) tmp_path = tmp.name try: result = _transcribe_file(tmp_path) except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc finally: Path(tmp_path).unlink(missing_ok=True) return JSONResponse(result) @router.post("/recording") async def transcribe_twilio_recording( RecordingUrl: str = Form(...), RecordingSid: str = Form(""), RecordingDuration: str = Form("0"), ) -> JSONResponse: """ Twilio status callback. Downloads the recording and transcribes it. Twilio posts RecordingUrl without an extension; appending .wav gives a direct download. Authentication uses TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN. """ account_sid = os.environ.get("TWILIO_ACCOUNT_SID", "") auth_token = os.environ.get("TWILIO_AUTH_TOKEN", "") wav_url = RecordingUrl.rstrip("/") + ".wav" auth = (account_sid, auth_token) if account_sid and auth_token else None async with httpx.AsyncClient(timeout=30) as client: try: resp = await client.get(wav_url, auth=auth) resp.raise_for_status() except httpx.HTTPError as exc: raise HTTPException( status_code=502, detail=f"Failed to download Twilio recording: {exc}", ) from exc with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: tmp.write(resp.content) tmp_path = tmp.name try: result = _transcribe_file(tmp_path) except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc finally: Path(tmp_path).unlink(missing_ok=True) return JSONResponse({**result, "recording_sid": RecordingSid, "duration_s": RecordingDuration})