diff --git a/backend/routers/speech_profile.py b/backend/routers/speech_profile.py index 1f9ae2de67e..e419116c925 100644 --- a/backend/routers/speech_profile.py +++ b/backend/routers/speech_profile.py @@ -21,7 +21,7 @@ ) from utils.multipart import MultipartMaxPartSizeRoute, SPEECH_PROFILE_MAX_PART_SIZE, max_part_size from utils.stt.speaker_embedding import extract_embedding -from utils.stt.vad import apply_vad_for_speech_profile +from utils.stt.vad import apply_vad_for_speech_profile, VADEmptyError import logging logger = logging.getLogger(__name__) @@ -81,7 +81,10 @@ def upload_profile(file: UploadFile, uid: str = Depends(auth.get_current_user_ui if aseg.duration_seconds < 5 or aseg.duration_seconds > 120: raise HTTPException(status_code=400, detail="Audio duration is invalid (must be 5-120 seconds)") - apply_vad_for_speech_profile(file_path) + try: + apply_vad_for_speech_profile(file_path) + except VADEmptyError: + raise HTTPException(status_code=400, detail="Audio is empty") # Write-ahead: Cache exact duration after VAD processing (use av for fast header-only read) with av.open(file_path) as container: diff --git a/backend/scripts/stt/j_apply_vad_to_speech_profiles.py b/backend/scripts/stt/j_apply_vad_to_speech_profiles.py index 2b208cb8e39..2f6962cc3f3 100644 --- a/backend/scripts/stt/j_apply_vad_to_speech_profiles.py +++ b/backend/scripts/stt/j_apply_vad_to_speech_profiles.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv from pydub import AudioSegment -from utils.stt.vad import apply_vad_for_speech_profile +from utils.stt.vad import apply_vad_for_speech_profile, VADEmptyError load_dotenv('../../.env') os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '../../' + os.getenv('GOOGLE_APPLICATION_CREDENTIALS', '') @@ -25,7 +25,13 @@ def single(uid: str) -> None: file_path = get_profile_audio_if_exists(uid) if not file_path: return - apply_vad_for_speech_profile(file_path) + + try: + apply_vad_for_speech_profile(file_path) + except VADEmptyError: + print('VAD empty for', uid) + return + aseg = cast( Any, AudioSegment.from_wav(file_path) ) # pyright: ignore[reportUnknownMemberType] # pydub has no type stubs diff --git a/backend/tests/unit/test_speech_profile_wav_decode.py b/backend/tests/unit/test_speech_profile_wav_decode.py index 3fc6046aa4e..ce402da928d 100644 --- a/backend/tests/unit/test_speech_profile_wav_decode.py +++ b/backend/tests/unit/test_speech_profile_wav_decode.py @@ -12,7 +12,6 @@ import importlib.abc import importlib.machinery -import importlib.util import io import os import sys @@ -20,6 +19,8 @@ from unittest.mock import MagicMock, patch import pytest +from scripts.stt import j_apply_vad_to_speech_profiles as batch_mod +from utils.stt import vad as vad_mod os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real") os.environ.setdefault("ENCRYPTION_SECRET", "omi_ZwB2ZNqB2HHpMK6wStk7sTpavJiPTFg7gXUHnc4tFABPU6pZ2c2DKgehtfgi4RZv") @@ -29,9 +30,7 @@ "database", "utils.other.storage", "utils.stt.speaker_embedding", - "utils.stt.vad", "av", - "pydub", "firebase_admin", "google", "pinecone", @@ -132,3 +131,48 @@ def test_invalid_wav_does_not_run_vad_or_upload(self): assert exc_info.value.status_code == 400 mock_vad.assert_not_called() mock_upload.assert_not_called() + + def test_empty_vad_returns_400_not_500(self): + fake_file = _fake_upload_file(b'silence') + + with patch.object(mod, "os") as mock_os, patch("builtins.open", MagicMock()), patch.object( + mod, "AudioSegment" + ) as mock_aseg, patch.object(vad_mod, "vad_is_empty", return_value=[]) as mock_vad, patch.object( + mod, "upload_profile_audio" + ) as mock_upload: + mock_os.makedirs.return_value = None + mock_aseg.from_wav.return_value = MagicMock(frame_rate=16000, duration_seconds=5) + + with pytest.raises(HTTPException) as exc_info: + mod.upload_profile(fake_file, uid="test-uid") + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Audio is empty" + mock_vad.assert_called_once_with("_temp/test-uid/speech_profile.wav", return_segments=True) + mock_upload.assert_not_called() + + def test_batch_skips_empty_vad_without_uploading(self): + class ImmediateThread: + def __init__(self, target, args): + self.target = target + self.args = args + + def start(self): + self.target(*self.args) + + def join(self): + pass + + with patch.object(batch_mod.os, "makedirs"), patch.object( + batch_mod, "get_users_uid", return_value=["test-uid"] + ), patch.object(batch_mod, "get_profile_audio_if_exists", return_value="/tmp/profile.wav"), patch.object( + batch_mod, "upload_profile_audio" + ) as mock_upload, patch.object( + vad_mod, "vad_is_empty", return_value=[] + ) as mock_vad, patch.object( + batch_mod.threading, "Thread", ImmediateThread + ): + batch_mod.execute() + + mock_vad.assert_called_once_with("/tmp/profile.wav", return_segments=True) + mock_upload.assert_not_called() diff --git a/backend/tests/unit/test_vad_onnx.py b/backend/tests/unit/test_vad_onnx.py index 5f41c305246..a90600bed12 100644 --- a/backend/tests/unit/test_vad_onnx.py +++ b/backend/tests/unit/test_vad_onnx.py @@ -23,6 +23,7 @@ from utils.stt.vad import ( VADAudioDecodeError, VADProcessingError, + VADEmptyError, vad_is_empty, _run_file_vad, _get_ort_session, @@ -36,6 +37,13 @@ _STATE_SHAPE, ) + +def test_apply_vad_for_speech_profile_raises_for_zero_segments(): + with patch.object(vad, 'vad_is_empty', return_value=[]): + with pytest.raises(VADEmptyError, match='Audio is empty'): + vad.apply_vad_for_speech_profile('/fake/path.wav') + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/backend/utils/stt/vad.py b/backend/utils/stt/vad.py index e175face926..2f7220dd19a 100644 --- a/backend/utils/stt/vad.py +++ b/backend/utils/stt/vad.py @@ -8,7 +8,6 @@ import numpy as np import onnxruntime as ort # onnxruntime is untyped import requests -from fastapi import HTTPException from pydub import AudioSegment # pydub is untyped from database import redis_db @@ -34,6 +33,10 @@ class VADProcessingError(RuntimeError): """The local VAD could not make a trustworthy speech decision.""" +class VADEmptyError(ValueError): + """The VAD found no speech segments in the audio.""" + + def _positive_timeout_seconds(env_name: str, default: float, maximum: float) -> float: raw_value = os.getenv(env_name) if raw_value is None: @@ -395,8 +398,8 @@ async def async_vad_is_empty( def apply_vad_for_speech_profile(file_path: str) -> None: logger.info(f'apply_vad_for_speech_profile {file_path}') voice_segments = vad_is_empty(file_path, return_segments=True) - if len(voice_segments) == 0: # TODO: front error on post-processing, audio sent is bad. - raise HTTPException(status_code=400, detail="Audio is empty") + if len(voice_segments) == 0: + raise VADEmptyError("Audio is empty") joined_segments: List[Dict[str, Any]] = [] for i, segment in enumerate(voice_segments): if joined_segments and (segment['start'] - joined_segments[-1]['end']) < 1: