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:
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()))
|
||||||
Reference in New Issue
Block a user