"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint. Bale voice notes arrive as OGG/Opus which not every provider accepts, so the audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies, self-hosted whisper servers exposing the same contract). """ import asyncio import logging import subprocess import tempfile import time from pathlib import Path import httpx from app import observability as obs from app.config import Settings logger = logging.getLogger("bot.stt") _CONVERT_TIMEOUT = 60 _STT_TIMEOUT = 120 def _to_mp3(raw: bytes, suffix: str) -> bytes: """Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg.""" with tempfile.TemporaryDirectory() as tmp: src = Path(tmp) / f"in{suffix or '.ogg'}" dst = Path(tmp) / "out.mp3" src.write_bytes(raw) proc = subprocess.run( ["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)], capture_output=True, timeout=_CONVERT_TIMEOUT, ) if proc.returncode != 0 or not dst.exists(): raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}") return dst.read_bytes() async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict: """Call one provider; always returns {"name","ok","text"/"error","model","latency_ms"}.""" name = provider.get("name") or "stt" base_url = (provider.get("base_url") or "").rstrip("/") api_key = provider.get("api_key") or "" model = provider.get("model") or "whisper-1" language = provider.get("language") or "fa" timeout = float(provider.get("timeout") or _STT_TIMEOUT) url = f"{base_url}/audio/transcriptions" t0 = time.perf_counter() try: resp = await client.post( url, headers={"Authorization": f"Bearer {api_key}"}, files={"file": ("voice.mp3", converted_mp3, "audio/mpeg")}, data={"model": model, "language": language}, timeout=timeout, ) ms = int((time.perf_counter() - t0) * 1000) if resp.status_code == 200: txt = str((resp.json() or {}).get("text") or "").strip() if txt: logger.info("STT provider %s succeeded: %r", name, txt[:60]) return {"name": name, "ok": True, "text": txt, "model": model, "latency_ms": ms} return {"name": name, "ok": False, "error": "empty transcript (HTTP 200)", "model": model, "latency_ms": ms} logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200]) return {"name": name, "ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}", "model": model, "latency_ms": ms} except Exception as exc: logger.warning("STT provider %s failed: %s", name, exc) return {"name": name, "ok": False, "error": str(exc)[:300], "model": model, "latency_ms": int((time.perf_counter() - t0) * 1000)} async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None, filename: str | None = None, trace_id: str | None = None, message_id: int | None = None) -> list[dict]: """Transcribe audio with all configured STT providers in parallel. Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...] Empty list if all failed or silence. Every provider outcome (success AND failure) is recorded in Observability when trace_id is given. """ providers = settings.stt_providers if not providers: raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)") # Audio enhancement / denoising step (pluggable, fail-soft, zero-transcode by default) from app.audio_enhancer import enhance_voice audio, _ = await enhance_voice( settings, audio, mime_type=mime_type, filename=filename, trace_id=trace_id, message_id=message_id, ) suffix = Path(filename or "").suffix if not suffix and mime_type: suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg") converted = await asyncio.to_thread(_to_mp3, audio, suffix) await obs.event(trace_id, "stt.run", payload={ "providers": [p.get("name") or "stt" for p in providers], "audio_bytes": len(converted), }) async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client: tasks = [_call_single_stt(client, p, converted) for p in providers] results = await asyncio.gather(*tasks, return_exceptions=True) valid_transcripts: list[dict] = [] for r in results: if not isinstance(r, dict): continue await obs.event(trace_id, "stt.result", ok=bool(r.get("ok")), latency_ms=r.get("latency_ms"), model=r.get("model"), payload={"provider": r.get("name"), "text": r.get("text"), "error": r.get("error")}) if r.get("ok") and r.get("text"): valid_transcripts.append({"name": r["name"], "text": r["text"], "model": r.get("model")}) return valid_transcripts async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None, filename: str | None = None, trace_id: str | None = None, message_id: int | None = None) -> str: """Return the Persian transcript for a voice note (empty string on silence).""" transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, filename=filename, trace_id=trace_id, message_id=message_id) return transcripts[0]["text"] if transcripts else ""