From 6d589b7452466bc5b59dce8ddc60e88e75b56aaa Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Tue, 6 Jan 2026 13:18:13 +0530 Subject: [PATCH] feat: add stt evals --- evals/stt/README.md | 100 ++++++++ evals/stt/__init__.py | 1 + evals/stt/audio/multi_speaker.m4a | Bin 0 -> 63136 bytes evals/stt/benchmark.py | 230 +++++++++++++++++++ evals/stt/providers/__init__.py | 11 + evals/stt/providers/base.py | 123 ++++++++++ evals/stt/providers/deepgram_provider.py | 174 ++++++++++++++ evals/stt/providers/speechmatics_provider.py | 210 +++++++++++++++++ 8 files changed, 849 insertions(+) create mode 100644 evals/stt/README.md create mode 100644 evals/stt/__init__.py create mode 100644 evals/stt/audio/multi_speaker.m4a create mode 100644 evals/stt/benchmark.py create mode 100644 evals/stt/providers/__init__.py create mode 100644 evals/stt/providers/base.py create mode 100644 evals/stt/providers/deepgram_provider.py create mode 100644 evals/stt/providers/speechmatics_provider.py diff --git a/evals/stt/README.md b/evals/stt/README.md new file mode 100644 index 0000000..b176039 --- /dev/null +++ b/evals/stt/README.md @@ -0,0 +1,100 @@ +# STT Evaluation Benchmark + +Benchmark for comparing Speech-to-Text providers with focus on: +- **Speaker diarization** - identifying who said what +- **Keyterm boosting** - improving recognition of specific terms (Deepgram) + +## Providers + +| Provider | Diarization | Keyterm Boost | Notes | +|----------|-------------|---------------|-------| +| Deepgram | Yes | Yes | `diarize=true`, `keyterm` param | +| Speechmatics | Yes | No | `diarization: "speaker"` config | + +## Setup + +```bash +# Install dependencies (httpx is required) +pip install httpx + +# Set API keys +export DEEPGRAM_API_KEY="your-key" +export SPEECHMATICS_API_KEY="your-key" +``` + +## Usage + +Run from the project root directory: + +```bash +# Test both providers with diarization +python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize + +# Test only Deepgram +python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize --providers deepgram + +# Test with keyterm boosting (Deepgram only) +python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize --keyterms "Dograh" "Pipecat" + +# Show word-level timings +python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize --show-words + +# Save results to JSON +python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize --save +``` + +## CLI Options + +| Option | Description | +|--------|-------------| +| `audio_file` | Path to audio file (relative to evals/stt/ or absolute) | +| `--providers` | Providers to test: `deepgram`, `speechmatics` (default: both) | +| `--diarize` | Enable speaker diarization | +| `--keyterms` | Keywords to boost (Deepgram only) | +| `--language` | Language code (default: en) | +| `--show-words` | Show individual word timings | +| `--save` | Save results to JSON in `results/` | + +## Directory Structure + +``` +evals/stt/ +├── audio/ # Audio test files +│ └── multi_speaker.m4a +├── results/ # Saved benchmark results (JSON) +├── providers/ # STT provider implementations +│ ├── base.py # Base classes +│ ├── deepgram_provider.py +│ └── speechmatics_provider.py +├── benchmark.py # Main runner script +└── README.md +``` + +## Output Example + +``` +Provider: DEEPGRAM +Duration: 45.32s +Speakers detected: 2 - ['0', '1'] + +Transcript: +Hello, welcome to the demo... + +--- Speaker Segments --- +[0.0s] Speaker 0: Hello, welcome to the demo. +[2.5s] Speaker 1: Thanks for having me. +... +``` + +## Adding New Providers + +1. Create a new file in `providers/` (e.g., `whisper_provider.py`) +2. Implement the `STTProvider` abstract class +3. Add to `providers/__init__.py` +4. Add to `benchmark.py` provider choices + +## API Documentation + +- Deepgram Diarization: https://developers.deepgram.com/docs/diarization +- Deepgram Keyterms: https://developers.deepgram.com/docs/keyterm +- Speechmatics Diarization: https://docs.speechmatics.com/features/diarization diff --git a/evals/stt/__init__.py b/evals/stt/__init__.py new file mode 100644 index 0000000..8c12ae8 --- /dev/null +++ b/evals/stt/__init__.py @@ -0,0 +1 @@ +# STT Evaluation Benchmark diff --git a/evals/stt/audio/multi_speaker.m4a b/evals/stt/audio/multi_speaker.m4a new file mode 100644 index 0000000000000000000000000000000000000000..d1ab1f5e5cb6a4aa7e9356cb4b1f4d11e6abf5bb GIT binary patch literal 63136 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 literal 0 HcmV?d00001 diff --git a/evals/stt/benchmark.py b/evals/stt/benchmark.py new file mode 100644 index 0000000..c1b5fed --- /dev/null +++ b/evals/stt/benchmark.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""STT Benchmark Runner. + +Compare speech-to-text transcription across providers with focus on: +- Speaker diarization accuracy +- Keyword/keyterm recognition +- Transcription quality + +Usage: + python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize + python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize --providers deepgram + python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize --keyterms "Dograh" "Pipecat" +""" + +import argparse +import asyncio +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from evals.stt.providers import DeepgramProvider, SpeechmaticsProvider, STTProvider, TranscriptionResult + + +def get_provider(name: str) -> STTProvider: + """Get provider instance by name.""" + providers = { + "deepgram": DeepgramProvider, + "speechmatics": SpeechmaticsProvider, + } + if name not in providers: + raise ValueError(f"Unknown provider: {name}. Available: {list(providers.keys())}") + return providers[name]() + + +async def run_transcription( + provider: STTProvider, + audio_path: Path, + diarize: bool = False, + keyterms: list[str] | None = None, + **kwargs: Any, +) -> TranscriptionResult: + """Run transcription with a provider.""" + print(f"\n{'='*60}") + print(f"Provider: {provider.name.upper()}") + print(f"{'='*60}") + + try: + result = await provider.transcribe( + audio_path, + diarize=diarize, + keyterms=keyterms, + **kwargs, + ) + return result + except Exception as e: + print(f"Error with {provider.name}: {e}") + raise + + +def print_result(result: TranscriptionResult, show_words: bool = False) -> None: + """Print transcription result.""" + print(f"\nDuration: {result.duration:.2f}s") + print(f"Speakers detected: {len(result.speakers)} - {result.speakers}") + print(f"\nTranscript:\n{result.transcript}") + + if result.speakers: + print(f"\n--- Speaker Segments ---") + for segment in result.get_speaker_segments(): + speaker = segment["speaker"] or "?" + text = segment["text"] + start = segment["start"] + print(f"[{start:.1f}s] Speaker {speaker}: {text}") + + if show_words: + print(f"\n--- Words ---") + for word in result.words[:50]: # First 50 words + speaker_info = f" (S{word.speaker})" if word.speaker else "" + print(f" {word.start:.2f}s: {word.word}{speaker_info} [{word.confidence:.2f}]") + if len(result.words) > 50: + print(f" ... and {len(result.words) - 50} more words") + + +def save_results( + results: list[TranscriptionResult], + output_dir: Path, + audio_name: str, +) -> Path: + """Save results to JSON file.""" + output_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_file = output_dir / f"{audio_name}_{timestamp}.json" + + output_data = { + "timestamp": timestamp, + "audio_file": audio_name, + "results": [r.to_dict() for r in results], + } + + with open(output_file, "w") as f: + json.dump(output_data, f, indent=2) + + print(f"\nResults saved to: {output_file}") + return output_file + + +def compare_results(results: list[TranscriptionResult]) -> None: + """Compare results across providers.""" + if len(results) < 2: + return + + print(f"\n{'='*60}") + print("COMPARISON SUMMARY") + print(f"{'='*60}") + + print(f"\n{'Provider':<15} {'Duration':<10} {'Speakers':<10} {'Words':<10}") + print("-" * 45) + for r in results: + print(f"{r.provider:<15} {r.duration:<10.2f} {len(r.speakers):<10} {len(r.words):<10}") + + # Compare speaker counts + speaker_counts = {r.provider: len(r.speakers) for r in results} + if len(set(speaker_counts.values())) > 1: + print(f"\nNote: Providers detected different speaker counts: {speaker_counts}") + + +async def main() -> int: + parser = argparse.ArgumentParser( + description="STT Benchmark - Compare transcription providers", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize + python -m evals.stt.benchmark audio/multi_speaker.m4a --diarize --providers deepgram + python -m evals.stt.benchmark audio/multi_speaker.m4a --keyterms "Dograh" "API" + """, + ) + parser.add_argument( + "audio_file", + type=str, + help="Path to audio file (relative to evals/stt/ or absolute)", + ) + parser.add_argument( + "--providers", + nargs="+", + default=["deepgram", "speechmatics"], + choices=["deepgram", "speechmatics"], + help="Providers to test (default: all)", + ) + parser.add_argument( + "--diarize", + action="store_true", + help="Enable speaker diarization", + ) + parser.add_argument( + "--keyterms", + nargs="+", + help="Keywords to boost (Deepgram only)", + ) + parser.add_argument( + "--language", + default="en", + help="Language code (default: en)", + ) + parser.add_argument( + "--show-words", + action="store_true", + help="Show individual word timings", + ) + parser.add_argument( + "--save", + action="store_true", + help="Save results to JSON file", + ) + parser.add_argument( + "--output-dir", + type=str, + default="results", + help="Output directory for results (default: results)", + ) + + args = parser.parse_args() + + # Resolve audio path + script_dir = Path(__file__).parent + audio_path = Path(args.audio_file) + if not audio_path.is_absolute(): + audio_path = script_dir / audio_path + + if not audio_path.exists(): + print(f"Error: Audio file not found: {audio_path}") + return 1 + + print(f"Audio file: {audio_path}") + print(f"Providers: {args.providers}") + print(f"Diarization: {args.diarize}") + if args.keyterms: + print(f"Keyterms: {args.keyterms}") + + results: list[TranscriptionResult] = [] + + for provider_name in args.providers: + try: + provider = get_provider(provider_name) + result = await run_transcription( + provider, + audio_path, + diarize=args.diarize, + keyterms=args.keyterms, + language=args.language, + ) + print_result(result, show_words=args.show_words) + results.append(result) + except Exception as e: + print(f"\nFailed to run {provider_name}: {e}") + continue + + if len(results) > 1: + compare_results(results) + + if args.save and results: + output_dir = script_dir / args.output_dir + save_results(results, output_dir, audio_path.stem) + + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/evals/stt/providers/__init__.py b/evals/stt/providers/__init__.py new file mode 100644 index 0000000..d73a2b8 --- /dev/null +++ b/evals/stt/providers/__init__.py @@ -0,0 +1,11 @@ +from .base import STTProvider, TranscriptionResult, Word +from .deepgram_provider import DeepgramProvider +from .speechmatics_provider import SpeechmaticsProvider + +__all__ = [ + "STTProvider", + "TranscriptionResult", + "Word", + "DeepgramProvider", + "SpeechmaticsProvider", +] diff --git a/evals/stt/providers/base.py b/evals/stt/providers/base.py new file mode 100644 index 0000000..cdb4af9 --- /dev/null +++ b/evals/stt/providers/base.py @@ -0,0 +1,123 @@ +"""Base classes for STT providers.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class Word: + """Represents a transcribed word with metadata.""" + + word: str + start: float + end: float + confidence: float + speaker: str | None = None + speaker_confidence: float | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "word": self.word, + "start": self.start, + "end": self.end, + "confidence": self.confidence, + "speaker": self.speaker, + "speaker_confidence": self.speaker_confidence, + } + + +@dataclass +class TranscriptionResult: + """Result from STT transcription.""" + + provider: str + transcript: str + words: list[Word] + speakers: list[str] + duration: float + raw_response: dict[str, Any] = field(default_factory=dict) + params: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "provider": self.provider, + "transcript": self.transcript, + "words": [w.to_dict() for w in self.words], + "speakers": self.speakers, + "duration": self.duration, + "params": self.params, + } + + def get_speaker_segments(self) -> list[dict[str, Any]]: + """Get transcript segmented by speaker.""" + if not self.words: + return [] + + segments = [] + current_speaker = None + current_text = [] + segment_start = 0.0 + + for word in self.words: + if word.speaker != current_speaker: + if current_text: + segments.append( + { + "speaker": current_speaker, + "text": " ".join(current_text), + "start": segment_start, + "end": self.words[len(segments) - 1].end + if segments + else word.start, + } + ) + current_speaker = word.speaker + current_text = [word.word] + segment_start = word.start + else: + current_text.append(word.word) + + if current_text: + segments.append( + { + "speaker": current_speaker, + "text": " ".join(current_text), + "start": segment_start, + "end": self.words[-1].end if self.words else 0.0, + } + ) + + return segments + + +class STTProvider(ABC): + """Abstract base class for STT providers.""" + + @property + @abstractmethod + def name(self) -> str: + """Provider name.""" + pass + + @abstractmethod + async def transcribe( + self, + audio_path: Path, + diarize: bool = False, + keyterms: list[str] | None = None, + **kwargs: Any, + ) -> TranscriptionResult: + """Transcribe audio file. + + Args: + audio_path: Path to the audio file + diarize: Enable speaker diarization + keyterms: List of keywords to boost (if supported) + **kwargs: Provider-specific parameters + + Returns: + TranscriptionResult with transcript and metadata + """ + pass diff --git a/evals/stt/providers/deepgram_provider.py b/evals/stt/providers/deepgram_provider.py new file mode 100644 index 0000000..c3b1ebd --- /dev/null +++ b/evals/stt/providers/deepgram_provider.py @@ -0,0 +1,174 @@ +"""Deepgram STT provider.""" + +import os +from pathlib import Path +from typing import Any + +import httpx + +from .base import STTProvider, TranscriptionResult, Word + + +class DeepgramProvider(STTProvider): + """Deepgram Speech-to-Text provider. + + API Docs: https://developers.deepgram.com/docs/ + + Supports: + - Speaker diarization via `diarize=true` + - Keyterm boosting via `keyterm` parameter (Nova-3 and Flux models) + """ + + API_URL = "https://api.deepgram.com/v1/listen" + + def __init__(self, api_key: str | None = None): + self.api_key = api_key or os.getenv("DEEPGRAM_API_KEY") + if not self.api_key: + raise ValueError( + "Deepgram API key required. Set DEEPGRAM_API_KEY env var or pass api_key." + ) + + @property + def name(self) -> str: + return "deepgram" + + async def transcribe( + self, + audio_path: Path, + diarize: bool = False, + keyterms: list[str] | None = None, + model: str = "nova-3", + language: str = "en", + punctuate: bool = True, + **kwargs: Any, + ) -> TranscriptionResult: + """Transcribe audio using Deepgram API. + + Args: + audio_path: Path to audio file + diarize: Enable speaker diarization + keyterms: List of keywords to boost recognition + model: Deepgram model (nova-3, nova-2, etc.) + language: Language code + punctuate: Add punctuation + **kwargs: Additional Deepgram parameters + + Returns: + TranscriptionResult with transcript and speaker info + """ + params: dict[str, Any] = { + "model": model, + "language": language, + "punctuate": str(punctuate).lower(), + } + + if diarize: + params["diarize"] = "true" + + # Add keyterms (Deepgram uses repeated keyterm params) + if keyterms: + params["keyterm"] = keyterms + + # Add any extra kwargs + params.update(kwargs) + + # Read audio file + audio_data = audio_path.read_bytes() + + # Determine content type + suffix = audio_path.suffix.lower() + content_types = { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".flac": "audio/flac", + ".ogg": "audio/ogg", + ".webm": "audio/webm", + } + content_type = content_types.get(suffix, "audio/wav") + + headers = { + "Authorization": f"Token {self.api_key}", + "Content-Type": content_type, + } + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + self.API_URL, + params=params, + headers=headers, + content=audio_data, + ) + response.raise_for_status() + data = response.json() + + return self._parse_response(data, params) + + def _parse_response( + self, data: dict[str, Any], params: dict[str, Any] + ) -> TranscriptionResult: + """Parse Deepgram API response.""" + results = data.get("results", {}) + channels = results.get("channels", []) + + if not channels: + return TranscriptionResult( + provider=self.name, + transcript="", + words=[], + speakers=[], + duration=0.0, + raw_response=data, + params=params, + ) + + # Get first channel, first alternative + channel = channels[0] + alternatives = channel.get("alternatives", []) + if not alternatives: + return TranscriptionResult( + provider=self.name, + transcript="", + words=[], + speakers=[], + duration=0.0, + raw_response=data, + params=params, + ) + + alt = alternatives[0] + transcript = alt.get("transcript", "") + + # Parse words with speaker info + words = [] + speakers_set: set[str] = set() + + for w in alt.get("words", []): + speaker = str(w.get("speaker", "")) if "speaker" in w else None + if speaker: + speakers_set.add(speaker) + + words.append( + Word( + word=w.get("word", ""), + start=w.get("start", 0.0), + end=w.get("end", 0.0), + confidence=w.get("confidence", 0.0), + speaker=speaker, + speaker_confidence=w.get("speaker_confidence"), + ) + ) + + # Get duration from metadata + metadata = results.get("metadata", {}) + duration = metadata.get("duration", 0.0) + + return TranscriptionResult( + provider=self.name, + transcript=transcript, + words=words, + speakers=sorted(speakers_set), + duration=duration, + raw_response=data, + params=params, + ) diff --git a/evals/stt/providers/speechmatics_provider.py b/evals/stt/providers/speechmatics_provider.py new file mode 100644 index 0000000..8f6ee63 --- /dev/null +++ b/evals/stt/providers/speechmatics_provider.py @@ -0,0 +1,210 @@ +"""Speechmatics STT provider.""" + +import os +from pathlib import Path +from typing import Any + +import httpx + +from .base import STTProvider, TranscriptionResult, Word + + +class SpeechmaticsProvider(STTProvider): + """Speechmatics Speech-to-Text provider. + + API Docs: https://docs.speechmatics.com/ + + Supports: + - Speaker diarization via `diarization: "speaker"` config + - Speaker sensitivity tuning + """ + + # EU and US endpoints available + API_URL = "https://asr.api.speechmatics.com/v2/jobs" + + def __init__(self, api_key: str | None = None, region: str = "eu1"): + self.api_key = api_key or os.getenv("SPEECHMATICS_API_KEY") + if not self.api_key: + raise ValueError( + "Speechmatics API key required. Set SPEECHMATICS_API_KEY env var or pass api_key." + ) + # Set region-specific endpoint + if region == "eu1": + self.api_url = "https://eu1.asr.api.speechmatics.com/v2/jobs" + else: + self.api_url = "https://asr.api.speechmatics.com/v2/jobs" + + @property + def name(self) -> str: + return "speechmatics" + + async def transcribe( + self, + audio_path: Path, + diarize: bool = False, + keyterms: list[str] | None = None, + language: str = "en", + operating_point: str = "enhanced", + speaker_sensitivity: float | None = None, + **kwargs: Any, + ) -> TranscriptionResult: + """Transcribe audio using Speechmatics API. + + Args: + audio_path: Path to audio file + diarize: Enable speaker diarization + keyterms: Not directly supported by Speechmatics (ignored) + language: Language code + operating_point: "standard" or "enhanced" + speaker_sensitivity: 0.0-1.0, higher = more speakers detected + **kwargs: Additional config parameters + + Returns: + TranscriptionResult with transcript and speaker info + """ + # Build transcription config + transcription_config: dict[str, Any] = { + "language": language, + "operating_point": operating_point, + } + + if diarize: + transcription_config["diarization"] = "speaker" + if speaker_sensitivity is not None: + transcription_config["speaker_diarization_config"] = { + "speaker_sensitivity": speaker_sensitivity + } + + # Add any extra config + transcription_config.update(kwargs) + + config = { + "type": "transcription", + "transcription_config": transcription_config, + } + + # Store params for result + params = { + "diarize": diarize, + "language": language, + "operating_point": operating_point, + "speaker_sensitivity": speaker_sensitivity, + } + + headers = { + "Authorization": f"Bearer {self.api_key}", + } + + # Create job with multipart form + async with httpx.AsyncClient(timeout=300.0) as client: + # Submit job + with open(audio_path, "rb") as f: + files = { + "data_file": (audio_path.name, f, "audio/mpeg"), + "config": (None, str(config).replace("'", '"'), "application/json"), + } + response = await client.post( + self.api_url, + headers=headers, + files=files, + ) + response.raise_for_status() + job_data = response.json() + + job_id = job_data.get("id") + if not job_id: + raise ValueError(f"No job ID in response: {job_data}") + + # Poll for completion + result_data = await self._wait_for_job(client, job_id, headers) + + return self._parse_response(result_data, params) + + async def _wait_for_job( + self, client: httpx.AsyncClient, job_id: str, headers: dict[str, str] + ) -> dict[str, Any]: + """Poll job status until complete.""" + import asyncio + + job_url = f"{self.api_url}/{job_id}" + transcript_url = f"{job_url}/transcript?format=json-v2" + + max_attempts = 120 # 10 minutes with 5s intervals + for _ in range(max_attempts): + # Check job status + status_response = await client.get(job_url, headers=headers) + status_response.raise_for_status() + status_data = status_response.json() + + job_status = status_data.get("job", {}).get("status") + + if job_status == "done": + # Get transcript + transcript_response = await client.get(transcript_url, headers=headers) + transcript_response.raise_for_status() + return transcript_response.json() + elif job_status == "rejected": + raise ValueError(f"Job rejected: {status_data}") + elif job_status == "deleted": + raise ValueError(f"Job deleted: {status_data}") + + await asyncio.sleep(5) + + raise TimeoutError(f"Job {job_id} did not complete in time") + + def _parse_response( + self, data: dict[str, Any], params: dict[str, Any] + ) -> TranscriptionResult: + """Parse Speechmatics API response.""" + results = data.get("results", []) + + words = [] + speakers_set: set[str] = set() + transcript_parts = [] + + for item in results: + item_type = item.get("type") + alternatives = item.get("alternatives", []) + + if not alternatives: + continue + + alt = alternatives[0] + content = alt.get("content", "") + speaker = alt.get("speaker") + + if speaker: + speakers_set.add(speaker) + + if item_type == "word": + words.append( + Word( + word=content, + start=item.get("start_time", 0.0), + end=item.get("end_time", 0.0), + confidence=alt.get("confidence", 0.0), + speaker=speaker, + speaker_confidence=None, # Not provided by Speechmatics + ) + ) + transcript_parts.append(content) + elif item_type == "punctuation": + # Append punctuation to last word in transcript + if transcript_parts: + transcript_parts[-1] += content + + # Get metadata + metadata = data.get("metadata", {}) + duration = metadata.get("duration", 0.0) + + transcript = " ".join(transcript_parts) + + return TranscriptionResult( + provider=self.name, + transcript=transcript, + words=words, + speakers=sorted(speakers_set), + duration=duration, + raw_response=data, + params=params, + )