Files
ERP/bot/app/stt.py
T
whqreza 76b297f823 feat(audio): add pluggable audio preprocessing and denoising layer before STT
- Add audio_enhancer module with 60s timeout, zero-transcoding default, and fail-soft fallback
- Persist enhanced audio in daily folders with _enhanced suffix and emit audio.enhance.result event
- Connect audio enhancer to transcribe_multi and transcribe in stt.py
- Forward message_id and trace_id from handlers
- Ensure consensus_text fallback persistence and remove scenario leakage in nlu.py
- Add test_enhancer.py unit suite and update smoke_test.py
2026-10-15 20:40:45 +03:30

135 lines
5.9 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
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 ""