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
This commit is contained in:
2026-10-15 20:40:45 +03:30
parent b44816aa98
commit 76b297f823
9 changed files with 442 additions and 12 deletions
+197
View File
@@ -0,0 +1,197 @@
"""Audio preprocessing & denoising pipeline for procurement bot.
Workflow:
Raw Voice Bytes (Bale)
→ enhance_voice() [Optional Audio Enhancement / Denoising API]
- If disabled or missing URL → zero overhead pass-through
- Zero transcoding by default (raw bytes sent as-is)
- Transcoding to target format ONLY if explicitly configured
- Graceful fail-soft on timeout or error (transparent raw audio fallback)
- Enhanced bytes saved to data/voices/{YYYYMMDD}/{trace_id}_{message_id}_enhanced.{ext}
- Observability events recorded (audio.enhance.result with ok/size/latency_ms/path)
→ Multi-STT Providers
"""
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.audio_enhancer")
_CONVERT_TIMEOUT = 60
def _transcode(raw: bytes, src_ext: str, target_fmt: str) -> bytes:
"""Convert audio to target format using ffmpeg only when explicitly requested."""
target_fmt = target_fmt.lstrip(".")
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / f"in{src_ext or '.ogg'}"
dst = Path(tmp) / f"out.{target_fmt}"
src.write_bytes(raw)
proc = subprocess.run(
["ffmpeg", "-y", "-i", str(src), "-vn", str(dst)],
capture_output=True,
timeout=_CONVERT_TIMEOUT,
)
if proc.returncode != 0 or not dst.exists():
raise RuntimeError(f"ffmpeg conversion to {target_fmt} failed: {proc.stderr[-300:]!r}")
return dst.read_bytes()
def _resolve_extension(filename: str | None, mime_type: str | None) -> str:
ext = Path(filename or "").suffix
if not ext and mime_type:
clean_mime = mime_type.split(";")[0].strip().lower()
ext = {
"audio/ogg": ".ogg",
"audio/opus": ".ogg",
"audio/mpeg": ".mp3",
"audio/mp4": ".m4a",
"audio/wav": ".wav",
"audio/x-wav": ".wav",
"audio/webm": ".webm",
"audio/flac": ".flac",
}.get(clean_mime, ".ogg")
return ext or ".ogg"
async def enhance_voice(
settings: Settings,
audio_bytes: bytes,
mime_type: str | None = None,
filename: str | None = None,
trace_id: str | None = None,
message_id: int | None = None,
) -> tuple[bytes, dict]:
"""Enhance and denoise voice audio before STT.
Returns:
(processed_audio_bytes, metadata)
Guarantees:
- Never raises an unhandled exception.
- On any failure or timeout, gracefully returns original audio_bytes (fallback).
- Enhanced bytes are saved to daily folder with _enhanced suffix when successful.
- Observability events are captured.
"""
if not settings.AUDIO_ENHANCE_ENABLED or not (settings.AUDIO_ENHANCE_BASE_URL or "").strip():
return audio_bytes, {"enhanced": False, "skipped": True}
base_url = settings.AUDIO_ENHANCE_BASE_URL.rstrip("/")
api_key = settings.AUDIO_ENHANCE_API_KEY or ""
model = settings.AUDIO_ENHANCE_MODEL or ""
timeout = float(settings.AUDIO_ENHANCE_TIMEOUT_SECONDS or 60.0)
target_format = (settings.AUDIO_ENHANCE_TARGET_FORMAT or "").strip().lower()
method = (settings.AUDIO_ENHANCE_METHOD or "auto").strip().lower()
orig_ext = _resolve_extension(filename, mime_type)
t0 = time.perf_counter()
# Condition 2: Zero transcoding unless explicitly requested by configuration
payload_bytes = audio_bytes
upload_ext = orig_ext
if target_format and not orig_ext.endswith(f".{target_format}"):
try:
payload_bytes = await asyncio.to_thread(_transcode, audio_bytes, orig_ext, target_format)
upload_ext = f".{target_format}"
except Exception as exc:
logger.warning("Target format conversion failed: %s; using raw bytes", exc)
payload_bytes = audio_bytes
upload_ext = orig_ext
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
upload_filename = f"voice{upload_ext}"
req_mime = mime_type or "audio/ogg"
if method == "raw_stream":
headers["Content-Type"] = req_mime
resp = await client.post(base_url, content=payload_bytes, headers=headers)
else:
# Default / multipart upload (compatible with standard voice enhancement APIs)
data = {"model": model} if model else {}
files = {"file": (upload_filename, payload_bytes, req_mime)}
resp = await client.post(base_url, files=files, data=data, headers=headers)
ms = int((time.perf_counter() - t0) * 1000)
if resp.status_code == 200 and len(resp.content) > 0:
enhanced_bytes = resp.content
# Determine response audio extension
resp_ct = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
out_ext = {
"audio/ogg": ".ogg",
"audio/opus": ".ogg",
"audio/mpeg": ".mp3",
"audio/mp4": ".m4a",
"audio/wav": ".wav",
"audio/x-wav": ".wav",
}.get(resp_ct, upload_ext)
saved_path = obs.save_enhanced_voice(
trace_id, message_id or 0, enhanced_bytes, ext=out_ext
)
await obs.event(
trace_id,
"audio.enhance.result",
ok=True,
latency_ms=ms,
model=model,
payload={
"path": saved_path,
"size": len(enhanced_bytes),
"original_size": len(audio_bytes),
"provider": model or "audio_enhancer",
},
)
logger.info(
"Audio enhancement succeeded in %d ms (size: %d -> %d bytes)",
ms,
len(audio_bytes),
len(enhanced_bytes),
)
return enhanced_bytes, {
"enhanced": True,
"path": saved_path,
"size": len(enhanced_bytes),
"latency_ms": ms,
}
# Non-200 or empty response -> graceful fallback
err_msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
logger.warning("Audio enhancement failed: %s; falling back to raw audio", err_msg)
await obs.event(
trace_id,
"audio.enhance.result",
ok=False,
latency_ms=ms,
model=model,
payload={"error": err_msg, "fallback": True},
)
return audio_bytes, {"enhanced": False, "fallback": True, "error": err_msg}
except Exception as exc:
ms = int((time.perf_counter() - t0) * 1000)
err_msg = str(exc)[:300]
logger.warning("Audio enhancement exception: %s; falling back to raw audio", err_msg)
await obs.event(
trace_id,
"audio.enhance.result",
ok=False,
latency_ms=ms,
model=model,
payload={"error": err_msg, "fallback": True},
)
return audio_bytes, {"enhanced": False, "fallback": True, "error": err_msg}
+9
View File
@@ -73,6 +73,15 @@ class Settings(BaseSettings):
STT_MODEL: str = "whisper-1"
STT_LANGUAGE: str = "fa"
# Audio Enhancement / Preprocessing (denoising & speech quality enhancement)
AUDIO_ENHANCE_ENABLED: bool = False
AUDIO_ENHANCE_BASE_URL: str = ""
AUDIO_ENHANCE_API_KEY: str = ""
AUDIO_ENHANCE_MODEL: str = ""
AUDIO_ENHANCE_TIMEOUT_SECONDS: float = 60.0
AUDIO_ENHANCE_TARGET_FORMAT: str = "" # empty = pass raw bytes directly; e.g. "wav" / "mp3"
AUDIO_ENHANCE_METHOD: str = "auto" # "auto" | "raw_stream" | "multipart"
@property
def stt_base_url(self) -> str:
return self.STT_BASE_URL or self.AI_BASE_URL
+4 -1
View File
@@ -104,6 +104,7 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
mime_type=audio_media.get("mime_type"),
filename=audio_media.get("file_name"),
trace_id=trace_id,
message_id=message_id,
)
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
@@ -421,7 +422,9 @@ async def _handle_ambiguity_user_reply(
audio_media = message.get("voice") or message.get("audio")
audio_bytes = await ctx.bale.download_file(audio_media["file_id"])
obs.save_voice(trace_id, incoming_msg_id or 0, audio_bytes)
text = await transcribe(settings, audio_bytes, trace_id=trace_id)
text = await transcribe(
settings, audio_bytes, trace_id=trace_id, message_id=incoming_msg_id
)
except Exception:
text = ""
+5 -5
View File
@@ -116,7 +116,7 @@ SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم
۶. استخراج موجودیت‌های قابل تطبیق (entity_mentions):
- فیلدهای مجاز: "project" | "supplier" | "material" | "brand" | "spec" | "unit"
- هر نام کالا/تأمین‌کننده/پروژه/برند/واحد و هر مشخصه که در پیام آمده، یک عضو جداگانه است.
- اگر در نسخه‌های مختلف STT همان نام با املاهای متفاوت پیاده شده (مثل «مصطفوی»، «مستفدی»، «مسطفی»)،
- اگر در نسخه‌های مختلف STT همان نام با املاهای متفاوت پیاده شده (مثل «فولاد گستر»، «پولاد گستر»)،
همه را در variants همان یک عضو بگذار — نه چند عضو جداگانه.
- برای پیام متنی معمولی، variants یک عضو تک‌عنصر است.
- مشخصه (spec) و برند (brand) جدا از متریال و جدا از هم استخراج شوند.
@@ -177,10 +177,6 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
"""Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
fallback = {"consensus_text": "", "intent": "unknown", "patch": {},
"entity_mentions": [], "confidence": 0.0}
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.warning("AI provider not configured; cannot understand message")
return fallback
if isinstance(text, list):
first_text = text[0].get("text") if text else ""
lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:"]
@@ -203,6 +199,10 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
fallback["consensus_text"] = first_text
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.warning("AI provider not configured; cannot understand message")
return fallback
system_prompt = _build_system_prompt(draft_summary)
prompt_hash = hashlib.sha256(
f"{settings.OBS_PROMPT_VERSION}|{system_prompt}".encode()
+17
View File
@@ -204,3 +204,20 @@ def save_voice(trace_id: str | None, message_id: int, content: bytes,
except Exception:
logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True)
return None
def save_enhanced_voice(trace_id: str | None, message_id: int, content: bytes,
ext: str = ".ogg") -> str | None:
"""Persist an enhanced voice note under OBS_VOICE_DIR/{YYYYMMDD}/{trace_id}_{message_id}_enhanced{ext}."""
try:
day = datetime.utcnow().strftime("%Y%m%d")
base = Path(settings.OBS_VOICE_DIR) / day
base.mkdir(parents=True, exist_ok=True)
if ext and not ext.startswith("."):
ext = f".{ext}"
path = base / f"{trace_id}_{message_id}_enhanced{ext or '.ogg'}"
path.write_bytes(content)
return str(path)
except Exception:
logger.warning("obs: save_enhanced_voice failed for trace %s", trace_id, exc_info=True)
return None
+13 -3
View File
@@ -76,7 +76,8 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None, trace_id: str | None = None) -> list[dict]:
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}, ...]
@@ -87,6 +88,13 @@ async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | No
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",
@@ -117,8 +125,10 @@ async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | No
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None, trace_id: str | None = None) -> str:
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)
filename=filename, trace_id=trace_id,
message_id=message_id)
return transcripts[0]["text"] if transcripts else ""