140 lines
4.8 KiB
Python
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})
|