osprey/api/transcription.py

140 lines
4.8 KiB
Python

"""
Transcription endpoints — faster-whisper audio-to-text for IVR analysis.
Two surfaces:
POST /transcribe — raw audio upload (internal / testing use)
POST /transcribe/recording — Twilio <Record> 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 <Record> 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})