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
+1
View File
@@ -31,6 +31,7 @@ scratch/
v1.2 - Copy/ v1.2 - Copy/
inv dash/ inv dash/
voice/ voice/
data/
# ---- Machine-local compose overrides (LAN phone access, etc.) ---- # ---- Machine-local compose overrides (LAN phone access, etc.) ----
docker-compose.override.yml docker-compose.override.yml
+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_MODEL: str = "whisper-1"
STT_LANGUAGE: str = "fa" 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 @property
def stt_base_url(self) -> str: def stt_base_url(self) -> str:
return self.STT_BASE_URL or self.AI_BASE_URL 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"), mime_type=audio_media.get("mime_type"),
filename=audio_media.get("file_name"), filename=audio_media.get("file_name"),
trace_id=trace_id, trace_id=trace_id,
message_id=message_id,
) )
except Exception as exc: except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200] 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_media = message.get("voice") or message.get("audio")
audio_bytes = await ctx.bale.download_file(audio_media["file_id"]) audio_bytes = await ctx.bale.download_file(audio_media["file_id"])
obs.save_voice(trace_id, incoming_msg_id or 0, audio_bytes) 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: except Exception:
text = "" text = ""
+5 -5
View File
@@ -116,7 +116,7 @@ SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم
۶. استخراج موجودیت‌های قابل تطبیق (entity_mentions): ۶. استخراج موجودیت‌های قابل تطبیق (entity_mentions):
- فیلدهای مجاز: "project" | "supplier" | "material" | "brand" | "spec" | "unit" - فیلدهای مجاز: "project" | "supplier" | "material" | "brand" | "spec" | "unit"
- هر نام کالا/تأمین‌کننده/پروژه/برند/واحد و هر مشخصه که در پیام آمده، یک عضو جداگانه است. - هر نام کالا/تأمین‌کننده/پروژه/برند/واحد و هر مشخصه که در پیام آمده، یک عضو جداگانه است.
- اگر در نسخه‌های مختلف STT همان نام با املاهای متفاوت پیاده شده (مثل «مصطفوی»، «مستفدی»، «مسطفی»)، - اگر در نسخه‌های مختلف STT همان نام با املاهای متفاوت پیاده شده (مثل «فولاد گستر»، «پولاد گستر»)،
همه را در variants همان یک عضو بگذار — نه چند عضو جداگانه. همه را در variants همان یک عضو بگذار — نه چند عضو جداگانه.
- برای پیام متنی معمولی، variants یک عضو تک‌عنصر است. - برای پیام متنی معمولی، variants یک عضو تک‌عنصر است.
- مشخصه (spec) و برند (brand) جدا از متریال و جدا از هم استخراج شوند. - مشخصه (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.""" """Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
fallback = {"consensus_text": "", "intent": "unknown", "patch": {}, fallback = {"consensus_text": "", "intent": "unknown", "patch": {},
"entity_mentions": [], "confidence": 0.0} "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): if isinstance(text, list):
first_text = text[0].get("text") if text else "" first_text = text[0].get("text") if text else ""
lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:"] lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:"]
@@ -203,6 +199,10 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
fallback["consensus_text"] = first_text 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) system_prompt = _build_system_prompt(draft_summary)
prompt_hash = hashlib.sha256( prompt_hash = hashlib.sha256(
f"{settings.OBS_PROMPT_VERSION}|{system_prompt}".encode() 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: except Exception:
logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True) logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True)
return None 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, 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. """Transcribe audio with all configured STT providers in parallel.
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...] 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: if not providers:
raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)") 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 suffix = Path(filename or "").suffix
if not suffix and mime_type: if not suffix and mime_type:
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3", 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, 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).""" """Return the Persian transcript for a voice note (empty string on silence)."""
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, 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 "" return transcripts[0]["text"] if transcripts else ""
+14 -3
View File
@@ -255,6 +255,17 @@ def _install_fake_stores() -> None:
runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete
import app.observability as obs
async def fake_obs(*args, **kwargs):
pass
async def fake_obs_trace(*args, **kwargs):
return None
obs.event = fake_obs
obs.trace_start = fake_obs
obs.link_draft = fake_obs
obs.set_trace_status = fake_obs
obs.latest_trace_for_draft = fake_obs_trace
async def fake_log(self, chat_id, direction, kind, body=None): async def fake_log(self, chat_id, direction, kind, body=None):
pass pass
@@ -263,16 +274,16 @@ async def fake_log(self, chat_id, direction, kind, body=None):
NLU_QUEUE: list[dict] = [] NLU_QUEUE: list[dict] = []
async def fake_understand(settings, text, draft_summary=None): async def fake_understand(settings, text, draft_summary=None, *args, **kwargs):
return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0} return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0}
async def fake_transcribe(settings, audio, mime_type=None, filename=None): async def fake_transcribe(settings, audio, mime_type=None, filename=None, *args, **kwargs):
return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن " return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
"پرداخت سه فاز") "پرداخت سه فاز")
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None): async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None, *args, **kwargs):
return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}] return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}]
+182
View File
@@ -0,0 +1,182 @@
"""Tests for audio preprocessing / enhancement module (audio_enhancer.py).
Validates:
1. Disabled enhancer -> zero overhead pass-through of raw bytes.
2. Successful enhancement -> receives enhanced audio, saves with _enhanced suffix,
emits audio.enhance.result with latency_ms/path/size.
3. Failure/timeout -> graceful fallback to raw audio bytes, ok=False event logged.
4. Zero transcoding by default (raw bytes sent without ffmpeg).
5. Timeout configuration adheres to 60.0s.
"""
import asyncio
import os
import sys
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.audio_enhancer import enhance_voice
from app.config import Settings
from app import observability as obs
_passed = _failed = 0
def check(name: str, ok: bool, detail: str = "") -> None:
global _passed, _failed
ok = bool(ok)
mark = "PASS" if ok else "FAIL"
print(f"[{mark}] {name}" + (f" — {detail}" if detail and not ok else ""))
_passed += ok
_failed += (not ok)
async def test_disabled_enhancer():
cfg = Settings(AUDIO_ENHANCE_ENABLED=False, AUDIO_ENHANCE_BASE_URL="http://mock/enhance")
raw_audio = b"fake-raw-voice-bytes"
out_audio, meta = await enhance_voice(cfg, raw_audio, mime_type="audio/ogg")
check("disabled: raw bytes returned directly", out_audio == raw_audio)
check("disabled: metadata indicates skipped", meta.get("skipped") is True)
async def test_timeout_setting():
cfg = Settings(AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0)
check("config: timeout defaults to 60.0s", cfg.AUDIO_ENHANCE_TIMEOUT_SECONDS == 60.0)
async def test_successful_enhancement():
cfg = Settings(
AUDIO_ENHANCE_ENABLED=True,
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
AUDIO_ENHANCE_MODEL="voice-cleaner-v1",
AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0,
)
raw_audio = b"noisy-voice-bytes"
enhanced_audio = b"crystal-clear-voice-bytes"
trace_id = "test-trace-enh-001"
message_id = 12345
events_captured = []
async def fake_event(t_id, stage, ok=True, latency_ms=None, model=None, payload=None, **kwargs):
events_captured.append({"stage": stage, "ok": ok, "latency_ms": latency_ms, "payload": payload})
# Mock httpx response
mock_resp = httpx.Response(
status_code=200,
content=enhanced_audio,
headers={"content-type": "audio/ogg"},
request=httpx.Request("POST", cfg.AUDIO_ENHANCE_BASE_URL),
)
with patch.object(obs, "event", side_effect=fake_event):
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_resp
out_bytes, meta = await enhance_voice(
cfg,
raw_audio,
mime_type="audio/ogg",
filename="voice.ogg",
trace_id=trace_id,
message_id=message_id,
)
check("success: enhanced bytes returned", out_bytes == enhanced_audio)
check("success: meta indicates enhanced", meta.get("enhanced") is True)
check("success: enhanced path exists in meta", bool(meta.get("path")))
check("success: path has _enhanced suffix", "_enhanced" in (meta.get("path") or ""))
# Verify physical file existence
if meta.get("path"):
saved_file = Path(meta["path"])
check("success: enhanced file written to disk", saved_file.exists() and saved_file.read_bytes() == enhanced_audio)
# Verify observability event
enh_events = [e for e in events_captured if e["stage"] == "audio.enhance.result"]
check("obs: audio.enhance.result event emitted", len(enh_events) == 1)
if enh_events:
ev = enh_events[0]
check("obs: event ok=True", ev["ok"] is True)
check("obs: payload contains size & latency_ms", "size" in ev["payload"] and ev.get("latency_ms") is not None)
check("obs: payload recorded correct size", ev["payload"]["size"] == len(enhanced_audio))
async def test_graceful_fallback_on_error():
cfg = Settings(
AUDIO_ENHANCE_ENABLED=True,
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0,
)
raw_audio = b"original-unprocessed-audio"
trace_id = "test-trace-enh-002"
message_id = 999
events_captured = []
async def fake_event(t_id, stage, ok=True, latency_ms=None, model=None, payload=None, **kwargs):
events_captured.append({"stage": stage, "ok": ok, "latency_ms": latency_ms, "payload": payload})
# Mock HTTP 500 error
mock_resp = httpx.Response(
status_code=500,
text="Internal Model Error",
request=httpx.Request("POST", cfg.AUDIO_ENHANCE_BASE_URL),
)
with patch.object(obs, "event", side_effect=fake_event):
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_resp
out_bytes, meta = await enhance_voice(
cfg,
raw_audio,
mime_type="audio/ogg",
trace_id=trace_id,
message_id=message_id,
)
check("fallback: returns raw original bytes on 500", out_bytes == raw_audio)
check("fallback: meta indicates fallback", meta.get("fallback") is True)
enh_events = [e for e in events_captured if e["stage"] == "audio.enhance.result"]
check("obs: fallback event ok=False", len(enh_events) == 1 and enh_events[0]["ok"] is False)
if enh_events:
check("obs: error detail captured", "HTTP 500" in enh_events[0]["payload"].get("error", ""))
async def test_zero_transcoding_by_default():
cfg = Settings(
AUDIO_ENHANCE_ENABLED=True,
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
AUDIO_ENHANCE_TARGET_FORMAT="", # empty -> zero transcoding
)
raw_audio = b"raw-bale-ogg-bytes"
captured_payload = None
async def fake_post(url, files=None, data=None, headers=None, **kwargs):
nonlocal captured_payload
captured_payload = files["file"][1]
return httpx.Response(status_code=200, content=b"clean", request=httpx.Request("POST", url))
with patch("httpx.AsyncClient.post", side_effect=fake_post):
await enhance_voice(cfg, raw_audio, mime_type="audio/ogg")
check("transcoding: raw bytes sent directly without modification", captured_payload == raw_audio)
async def main():
print("Running audio_enhancer unit tests...")
await test_disabled_enhancer()
await test_timeout_setting()
await test_successful_enhancement()
await test_graceful_fallback_on_error()
await test_zero_transcoding_by_default()
print(f"\nResult: {_passed} passed, {_failed} failed")
return 1 if _failed else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))