"""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 from pathlib import Path import httpx 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 | None: 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" 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, ) 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, "text": txt, "model": model} else: logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200]) except Exception as exc: logger.warning("STT provider %s failed: %s", name, exc) return None async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None, filename: str | 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. """ providers = settings.stt_providers if not providers: raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)") 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) 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 isinstance(r, dict) and r.get("text"): valid_transcripts.append(r) return valid_transcripts async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None, filename: str | 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) return transcripts[0]["text"] if transcripts else ""