From 76b297f823b272455a4ec7f38d8373273489d8d0 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Thu, 15 Oct 2026 20:40:45 +0330 Subject: [PATCH] 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 --- .gitignore | 1 + bot/app/audio_enhancer.py | 197 +++++++++++++++++++++++++++++++++++ bot/app/config.py | 9 ++ bot/app/handlers/__init__.py | 5 +- bot/app/nlu.py | 10 +- bot/app/observability.py | 17 +++ bot/app/stt.py | 16 ++- bot/tools/smoke_test.py | 17 ++- bot/tools/test_enhancer.py | 182 ++++++++++++++++++++++++++++++++ 9 files changed, 442 insertions(+), 12 deletions(-) create mode 100644 bot/app/audio_enhancer.py create mode 100644 bot/tools/test_enhancer.py diff --git a/.gitignore b/.gitignore index 8954a55..f204664 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ scratch/ v1.2 - Copy/ inv dash/ voice/ +data/ # ---- Machine-local compose overrides (LAN phone access, etc.) ---- docker-compose.override.yml diff --git a/bot/app/audio_enhancer.py b/bot/app/audio_enhancer.py new file mode 100644 index 0000000..d94e528 --- /dev/null +++ b/bot/app/audio_enhancer.py @@ -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} diff --git a/bot/app/config.py b/bot/app/config.py index f652745..7ebc7cd 100644 --- a/bot/app/config.py +++ b/bot/app/config.py @@ -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 diff --git a/bot/app/handlers/__init__.py b/bot/app/handlers/__init__.py index c8a7196..90c01e0 100644 --- a/bot/app/handlers/__init__.py +++ b/bot/app/handlers/__init__.py @@ -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 = "" diff --git a/bot/app/nlu.py b/bot/app/nlu.py index 3e68fd9..d5413ad 100644 --- a/bot/app/nlu.py +++ b/bot/app/nlu.py @@ -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() diff --git a/bot/app/observability.py b/bot/app/observability.py index 34fcc5a..34467fc 100644 --- a/bot/app/observability.py +++ b/bot/app/observability.py @@ -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 diff --git a/bot/app/stt.py b/bot/app/stt.py index 1dc7f7e..11993f3 100644 --- a/bot/app/stt.py +++ b/bot/app/stt.py @@ -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 "" diff --git a/bot/tools/smoke_test.py b/bot/tools/smoke_test.py index e9e7902..9c9f3b9 100644 --- a/bot/tools/smoke_test.py +++ b/bot/tools/smoke_test.py @@ -255,6 +255,17 @@ def _install_fake_stores() -> None: 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): pass @@ -263,16 +274,16 @@ async def fake_log(self, chat_id, direction, kind, body=None): 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} -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 سایز ۱۴ هر کیلو ۴۵ هزار تومن " "پرداخت سه فاز") -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 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}] diff --git a/bot/tools/test_enhancer.py b/bot/tools/test_enhancer.py new file mode 100644 index 0000000..fb84dd7 --- /dev/null +++ b/bot/tools/test_enhancer.py @@ -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()))