Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/v1/routes/voice_playground.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ class TTSComparisonCreate(BaseModel):
voices_b: Optional[List[Dict[str, Any]]] = None
sample_texts: List[str]
num_runs: int = 1
eval_stt_provider: Optional[str] = None
eval_stt_model: Optional[str] = None


class BlindTestSubmit(BaseModel):
Expand Down Expand Up @@ -663,6 +665,8 @@ async def create_comparison(
voices_b=[v if isinstance(v, dict) else {"id": v} for v in voices_b] if voices_b else [],
sample_texts=data.sample_texts,
num_runs=num_runs,
eval_stt_provider=data.eval_stt_provider,
eval_stt_model=data.eval_stt_model,
)
db.add(comparison)
db.flush()
Expand Down Expand Up @@ -1248,6 +1252,8 @@ def _serialize_comparison(c: TTSComparison, db: Session) -> Dict[str, Any]:
"num_runs": c.num_runs or 1,
"blind_test_results": c.blind_test_results,
"evaluation_summary": c.evaluation_summary,
"eval_stt_provider": getattr(c, "eval_stt_provider", None),
"eval_stt_model": getattr(c, "eval_stt_model", None),
"error_message": c.error_message,
"samples": serialized_samples,
"created_at": c.created_at.isoformat() if c.created_at else None,
Expand Down
47 changes: 47 additions & 0 deletions app/migrations/015_add_eval_stt_columns_to_tts_comparisons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""
Migration: Add eval_stt_provider and eval_stt_model to tts_comparisons.

Allows per-comparison STT provider override for WER/CER evaluation.
When NULL the worker falls back to the org's first Voice Bundle STT config.
"""

from sqlalchemy import text
from sqlalchemy.orm import Session

description = "Add eval_stt_provider and eval_stt_model columns to tts_comparisons"


def upgrade(db: Session):
existing = db.execute(
text(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'tts_comparisons'
AND column_name = 'eval_stt_provider'
"""
)
)

if existing.fetchone() is not None:
print("Column eval_stt_provider already exists on tts_comparisons, skipping...")
return

db.execute(
text(
"""
ALTER TABLE tts_comparisons
ADD COLUMN eval_stt_provider VARCHAR(100),
ADD COLUMN eval_stt_model VARCHAR(100)
"""
)
)

db.commit()
print("Added eval_stt_provider and eval_stt_model columns to tts_comparisons")


def downgrade(db: Session):
db.execute(text("ALTER TABLE tts_comparisons DROP COLUMN IF EXISTS eval_stt_provider"))
db.execute(text("ALTER TABLE tts_comparisons DROP COLUMN IF EXISTS eval_stt_model"))
db.commit()
3 changes: 3 additions & 0 deletions app/models/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,9 @@ class TTSComparison(Base):
blind_test_results = Column(JSON, nullable=True)
evaluation_summary = Column(JSON, nullable=True)

eval_stt_provider = Column(String(100), nullable=True)
eval_stt_model = Column(String(100), nullable=True)

celery_task_id = Column(String, nullable=True, index=True)
error_message = Column(String, nullable=True)

Expand Down
185 changes: 185 additions & 0 deletions app/services/ai/stt_clients.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""
Centralized STT provider API clients for batch / file-based transcription.

Every provider-specific HTTP or SDK call for transcribing an audio *file*
lives here. ``TranscriptionService`` (and any future consumer) delegates
to these functions so that endpoint URLs, header conventions, and SDK
usage patterns are defined in exactly one place.

The streaming / real-time STT services under ``src/efficientai/services/``
serve a different purpose (WebSocket streams, frame pipelines) and are
intentionally separate.
"""

import logging
from typing import Any, Dict, Optional

logger = logging.getLogger(__name__)


def transcribe_openai(
audio_file_path: str,
model: str,
api_key: str,
language: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe an audio file via the OpenAI Whisper / GPT-4o transcription API.

Returns the standardised dict ``{"text", "language", "segments", "words"}``.
"""
try:
from openai import OpenAI
except ImportError:
raise RuntimeError("OpenAI library not installed. Install with: pip install openai")

client = OpenAI(api_key=api_key)

with open(audio_file_path, "rb") as audio_file:
transcript = client.audio.transcriptions.create(
model=model,
file=audio_file,
language=language,
response_format="verbose_json",
timestamp_granularities=["word", "segment"],
)

result: Dict[str, Any] = {
"text": transcript.text if hasattr(transcript, "text") else str(transcript),
"language": (
getattr(transcript, "language", language)
if language
else getattr(transcript, "language", "en")
),
"segments": [],
"words": [],
}

# --- segments -----------------------------------------------------------
raw_segments = (
getattr(transcript, "segments", None)
if hasattr(transcript, "segments")
else (transcript.get("segments") if isinstance(transcript, dict) else None)
)
if raw_segments:
for seg in raw_segments:
if isinstance(seg, dict):
result["segments"].append(
{"start": seg.get("start", 0), "end": seg.get("end", 0), "text": seg.get("text", "")}
)
else:
result["segments"].append(
{"start": getattr(seg, "start", 0), "end": getattr(seg, "end", 0), "text": getattr(seg, "text", "")}
)

# --- words --------------------------------------------------------------
raw_words = (
getattr(transcript, "words", None)
if hasattr(transcript, "words")
else (transcript.get("words") if isinstance(transcript, dict) else None)
)
if raw_words:
for w in raw_words:
if isinstance(w, dict):
result["words"].append(
{"word": w.get("word", ""), "start": w.get("start", 0) or 0, "end": w.get("end", 0) or 0}
)
else:
word_start = getattr(w, "start", None)
if word_start is None:
word_start = getattr(w, "start_time", 0) or 0
word_end = getattr(w, "end", None)
if word_end is None:
word_end = getattr(w, "end_time", 0) or 0
result["words"].append(
{
"word": getattr(w, "word", "") or "",
"start": float(word_start) if word_start else 0.0,
"end": float(word_end) if word_end else 0.0,
}
)

# Synthesise a single segment when the API returned none
if not result["segments"] and result["text"]:
import re

sentences = [s.strip() for s in re.split(r"[.!?]+\s+", result["text"].strip()) if s.strip()]
if sentences:
current_time = 0.0
for sentence in sentences:
dur = max(0.5, (len(sentence.split()) / 150.0) * 60.0)
result["segments"].append({"start": current_time, "end": current_time + dur, "text": sentence})
current_time += dur
else:
word_count = len(result["text"].split())
est = max(1.0, (word_count / 150.0) * 60.0)
result["segments"] = [{"start": 0.0, "end": est, "text": result["text"]}]

return result


def transcribe_deepgram(
audio_file_path: str,
model: str,
api_key: str,
language: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe an audio file via the Deepgram REST (prerecorded) API.

Uses the ``deepgram-sdk`` which is already a project dependency.
"""
from deepgram import DeepgramClient

client = DeepgramClient(api_key=api_key)

with open(audio_file_path, "rb") as f:
audio_bytes = f.read()

options: Dict[str, Any] = {"model": model or "nova-2", "smart_format": True}
if language:
options["language"] = language

response = client.listen.rest.v("1").transcribe_file({"buffer": audio_bytes}, options)

transcript = ""
try:
transcript = response.results.channels[0].alternatives[0].transcript
except (AttributeError, IndexError):
pass

return {"text": transcript, "language": language or "en", "segments": []}


def transcribe_elevenlabs(
audio_file_path: str,
model: str,
api_key: str,
language: Optional[str] = None,
) -> Dict[str, Any]:
"""Transcribe an audio file via the ElevenLabs Speech-to-Text REST API.

Uses ``httpx`` for a synchronous POST (no ElevenLabs SDK in the project).
The endpoint, headers, and form-field names are identical to those used by
``src/efficientai/services/elevenlabs/stt.py`` (async variant).
"""
import httpx

url = "https://api.elevenlabs.io/v1/speech-to-text"
headers = {"xi-api-key": api_key}

with open(audio_file_path, "rb") as f:
audio_bytes = f.read()

files = {"file": ("audio.wav", audio_bytes, "audio/wav")}
data: Dict[str, str] = {"model_id": model or "scribe_v2"}
if language:
data["language_code"] = language

resp = httpx.post(url, headers=headers, files=files, data=data, timeout=60.0)
resp.raise_for_status()
result = resp.json()

return {
"text": result.get("text", ""),
"language": result.get("language_code", language or "en"),
"segments": [],
}
Loading
Loading