EmpathRAG / src /pipeline /voice.py
MukulRay's picture
V4: streaming, controlled paraphrasing, support plan, voice, sweeps
655c300
Raw
History Blame
5.17 kB
"""Voice transcription via Groq Whisper turbo.
Same Groq key as the text rephraser, same Cloudflare-aware UA. Multipart
upload of the recorded audio file; returns plain text.
Usage:
transcriber = GroqWhisperTranscriber()
if transcriber.available():
text = transcriber.transcribe(audio_path)
Privacy: the audio file is sent to Groq for transcription. The local copy is
the user's own browser-side recording surfaced by Gradio; we never persist it
beyond the request.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass
class TranscriptionResult:
text: str
provider: str
latency_ms: float
error: str = ""
def ok(self) -> bool:
return bool(self.text) and not self.error
class GroqWhisperTranscriber:
"""Groq Whisper transcription endpoint (OpenAI-compatible)."""
# whisper-large-v3-turbo is ~200x realtime, ~$0.0007/min, multilingual.
DEFAULT_MODEL = "whisper-large-v3-turbo"
ENDPOINT = "https://api.groq.com/openai/v1/audio/transcriptions"
# Reject obviously-bogus transcripts: Whisper sometimes hallucinates a
# default phrase ("Thank you for watching", "Thanks for watching") on
# silence or noise. Caught here so they don't reach the chat composer.
_HALLUCINATED_FALLBACKS = (
"thank you for watching",
"thanks for watching",
"thank you.",
".",
)
def __init__(self) -> None:
self.api_key = (
os.getenv("GROQ_API_KEY")
or os.getenv("GROQ_KEY")
or ""
).strip()
self.model = os.getenv("EMPATHRAG_WHISPER_MODEL", self.DEFAULT_MODEL).strip()
def available(self) -> bool:
return bool(self.api_key)
def transcribe(
self,
audio_path: str | Path,
language: str | None = None,
timeout_s: float = 30.0,
) -> TranscriptionResult:
"""Transcribe a local audio file. Returns text or an error result."""
import time
if not self.api_key:
return TranscriptionResult(text="", provider="groq_whisper",
latency_ms=0.0, error="no_api_key")
path = Path(audio_path)
if not path.exists() or path.stat().st_size == 0:
return TranscriptionResult(text="", provider="groq_whisper",
latency_ms=0.0, error="empty_or_missing_audio")
# `requests` handles multipart cleanly; urllib's stdlib multipart is
# painful and the rephraser already pulls in this dependency tree.
import requests # type: ignore
t0 = time.perf_counter()
try:
with path.open("rb") as fh:
data: dict = {"model": self.model, "response_format": "json"}
if language:
data["language"] = language
resp = requests.post(
self.ENDPOINT,
headers={
"Authorization": f"Bearer {self.api_key}",
"User-Agent": "EmpathRAG/0.3 (+https://github.com/MukulRay1603/Empath-RAG)",
},
files={"file": (path.name, fh, "application/octet-stream")},
data=data,
timeout=timeout_s,
)
except requests.RequestException as e: # type: ignore
return TranscriptionResult(text="", provider="groq_whisper",
latency_ms=(time.perf_counter() - t0) * 1000.0,
error=f"network:{type(e).__name__}")
elapsed = (time.perf_counter() - t0) * 1000.0
if resp.status_code >= 400:
return TranscriptionResult(text="", provider=f"groq_whisper:{self.model}",
latency_ms=elapsed,
error=f"http_{resp.status_code}:{resp.text[:240]}")
try:
payload = resp.json()
text = (payload.get("text") or "").strip()
except ValueError:
return TranscriptionResult(text="", provider=f"groq_whisper:{self.model}",
latency_ms=elapsed, error="bad_json")
# Length sanity + hallucination filter.
if len(text) < 2:
return TranscriptionResult(text="", provider=f"groq_whisper:{self.model}",
latency_ms=elapsed, error="empty_transcript")
lowered = text.lower().strip()
if lowered in self._HALLUCINATED_FALLBACKS:
return TranscriptionResult(text="", provider=f"groq_whisper:{self.model}",
latency_ms=elapsed,
error=f"likely_hallucination:{text[:40]}")
# Cap at a reasonable length so a runaway transcript doesn't explode
# downstream prompts.
if len(text) > 4000:
text = text[:4000].rstrip() + "..."
return TranscriptionResult(text=text, provider=f"groq_whisper:{self.model}",
latency_ms=elapsed)