Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions backend/routers/speech_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new VADEmptyError handling path is untested. The router branch that converts VADEmptyError into the 400 "Audio is empty" response is the exact user-facing behavior this PR preserves, and the script's except VADEmptyError: return is the new robustness behavior that motivated the change — but neither has a regression test (existing tests only cover the WAV decode/duration failure branches, and nothing asserts the empty-audio path). Consider adding a small unit test that patches apply_vad_for_speech_profile to raise VADEmptyError and asserts the router returns 400 without uploading, and one for the script's early-return-on-empty branch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/routers/speech_profile.py, line 86:

<comment>The new `VADEmptyError` handling path is untested. The router branch that converts `VADEmptyError` into the `400 "Audio is empty"` response is the exact user-facing behavior this PR preserves, and the script's `except VADEmptyError: return` is the new robustness behavior that motivated the change — but neither has a regression test (existing tests only cover the WAV decode/duration failure branches, and nothing asserts the empty-audio path). Consider adding a small unit test that patches `apply_vad_for_speech_profile` to raise `VADEmptyError` and asserts the router returns 400 without uploading, and one for the script's early-return-on-empty branch.</comment>

<file context>
@@ -81,7 +81,10 @@ def upload_profile(file: UploadFile, uid: str = Depends(auth.get_current_user_ui
-    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")
 
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: test_empty_vad_returns_400_not_500 asserts status 400 + detail "Audio is empty" and no upload; test_batch_skips_empty_vad_without_uploading covers the script early-return branch. Both pass.

raise HTTPException(status_code=400, detail="Audio is empty")
Comment on lines +86 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add regression coverage for the new exception boundary

When VAD finds no speech, this commit changes the utility's exception contract and adds translations at the HTTP and batch-script boundaries, but it modifies no tests; an incorrect exception type or catch would therefore turn the upload's intended 400 into a 500 or let the maintenance thread fail unnoticed. Add a behavioral test that drives the zero-segment path and verifies VADEmptyError is translated correctly, as required for behavior-changing bug fixes.

AGENTS.md reference: AGENTS.md:L26-L28

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: the zero-segment path is now covered end-to-end — test_empty_vad_returns_400_not_500 drives the real apply_vad_for_speech_profile through the upload route and asserts the 400 (no upload); test_apply_vad_for_speech_profile_raises_for_zero_segments asserts the real utility raises VADEmptyError; test_batch_skips_empty_vad_without_uploading covers the script boundary. All pass (34/34).


# Write-ahead: Cache exact duration after VAD processing (use av for fast header-only read)
with av.open(file_path) as container:
Expand Down
10 changes: 8 additions & 2 deletions backend/scripts/stt/j_apply_vad_to_speech_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', '')
Expand All @@ -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
Expand Down
37 changes: 34 additions & 3 deletions backend/tests/unit/test_speech_profile_wav_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import importlib.abc
import importlib.machinery
import importlib.util
import io
import os
import sys
Expand All @@ -29,9 +28,7 @@
"database",
"utils.other.storage",
"utils.stt.speaker_embedding",
"utils.stt.vad",
"av",
"pydub",
"firebase_admin",
"google",
"pinecone",
Expand Down Expand Up @@ -75,6 +72,8 @@ def exec_module(self, module):
sys.meta_path.insert(0, _f)
try:
from routers import speech_profile as mod
from scripts.stt import j_apply_vad_to_speech_profiles as batch_mod
from utils.stt import vad as vad_mod
finally:
sys.meta_path.remove(_f)
for _n in list(sys.modules):
Expand Down Expand Up @@ -132,3 +131,35 @@ 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):
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:
batch_mod.execute()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the batch test propagate worker exceptions

Fresh evidence since the earlier coverage comments is that this added test invokes the failure path through threading.Thread: if the new except VADEmptyError block is removed or mistyped, the exception terminates only the worker, join() still returns, and both assertions still pass (pytest merely reports an unhandled-thread warning by default). Run the worker synchronously through a controllable seam or otherwise assert that no worker exception occurred so the regression test actually protects the batch exception boundary.

AGENTS.md reference: AGENTS.md:L43-L45

Useful? React with 👍 / 👎.


mock_vad.assert_called_once_with("/tmp/profile.wav", return_segments=True)
mock_upload.assert_not_called()
8 changes: 8 additions & 0 deletions backend/tests/unit/test_vad_onnx.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from utils.stt.vad import (
VADAudioDecodeError,
VADProcessingError,
VADEmptyError,
vad_is_empty,
_run_file_vad,
_get_ort_session,
Expand All @@ -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
# ---------------------------------------------------------------------------
Expand Down
9 changes: 6 additions & 3 deletions backend/utils/stt/vad.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading