595 lines
24 KiB
Python
595 lines
24 KiB
Python
"""STT backends, transcription endpoints."""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import re
|
||
import struct
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
import requests
|
||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||
|
||
from core.config import _load_settings
|
||
from core.constants import (
|
||
_WHISPER_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT,
|
||
_GROQ_STT_ENDPOINT, _NVIDIA_ASR_DEFAULT, _NVIDIA_ROUTER_DEFAULT,
|
||
_MAX_UPLOAD_BYTES, _STT_REQUEST_TIMEOUT,
|
||
)
|
||
from core.registry import _registry_get, TEMP_DIR
|
||
from core.validation import _validate_http_url, _copy_limited
|
||
from core.audio import _to_wav_16k
|
||
from core.voice import _AUDIO_EXTS, _UPLOAD_EXTS
|
||
|
||
router = APIRouter()
|
||
|
||
# ── STT backend definitions ───────────────────────────────────────────────────
|
||
|
||
_STT_BACKEND_ALIASES = {
|
||
"": "configured",
|
||
"default": "configured",
|
||
"whisper": "configured",
|
||
"configured_whisper": "configured",
|
||
"configured_stt": "configured",
|
||
"parakeet": "nvidia_parakeet",
|
||
"nvidia": "nvidia_parakeet",
|
||
"nvidia_asr": "nvidia_parakeet",
|
||
"nvidia_parakeet_asr": "nvidia_parakeet",
|
||
"router": "nvidia_router",
|
||
"speech_router": "nvidia_router",
|
||
"nvidia_speech_router": "nvidia_router",
|
||
"faster_whisper_server": "faster_whisper",
|
||
"faster-whisper": "faster_whisper",
|
||
"ctranslate2": "faster_whisper",
|
||
"faster_w": "faster_whisper",
|
||
"whisper-cpp": "whisper_cpp",
|
||
"whisper_cpp_server": "whisper_cpp",
|
||
"cpp": "whisper_cpp",
|
||
"groq": "groq_whisper",
|
||
"groq_stt": "groq_whisper",
|
||
"groq-whisper": "groq_whisper",
|
||
}
|
||
|
||
_STT_BACKEND_METRICS: dict[str, dict] = {
|
||
"configured": {"speed": "GPU / CPU", "latency": "1–5 s", "quality": "large-v3", "ram": "3 GB VRAM"},
|
||
"faster_whisper": {"speed": "~70× RT · GPU", "latency": "0.5–2 s", "quality": "large-v3", "ram": "1.5 GB VRAM"},
|
||
"whisper_cpp": {"speed": "~8–15× RT · CPU", "latency": "1–5 s", "quality": "large-v3 Q5", "ram": "~1 GB RAM"},
|
||
"groq_whisper": {"speed": "fastest cloud", "latency": "0.5–1 s", "quality": "Whisper Turbo", "ram": "cloud · 0"},
|
||
"nvidia_parakeet":{"speed": "~200× RT · GPU", "latency": "<0.3 s", "quality": "Parakeet-TDT", "ram": "~3 GB"},
|
||
"nvidia_router": {"speed": "GPU routed", "latency": "~0.5 s", "quality": "varies", "ram": "~11 GB"},
|
||
}
|
||
|
||
_STT_VALID_BACKENDS = {"configured", "nvidia_parakeet", "nvidia_router", "faster_whisper", "whisper_cpp", "groq_whisper"}
|
||
|
||
|
||
def _clean_stt_backend(value: str) -> str:
|
||
import re
|
||
original = str(value or "").strip()
|
||
if original.startswith("custom:"):
|
||
return original
|
||
key = re.sub(r"[^a-z0-9]+", "_", original.lower()).strip("_")
|
||
key = _STT_BACKEND_ALIASES.get(key, key)
|
||
return key if key in _STT_VALID_BACKENDS else "configured"
|
||
|
||
|
||
def _stt_backend_url(settings: dict, backend: str) -> str:
|
||
backend = _clean_stt_backend(backend)
|
||
if backend.startswith("custom:"):
|
||
custom_id = backend[len("custom:"):]
|
||
for card in settings.get("custom_engine_cards", []):
|
||
if str(card.get("id", card.get("name", ""))) == custom_id:
|
||
return card.get("url", "")
|
||
return ""
|
||
if backend == "nvidia_parakeet":
|
||
return settings.get("nvidia_asr_url") or _NVIDIA_ASR_DEFAULT
|
||
if backend == "nvidia_router":
|
||
return settings.get("nvidia_router_url") or _NVIDIA_ROUTER_DEFAULT
|
||
if backend == "faster_whisper":
|
||
return settings.get("faster_whisper_url") or _FASTER_WHISPER_DEFAULT
|
||
if backend == "whisper_cpp":
|
||
return settings.get("whisper_cpp_url") or _WHISPER_CPP_DEFAULT
|
||
if backend == "groq_whisper":
|
||
return _GROQ_STT_ENDPOINT
|
||
return settings.get("whisper_url") or _WHISPER_DEFAULT
|
||
|
||
|
||
def _stt_backend_model(backend: str) -> str:
|
||
backend = _clean_stt_backend(backend)
|
||
if backend in {"nvidia_parakeet", "nvidia_router"}:
|
||
return "whisper-1"
|
||
if backend == "whisper_cpp":
|
||
return "whisper-1"
|
||
if backend == "groq_whisper":
|
||
return "whisper-large-v3-turbo"
|
||
return "large-v3"
|
||
|
||
|
||
def _backend_port_label(url: str) -> str:
|
||
from urllib.parse import urlsplit
|
||
try:
|
||
parts = urlsplit(url)
|
||
if parts.port:
|
||
return str(parts.port)
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
|
||
def _stt_backend_label(backend: str, url: str) -> str:
|
||
labels = {
|
||
"configured": "Configured Whisper/STT",
|
||
"nvidia_parakeet": "NVIDIA Parakeet ASR",
|
||
"nvidia_router": "NVIDIA Speech Router",
|
||
"faster_whisper": "faster-whisper (CTranslate2 GPU)",
|
||
"whisper_cpp": "whisper.cpp (CPU/CUDA)",
|
||
"groq_whisper": "Groq Whisper (cloud · free)",
|
||
}
|
||
port = _backend_port_label(url)
|
||
label = labels.get(backend, backend)
|
||
return f"{port} {label}" if port else label
|
||
|
||
|
||
def _stt_backend_api_key(settings: dict, backend: str) -> str:
|
||
if backend == "groq_whisper":
|
||
return settings.get("groq_api_key", "").strip()
|
||
return settings.get("whisper_api_key", "").strip()
|
||
|
||
|
||
def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes:
|
||
"""Minimal WAV: mono 16-bit silence of given duration at given sample rate."""
|
||
num_frames = sample_rate * duration_ms // 1000
|
||
data = b"\x00\x00" * num_frames
|
||
header = struct.pack(
|
||
"<4sI4s4sIHHIIHH4sI",
|
||
b"RIFF", 36 + len(data), b"WAVE",
|
||
b"fmt ", 16, 1, 1, sample_rate, sample_rate * 2, 2, 16,
|
||
b"data", len(data),
|
||
)
|
||
return header + data
|
||
|
||
|
||
def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
|
||
base = _validate_http_url(url, allow_private=True).rstrip("/")
|
||
models: list[str] = []
|
||
ok = False
|
||
try:
|
||
r = requests.get(f"{base}/health", timeout=2)
|
||
ok = r.status_code == 200
|
||
except Exception:
|
||
pass
|
||
try:
|
||
r = requests.get(f"{base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"})
|
||
if r.status_code == 200:
|
||
ok = True
|
||
payload = r.json()
|
||
data = payload.get("data", []) if isinstance(payload, dict) else []
|
||
for item in data:
|
||
if isinstance(item, dict) and item.get("id"):
|
||
models.append(str(item["id"]))
|
||
elif isinstance(item, str):
|
||
models.append(item)
|
||
except Exception:
|
||
pass
|
||
if not ok:
|
||
return False, models
|
||
try:
|
||
wav = _make_minimal_wav()
|
||
endpoint_found = False
|
||
timed_out = False
|
||
for path in ("/v1/audio/transcriptions", "/transcribe"):
|
||
try:
|
||
r = requests.post(
|
||
f"{base}{path}",
|
||
files={"file": ("probe.wav", wav, "audio/wav")},
|
||
data={"model": "whisper-1", "response_format": "text"},
|
||
timeout=8,
|
||
)
|
||
if r.status_code in {404, 405}:
|
||
continue
|
||
if r.status_code == 500:
|
||
try:
|
||
detail = r.json().get("detail", "")
|
||
audio_quality_words = ("too short", "no speech", "audio", "empty",
|
||
"duration", "length", "silence")
|
||
ok = bool(detail) and any(w in detail.lower() for w in audio_quality_words)
|
||
except Exception:
|
||
ok = False
|
||
else:
|
||
ok = True
|
||
endpoint_found = True
|
||
break
|
||
except requests.exceptions.ConnectionError:
|
||
ok = False
|
||
endpoint_found = True
|
||
break
|
||
except Exception:
|
||
timed_out = True
|
||
if not endpoint_found and not timed_out:
|
||
ok = False
|
||
except Exception:
|
||
pass
|
||
return ok, models
|
||
|
||
|
||
def _transcription_text_from_response(resp: requests.Response) -> str:
|
||
try:
|
||
payload = resp.json()
|
||
if isinstance(payload, str):
|
||
return payload.strip()
|
||
if isinstance(payload, dict):
|
||
for key in ("text", "transcript", "transcription"):
|
||
if payload.get(key) is not None:
|
||
return str(payload[key]).strip()
|
||
segments = payload.get("segments")
|
||
if isinstance(segments, list):
|
||
return " ".join(s.get("text", "").strip() for s in segments if s.get("text")).strip()
|
||
except Exception:
|
||
pass
|
||
return resp.text.strip()
|
||
|
||
|
||
def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") -> tuple[str, str]:
|
||
backend = _clean_stt_backend(backend)
|
||
stt_url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/")
|
||
stt_key = _stt_backend_api_key(settings, backend)
|
||
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
|
||
model = _stt_backend_model(backend)
|
||
paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"]
|
||
resp = None
|
||
for path in paths:
|
||
with src.open("rb") as f:
|
||
resp = requests.post(
|
||
f"{stt_url}{path}",
|
||
files={"file": ("audio.wav", f, "audio/wav")},
|
||
data={"model": model, "response_format": "text"},
|
||
headers=hdrs,
|
||
timeout=_STT_REQUEST_TIMEOUT,
|
||
)
|
||
if resp.status_code == 404 and len(paths) > 1:
|
||
continue
|
||
break
|
||
if resp.status_code in {400, 404, 422, 500} and model != "whisper-1":
|
||
path = paths[-1]
|
||
with src.open("rb") as f:
|
||
resp = requests.post(
|
||
f"{stt_url}{path}",
|
||
files={"file": ("audio.wav", f, "audio/wav")},
|
||
data={"model": "whisper-1", "response_format": "text"},
|
||
headers=hdrs,
|
||
timeout=60,
|
||
)
|
||
if not resp.ok:
|
||
try:
|
||
body = resp.json()
|
||
detail = body.get("detail") or body.get("error") or body.get("message") or str(body)
|
||
except Exception:
|
||
detail = resp.text[:300].strip()
|
||
if not detail or detail.lower() in {"internal server error", "unknown error"}:
|
||
detail = f"HTTP {resp.status_code} — backend may be misconfigured or missing CUDA support"
|
||
if "'NoneType'" in detail and "'to'" in detail:
|
||
detail = ("Speaker diarization failed — pyannote/speaker-diarization-3.1 requires "
|
||
"a HuggingFace token. Get one at hf.co/settings/tokens and accept the "
|
||
"model license at hf.co/pyannote/speaker-diarization-3.1, then add the "
|
||
"token to the whisperx-gpu container env as HF_TOKEN.")
|
||
raise RuntimeError(f"STT ({stt_url}): {detail}")
|
||
return _transcription_text_from_response(resp), backend
|
||
|
||
|
||
def _word_tokens(text: str) -> list[str]:
|
||
return re.findall(r"[\w']+", (text or "").lower(), flags=re.UNICODE)
|
||
|
||
|
||
def _word_accuracy(reference: str, hypothesis: str) -> float | None:
|
||
ref = _word_tokens(reference)
|
||
hyp = _word_tokens(hypothesis)
|
||
if not ref:
|
||
return None
|
||
prev = list(range(len(hyp) + 1))
|
||
for i, rw in enumerate(ref, 1):
|
||
cur = [i]
|
||
for j, hw in enumerate(hyp, 1):
|
||
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (0 if rw == hw else 1)))
|
||
prev = cur
|
||
return max(0.0, (1.0 - prev[-1] / max(1, len(ref))) * 100.0)
|
||
|
||
|
||
def _stt_device_hint(engine: dict, settings: dict) -> str:
|
||
text = " ".join(str(engine.get(k, "")) for k in ("id", "backend", "label", "url", "model", "source")).lower()
|
||
if any(token in text for token in ("nvidia", "parakeet", "nemotron", "whisperx", "faster", "cuda", "gpu", "blackwell")):
|
||
return "CUDA/GPU"
|
||
if "cpu" in text or "whisper_cpp" in text or "whisper.cpp" in text:
|
||
return "CPU"
|
||
backend = _clean_stt_backend(str(engine.get("backend") or engine.get("id") or ""))
|
||
metrics = _STT_BACKEND_METRICS.get(backend, {})
|
||
speed = str(metrics.get("speed", "")).lower()
|
||
if "gpu" in speed or "cuda" in speed:
|
||
return "CUDA/GPU"
|
||
if "cpu" in speed:
|
||
return "CPU"
|
||
return "Unknown"
|
||
|
||
|
||
def _transcribe_url_for_benchmark(src: Path, url: str, model: str, api_key: str = "") -> str:
|
||
base = _validate_http_url(url, allow_private=True).rstrip("/")
|
||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||
last_resp: requests.Response | None = None
|
||
last_error = ""
|
||
for path in ("/v1/audio/transcriptions", "/transcribe", "/audio/transcriptions"):
|
||
try:
|
||
with src.open("rb") as f:
|
||
resp = requests.post(
|
||
f"{base}{path}",
|
||
files={"file": ("benchmark.wav", f, "audio/wav")},
|
||
data={"model": model or "whisper-1", "response_format": "text"},
|
||
headers=headers,
|
||
timeout=_STT_REQUEST_TIMEOUT,
|
||
)
|
||
last_resp = resp
|
||
if resp.status_code in {404, 405}:
|
||
continue
|
||
if not resp.ok and (model or "whisper-1") != "whisper-1":
|
||
with src.open("rb") as f:
|
||
retry = requests.post(
|
||
f"{base}{path}",
|
||
files={"file": ("benchmark.wav", f, "audio/wav")},
|
||
data={"model": "whisper-1", "response_format": "text"},
|
||
headers=headers,
|
||
timeout=_STT_REQUEST_TIMEOUT,
|
||
)
|
||
if retry.ok:
|
||
return _transcription_text_from_response(retry)
|
||
last_resp = retry
|
||
if resp.ok:
|
||
return _transcription_text_from_response(resp)
|
||
try:
|
||
body = resp.json()
|
||
last_error = body.get("detail") or body.get("error") or body.get("message") or str(body)
|
||
except Exception:
|
||
last_error = resp.text[:400].strip()
|
||
except Exception as e:
|
||
last_error = str(e)
|
||
status = f"HTTP {last_resp.status_code}" if last_resp is not None else "STT request failed"
|
||
raise RuntimeError(f"{status}: {last_error or 'No compatible transcription endpoint'}")
|
||
|
||
|
||
def _benchmark_stt_engine(src: Path, engine: dict, settings: dict, reference: str) -> dict:
|
||
label = str(engine.get("label") or engine.get("id") or engine.get("backend") or "STT")
|
||
backend = str(engine.get("backend") or engine.get("id") or "").strip()
|
||
url = str(engine.get("url") or "").strip()
|
||
model = str(engine.get("model") or "").strip() or (backend and _stt_backend_model(backend)) or "whisper-1"
|
||
device = _stt_device_hint(engine, settings)
|
||
t0 = time.perf_counter()
|
||
try:
|
||
if url:
|
||
api_key = _stt_backend_api_key(settings, _clean_stt_backend(backend)) if backend else settings.get("whisper_api_key", "")
|
||
text = _transcribe_url_for_benchmark(src, url, model, api_key)
|
||
used_url = _validate_http_url(url, allow_private=True).rstrip("/")
|
||
else:
|
||
text, used_backend = _transcribe_audio(src, settings, backend or "configured")
|
||
used_url = _stt_backend_url(settings, used_backend)
|
||
model = model or _stt_backend_model(used_backend)
|
||
elapsed = time.perf_counter() - t0
|
||
accuracy = _word_accuracy(reference, text)
|
||
return {
|
||
"ok": True,
|
||
"engine": label,
|
||
"backend": backend,
|
||
"url": used_url,
|
||
"model": model,
|
||
"device": device,
|
||
"time_sec": elapsed,
|
||
"accuracy": accuracy,
|
||
"output": text,
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"ok": False,
|
||
"engine": label,
|
||
"backend": backend,
|
||
"url": url,
|
||
"model": model,
|
||
"device": device,
|
||
"time_sec": time.perf_counter() - t0,
|
||
"accuracy": None,
|
||
"output": "",
|
||
"error": str(e),
|
||
}
|
||
|
||
|
||
async def _default_stt_benchmark_engines() -> list[dict]:
|
||
payload = await stt_backends()
|
||
engines = []
|
||
for item in payload.get("backends", []):
|
||
if item.get("available"):
|
||
engines.append({
|
||
"id": item.get("id"),
|
||
"backend": item.get("id"),
|
||
"label": item.get("label"),
|
||
"url": item.get("url"),
|
||
"model": item.get("model") or (item.get("models") or ["whisper-1"])[0],
|
||
})
|
||
return engines
|
||
|
||
|
||
# ── STT routes ────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/api/stt-backends")
|
||
async def stt_backends():
|
||
settings = _load_settings()
|
||
items = []
|
||
seen_urls: set[tuple[str, str]] = set()
|
||
ordered = ("configured", "faster_whisper", "whisper_cpp", "groq_whisper", "nvidia_parakeet", "nvidia_router")
|
||
for backend in ordered:
|
||
raw_url = _stt_backend_url(settings, backend)
|
||
url = _validate_http_url(raw_url, allow_private=True).rstrip("/")
|
||
key = (backend, url)
|
||
if key in seen_urls:
|
||
continue
|
||
seen_urls.add(key)
|
||
api_key = _stt_backend_api_key(settings, backend)
|
||
if backend == "groq_whisper":
|
||
ok = bool(api_key)
|
||
models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"]
|
||
else:
|
||
ok, models = _stt_backend_health(url)
|
||
items.append({
|
||
"id": backend,
|
||
"label": _stt_backend_label(backend, url),
|
||
"url": url,
|
||
"port": _backend_port_label(url),
|
||
"available": ok,
|
||
"model": _stt_backend_model(backend),
|
||
"models": models,
|
||
"metrics": _STT_BACKEND_METRICS.get(backend, {}),
|
||
})
|
||
for card in settings.get("custom_engine_cards", []):
|
||
if card.get("role") not in ("stt", "stt+tts"):
|
||
continue
|
||
raw_url = card.get("url", "").strip()
|
||
url = _validate_http_url(raw_url, allow_private=True).rstrip("/")
|
||
if not url:
|
||
continue
|
||
card_id = "custom:" + str(card.get("id", card.get("name", "")))
|
||
if (card_id, url) in seen_urls:
|
||
continue
|
||
seen_urls.add((card_id, url))
|
||
ok, models = _stt_backend_health(url)
|
||
port = _backend_port_label(url)
|
||
label = card.get("label") or card.get("name") or "Custom STT"
|
||
if port:
|
||
label = f"{port} {label}"
|
||
items.append({
|
||
"id": card_id,
|
||
"label": label,
|
||
"url": url,
|
||
"port": port,
|
||
"available": ok,
|
||
"model": "whisper-1",
|
||
"models": models,
|
||
"metrics": {},
|
||
})
|
||
return {"backends": items}
|
||
|
||
|
||
@router.post("/api/transcribe")
|
||
async def transcribe(request: Request):
|
||
data = await request.json()
|
||
fid: str = data["id"]
|
||
src = _registry_get(fid)
|
||
if src is None or not src.exists():
|
||
raise HTTPException(404, "Audio not found")
|
||
settings = _load_settings()
|
||
backend = _clean_stt_backend(str(data.get("backend") or data.get("stt_backend") or "configured"))
|
||
try:
|
||
text, used_backend = await asyncio.to_thread(_transcribe_audio, src, settings, backend)
|
||
return {"text": text, "backend": used_backend}
|
||
except Exception as e:
|
||
raise HTTPException(502, f"STT error ({backend}): {e}")
|
||
|
||
|
||
@router.post("/api/transcribe-bytes")
|
||
async def transcribe_bytes(
|
||
file: UploadFile = File(...),
|
||
backend: str = Form("configured"),
|
||
):
|
||
"""Accept raw audio upload and return transcription directly (used by hotkey daemon)."""
|
||
suffix = Path(file.filename or "audio.wav").suffix.lower() or ".wav"
|
||
if suffix not in _AUDIO_EXTS:
|
||
raise HTTPException(400, "Unsupported audio type")
|
||
tmp = TEMP_DIR / f"{uuid.uuid4().hex}_daemon{suffix}"
|
||
wav_tmp = tmp
|
||
try:
|
||
with tmp.open("wb") as f:
|
||
_copy_limited(file.file, f, _MAX_UPLOAD_BYTES)
|
||
if suffix != ".wav":
|
||
wav_tmp = _to_wav_16k(tmp)
|
||
settings = _load_settings()
|
||
stt_backend = _clean_stt_backend(backend)
|
||
text, used_backend = await asyncio.to_thread(_transcribe_audio, wav_tmp, settings, stt_backend)
|
||
return {"text": text, "backend": used_backend}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(502, f"STT error: {e}")
|
||
finally:
|
||
cleanup = {p for p in (tmp, wav_tmp) if p is not None and p != _registry_get(source_id)}
|
||
for p in cleanup:
|
||
try:
|
||
p.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
|
||
@router.post("/api/stt-benchmark")
|
||
async def stt_benchmark(
|
||
audio: UploadFile | None = File(None),
|
||
source_id: str = Form(""),
|
||
reference_text: str = Form(""),
|
||
reference_file: UploadFile | None = File(None),
|
||
engines_json: str = Form(""),
|
||
):
|
||
tmp: Path | None = None
|
||
wav_tmp: Path | None = None
|
||
try:
|
||
if source_id:
|
||
src = _registry_get(source_id)
|
||
if src is None or not src.exists():
|
||
raise HTTPException(404, "Loaded voice sample not found")
|
||
wav_tmp = src
|
||
else:
|
||
if audio is None:
|
||
raise HTTPException(400, "Audio file or voice library sample is required")
|
||
suffix = Path(audio.filename or "audio.wav").suffix.lower() or ".wav"
|
||
if suffix not in (_AUDIO_EXTS | _UPLOAD_EXTS):
|
||
raise HTTPException(400, "Unsupported audio type")
|
||
tmp = TEMP_DIR / f"{uuid.uuid4().hex}_stt_bench{suffix}"
|
||
wav_tmp = tmp
|
||
with tmp.open("wb") as f:
|
||
_copy_limited(audio.file, f, _MAX_UPLOAD_BYTES)
|
||
if suffix != ".wav":
|
||
wav_tmp = _to_wav_16k(tmp)
|
||
reference = (reference_text or "").strip()
|
||
if reference_file is not None:
|
||
raw = await reference_file.read()
|
||
if raw:
|
||
reference = raw[:2_000_000].decode("utf-8", errors="replace").strip()
|
||
if not reference:
|
||
raise HTTPException(400, "Reference text is required")
|
||
try:
|
||
parsed = json.loads(engines_json) if engines_json else []
|
||
engines = parsed if isinstance(parsed, list) else []
|
||
except Exception:
|
||
engines = []
|
||
if not engines:
|
||
engines = await _default_stt_benchmark_engines()
|
||
if not engines:
|
||
raise HTTPException(400, "No STT engines selected")
|
||
settings = _load_settings()
|
||
results = []
|
||
for engine in engines:
|
||
if not isinstance(engine, dict):
|
||
continue
|
||
results.append(await asyncio.to_thread(_benchmark_stt_engine, wav_tmp, engine, settings, reference))
|
||
ok_rows = [r for r in results if r.get("ok")]
|
||
best_time = min((r.get("time_sec") for r in ok_rows if isinstance(r.get("time_sec"), (int, float))), default=None)
|
||
best_accuracy = max((r.get("accuracy") for r in ok_rows if isinstance(r.get("accuracy"), (int, float))), default=None)
|
||
return {
|
||
"ok": True,
|
||
"reference_words": len(_word_tokens(reference)),
|
||
"results": results,
|
||
"best_time_sec": best_time,
|
||
"best_accuracy": best_accuracy,
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(500, f"STT benchmark failed: {e}")
|
||
finally:
|
||
cleanup = {p for p in (tmp, wav_tmp) if p is not None and p != _registry_get(source_id)}
|
||
for p in cleanup:
|
||
try:
|
||
p.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|