- 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
198 lines
7.3 KiB
Python
198 lines
7.3 KiB
Python
"""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}
|