Files
ERP/ai procurement/app/stt.py
T

103 lines
4.0 KiB
Python

"""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 ""