"""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 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).""" if not settings.stt_base_url or not settings.stt_api_key: raise RuntimeError("STT provider is not configured (PROCUREMENT_BOT_STT_BASE_URL / _AI_BASE_URL)") 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) url = f"{settings.stt_base_url.rstrip('/')}/audio/transcriptions" async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client: resp = await client.post( url, headers={"Authorization": f"Bearer {settings.stt_api_key}"}, files={"file": ("voice.mp3", converted, "audio/mpeg")}, data={"model": settings.STT_MODEL, "language": settings.STT_LANGUAGE}, ) if resp.status_code != 200: raise RuntimeError(f"STT provider returned HTTP {resp.status_code}: {resp.text[:300]}") return str((resp.json() or {}).get("text") or "").strip()