study-buddy / app /benchmarks /stt_offline_benchmark.py
GitHub Actions
deploy d092bea3608b7a29952f16357fda39b7a29e399b
2e818da
Raw
History Blame Contribute Delete
57.7 kB
"""Offline fixed-audio STT benchmark runner.
Run from backend:
.\\.venv\\Scripts\\python.exe -m app.benchmarks.stt_offline_benchmark --vad none,silero
"""
from __future__ import annotations
import argparse
import hashlib
import os
from collections import defaultdict
from difflib import SequenceMatcher
import json
import math
import re
import time
import wave
from array import array
from pathlib import Path
from typing import Any
from app.services.transcript_diagnostics import repetition_diagnostics
from app.services.pause_detection_service import PauseDetectionService
from app.services.transcription_service import TranscriptionService
from app.services.voice_activity_service import VoiceActivityService, cleanup_vad_result
from app.services.voice_benchmark_logger import (
SCHEMA_VERSION,
app_version,
git_commit,
LATEST_PAUSE_FAILURES_TOP20_PATH,
LATEST_SUMMARY_JSON_PATH,
LATEST_SUMMARY_PATH,
LATEST_TERM_FAILURES_PATH,
LATEST_TIMINGS_PATH,
LATEST_TRANSCRIPT_FAILURES_TOP20_PATH,
VOICE_RUNS_DIR,
log_jsonl,
new_request_id,
now_iso,
repo_root,
write_json_atomic,
write_jsonl_atomic,
write_text_atomic,
)
def main() -> int:
args = _parse_args()
manifest_path = _resolve_path(args.manifest) if args.manifest else None
raw_dir = _resolve_path(args.raw_dir)
vad_engines = [item.strip().lower() for item in args.vad.split(",") if item.strip()]
pause_detectors = [item.strip().lower() for item in args.pause_detectors.split(",") if item.strip()]
clip_index = _read_manifest_index(manifest_path) if manifest_path and manifest_path.exists() else {}
clips = _discover_clips(raw_dir, clip_index)
try:
TranscriptionService.preflight_benchmark_model_cache()
except Exception as exc:
print(f"STT model preflight failed; benchmark aborted: {exc}")
return 1
os.environ["STT_DISABLE_MODEL_RECOVERY"] = "1"
print("Loading STT model...", flush=True)
stt = TranscriptionService.get()
_wait_for_stt(stt)
if not stt.is_available:
print("STT model failed to load; benchmark aborted.")
return 1
print(f"STT model ready: {stt.model_config.model_dump()}", flush=True)
run_config = _build_run_config(
args=args,
manifest_path=manifest_path,
raw_dir=raw_dir,
vad_engines=vad_engines,
pause_detectors=pause_detectors,
clip_count=len(clips),
stt=stt,
)
run_id = args.run_id or _build_run_id(run_config)
run_dir = VOICE_RUNS_DIR / run_id
run_dir.mkdir(parents=True, exist_ok=True)
full_path = run_dir / "full.jsonl"
full_path.write_text("", encoding="utf-8")
total_jobs = len(clips) * (len(pause_detectors) + len(vad_engines))
completed_jobs = 0
print(f"Benchmark run_id: {run_id}", flush=True)
print(f"Writing live rows to: {full_path}", flush=True)
print(f"Clips: {len(clips)} | jobs: {total_jobs}", flush=True)
vad = VoiceActivityService()
pause_service = PauseDetectionService()
run_rows: list[dict[str, Any]] = []
for clip_index, clip in enumerate(clips, start=1):
clip_id = str(clip.get("clip_id") or "unknown")
if pause_detectors:
print(f"[{clip_index}/{len(clips)}] {clip_id} | pause detectors: {','.join(pause_detectors)}", flush=True)
run_rows.extend(_run_pause_detector_clip(clip, pause_detectors, run_id, full_path, stt, pause_service))
completed_jobs += len(pause_detectors)
print(f" completed {completed_jobs}/{total_jobs} jobs", flush=True)
for engine in vad_engines:
print(f"[{clip_index}/{len(clips)}] {clip_id} | vad={engine}", flush=True)
row = _run_clip(clip, engine, run_id, full_path, stt, vad)
run_rows.append(row)
completed_jobs += 1
wer_value = row.get("wer")
wer_text = f"{float(wer_value):.4f}" if wer_value is not None else "n/a"
print(f" completed {completed_jobs}/{total_jobs} jobs | wer={wer_text}", flush=True)
rows = _load_jsonl_rows(full_path) or run_rows
print("Exporting benchmark summaries...", flush=True)
_export_run_artifacts(run_dir, run_id, run_config, rows)
_print_summary(rows, run_config, run_dir)
return 0
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run fixed WAV STT benchmarks.")
parser.add_argument("--manifest", default=str(repo_root() / "benchmarks" / "stt_clips.json"))
parser.add_argument("--raw-dir", default=str(repo_root() / "benchmarks" / "audio" / "raw"))
parser.add_argument("--vad", default="", help="Optional comma-separated VAD trim engines: none,silero")
parser.add_argument("--pause-detectors", default="rms_energy,silero", help="Comma-separated pause detectors: rms_energy,silero")
parser.add_argument("--term-mapping", action="store_true", help="Record term mapping as enabled in the run config.")
parser.add_argument("--run-id", default="")
return parser.parse_args()
def _resolve_path(path: str) -> Path:
candidate = Path(path)
if candidate.is_absolute():
return candidate
cwd_candidate = Path.cwd() / candidate
if cwd_candidate.exists():
return cwd_candidate
return repo_root() / candidate
def _read_manifest_index(path: Path) -> dict[str, dict[str, Any]]:
clips = json.loads(path.read_text(encoding="utf-8"))
index: dict[str, dict[str, Any]] = {}
for clip in clips:
clip_id = str(clip.get("clip_id") or "").strip()
if clip_id:
index[clip_id] = clip
clip_path = str(clip.get("path") or "").strip()
if clip_path:
index[Path(clip_path).stem] = clip
return index
def _discover_clips(raw_dir: Path, clip_index: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
if clip_index:
clips: list[dict[str, Any]] = []
for clip_id in sorted(clip_index):
metadata = clip_index[clip_id]
clip_path = _clip_path_from_manifest(raw_dir, clip_id, metadata)
if clip_path is None:
continue
clips.append({
"clip_id": clip_id,
"path": str(clip_path),
"language": metadata.get("language") or _language_from_clip_id(clip_id),
"recording_instruction": metadata.get("recording_instruction"),
"expected_text": metadata.get("expected_text"),
"expected_pause_text": metadata.get("expected_pause_text"),
"category": metadata.get("clip_category") or metadata.get("category"),
"notes": metadata.get("notes"),
})
return clips
if not raw_dir.exists():
return []
clips: list[dict[str, Any]] = []
for wav_path in sorted(raw_dir.rglob("*.wav")):
clip_id = wav_path.stem
clips.append({
"clip_id": clip_id,
"path": str(wav_path),
"language": _language_from_clip_id(clip_id),
"recording_instruction": None,
"expected_text": None,
"expected_pause_text": None,
"category": None,
"notes": None,
})
return clips
def _clip_path_from_manifest(raw_dir: Path, clip_id: str, metadata: dict[str, Any]) -> Path | None:
path_value = str(metadata.get("path") or "").strip()
if path_value:
candidate = _resolve_path(path_value)
if candidate.exists():
return candidate
fallback = raw_dir / f"{clip_id}.wav"
if fallback.exists():
return fallback
matches = list(raw_dir.rglob(f"{clip_id}.wav"))
return matches[0] if matches else None
def _language_from_clip_id(clip_id: str) -> str | None:
if "_" not in clip_id:
return None
language = clip_id.split("_", 1)[0].strip()
return language or None
def _run_pause_detector_clip(
clip: dict[str, Any],
pause_detectors: list[str],
run_id: str,
output_path: Path,
stt: TranscriptionService,
pause_service: PauseDetectionService,
) -> list[dict[str, Any]]:
request_id = new_request_id("stt_offline")
clip_path = _resolve_path(str(clip.get("path", "")))
expected_text = str(clip.get("expected_text") or "")
expected_pause_text = str(clip.get("expected_pause_text") or "")
started = time.perf_counter()
wav_meta: dict[str, Any] = {}
original_wav_meta: dict[str, Any] = {}
normalize_time_ms = 0.0
normalized_path: Path | None = None
transcript = ""
result = None
error: str | None = None
try:
if not clip_path.exists():
raise FileNotFoundError(str(clip_path))
if not stt.is_available:
raise RuntimeError("stt_unavailable")
original_wav_meta = _wav_metadata(clip_path)
normalized_path = _normalized_audio_path(clip)
normalize_start = time.perf_counter()
_normalize_wav_for_stt(clip_path, normalized_path)
normalize_time_ms = round((time.perf_counter() - normalize_start) * 1000, 2)
wav_meta = _wav_metadata(normalized_path)
result = stt.transcribe(str(normalized_path))
transcript = result.transcript if result.success else ""
error = result.error
except Exception as exc:
error = repr(exc)
stt_total_ms = round((time.perf_counter() - started) * 1000, 2)
model_config = (
result.model_config.model_dump()
if result and result.model_config
else stt.model_config.model_dump()
)
success = bool(result.success) if result else False
rows: list[dict[str, Any]] = []
for detector in pause_detectors:
row_start = time.perf_counter()
pause_result = None
pause_text = transcript
try:
if normalized_path and normalized_path.exists():
pause_result = pause_service.detect(str(normalized_path), detector)
pause_text = pause_service.build_pause_text(
transcript,
result.segments if result else None,
pause_result.pauses,
).pause_text
except Exception as exc:
error = repr(exc)
normalized_expected = normalize_text(expected_text)
normalized_actual = normalize_text(transcript)
normalized_expected_pause = normalize_pause_text(expected_pause_text)
normalized_actual_pause = normalize_pause_text(pause_text)
payload = {
"schema_version": SCHEMA_VERSION,
"event": "stt_transcription",
"benchmark_type": "offline_model",
"request_id": request_id,
"run_id": run_id,
"timestamp": now_iso(),
"app_version": app_version(),
"git_commit": git_commit(),
**model_config,
**wav_meta,
**_normalization_log_fields(original_wav_meta, wav_meta, clip_path),
"normalized_audio_path": str(normalized_path) if normalized_path else None,
"clip_id": clip.get("clip_id"),
"clip_category": clip.get("category"),
"clip_language": clip.get("language"),
"recording_instruction": clip.get("recording_instruction"),
"expected_text": expected_text,
"expected_pause_text": expected_pause_text or None,
"actual_transcript": transcript,
"transcript": transcript,
"transcript_char_count": len(transcript),
**repetition_diagnostics(transcript),
"normalized_match": normalized_expected == normalized_actual if expected_text else None,
"manual_score": None,
"wer": word_error_rate(normalized_expected, normalized_actual) if expected_text else None,
**_no_vad_log_fields(wav_meta),
**_pause_log_fields(pause_result, pause_text),
"pause_text_wer": word_error_rate(normalized_expected_pause, normalized_actual_pause) if expected_pause_text else None,
"pause_text_normalized_match": normalized_expected_pause == normalized_actual_pause if expected_pause_text else None,
"preprocess_time_ms": normalize_time_ms,
"normalization_time_ms": normalize_time_ms,
"metadata_time_ms": None,
"stt_inference_time_ms": result.inference_time_ms if result else 0.0,
"total_backend_time_ms": round(stt_total_ms + ((time.perf_counter() - row_start) * 1000), 2),
"detected_language": result.detected_language if result else None,
"detected_language_probability": result.detected_language_probability if result else None,
"segments_count": result.segments_count if result else 0,
"success": success,
"error": None if success and not (pause_result and pause_result.error) else (pause_result.error if pause_result and pause_result.error else error),
}
log_jsonl(output_path, payload)
rows.append(payload)
return rows
def _wait_for_stt(stt: TranscriptionService, timeout_s: float = 60.0) -> None:
started = time.perf_counter()
while not stt.is_ready and time.perf_counter() - started < timeout_s:
time.sleep(0.1)
def _run_clip(
clip: dict[str, Any],
vad_engine: str,
run_id: str,
output_path: Path,
stt: TranscriptionService,
vad: VoiceActivityService,
) -> dict[str, Any]:
request_id = new_request_id("stt_offline")
clip_path = _resolve_path(str(clip.get("path", "")))
expected_text = str(clip.get("expected_text") or "")
expected_pause_text = str(clip.get("expected_pause_text") or "")
started = time.perf_counter()
vad_result = None
wav_meta: dict[str, Any] = {}
original_wav_meta: dict[str, Any] = {}
normalize_time_ms = 0.0
normalized_path: Path | None = None
transcript = ""
result = None
error: str | None = None
try:
if not clip_path.exists():
raise FileNotFoundError(str(clip_path))
if not stt.is_available:
raise RuntimeError("stt_unavailable")
original_wav_meta = _wav_metadata(clip_path)
normalized_path = _normalized_audio_path(clip)
normalize_start = time.perf_counter()
_normalize_wav_for_stt(clip_path, normalized_path)
normalize_time_ms = round((time.perf_counter() - normalize_start) * 1000, 2)
wav_meta = _wav_metadata(normalized_path)
processed_path = _processed_audio_path(clip, vad_engine)
vad_result = vad.process(str(normalized_path), vad_engine, str(processed_path))
result = stt.transcribe(vad_result.audio_path)
transcript = result.transcript if result.success else ""
error = result.error
except Exception as exc:
error = repr(exc)
finally:
if vad_result:
cleanup_vad_result(vad_result)
total_ms = round((time.perf_counter() - started) * 1000, 2)
normalized_expected = normalize_text(expected_text)
normalized_actual = normalize_text(transcript)
wer = word_error_rate(normalized_expected, normalized_actual) if expected_text else None
model_config = (
result.model_config.model_dump()
if result and result.model_config
else stt.model_config.model_dump()
)
success = bool(result.success) if result else False
payload = {
"schema_version": SCHEMA_VERSION,
"event": "stt_transcription",
"benchmark_type": "offline_model",
"request_id": request_id,
"run_id": run_id,
"timestamp": now_iso(),
"app_version": app_version(),
"git_commit": git_commit(),
**model_config,
**wav_meta,
**_normalization_log_fields(original_wav_meta, wav_meta, clip_path),
"normalized_audio_path": str(normalized_path) if normalized_path else None,
"clip_id": clip.get("clip_id"),
"clip_category": clip.get("category"),
"clip_language": clip.get("language"),
"recording_instruction": clip.get("recording_instruction"),
"expected_text": expected_text,
"expected_pause_text": expected_pause_text or None,
"actual_transcript": transcript,
"transcript": transcript,
"transcript_char_count": len(transcript),
**repetition_diagnostics(transcript),
"normalized_match": normalized_expected == normalized_actual if expected_text else None,
"manual_score": None,
"wer": wer,
**_server_vad_log_fields(vad_result, wav_meta, vad_engine),
"pause_detection_enabled": False,
"pause_detector": None,
"pause_detection_time_ms": 0.0,
"pause_count": 0,
"pauses": [],
"pause_text": None,
"pause_text_char_count": 0,
"pause_text_wer": None,
"pause_text_normalized_match": None,
"pause_detector_error": "vad_trim_benchmark_row",
"pause_detector_fallback_used": False,
"preprocess_time_ms": normalize_time_ms,
"normalization_time_ms": normalize_time_ms,
"metadata_time_ms": None,
"stt_inference_time_ms": result.inference_time_ms if result else 0.0,
"total_backend_time_ms": total_ms,
"detected_language": result.detected_language if result else None,
"detected_language_probability": result.detected_language_probability if result else None,
"segments_count": result.segments_count if result else 0,
"success": success,
"error": None if success else error,
}
log_jsonl(output_path, payload)
return payload
def _normalization_log_fields(raw_meta: dict[str, Any], normalized_meta: dict[str, Any], clip_path: Path) -> dict[str, Any]:
if not raw_meta:
return {
"raw_audio_path": str(clip_path),
"raw_audio_duration_ms": None,
"raw_sample_rate": None,
"raw_channels": None,
"raw_sample_width_bytes": None,
"raw_wav_size_bytes": None,
"normalized_sample_rate": normalized_meta.get("sample_rate"),
"normalized_channels": normalized_meta.get("channels"),
"normalized_sample_width_bytes": normalized_meta.get("sample_width_bytes"),
"normalization_applied": False,
}
normalization_applied = any([
raw_meta.get("sample_rate") != normalized_meta.get("sample_rate"),
raw_meta.get("channels") != normalized_meta.get("channels"),
raw_meta.get("sample_width_bytes") != normalized_meta.get("sample_width_bytes"),
])
return {
"raw_audio_path": str(clip_path),
"raw_audio_duration_ms": raw_meta.get("audio_duration_ms"),
"raw_sample_rate": raw_meta.get("sample_rate"),
"raw_channels": raw_meta.get("channels"),
"raw_sample_width_bytes": raw_meta.get("sample_width_bytes"),
"raw_wav_size_bytes": raw_meta.get("wav_size_bytes"),
"normalized_sample_rate": normalized_meta.get("sample_rate"),
"normalized_channels": normalized_meta.get("channels"),
"normalized_sample_width_bytes": normalized_meta.get("sample_width_bytes"),
"normalization_applied": normalization_applied,
}
def _server_vad_log_fields(vad_result: Any, wav_meta: dict[str, Any], requested_engine: str) -> dict[str, Any]:
enabled = vad_result.vad_enabled if vad_result else requested_engine != "none"
engine = vad_result.vad_engine if vad_result else (None if requested_engine == "none" else requested_engine)
speech_duration_ms = vad_result.speech_duration_ms if vad_result else None
segments_count = vad_result.vad_segments_count if vad_result else 0
speech_start_ms = vad_result.speech_start_ms if vad_result else None
speech_end_ms = vad_result.speech_end_ms if vad_result else None
silence_trimmed_ms = vad_result.silence_trimmed_ms if vad_result else 0.0
trim_ratio = vad_result.trim_ratio if vad_result else 0.0
fallback_used = vad_result.fallback_used if vad_result else False
fallback_reason = vad_result.fallback_reason if vad_result else None
processed_duration_ms = vad_result.processed_audio_duration_ms if vad_result else wav_meta.get("audio_duration_ms")
processed_audio_path = vad_result.audio_path if vad_result else None
vad_time_ms = vad_result.vad_time_ms if vad_result else 0.0
vad_error = vad_result.error if vad_result else None
return {
"server_vad_enabled": enabled,
"server_vad_engine": engine,
"server_speech_duration_ms": speech_duration_ms,
"server_vad_segments_count": segments_count,
"server_speech_start_ms": speech_start_ms,
"server_speech_end_ms": speech_end_ms,
"server_silence_trimmed_ms": silence_trimmed_ms,
"server_trim_ratio": trim_ratio,
"server_vad_fallback_used": fallback_used,
"server_vad_fallback_reason": fallback_reason,
"server_processed_audio_duration_ms": processed_duration_ms,
"server_processed_audio_path": processed_audio_path,
"server_vad_time_ms": vad_time_ms,
"server_vad_error": vad_error,
"vad_enabled": enabled,
"vad_engine": engine,
"speech_duration_ms": speech_duration_ms,
"vad_segments_count": segments_count,
"speech_start_ms": speech_start_ms,
"speech_end_ms": speech_end_ms,
"silence_trimmed_ms": silence_trimmed_ms,
"trim_ratio": trim_ratio,
"fallback_used": fallback_used,
"vad_fallback_used": fallback_used,
"vad_fallback_reason": fallback_reason,
"processed_audio_duration_ms": processed_duration_ms,
"processed_audio_path": processed_audio_path,
"vad_time_ms": vad_time_ms,
"vad_error": vad_error,
}
def _no_vad_log_fields(wav_meta: dict[str, Any]) -> dict[str, Any]:
duration_ms = wav_meta.get("audio_duration_ms")
return {
"server_vad_enabled": False,
"server_vad_engine": None,
"server_speech_duration_ms": duration_ms,
"server_vad_segments_count": 0,
"server_speech_start_ms": None,
"server_speech_end_ms": None,
"server_silence_trimmed_ms": 0.0,
"server_trim_ratio": 0.0,
"server_vad_fallback_used": False,
"server_vad_fallback_reason": None,
"server_processed_audio_duration_ms": duration_ms,
"server_processed_audio_path": None,
"server_vad_time_ms": 0.0,
"server_vad_error": None,
"vad_enabled": False,
"vad_engine": None,
"speech_duration_ms": duration_ms,
"vad_segments_count": 0,
"speech_start_ms": None,
"speech_end_ms": None,
"silence_trimmed_ms": 0.0,
"trim_ratio": 0.0,
"fallback_used": False,
"vad_fallback_used": False,
"vad_fallback_reason": None,
"processed_audio_duration_ms": duration_ms,
"processed_audio_path": None,
"vad_time_ms": 0.0,
"vad_error": None,
}
def _pause_log_fields(pause_result: Any, pause_text: str) -> dict[str, Any]:
if pause_result is None:
return {
"pause_detection_enabled": False,
"pause_detector": None,
"pause_detection_time_ms": 0.0,
"pause_count": 0,
"pauses": [],
"pause_text": pause_text,
"pause_text_char_count": len(pause_text or ""),
"pause_detector_error": "pause_detection_not_run",
"pause_detector_fallback_used": True,
}
return {
"pause_detection_enabled": pause_result.enabled,
"pause_detector": pause_result.detector,
"pause_detection_time_ms": pause_result.detection_time_ms,
"pause_count": pause_result.pause_count,
"pauses": [pause.model_dump() for pause in pause_result.pauses],
"pause_speech_regions_count": pause_result.speech_regions_count,
"pause_speech_regions": [region.model_dump() for region in pause_result.speech_regions],
"pause_rms_threshold": pause_result.threshold,
"pause_frame_ms": pause_result.frame_ms,
"pause_text": pause_text,
"pause_text_char_count": len(pause_text or ""),
"pause_detector_error": pause_result.error,
"pause_detector_fallback_used": pause_result.fallback_used,
}
def _processed_audio_path(clip: dict[str, Any], vad_engine: str) -> Path:
clip_id = str(clip.get("clip_id") or "clip")
safe_clip_id = re.sub(r"[^a-zA-Z0-9_-]+", "_", clip_id).strip("_") or "clip"
safe_engine = re.sub(r"[^a-zA-Z0-9_-]+", "_", vad_engine).strip("_") or "none"
return repo_root() / "benchmarks" / "audio" / "processed" / f"{safe_clip_id}__vad_{safe_engine}.wav"
def _normalized_audio_path(clip: dict[str, Any]) -> Path:
clip_id = str(clip.get("clip_id") or "clip")
safe_clip_id = re.sub(r"[^a-zA-Z0-9_-]+", "_", clip_id).strip("_") or "clip"
return repo_root() / "benchmarks" / "audio" / "normalized" / f"{safe_clip_id}.wav"
def _normalize_wav_for_stt(input_path: Path, output_path: Path, sample_rate: int = 16000) -> None:
samples, source_rate = _read_wav_as_mono_float(input_path)
normalized = _resample_linear(samples, source_rate, sample_rate)
pcm = array("h")
for sample in normalized:
clipped = max(-1.0, min(1.0, sample))
pcm.append(int(clipped * 32767) if clipped >= 0 else int(clipped * 32768))
output_path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(output_path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(pcm.tobytes())
def _read_wav_as_mono_float(path: Path) -> tuple[list[float], int]:
with wave.open(str(path), "rb") as wav:
channels = wav.getnchannels()
sample_width = wav.getsampwidth()
sample_rate = wav.getframerate()
frames = wav.readframes(wav.getnframes())
if sample_width == 1:
values = [(byte - 128) / 128.0 for byte in frames]
elif sample_width == 2:
pcm = array("h")
pcm.frombytes(frames)
values = [sample / 32768.0 for sample in pcm]
elif sample_width == 4:
pcm = array("i")
pcm.frombytes(frames)
values = [sample / 2147483648.0 for sample in pcm]
else:
raise ValueError(f"unsupported_wav_sample_width:{sample_width}")
if channels <= 1:
return values, sample_rate
mono: list[float] = []
for index in range(0, len(values), channels):
frame = values[index:index + channels]
if frame:
mono.append(sum(frame) / len(frame))
return mono, sample_rate
def _resample_linear(samples: list[float], source_rate: int, target_rate: int) -> list[float]:
if source_rate == target_rate:
return samples
if not samples or source_rate <= 0 or target_rate <= 0:
return []
ratio = source_rate / target_rate
output_length = max(1, round(len(samples) / ratio))
output: list[float] = []
for index in range(output_length):
source_index = index * ratio
left_index = int(source_index)
right_index = min(left_index + 1, len(samples) - 1)
weight = source_index - left_index
output.append(samples[left_index] * (1 - weight) + samples[right_index] * weight)
return output
def _wav_metadata(path: Path) -> dict[str, Any]:
size_bytes = path.stat().st_size
with wave.open(str(path), "rb") as wav:
frames = wav.getnframes()
sample_rate = wav.getframerate()
channels = wav.getnchannels()
sample_width = wav.getsampwidth()
return {
"audio_duration_ms": round((frames / sample_rate) * 1000, 2) if sample_rate else None,
"sample_rate": sample_rate,
"channels": channels,
"sample_width_bytes": sample_width,
"wav_size_bytes": size_bytes,
}
def normalize_text(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"[^a-z0-9\s]", " ", text)
return re.sub(r"\s+", " ", text).strip()
def normalize_pause_text(text: str) -> str:
text = text.lower().strip()
text = re.sub(r"\.{9,}", " pause_long ", text)
text = re.sub(r"\.{6,8}", " pause_medium ", text)
text = re.sub(r"\.{3,5}", " pause_short ", text)
text = re.sub(r"[^a-z0-9_\s]", " ", text)
return re.sub(r"\s+", " ", text).strip()
def word_error_rate(expected: str, actual: str) -> float | None:
expected_words = expected.split()
actual_words = actual.split()
if not expected_words:
return 0.0 if not actual_words else None
distance = _levenshtein(expected_words, actual_words)
return round(distance / len(expected_words), 4)
def _levenshtein(left: list[str], right: list[str]) -> int:
previous = list(range(len(right) + 1))
for i, left_word in enumerate(left, start=1):
current = [i]
for j, right_word in enumerate(right, start=1):
insert = current[j - 1] + 1
delete = previous[j] + 1
replace = previous[j - 1] + (0 if left_word == right_word else 1)
current.append(min(insert, delete, replace))
previous = current
return previous[-1]
def _build_run_config(
args: argparse.Namespace,
manifest_path: Path | None,
raw_dir: Path,
vad_engines: list[str],
pause_detectors: list[str],
clip_count: int,
stt: TranscriptionService,
) -> dict[str, Any]:
model_config = stt.model_config.model_dump()
config: dict[str, Any] = {
"backend": model_config.get("backend"),
"wrapper": model_config.get("wrapper"),
"model": model_config.get("model"),
"model_file": model_config.get("model_file"),
"model_path": model_config.get("model_path"),
"language": model_config.get("language"),
"task": model_config.get("task"),
"translate": model_config.get("translate"),
"device": model_config.get("device"),
"quantization": model_config.get("quantization"),
"compute_type": model_config.get("compute_type"),
"vad_engines": vad_engines,
"pause_detectors": pause_detectors,
"term_mapping_enabled": bool(getattr(args, "term_mapping", False)),
"dataset_manifest": _display_repo_path(manifest_path) if manifest_path else None,
"dataset_manifest_hash": _sha256_file(manifest_path) if manifest_path and manifest_path.exists() else None,
"raw_dir": _display_repo_path(raw_dir),
"clip_count": clip_count,
"created_at": now_iso(),
"git_commit": git_commit(),
}
config["config_label"] = _build_config_label(config)
config["config_hash"] = _sha256_text(_canonical_json(_config_hash_payload(config)))
config["config_hash_short"] = config["config_hash"][:6]
return config
def _build_run_id(run_config: dict[str, Any]) -> str:
timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
model = _slugify_token(str(run_config.get("model") or "model"))
quantization = _slugify_token(str(run_config.get("quantization") or "unknown"))
config_label = _slugify_token(str(run_config.get("config_label") or "cfg"))
config_hash_short = str(run_config.get("config_hash_short") or "000000")
return f"{timestamp}_{model}_{quantization}_{config_label}_cfg{config_hash_short}"
def _export_run_artifacts(run_dir: Path, run_id: str, run_config: dict[str, Any], rows: list[dict[str, Any]]) -> None:
config = dict(run_config)
config["run_id"] = run_id
config["run_dir"] = _display_repo_path(run_dir)
summary = _build_summary_payload(run_id, config, rows)
timings = _build_timings_payload(run_id, config, rows)
transcript_failures, pause_failures, term_failures, failures_by_category = _build_failure_exports(rows, run_id)
summary_text = _render_summary_text(summary, failures_by_category)
write_json_atomic(run_dir / "config.json", config)
write_json_atomic(run_dir / "summary.json", summary)
write_text_atomic(run_dir / "summary.txt", summary_text)
write_json_atomic(run_dir / "timings.json", timings)
write_json_atomic(run_dir / "failures_by_category.json", failures_by_category)
write_jsonl_atomic(run_dir / "transcript_failures_top20.jsonl", transcript_failures)
write_jsonl_atomic(run_dir / "pause_failures_top20.jsonl", pause_failures)
write_jsonl_atomic(run_dir / "term_failures.jsonl", term_failures)
write_text_atomic(LATEST_SUMMARY_PATH, summary_text)
write_json_atomic(LATEST_SUMMARY_JSON_PATH, summary)
write_jsonl_atomic(LATEST_TRANSCRIPT_FAILURES_TOP20_PATH, transcript_failures)
write_jsonl_atomic(LATEST_PAUSE_FAILURES_TOP20_PATH, pause_failures)
write_jsonl_atomic(LATEST_TERM_FAILURES_PATH, term_failures)
write_json_atomic(LATEST_TIMINGS_PATH, timings)
def _build_summary_payload(run_id: str, run_config: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
wer_values = [float(row["wer"]) for row in rows if row.get("wer") is not None]
pause_wer_values = [float(row["pause_text_wer"]) for row in rows if row.get("pause_text_wer") is not None]
inference_values = [float(row["stt_inference_time_ms"]) for row in rows if row.get("stt_inference_time_ms") is not None]
summary = {
"run_id": run_id,
"rows": len(rows),
"model": run_config.get("model"),
"model_file": run_config.get("model_file"),
"quantization": run_config.get("quantization"),
"overall_wer": round(sum(wer_values) / len(wer_values), 4) if wer_values else None,
"overall_pause_text_wer": round(sum(pause_wer_values) / len(pause_wer_values), 4) if pause_wer_values else None,
"wer_by_category": _metric_average_by(rows, "clip_category", "wer"),
"pause_text_wer_by_detector": _metric_average_by(rows, "pause_detector", "pause_text_wer", skip_none_key="none"),
"median_inference_time_ms": round(_median(inference_values), 2) if inference_values else None,
"p95_inference_time_ms": round(_percentile(inference_values, 95), 2) if inference_values else None,
"config": dict(run_config),
"generated_at": now_iso(),
}
return summary
def _build_timings_payload(run_id: str, run_config: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
payload: dict[str, Any] = {
"run_id": run_id,
"model": run_config.get("model"),
"quantization": run_config.get("quantization"),
"rows": len(rows),
"stt_inference_time_ms": _metric_stats([float(row["stt_inference_time_ms"]) for row in rows if row.get("stt_inference_time_ms") is not None]),
"total_backend_time_ms": _metric_stats([float(row["total_backend_time_ms"]) for row in rows if row.get("total_backend_time_ms") is not None]),
"preprocess_time_ms": _metric_stats([float(row["preprocess_time_ms"]) for row in rows if row.get("preprocess_time_ms") is not None]),
"normalization_time_ms": _metric_stats([float(row["normalization_time_ms"]) for row in rows if row.get("normalization_time_ms") is not None]),
"pause_detection_time_ms_by_detector": {},
"stt_inference_time_ms_by_vad_engine": {},
}
detector_groups: dict[str, list[float]] = defaultdict(list)
vad_groups: dict[str, list[float]] = defaultdict(list)
for row in rows:
pause_detector = str(row.get("pause_detector") or "none")
pause_time = row.get("pause_detection_time_ms")
if pause_time is not None and pause_detector != "none":
detector_groups[pause_detector].append(float(pause_time))
vad_engine = str(row.get("server_vad_engine") or "none")
stt_time = row.get("stt_inference_time_ms")
if stt_time is not None:
vad_groups[vad_engine].append(float(stt_time))
payload["pause_detection_time_ms_by_detector"] = {
detector: _metric_stats(values) for detector, values in sorted(detector_groups.items())
}
payload["stt_inference_time_ms_by_vad_engine"] = {
engine: _metric_stats(values) for engine, values in sorted(vad_groups.items())
}
return payload
def _build_failure_exports(
rows: list[dict[str, Any]],
run_id: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
transcript_worst_rows = _best_rows_by_clip(rows, "wer")
pause_worst_rows = _best_rows_by_clip(rows, "pause_text_wer")
transcript_failures = [_transcript_failure_entry(row, run_id) for row in transcript_worst_rows]
pause_failures = [_pause_failure_entry(row, run_id) for row in pause_worst_rows]
term_candidates: list[dict[str, Any]] = []
for clip_rows in _rows_by_clip(rows).values():
best_term_entry = _best_term_failure_entry(clip_rows, run_id)
if best_term_entry is not None:
term_candidates.append(best_term_entry)
term_failures = sorted(
term_candidates,
key=lambda row: (
-int(row.get("term_mismatch_count") or 0),
-float(row.get("term_match_score") or 0.0),
str(row.get("clip_id") or ""),
),
)
failures_by_category = {
"run_id": run_id,
"categories": _category_failure_summary(transcript_worst_rows, pause_worst_rows, term_failures),
}
return transcript_failures[:20], pause_failures[:20], term_failures, failures_by_category
def _rows_by_clip(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
clip_id = str(row.get("clip_id") or "").strip()
if not clip_id:
continue
grouped[clip_id].append(row)
return grouped
def _load_jsonl_rows(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
return rows
def _best_rows_by_clip(rows: list[dict[str, Any]], score_key: str) -> list[dict[str, Any]]:
best_rows: list[dict[str, Any]] = []
for clip_rows in _rows_by_clip(rows).values():
eligible = [row for row in clip_rows if row.get(score_key) is not None]
if not eligible:
continue
best_row = max(eligible, key=lambda row: (
float(row.get(score_key) or 0.0),
float(row.get("stt_inference_time_ms") or 0.0),
str(row.get("request_id") or ""),
))
if float(best_row.get(score_key) or 0.0) > 0.0:
best_rows.append(best_row)
best_rows.sort(key=lambda row: (
-float(row.get(score_key) or 0.0),
str(row.get("clip_id") or ""),
str(row.get("request_id") or ""),
))
return best_rows
def _transcript_failure_entry(row: dict[str, Any], run_id: str) -> dict[str, Any]:
return {
"score_source": "transcript_wer",
"run_id": run_id,
"request_id": row.get("request_id"),
"clip_id": row.get("clip_id"),
"clip_category": row.get("clip_category") or "uncategorized",
"wer": row.get("wer"),
"expected_text": row.get("expected_text"),
"transcript": row.get("transcript"),
"likely_issue": _likely_issue(row),
"vad_engine": row.get("server_vad_engine"),
"pause_detector": row.get("pause_detector"),
"segments_count": row.get("segments_count"),
"error": row.get("error"),
}
def _pause_failure_entry(row: dict[str, Any], run_id: str) -> dict[str, Any]:
return {
"score_source": "pause_text_wer",
"run_id": run_id,
"request_id": row.get("request_id"),
"clip_id": row.get("clip_id"),
"clip_category": row.get("clip_category") or "uncategorized",
"pause_detector": row.get("pause_detector"),
"pause_text_wer": row.get("pause_text_wer"),
"expected_pause_text": row.get("expected_pause_text"),
"pause_text": row.get("pause_text"),
"pauses": row.get("pauses") or [],
"likely_issue": "pause_alignment",
"vad_engine": row.get("server_vad_engine"),
"pause_count": row.get("pause_count"),
"pause_detector_error": row.get("pause_detector_error"),
"pause_detector_fallback_used": row.get("pause_detector_fallback_used"),
}
def _best_term_failure_entry(rows: list[dict[str, Any]], run_id: str) -> dict[str, Any] | None:
best_entry: dict[str, Any] | None = None
for row in rows:
entry = _term_failure_entry(row, run_id)
if entry is None:
continue
if best_entry is None:
best_entry = entry
continue
best_score = (int(best_entry.get("term_mismatch_count") or 0), float(best_entry.get("term_match_score") or 0.0))
candidate_score = (int(entry.get("term_mismatch_count") or 0), float(entry.get("term_match_score") or 0.0))
if candidate_score > best_score:
best_entry = entry
return best_entry
def _term_failure_entry(row: dict[str, Any], run_id: str) -> dict[str, Any] | None:
transcript = str(row.get("transcript") or "").strip()
if not transcript:
return None
expected_terms = _extract_expected_terms(str(row.get("expected_text") or ""))
if not expected_terms:
return None
transcript_candidates = _transcript_term_candidates(transcript)
matches: list[dict[str, Any]] = []
for term in expected_terms:
match = _best_term_match(term, transcript_candidates)
if match is None:
continue
if match["normalized_expected"] == match["normalized_candidate"]:
continue
if float(match["similarity"]) < 0.75:
continue
matches.append(match)
if not matches:
return None
observed_candidates: list[str] = []
for match in matches:
candidate = str(match["observed_candidate"])
if candidate not in observed_candidates:
observed_candidates.append(candidate)
average_similarity = sum(float(match["similarity"]) for match in matches) / len(matches)
confidence = "likely" if average_similarity >= 0.85 and len(matches) <= 3 else "uncertain"
return {
"score_source": "term_advisory",
"run_id": run_id,
"request_id": row.get("request_id"),
"clip_id": row.get("clip_id"),
"clip_category": row.get("clip_category") or "uncategorized",
"expected_terms": [str(match["expected_term"]) for match in matches],
"observed_candidates": observed_candidates,
"term_matches": [
{
"expected_term": match["expected_term"],
"observed_candidate": match["observed_candidate"],
"similarity": match["similarity"],
}
for match in matches
],
"term_mismatch_count": len(matches),
"term_match_score": round(average_similarity, 4),
"confidence": confidence,
"manual_review_needed": True,
"transcript": row.get("transcript"),
"expected_text": row.get("expected_text"),
}
def _extract_expected_terms(text: str) -> list[str]:
tokens = list(re.finditer(r"[A-Za-z0-9][A-Za-z0-9\-]*", text or ""))
terms: list[str] = []
for index, match in enumerate(tokens):
token = match.group(0)
if _looks_like_term(token, index):
cleaned = token.strip(" ,.;:!?\"'()[]{}")
if cleaned and cleaned not in terms:
terms.append(cleaned)
return terms
def _looks_like_term(token: str, index: int) -> bool:
if not token:
return False
if re.search(r"[0-9]", token):
return True
if "-" in token:
return True
if token.isupper() and len(token) >= 2:
return True
if any(char.isupper() for char in token[1:]):
return True
if index > 0 and token[0].isupper():
return True
return False
def _transcript_term_candidates(transcript: str) -> list[str]:
words = [part for part in re.findall(r"[A-Za-z0-9][A-Za-z0-9\-]*", transcript or "") if part]
candidates: list[str] = []
for size in (1, 2, 3):
if len(words) < size:
continue
for start in range(0, len(words) - size + 1):
phrase = " ".join(words[start:start + size]).strip()
if phrase and phrase not in candidates:
candidates.append(phrase)
return candidates
def _best_term_match(expected_term: str, candidates: list[str]) -> dict[str, Any] | None:
normalized_expected = _normalize_term(expected_term)
if not normalized_expected:
return None
best_candidate = ""
best_similarity = 0.0
for candidate in candidates:
normalized_candidate = _normalize_term(candidate)
if not normalized_candidate:
continue
similarity = SequenceMatcher(None, normalized_expected, normalized_candidate).ratio()
if similarity > best_similarity:
best_similarity = similarity
best_candidate = candidate
if not best_candidate or best_similarity < 0.75:
return None
return {
"expected_term": expected_term,
"observed_candidate": best_candidate,
"similarity": round(best_similarity, 4),
"normalized_expected": normalized_expected,
"normalized_candidate": _normalize_term(best_candidate),
}
def _normalize_term(term: str) -> str:
return re.sub(r"[^a-z0-9]+", "", str(term or "").lower())
def _likely_issue(row: dict[str, Any]) -> str:
category = str(row.get("clip_category") or "").strip().lower()
if bool(row.get("possible_repetition_loop")):
return "repetition_loop"
if category == "model_names":
return "model_name"
if category in {"paper_results_metrics", "acronyms"}:
return "metric_or_acronym"
if category in {"dead_air_noise"}:
return "silence_or_noise"
if "pause" in category or category in {"abandoned_thought", "student_confusion", "long_realistic_dictation", "long_realistic"}:
return "pause_alignment"
if category == "emotion_sentiment":
return "sentiment_label"
if not str(row.get("transcript") or "").strip():
return "blank_transcript"
return category or "general_transcript"
def _category_failure_summary(
transcript_failures: list[dict[str, Any]],
pause_failures: list[dict[str, Any]],
term_failures: list[dict[str, Any]],
) -> dict[str, Any]:
categories: dict[str, dict[str, Any]] = {}
for source, rows, score_key in (
("transcript", transcript_failures, "wer"),
("pause", pause_failures, "pause_text_wer"),
("term", term_failures, "term_match_score"),
):
for row in rows:
category = str(row.get("clip_category") or "uncategorized")
bucket = categories.setdefault(category, {})
source_bucket = bucket.setdefault(source, {"count": 0, "worst_row_ref": None})
source_bucket["count"] += 1
current_score = float(row.get(score_key) or 0.0)
worst_ref = source_bucket["worst_row_ref"]
if worst_ref is None or current_score > float(worst_ref.get("score") or 0.0):
source_bucket["worst_row_ref"] = {
"clip_id": row.get("clip_id"),
"request_id": row.get("request_id"),
"score": round(current_score, 4),
"score_source": row.get("score_source"),
"pause_detector": row.get("pause_detector"),
"vad_engine": row.get("vad_engine"),
}
return categories
def _metric_average_by(
rows: list[dict[str, Any]],
group_key: str,
value_key: str,
skip_none_key: str | None = None,
) -> dict[str, float]:
grouped: dict[str, list[float]] = defaultdict(list)
for row in rows:
value = row.get(value_key)
if value is None:
continue
group_value = str(row.get(group_key) or "uncategorized")
if skip_none_key is not None and group_value == skip_none_key:
continue
grouped[group_value].append(float(value))
return {
group: round(sum(values) / len(values), 4)
for group, values in sorted(grouped.items())
if values
}
def _metric_stats(values: list[float]) -> dict[str, float | int | None]:
if not values:
return {"count": 0, "median": None, "p95": None, "min": None, "max": None}
ordered = sorted(values)
return {
"count": len(values),
"median": round(_median(ordered), 2),
"p95": round(_percentile(ordered, 95), 2),
"min": round(ordered[0], 2),
"max": round(ordered[-1], 2),
}
def _render_summary_text(summary: dict[str, Any], failures_by_category: dict[str, Any]) -> str:
lines = [
f"run_id: {summary.get('run_id')}",
f"rows: {summary.get('rows')}",
f"model: {summary.get('model')}",
f"model_file: {summary.get('model_file')}",
f"quantization: {summary.get('quantization')}",
f"overall_wer: {summary.get('overall_wer')}",
f"overall_pause_text_wer: {summary.get('overall_pause_text_wer')}",
f"median_inference_time_ms: {summary.get('median_inference_time_ms')}",
f"p95_inference_time_ms: {summary.get('p95_inference_time_ms')}",
f"config_label: {summary.get('config', {}).get('config_label')}",
f"config_hash: {summary.get('config', {}).get('config_hash_short')}",
"",
"wer_by_category:",
]
for category, value in sorted(summary.get("wer_by_category", {}).items()):
lines.append(f" {category}: {value}")
lines.append("")
lines.append("pause_text_wer_by_detector:")
for detector, value in sorted(summary.get("pause_text_wer_by_detector", {}).items()):
lines.append(f" {detector}: {value}")
lines.append("")
lines.append("failure_counts_by_category:")
for category, payload in sorted(failures_by_category.get("categories", {}).items()):
transcript_count = payload.get("transcript", {}).get("count", 0)
pause_count = payload.get("pause", {}).get("count", 0)
term_count = payload.get("term", {}).get("count", 0)
lines.append(f" {category}: transcript={transcript_count} pause={pause_count} term={term_count}")
return "\n".join(lines).rstrip() + "\n"
def _display_repo_path(path: Path | None) -> str | None:
if path is None:
return None
try:
return str(path.resolve().relative_to(repo_root()))
except Exception:
return str(path.resolve())
def _sha256_file(path: Path) -> str | None:
if path is None or not path.exists():
return None
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def _canonical_json(payload: dict[str, Any]) -> str:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
def _config_hash_payload(config: dict[str, Any]) -> dict[str, Any]:
return {
"backend": config.get("backend"),
"wrapper": config.get("wrapper"),
"model": config.get("model"),
"model_file": config.get("model_file"),
"model_path": config.get("model_path"),
"language": config.get("language"),
"task": config.get("task"),
"translate": config.get("translate"),
"quantization": config.get("quantization"),
"compute_type": config.get("compute_type"),
"vad_engines": config.get("vad_engines"),
"pause_detectors": config.get("pause_detectors"),
"term_mapping_enabled": config.get("term_mapping_enabled"),
"dataset_manifest": config.get("dataset_manifest"),
"dataset_manifest_hash": config.get("dataset_manifest_hash"),
"raw_dir": config.get("raw_dir"),
"clip_count": config.get("clip_count"),
"git_commit": config.get("git_commit"),
}
def _build_config_label(config: dict[str, Any]) -> str:
vad_engines = [str(item).strip().lower() for item in config.get("vad_engines") or [] if str(item).strip()]
if not vad_engines or vad_engines == ["none"]:
vad_label = "no_vad"
else:
vad_label = "vad_" + "_".join(_slugify_token(item) for item in vad_engines)
pause_detectors = [str(item).strip().lower() for item in config.get("pause_detectors") or [] if str(item).strip()]
if pause_detectors:
pause_label = "pause_" + "_".join(_slugify_pause_detector(item) for item in pause_detectors)
else:
pause_label = "pause_none"
term_label = "term_on" if config.get("term_mapping_enabled") else "term_off"
return "_".join([vad_label, pause_label, term_label])
def _slugify_pause_detector(detector: str) -> str:
if detector == "rms_energy":
return "rms"
return _slugify_token(detector)
def _slugify_token(value: str) -> str:
return re.sub(r"_+", "_", re.sub(r"[^a-zA-Z0-9]+", "_", value)).strip("_").lower() or "cfg"
def _sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _print_summary(rows: list[dict[str, Any]], run_config: dict[str, Any], run_dir: Path) -> None:
if not rows:
print("No benchmark rows produced.")
return
print("\nBenchmark summary")
print(f"Rows: {len(rows)}")
print(f"Run dir: {run_dir}")
print(f"Config: {run_config.get('config_label')} ({run_config.get('config_hash_short')})")
_print_metric_block("Overall", rows)
engines = sorted({str(row.get("server_vad_engine") or "none") for row in rows})
for engine in engines:
engine_rows = [row for row in rows if str(row.get("server_vad_engine") or "none") == engine]
_print_metric_block(f"VAD {engine}", engine_rows)
detectors = sorted({str(row.get("pause_detector") or "none") for row in rows})
for detector in detectors:
if detector == "none":
continue
detector_rows = [row for row in rows if str(row.get("pause_detector") or "none") == detector]
_print_metric_block(f"Pause detector {detector}", detector_rows)
def _print_metric_block(label: str, rows: list[dict[str, Any]]) -> None:
if not rows:
return
wer_values = [float(row["wer"]) for row in rows if row.get("wer") is not None]
inference_values = [float(row["stt_inference_time_ms"]) for row in rows if row.get("stt_inference_time_ms") is not None]
blank_rows = [row for row in rows if not str(row.get("transcript") or "").strip()]
fallback_rows = [row for row in rows if bool(row.get("server_vad_fallback_used"))]
vad_error_rows = [row for row in rows if row.get("server_vad_error")]
repetition_rows = [row for row in rows if bool(row.get("possible_repetition_loop"))]
pause_wer_values = [float(row["pause_text_wer"]) for row in rows if row.get("pause_text_wer") is not None]
pause_error_rows = [row for row in rows if row.get("pause_detector_error")]
print(f"\n{label}")
if wer_values:
print(f" overall WER: {round(sum(wer_values) / len(wer_values), 4)}")
else:
print(" overall WER: n/a")
if pause_wer_values:
print(f" pause-text WER: {round(sum(pause_wer_values) / len(pause_wer_values), 4)}")
else:
print(" pause-text WER: n/a")
category_map: dict[str, list[float]] = {}
for row in rows:
category = str(row.get("clip_category") or "uncategorized")
wer_value = row.get("wer")
if wer_value is None:
continue
category_map.setdefault(category, []).append(float(wer_value))
if category_map:
print(" WER by category:")
for category in sorted(category_map):
values = category_map[category]
print(f" {category}: {round(sum(values) / len(values), 4)}")
else:
print(" WER by category: n/a")
if inference_values:
print(f" median inference time: {round(_median(inference_values), 2)} ms")
print(f" p95 inference time: {round(_percentile(inference_values, 95), 2)} ms")
else:
print(" median inference time: n/a")
print(" p95 inference time: n/a")
print(f" blank rate: {round(len(blank_rows) / len(rows) * 100, 2)}%")
print(f" server_vad_fallback_rate: {round(len(fallback_rows) / len(rows) * 100, 2)}%")
print(f" server_vad_error_rate: {round(len(vad_error_rows) / len(rows) * 100, 2)}%")
print(f" pause_detector_error_rate: {round(len(pause_error_rows) / len(rows) * 100, 2)}%")
print(f" hallucination/repetition rate: {round(len(repetition_rows) / len(rows) * 100, 2)}%")
def _median(values: list[float]) -> float:
ordered = sorted(values)
midpoint = len(ordered) // 2
if len(ordered) % 2:
return ordered[midpoint]
return (ordered[midpoint - 1] + ordered[midpoint]) / 2
def _percentile(values: list[float], percentile: float) -> float:
ordered = sorted(values)
if not ordered:
return math.nan
if len(ordered) == 1:
return ordered[0]
rank = (percentile / 100) * (len(ordered) - 1)
lower = math.floor(rank)
upper = math.ceil(rank)
if lower == upper:
return ordered[int(rank)]
weight = rank - lower
return ordered[lower] * (1 - weight) + ordered[upper] * weight
if __name__ == "__main__":
raise SystemExit(main())