* feat(vad): replace amplitude-based VAD with Silero neural network Replace the simple amplitude threshold VAD with Silero VAD, a lightweight neural network model that classifies audio as speech/non-speech. This addresses the root cause of #302 (silence not stopping recording) and - Add silero_vad.py wrapper using ONNX Runtime with bundled Silero v5 model - Accumulate 512-sample chunks with 64-sample context window per official API - Map existing vad_sensitivity (1-5) to Silero thresholds (0.8-0.3) - Use max() across sub-chunks to avoid missing speech onsets - Fall back to amplitude VAD when onnxruntime is unavailable - Add onnxruntime as optional dependency (pip install vocalinux[vad]) Closes #344 * address review: add tests, LICENSE, logging, validation fixes - Add unit tests for SileroVAD (process, reset, fallback, sensitivity mapping) - Add MIT LICENSE for bundled Silero VAD model in data/ - Log which VAD backend is active on init - Validate chunk length in SileroVAD.process() - Skip is_speech check until a full Silero chunk is processed - Add thread safety note to SileroVAD docstring - Document resampling rate effect on VAD resolution - Comment explaining why load_silero_vad() catches broad Exception * address review: bundle license, document [vad] extra, surface backend in UI - Pack data/LICENSE into the wheel (pyproject package-data) so attribution travels with the model - README: short note on enabling neural VAD via vocalinux[vad] - Settings: VAD Sensitivity row subtitle shows the active backend; if onnxruntime is missing, an info-row beneath the group spells out the install command and the restart requirement - silero_vad.is_silero_available() probes onnxruntime + model presence w/o instantiating an InferenceSession - CI installs .[dev,vad] so Silero paths get exercised - Integration test for _record_audio Silero branch (mocked pyaudio + scripted SileroVAD), covering silence flush, speech reset, and sensitivity threshold mapping at sens=1 vs sens=5 - Tests no longer skip when other modules globally mock numpy: pop the top-level numpy ref so a fresh import gives the real package, and swap sys.modules["numpy"] inside the _record_audio call so the lazy import inside the function picks up real numpy too * fix(vad): drop silence-only recording buffers * test(vad): align stop buffer expectations * test(vad): preserve real numpy module * fix(install): install vad support opportunistically * test(vad): cover recognition manager vad edges --------- Co-authored-by: Sbenazar <127324689+Sbenazar@users.noreply.github.com>
244 lines
8.4 KiB
Python
244 lines
8.4 KiB
Python
"""
|
|
Tests for Silero VAD wrapper (silero_vad.py).
|
|
|
|
Covers:
|
|
- SileroVAD.process() return type and validation
|
|
- SileroVAD.reset() state clearing
|
|
- load_silero_vad() success and graceful fallback
|
|
- Sensitivity-to-threshold mapping
|
|
"""
|
|
|
|
import sys
|
|
from unittest.mock import MagicMock, patch # noqa: E402
|
|
|
|
# Earlier test modules install `sys.modules["numpy"] = MagicMock()` at module
|
|
# load and don't restore it. Reuse the real module cached by conftest instead
|
|
# of unloading/re-importing NumPy's compiled extensions.
|
|
if isinstance(sys.modules.get("numpy"), MagicMock):
|
|
_real_numpy = getattr(sys, "_vocalinux_real_numpy", None)
|
|
if _real_numpy is not None:
|
|
sys.modules["numpy"] = _real_numpy
|
|
|
|
import numpy as np # noqa: E402
|
|
import pytest # noqa: E402
|
|
|
|
from vocalinux.speech_recognition import silero_vad as _sv_mod # noqa: E402
|
|
from vocalinux.speech_recognition.silero_vad import ( # noqa: E402
|
|
_CONTEXT_SIZE,
|
|
SILERO_CHUNK_SIZE,
|
|
SILERO_SAMPLE_RATE,
|
|
SileroVAD,
|
|
is_silero_available,
|
|
load_silero_vad,
|
|
)
|
|
|
|
# silero_vad imported numpy when first loaded (likely with a mocked numpy
|
|
# reference). Point its module-level `np` at the real numpy so SileroVAD
|
|
# methods use real array operations.
|
|
_sv_mod.np = np
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_session(prob=0.05):
|
|
"""Create a mock ONNX InferenceSession mimicking Silero VAD output."""
|
|
session = MagicMock()
|
|
state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
session.run.return_value = ([[prob]], state)
|
|
return session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSileroVADProcess:
|
|
"""Test the process() method."""
|
|
|
|
def test_returns_float(self):
|
|
vad = SileroVAD.__new__(SileroVAD)
|
|
vad._session = _make_mock_session()
|
|
vad._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
vad._context = np.zeros((1, _CONTEXT_SIZE), dtype=np.float32)
|
|
vad._sr = np.array(SILERO_SAMPLE_RATE, dtype=np.int64)
|
|
|
|
chunk = np.zeros(SILERO_CHUNK_SIZE, dtype=np.int16)
|
|
prob = vad.process(chunk)
|
|
assert isinstance(prob, float)
|
|
|
|
def test_prob_from_session(self):
|
|
vad = SileroVAD.__new__(SileroVAD)
|
|
vad._session = _make_mock_session(prob=0.42)
|
|
vad._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
vad._context = np.zeros((1, _CONTEXT_SIZE), dtype=np.float32)
|
|
vad._sr = np.array(SILERO_SAMPLE_RATE, dtype=np.int64)
|
|
|
|
chunk = np.zeros(SILERO_CHUNK_SIZE, dtype=np.int16)
|
|
prob = vad.process(chunk)
|
|
assert abs(prob - 0.42) < 1e-6
|
|
|
|
def test_session_receives_correct_input_shape(self):
|
|
"""Session should receive (1, 512+64) float32 input."""
|
|
session = _make_mock_session()
|
|
vad = SileroVAD.__new__(SileroVAD)
|
|
vad._session = session
|
|
vad._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
vad._context = np.zeros((1, _CONTEXT_SIZE), dtype=np.float32)
|
|
vad._sr = np.array(SILERO_SAMPLE_RATE, dtype=np.int64)
|
|
|
|
chunk = np.zeros(SILERO_CHUNK_SIZE, dtype=np.int16)
|
|
vad.process(chunk)
|
|
|
|
call_kwargs = session.run.call_args
|
|
input_tensor = call_kwargs[1]["input"] if call_kwargs[1] else call_kwargs[0][1]["input"]
|
|
assert input_tensor.shape == (1, SILERO_CHUNK_SIZE + _CONTEXT_SIZE)
|
|
assert input_tensor.dtype == np.float32
|
|
|
|
def test_wrong_length_raises_value_error(self):
|
|
vad = SileroVAD.__new__(SileroVAD)
|
|
vad._session = _make_mock_session()
|
|
vad._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
vad._context = np.zeros((1, _CONTEXT_SIZE), dtype=np.float32)
|
|
vad._sr = np.array(SILERO_SAMPLE_RATE, dtype=np.int64)
|
|
|
|
with pytest.raises(ValueError, match="Expected 512"):
|
|
vad.process(np.zeros(256, dtype=np.int16))
|
|
with pytest.raises(ValueError, match="Expected 512"):
|
|
vad.process(np.zeros(1024, dtype=np.int16))
|
|
|
|
def test_context_updated_after_process(self):
|
|
"""Context should contain last 64 samples of the processed chunk."""
|
|
vad = SileroVAD.__new__(SileroVAD)
|
|
vad._session = _make_mock_session()
|
|
vad._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
vad._context = np.zeros((1, _CONTEXT_SIZE), dtype=np.float32)
|
|
vad._sr = np.array(SILERO_SAMPLE_RATE, dtype=np.int64)
|
|
|
|
chunk = np.arange(SILERO_CHUNK_SIZE, dtype=np.int16)
|
|
vad.process(chunk)
|
|
|
|
expected = chunk[-_CONTEXT_SIZE:].astype(np.float32) / 32768.0
|
|
assert np.allclose(vad._context[0], expected)
|
|
|
|
|
|
class TestSileroVADReset:
|
|
"""Test state reset between sessions."""
|
|
|
|
def test_reset_clears_state(self):
|
|
vad = SileroVAD.__new__(SileroVAD)
|
|
vad._state = np.ones((2, 1, 128), dtype=np.float32)
|
|
vad._context = np.ones((1, _CONTEXT_SIZE), dtype=np.float32)
|
|
|
|
vad.reset()
|
|
|
|
assert np.allclose(vad._state, 0.0)
|
|
assert np.allclose(vad._context, 0.0)
|
|
|
|
def test_reset_preserves_correct_shapes(self):
|
|
vad = SileroVAD.__new__(SileroVAD)
|
|
vad._state = np.ones((2, 1, 128), dtype=np.float32)
|
|
vad._context = np.ones((1, _CONTEXT_SIZE), dtype=np.float32)
|
|
|
|
vad.reset()
|
|
|
|
assert vad._state.shape == (2, 1, 128)
|
|
assert vad._context.shape == (1, _CONTEXT_SIZE)
|
|
|
|
|
|
class TestSensitivityMapping:
|
|
"""Test the sensitivity -> threshold mapping used in recognition_manager."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"sensitivity,expected_threshold",
|
|
[
|
|
(1, 0.800),
|
|
(2, 0.675),
|
|
(3, 0.550),
|
|
(4, 0.425),
|
|
(5, 0.300),
|
|
],
|
|
)
|
|
def test_mapping(self, sensitivity, expected_threshold):
|
|
threshold = 0.8 - (sensitivity - 1) * 0.125
|
|
assert abs(threshold - expected_threshold) < 1e-10
|
|
|
|
|
|
class TestConstants:
|
|
"""Test module constants."""
|
|
|
|
def test_chunk_size(self):
|
|
assert SILERO_CHUNK_SIZE == 512
|
|
|
|
def test_sample_rate(self):
|
|
assert SILERO_SAMPLE_RATE == 16000
|
|
|
|
def test_context_size(self):
|
|
assert _CONTEXT_SIZE == 64
|
|
|
|
|
|
class TestLoadFallback:
|
|
"""Test graceful fallback when onnxruntime is unavailable."""
|
|
|
|
def test_load_returns_none_on_import_error(self):
|
|
"""If SileroVAD() raises ImportError, load_silero_vad returns None."""
|
|
with patch(
|
|
"vocalinux.speech_recognition.silero_vad.SileroVAD",
|
|
side_effect=ImportError("no onnxruntime"),
|
|
):
|
|
result = load_silero_vad()
|
|
assert result is None
|
|
|
|
def test_load_returns_none_on_runtime_error(self):
|
|
"""If SileroVAD() raises RuntimeError (bad model), load returns None."""
|
|
with patch(
|
|
"vocalinux.speech_recognition.silero_vad.SileroVAD",
|
|
side_effect=RuntimeError("corrupted model"),
|
|
):
|
|
result = load_silero_vad()
|
|
assert result is None
|
|
|
|
def test_load_returns_vad_on_success(self):
|
|
"""load_silero_vad() returns a SileroVAD when init succeeds."""
|
|
mock_vad = MagicMock(spec=SileroVAD)
|
|
with patch(
|
|
"vocalinux.speech_recognition.silero_vad.SileroVAD",
|
|
return_value=mock_vad,
|
|
):
|
|
result = load_silero_vad()
|
|
assert result is mock_vad
|
|
|
|
|
|
class TestIsSileroAvailable:
|
|
"""Cheap probe used by Settings UI to decide whether to show the install hint."""
|
|
|
|
def test_returns_false_without_onnxruntime(self):
|
|
with patch.dict(sys.modules, {"onnxruntime": None}):
|
|
assert is_silero_available() is False
|
|
|
|
def test_returns_false_when_model_missing(self):
|
|
# onnxruntime importable, but model file gone
|
|
ort_mod = MagicMock()
|
|
with (
|
|
patch.dict(sys.modules, {"onnxruntime": ort_mod}),
|
|
patch(
|
|
"vocalinux.speech_recognition.silero_vad.os.path.exists",
|
|
return_value=False,
|
|
),
|
|
):
|
|
assert is_silero_available() is False
|
|
|
|
def test_returns_true_when_both_present(self):
|
|
ort_mod = MagicMock()
|
|
with (
|
|
patch.dict(sys.modules, {"onnxruntime": ort_mod}),
|
|
patch(
|
|
"vocalinux.speech_recognition.silero_vad.os.path.exists",
|
|
return_value=True,
|
|
),
|
|
):
|
|
assert is_silero_available() is True
|