diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 93e29cb..a422bf8 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -143,7 +143,7 @@ jobs: python -m pip install --upgrade pip setuptools wheel # Install PyGObject from pip (with build deps installed above) pip install PyGObject - pip install -e ".[dev]" + pip install -e ".[dev,vad]" echo "VIRTUAL_ENV=$VIRTUAL_ENV" >> $GITHUB_ENV echo "PATH=$VIRTUAL_ENV/bin:$PATH" >> $GITHUB_ENV diff --git a/README.md b/README.md index 0c46282..50e7684 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ The installer will: - **Auto-detect your hardware** (GPU, RAM, Vulkan support) - **Recommend the best engine** for your system - **Download the appropriate model** (~39MB for whisper.cpp tiny) +- **Install neural VAD support** when ONNX Runtime is available - **Install in ~1-2 minutes** (vs 5-10 min with old Whisper) > **Note**: Always installs the latest release. For a specific version, check [GitHub Releases](https://github.com/jatinkrmalik/vocalinux/releases). @@ -294,6 +295,18 @@ Configuration is stored in `~/.config/vocalinux/config.json`: You can also configure settings through the graphical Settings dialog (right-click the tray icon). +### Neural Voice Activity Detection + +Vocalinux ships with a Silero VAD model and uses it automatically when `onnxruntime` is available. The official installer attempts to install this support automatically. Without it, recording falls back to the simpler amplitude-threshold VAD. + +For manual or PyPI installs, enable neural VAD with: + +```bash +pip install "vocalinux[vad]" +``` + +Restart Vocalinux after install. The Recognition tab in Settings shows which backend is active. The same `vad_sensitivity` (1-5) works for both -- it's mapped to a Silero probability threshold internally (1 = 0.8, 5 = 0.3). + ## 🔧 Development Setup ```bash diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 188c5ea..0ee003a 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -15,6 +15,7 @@ curl -fsSL https://raw.githubusercontent.com/jatinkrmalik/vocalinux/main/install That's it! The installer handles everything automatically: - ✅ Installs whisper.cpp (~1-2 minutes, no heavy dependencies!) - ✅ Auto-detects your GPU (AMD, Intel, NVIDIA all supported) +- ✅ Installs neural VAD support when ONNX Runtime is available - ✅ Downloads the tiny model (~39MB) - ✅ Configures everything automatically @@ -202,12 +203,13 @@ Examples: - **whisper.cpp** (default): High-performance C++ engine with Vulkan GPU support - **Whisper**: OpenAI's PyTorch-based engine (NVIDIA GPU only) - **VOSK**: Lightweight engine for older systems -5. **Downloads speech recognition models**: +5. **Installs neural VAD support** when ONNX Runtime is available, with a safe amplitude-VAD fallback otherwise +6. **Downloads speech recognition models**: - whisper.cpp: ~39MB tiny model (or larger if selected) - Whisper: ~75MB tiny model + PyTorch dependencies (~2.3GB with CUDA) - VOSK: ~40MB small model -6. **Installs desktop integration** (icons, .desktop file) -7. **Creates activation script** for easy environment activation +7. **Installs desktop integration** (icons, .desktop file) +8. **Creates activation script** for easy environment activation ### Installation Time Comparison @@ -397,8 +399,11 @@ pip install . # With Whisper support pip install ".[whisper]" +# With neural VAD support +pip install ".[vad]" + # Development mode -pip install -e ".[dev]" +pip install -e ".[dev,vad]" ``` For PyPI instead of a local checkout, use `pip install vocalinux` after installing diff --git a/docs/UPDATE.md b/docs/UPDATE.md index b8c257f..bc42927 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -205,6 +205,7 @@ The installer will: - ✅ Update your existing installation - ✅ Preserve your configuration and models - ✅ Install any new dependencies +- ✅ Attempt to install neural VAD support, falling back safely if ONNX Runtime is unavailable ### If You Installed from Source diff --git a/install.sh b/install.sh index 860e28d..a094921 100755 --- a/install.sh +++ b/install.sh @@ -1659,7 +1659,7 @@ install_system_dependencies() { print_info "4. Or install from source in a virtual environment:" print_info " python3 -m venv venv" print_info " source venv/bin/activate" - print_info " pip install -e .[whisper]" + print_info " pip install -e .[whisper,vad]" print_info "" print_info "For more information, see the project wiki:" print_info " https://github.com/jatinkrmalik/vocalinux/wiki" @@ -2402,6 +2402,40 @@ install_python_package() { return $? } + # Silero/ONNX Runtime gives much better speech/silence decisions, but + # onnxruntime wheels are not guaranteed for every Python/platform combo. + # Install it opportunistically so fresh installs and rerun-updates get the + # neural VAD when available without blocking the amplitude fallback path. + install_vad_support() { + local PIP_LOG_FILE="$1" + local EDITABLE_MODE="${2:-no}" + + print_info "Installing neural VAD support (Silero / ONNX Runtime)..." + local VAD_INSTALL_SUCCESS=false + if [[ "$EDITABLE_MODE" == "yes" ]]; then + pip install -e ".[vad]" --log "$PIP_LOG_FILE" && VAD_INSTALL_SUCCESS=true + else + pip install ".[vad]" --log "$PIP_LOG_FILE" && VAD_INSTALL_SUCCESS=true + fi + + if [[ "$VAD_INSTALL_SUCCESS" == "true" ]]; then + if "$VENV_DIR/bin/python" - <<'PY' 2>/dev/null +from vocalinux.speech_recognition.silero_vad import is_silero_available +raise SystemExit(0 if is_silero_available() else 1) +PY + then + print_success "Neural VAD support installed and verified successfully." + else + print_warning "Neural VAD dependencies installed, but Silero VAD could not be verified." + print_warning "Vocalinux will use amplitude-based VAD until this is resolved." + fi + else + print_warning "Failed to install neural VAD support." + print_warning "Vocalinux will still work using amplitude-based VAD." + print_warning "Check the pip log for details: $PIP_LOG_FILE" + fi + } + if [[ "$DEV_MODE" == "yes" ]]; then print_info "Installing Vocalinux in development mode..." @@ -2420,11 +2454,13 @@ install_python_package() { # Install all optional dependencies for development print_info "Installing all optional dependencies for development..." - pip install ".[whisper,dev]" --log "$PIP_LOG_FILE" || { + pip install -e ".[whisper,dev]" --log "$PIP_LOG_FILE" || { print_warning "Failed to install some optional dependencies." print_warning "Some features may not work correctly." } + install_vad_support "$PIP_LOG_FILE" yes + if [[ "${SELECTED_ENGINE:-whisper_cpp}" == "whisper_cpp" ]]; then install_whispercpp_with_gpu_support "$PIP_LOG_FILE" fi @@ -2438,6 +2474,8 @@ install_python_package() { return 1 } + install_vad_support "$PIP_LOG_FILE" + # Engine installation logic: # - SELECTED_ENGINE is set by interactive mode or --engine flag # - WHISPERCPP_BACKEND is set by interactive mode ("gpu" or "cpu") diff --git a/pyproject.toml b/pyproject.toml index 05eb125..87ce4dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ whisper = [ "torch>=2.0.0", "torchaudio>=2.0.0", ] +vad = [ + "onnxruntime>=1.16.0", +] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", @@ -96,8 +99,9 @@ package-dir = {"" = "src"} version = {attr = "vocalinux.version.__version__"} [tool.setuptools.package-data] -"*" = ["*.svg", "*.wav"] +"*" = ["*.svg", "*.wav", "*.onnx"] "vocalinux" = ["resources/**/*"] +"vocalinux.speech_recognition" = ["data/*.onnx", "data/LICENSE"] [tool.black] line-length = 100 diff --git a/src/vocalinux/speech_recognition/data/LICENSE b/src/vocalinux/speech_recognition/data/LICENSE new file mode 100644 index 0000000..fe347eb --- /dev/null +++ b/src/vocalinux/speech_recognition/data/LICENSE @@ -0,0 +1,25 @@ +The silero_vad.onnx model is from the Silero VAD project: + + https://github.com/snakers4/silero-vad + +Version: v5 (ONNX export) +License: MIT +Copyright (c) Silero Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/vocalinux/speech_recognition/data/silero_vad.onnx b/src/vocalinux/speech_recognition/data/silero_vad.onnx new file mode 100644 index 0000000..80c5592 Binary files /dev/null and b/src/vocalinux/speech_recognition/data/silero_vad.onnx differ diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index a53fad9..a183f1b 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -22,6 +22,7 @@ from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_so from ..utils.vosk_model_info import VOSK_MODEL_INFO from ..utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, get_model_path, is_model_downloaded from .command_processor import CommandProcessor +from .silero_vad import SILERO_CHUNK_SIZE, load_silero_vad # ALSA error handler to suppress warnings during PyAudio initialization @@ -742,6 +743,13 @@ class SpeechRecognitionManager: self.vad_sensitivity = kwargs.get("vad_sensitivity", 3) self.silence_timeout = kwargs.get("silence_timeout", 2.0) + # Silero VAD (neural-network-based, falls back to amplitude if unavailable) + self._silero_vad = load_silero_vad() + if self._silero_vad is not None: + logger.info("Using Silero neural VAD") + else: + logger.info("Using amplitude-based VAD (install vocalinux[vad] for neural VAD)") + # Audio device selection (None means use system default) self.audio_device_index = kwargs.get("audio_device_index", None) @@ -764,6 +772,7 @@ class SpeechRecognitionManager: self.should_record = False self._recognition_mode = "toggle" # "toggle" or "push_to_talk" self.audio_buffer = [] + self._recording_segment_has_speech = False self._buffer_lock = threading.Lock() # Thread safety for audio_buffer self._model_lock = threading.Lock() # Thread safety for model/recognizer access self._segment_queue = queue.Queue(maxsize=32) @@ -1895,10 +1904,17 @@ class SpeechRecognitionManager: self.stop_sound_guard_ms, ) - if self.audio_buffer: - logger.debug(f"Enqueuing final buffer with {len(self.audio_buffer)} chunks") + if self.audio_buffer and self._recording_segment_has_speech: + logger.debug(f"Enqueuing final speech buffer with {len(self.audio_buffer)} chunks") self._enqueue_audio_segment(self.audio_buffer) self.audio_buffer = [] + elif self.audio_buffer: + logger.debug( + "Dropping final audio buffer with no detected speech " + f"({len(self.audio_buffer)} chunks)" + ) + self.audio_buffer = [] + self._recording_segment_has_speech = False # Wake up recognition thread so it can drain queued segments and stop self._signal_recognition_stop() @@ -2016,8 +2032,20 @@ class SpeechRecognitionManager: # Record audio while should_record is True silence_counter = 0 speech_detected_in_session = False + self._recording_segment_has_speech = False log_level_interval = 0 # Counter for periodic level logging max_level_seen = 0.0 + # Accumulator for 512-sample Silero chunks. When the capture rate + # is higher than 16 kHz (e.g. 48 kHz), resampling produces fewer + # than 1024 samples per read (~341 at 48 kHz), so the buffer may + # need several reads to fill a full 512-sample chunk. This is + # expected -- VAD decisions simply arrive less frequently (every + # ~128 ms instead of ~64 ms) with no impact on accuracy. + silero_chunk_buf = np.array([], dtype=np.int16) + + # Reset Silero VAD state for this recording session + if self._silero_vad is not None: + self._silero_vad.reset() while self.should_record: try: @@ -2057,7 +2085,7 @@ class SpeechRecognitionManager: self.audio_buffer.append(data) - # Simple Voice Activity Detection (VAD) + # Voice Activity Detection (VAD) audio_data = np.frombuffer(data, dtype=np.int16) volume = np.abs(audio_data).mean() @@ -2082,22 +2110,54 @@ class SpeechRecognitionManager: ) log_level_interval = 0 - # Threshold based on sensitivity (1-5) - # Ensure vad_sensitivity is treated as integer for calculation - try: - vad_sens = int(self.vad_sensitivity) - threshold = 500 / max(1, min(5, vad_sens)) # Use self.vad_sensitivity - except ValueError: - logger.warning( - f"Invalid VAD sensitivity value: {self.vad_sensitivity}. Using default 3." - ) - threshold = 500 / 3 + # Determine if current chunk contains speech + is_speech = False + if self._silero_vad is not None: + # Silero VAD: accumulate samples into 512-sample chunks + speech_prob = 0.0 + chunk_processed = False + silero_chunk_buf = np.concatenate([silero_chunk_buf, audio_data]) + while len(silero_chunk_buf) >= SILERO_CHUNK_SIZE: + chunk_512 = silero_chunk_buf[:SILERO_CHUNK_SIZE] + silero_chunk_buf = silero_chunk_buf[SILERO_CHUNK_SIZE:] + speech_prob = max(speech_prob, self._silero_vad.process(chunk_512)) + chunk_processed = True - if volume < threshold: # Silence + # Map vad_sensitivity (1-5) to threshold: + # 1 (least sensitive) -> 0.8, 5 (most sensitive) -> 0.3 + try: + vad_sens = int(self.vad_sensitivity) + vad_sens = max(1, min(5, vad_sens)) + except ValueError: + vad_sens = 3 + silero_threshold = 0.8 - (vad_sens - 1) * 0.125 + + # Skip speech decision until at least one full chunk + # has been processed to avoid false silence detection + if chunk_processed: + is_speech = speech_prob >= silero_threshold + else: + # Amplitude fallback when Silero is unavailable + try: + vad_sens = int(self.vad_sensitivity) + threshold = 500 / max(1, min(5, vad_sens)) + except ValueError: + logger.warning( + f"Invalid VAD sensitivity value: {self.vad_sensitivity}. Using default 3." + ) + threshold = 500 / 3 + is_speech = volume >= threshold + + if not is_speech: # Silence silence_counter += CHUNK / RATE # Convert chunks to seconds - if silence_counter > self.silence_timeout: # Use self.silence_timeout + if silence_counter > self.silence_timeout: if len(self.audio_buffer) > 0: - if self._recognition_mode == "push_to_talk": + if not self._recording_segment_has_speech: + logger.debug( + "Silence detected with no speech, dropping audio buffer" + ) + self.audio_buffer = [] + elif self._recognition_mode == "push_to_talk": logger.debug( "Silence detected in push-to-talk mode, " "deferring transcription until key release" @@ -2106,13 +2166,21 @@ class SpeechRecognitionManager: logger.debug("Silence detected, queueing audio segment") self._enqueue_audio_segment(self.audio_buffer) self.audio_buffer = [] + self._recording_segment_has_speech = False silence_counter = 0 else: # Speech + self._recording_segment_has_speech = True if not speech_detected_in_session: - logger.debug( - f"Speech detected (level={normalized_level:.1f}%, " - f"threshold={500 / max(1, min(5, int(self.vad_sensitivity))):.0f})" - ) + if self._silero_vad is not None: + logger.debug( + f"Speech detected (silero_prob={speech_prob:.2f}, " + f"threshold={silero_threshold:.3f})" + ) + else: + logger.debug( + f"Speech detected (level={normalized_level:.1f}%, " + f"threshold={500 / max(1, min(5, int(self.vad_sensitivity))):.0f})" + ) speech_detected_in_session = True silence_counter = 0 except (IOError, OSError) as e: diff --git a/src/vocalinux/speech_recognition/silero_vad.py b/src/vocalinux/speech_recognition/silero_vad.py new file mode 100644 index 0000000..0d526af --- /dev/null +++ b/src/vocalinux/speech_recognition/silero_vad.py @@ -0,0 +1,104 @@ +""" +Silero VAD wrapper for Vocalinux. + +Provides neural-network-based Voice Activity Detection using the Silero VAD +ONNX model. Falls back gracefully when onnxruntime is unavailable. +""" + +import logging +import os + +import numpy as np + +logger = logging.getLogger(__name__) + +# Path to the bundled ONNX model +_MODEL_PATH = os.path.join(os.path.dirname(__file__), "data", "silero_vad.onnx") + +# Silero VAD expects 512 samples per chunk at 16kHz (32ms) +SILERO_CHUNK_SIZE = 512 +SILERO_SAMPLE_RATE = 16000 + +# Context window size (prepended to each chunk for cross-chunk continuity) +_CONTEXT_SIZE = 64 + + +class SileroVAD: + """Silero VAD wrapper using ONNX Runtime inference. + + Not thread-safe -- process() and reset() mutate internal LSTM state. + Currently only called from the recording thread in _record_audio(). + """ + + def __init__(self): + import onnxruntime as ort + + opts = ort.SessionOptions() + opts.inter_op_num_threads = 1 + opts.intra_op_num_threads = 1 + opts.log_severity_level = 3 # suppress ort warnings + self._session = ort.InferenceSession( + _MODEL_PATH, sess_options=opts, providers=["CPUExecutionProvider"] + ) + self._state = np.zeros((2, 1, 128), dtype=np.float32) + self._context = np.zeros((1, _CONTEXT_SIZE), dtype=np.float32) + self._sr = np.array(SILERO_SAMPLE_RATE, dtype=np.int64) + logger.info("Silero VAD model loaded") + + def reset(self): + """Reset internal LSTM state and context (call between utterances).""" + self._state = np.zeros((2, 1, 128), dtype=np.float32) + self._context = np.zeros((1, _CONTEXT_SIZE), dtype=np.float32) + + def process(self, audio_int16: np.ndarray) -> float: + """Return speech probability for a 512-sample int16 chunk. + + Args: + audio_int16: numpy array of int16 PCM samples, length 512. + + Returns: + Speech probability in [0, 1]. + + Raises: + ValueError: If audio_int16 length is not SILERO_CHUNK_SIZE. + """ + if len(audio_int16) != SILERO_CHUNK_SIZE: + raise ValueError(f"Expected {SILERO_CHUNK_SIZE} samples, got {len(audio_int16)}") + # Normalize int16 -> float32 [-1, 1] + audio_f32 = audio_int16.astype(np.float32) / 32768.0 + audio_f32 = audio_f32.reshape(1, -1) + + # Prepend context from previous chunk (64 samples) for cross-chunk continuity + input_with_context = np.concatenate([self._context, audio_f32], axis=1) + self._context = audio_f32[:, -_CONTEXT_SIZE:] + + output, self._state = self._session.run( + None, {"input": input_with_context, "sr": self._sr, "state": self._state} + ) + return float(output[0][0]) + + +def is_silero_available() -> bool: + """Cheap probe for whether neural VAD can be loaded. + + Checks onnxruntime importability and model file presence without + instantiating an InferenceSession. + """ + try: + import onnxruntime # noqa: F401 + except ImportError: + return False + return os.path.exists(_MODEL_PATH) + + +def load_silero_vad(): + """Try to load Silero VAD, return None on failure.""" + try: + vad = SileroVAD() + return vad + except Exception as e: + # Catches ImportError (missing onnxruntime), FileNotFoundError, + # onnxruntime.capi.onnxruntime_pybind11_state.NoSuchFile (missing model), + # and RuntimeError (corrupted model / ONNX errors). + logger.warning(f"Silero VAD unavailable, falling back to amplitude VAD: {e}") + return None diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 6fb10bf..314865d 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -28,6 +28,7 @@ gi.require_version("Gdk", "3.0") from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState # noqa: E402 +from ..speech_recognition.silero_vad import is_silero_available # noqa: E402 from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402 from ..utils.whispercpp_model_info import ( WHISPERCPP_MODEL_INFO, @@ -1162,12 +1163,18 @@ class SettingsDialog(Gtk.Dialog): self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1) self.vad_spin.set_tooltip_text("Higher = more sensitive to quiet speech") _prevent_scroll_on_hover(self.vad_spin) - vad_row = PreferenceRow( + silero_active = is_silero_available() + vad_subtitle = ( + "Sensitivity (1-5) -- backend: Silero neural VAD" + if silero_active + else "Sensitivity (1-5) -- backend: amplitude (install vocalinux[vad] for neural)" + ) + self.vad_row = PreferenceRow( title="VAD Sensitivity", - subtitle="Voice Activity Detection sensitivity (1-5)", + subtitle=vad_subtitle, widget=self.vad_spin, ) - group.add_row(vad_row) + group.add_row(self.vad_row) # Silence Timeout self.silence_spin = Gtk.SpinButton.new_with_range(0.5, 5.0, 0.1) @@ -1196,6 +1203,33 @@ class SettingsDialog(Gtk.Dialog): self.recognition_settings_tab.pack_start(group, False, False, 0) + if not silero_active: + vad_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + vad_info_box.get_style_context().add_class("info-box") + vad_info_box.set_margin_start(4) + vad_info_box.set_margin_end(4) + vad_info_box.set_margin_top(4) + + info_icon = Gtk.Image.new_from_icon_name( + "dialog-information-symbolic", Gtk.IconSize.MENU + ) + vad_info_box.pack_start(info_icon, False, False, 0) + + vad_info_label = Gtk.Label( + label=( + "Neural VAD is more accurate at separating speech from background " + "noise. To enable it, install onnxruntime and restart Vocalinux:\n" + ' pip install "vocalinux[vad]"' + ), + xalign=0, + wrap=True, + selectable=True, + ) + vad_info_label.get_style_context().add_class("tip-label") + vad_info_box.pack_start(vad_info_label, True, True, 0) + + self.recognition_settings_tab.pack_start(vad_info_box, False, False, 0) + # Connect signals self.vad_spin.connect("value-changed", self._on_vad_changed) self.silence_spin.connect("value-changed", self._on_silence_changed) diff --git a/tests/conftest.py b/tests/conftest.py index 2f11221..a92e518 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,16 @@ import pytest # Set PYTEST_RUNNING early so audio_feedback module can detect it os.environ["PYTEST_RUNNING"] = "1" +# Several legacy tests replace sys.modules["numpy"] with MagicMock at module +# import time. Preserve the real module once so newer tests can restore it +# without unloading/re-importing NumPy's compiled extensions. +try: + import numpy as _real_numpy +except ImportError: + _real_numpy = None +else: + sys._vocalinux_real_numpy = _real_numpy + # Prevent specific known-blocking daemon threads from starting during tests. # Source code in ibus_engine.py and evdev_backend.py spawns daemon threads # (socket server, device monitor) that block on socket.accept() or diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index 9471c22..9d2ab2c 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -643,6 +643,7 @@ class TestStartStopRecognition(unittest.TestCase): manager.state = RecognitionState.LISTENING manager.should_record = True manager.audio_buffer = [b"\x00\x00" for _ in range(20)] + manager._recording_segment_has_speech = True # Create dummy threads manager.audio_thread = MagicMock() @@ -656,6 +657,27 @@ class TestStartStopRecognition(unittest.TestCase): manager.stop_recognition() assert manager.should_record is False assert len(enqueue_mock.call_args.args[0]) == 17 + assert manager._recording_segment_has_speech is False + + def test_stop_recognition_drops_final_buffer_without_speech(self): + """Final silence-only buffers should not be queued for transcription.""" + manager = _make_manager() + manager.state = RecognitionState.LISTENING + manager.should_record = True + manager.audio_buffer = [b"\x00\x00" for _ in range(20)] + manager._recording_segment_has_speech = False + + manager.audio_thread = MagicMock() + manager.audio_thread.is_alive.return_value = False + manager.recognition_thread = MagicMock() + manager.recognition_thread.is_alive.return_value = False + + with patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"): + with patch.object(manager, "_signal_recognition_stop"): + with patch.object(manager, "_enqueue_audio_segment") as enqueue_mock: + manager.stop_recognition() + enqueue_mock.assert_not_called() + assert manager.audio_buffer == [] class TestDownloads(unittest.TestCase): diff --git a/tests/test_recognition_manager_download_buffer.py b/tests/test_recognition_manager_download_buffer.py index 8c028dd..0ac4f05 100644 --- a/tests/test_recognition_manager_download_buffer.py +++ b/tests/test_recognition_manager_download_buffer.py @@ -339,6 +339,7 @@ class TestBufferManagement(unittest.TestCase): manager.state = RecognitionState.LISTENING manager.should_record = True manager.audio_buffer = [b"small"] + manager._recording_segment_has_speech = True manager.audio_thread = MagicMock() manager.audio_thread.is_alive.return_value = False manager.recognition_thread = MagicMock() @@ -357,6 +358,7 @@ class TestBufferManagement(unittest.TestCase): manager.state = RecognitionState.LISTENING manager.should_record = True manager.audio_buffer = [b"x" * 100 for _ in range(20)] + manager._recording_segment_has_speech = True manager.audio_thread = MagicMock() manager.audio_thread.is_alive.return_value = False manager.recognition_thread = MagicMock() diff --git a/tests/test_recognition_manager_silero.py b/tests/test_recognition_manager_silero.py new file mode 100644 index 0000000..9c3dc8c --- /dev/null +++ b/tests/test_recognition_manager_silero.py @@ -0,0 +1,388 @@ +""" +Integration-style tests for the Silero VAD path in _record_audio. + +Drives the recording loop with a mocked PyAudio stream and a Mock SileroVAD +returning scripted speech probabilities. Verifies: + +- silence-only input -> no transcription segment is enqueued +- speech followed by silence -> _enqueue_audio_segment is called after silence_timeout +- speech -> silence_counter resets, no premature flush +- vad_sensitivity threshold mapping kicks in (sens=1 vs sens=5 give different decisions) +- Silero state is reset at the start of every recording session +""" + +import sys +import unittest # noqa: E402 +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 + +# ---- Mock external deps that recognition_manager imports ---- +_MOCK_KEYS = ["vosk", "whisper", "torch", "pyaudio", "pywhispercpp", "pywhispercpp.model"] +_ORIG_MODULES = {} +for _k in _MOCK_KEYS: + _ORIG_MODULES[_k] = sys.modules.get(_k) + if _k not in sys.modules: + sys.modules[_k] = MagicMock() + +if "gi" not in sys.modules: + sys.modules["gi"] = MagicMock() +if "gi.repository" not in sys.modules: + sys.modules["gi.repository"] = MagicMock() + +from vocalinux.common_types import RecognitionState # noqa: E402 +from vocalinux.speech_recognition.recognition_manager import ( # noqa: E402 + SpeechRecognitionManager, +) + +for _k, _v in _ORIG_MODULES.items(): + if _v is not None: + sys.modules[_k] = _v + elif _k in sys.modules and isinstance(sys.modules[_k], MagicMock): + del sys.modules[_k] + + +CHUNK_BYTES = 1024 * 2 # 1024 int16 samples +SILENT_CHUNK = b"\x00" * (1024 * 2) # 1024 int16 zero samples + + +def _make_manager(): + """Create a SpeechRecognitionManager with init paths stubbed out.""" + with ( + patch.object(SpeechRecognitionManager, "_init_vosk"), + patch.object(SpeechRecognitionManager, "_init_whisper"), + patch.object(SpeechRecognitionManager, "_init_whispercpp"), + ): + mgr = SpeechRecognitionManager( + engine="whisper_cpp", + model_size="small", + language="en-us", + defer_download=True, + ) + return mgr + + +class TestManagerSileroInitialization(unittest.TestCase): + def test_logs_when_silero_vad_loads(self): + """Manager initialization should report when neural VAD is active.""" + vad = MagicMock() + with ( + patch.object(SpeechRecognitionManager, "_init_vosk"), + patch.object(SpeechRecognitionManager, "_init_whisper"), + patch.object(SpeechRecognitionManager, "_init_whispercpp"), + patch( + "vocalinux.speech_recognition.recognition_manager.load_silero_vad", + return_value=vad, + ), + patch("vocalinux.speech_recognition.recognition_manager.logger") as logger_mock, + ): + mgr = SpeechRecognitionManager( + engine="whisper_cpp", + model_size="small", + language="en-us", + defer_download=True, + ) + + self.assertIs(mgr._silero_vad, vad) + logger_mock.info.assert_any_call("Using Silero neural VAD") + + +def _make_pyaudio_module(stream): + """Build a fake pyaudio module that returns the given stream from audio.open().""" + audio = MagicMock() + audio.get_device_count.return_value = 1 + audio.get_default_input_device_info.return_value = { + "index": 0, + "name": "mock", + "maxInputChannels": 1, + "defaultSampleRate": 16000, + } + audio.get_device_info_by_index.return_value = { + "index": 0, + "name": "mock", + "maxInputChannels": 1, + "defaultSampleRate": 16000, + } + audio.is_format_supported.return_value = True + audio.open.return_value = stream + + pyaudio_mod = MagicMock(paInt16=8, PyAudio=MagicMock(return_value=audio)) + return pyaudio_mod, audio + + +def _scripted_stream(probs_per_chunk, manager, max_iters=None, chunk_bytes=None): + """Return a Mock stream whose read() drives `manager.should_record` to False + once we've consumed `max_iters` (or len(probs_per_chunk)) chunks. + + Each read() returns silent bytes; the speech/silence decision comes from + the scripted probabilities fed via the mocked SileroVAD. `chunk_bytes` + overrides the default 1024-int16 size (e.g. stereo at 16 kHz needs 4096 + bytes per CHUNK frame). + """ + stream = MagicMock() + counter = {"n": 0} + limit = max_iters if max_iters is not None else len(probs_per_chunk) + payload = b"\x00" * (chunk_bytes if chunk_bytes is not None else 1024 * 2) + + def _read(*args, **kwargs): + counter["n"] += 1 + if counter["n"] >= limit: + manager.should_record = False + return payload + + stream.read.side_effect = _read + return stream + + +def _make_silero(probs): + """Mock SileroVAD: process() returns scripted probs, repeating the last + value forever once the list is exhausted (each outer read() drives 2 silero + chunks of 512 samples, so we need plenty of values). + """ + vad = MagicMock() + state = {"i": 0, "last": probs[-1] if probs else 0.0} + + def _process(_chunk): + i = state["i"] + if i < len(probs): + state["i"] = i + 1 + return probs[i] + return state["last"] + + vad.process.side_effect = _process + return vad + + +class TestRecordAudioSileroPath(unittest.TestCase): + def setUp(self): + self.mgr = _make_manager() + self.mgr.state = RecognitionState.LISTENING + self.mgr.should_record = True + self.mgr.audio_buffer = [] + self.mgr.silence_timeout = 0.5 # short timeout for fast tests + self.mgr.vad_sensitivity = 3 + + # Capture flushes triggered by the silence-detection branch + self.enqueued = [] + self.mgr._enqueue_audio_segment = lambda buf: self.enqueued.append(list(buf)) + + def _drive( + self, + probs, + vad_sensitivity=3, + max_iters=None, + channels=1, + rate=16000, + chunk_bytes=None, + ): + """Run _record_audio with the given scripted Silero probabilities.""" + self.mgr.vad_sensitivity = vad_sensitivity + self.mgr._silero_vad = _make_silero(probs) + + stream = _scripted_stream( + probs, + self.mgr, + max_iters=max_iters or len(probs), + chunk_bytes=chunk_bytes, + ) + pyaudio_mod, _audio = _make_pyaudio_module(stream) + + # _record_audio does `import numpy as np` lazily inside the function; + # earlier test modules may have left sys.modules["numpy"] as a + # MagicMock, so put real numpy back for the duration of the call. + with ( + patch.dict(sys.modules, {"pyaudio": pyaudio_mod, "numpy": np}), + patch( + "vocalinux.speech_recognition.recognition_manager._get_supported_channels", + return_value=channels, + ), + patch( + "vocalinux.speech_recognition.recognition_manager._get_supported_sample_rate", + return_value=rate, + ), + ): + self.mgr._record_audio() + + def test_reset_called_at_session_start(self): + """Silero state must be cleared between recording sessions.""" + self._drive(probs=[0.1] * 5) + self.assertTrue(self.mgr._silero_vad.reset.called) + + def test_silence_only_drops_buffer_without_enqueue(self): + """Sustained silence past silence_timeout should not reach transcription.""" + # Each chunk = 1024/16000 = 0.064s. silence_timeout=0.5s, so ~8 chunks + # of silence crosses the timeout. Run 20 chunks to ensure the threshold fires. + self._drive(probs=[0.05] * 20, vad_sensitivity=3) + self.assertEqual(len(self.enqueued), 0, "silence-only input should be dropped") + + def test_speech_then_silence_triggers_segment_enqueue(self): + """Speech followed by sustained silence should flush the speech segment.""" + probs = [0.95] * 6 + [0.05] * 20 + self._drive(probs=probs, vad_sensitivity=3) + self.assertGreaterEqual( + len(self.enqueued), 1, "expected speech-followed-by-silence to enqueue" + ) + + def test_speech_prevents_premature_flush(self): + """Speech probs above threshold should reset the silence counter.""" + # Continuous speech (prob well above threshold for sens=3 -> 0.55). + # 20 chunks * 0.064s = 1.28s, more than silence_timeout=0.5s, + # but speech_counter never accumulates -> no flush. + self._drive(probs=[0.95] * 20, vad_sensitivity=3) + self.assertEqual(len(self.enqueued), 0, "speech should keep silence counter at 0, no flush") + + def test_sensitivity_lowest_blocks_borderline_speech(self): + """At sensitivity=1 (threshold 0.8), prob=0.6 is silence.""" + # 20 chunks of borderline prob; with strict threshold these read as silence + # and the silence-only buffer gets dropped. + self._drive(probs=[0.6] * 20, vad_sensitivity=1) + self.assertEqual(len(self.enqueued), 0) + + def test_sensitivity_highest_accepts_borderline_speech(self): + """At sensitivity=5 (threshold 0.3), prob=0.6 is speech, no flush.""" + self._drive(probs=[0.6] * 20, vad_sensitivity=5) + self.assertEqual(len(self.enqueued), 0) + + def test_invalid_sensitivity_uses_default_silero_threshold(self): + """Bad sensitivity values should fall back to the default Silero threshold.""" + self._drive(probs=[0.95] * 20, vad_sensitivity="bad") + self.assertEqual(len(self.enqueued), 0) + + def test_push_to_talk_defers_speech_segment_until_release(self): + """Push-to-talk should keep a spoken segment buffered after silence.""" + self.mgr._recognition_mode = "push_to_talk" + + self._drive(probs=[0.95] * 6 + [0.05] * 20, vad_sensitivity=3) + + self.assertEqual(len(self.enqueued), 0) + self.assertGreater(len(self.mgr.audio_buffer), 0) + self.assertTrue(self.mgr._recording_segment_has_speech) + + def test_stereo_capture_is_downmixed_without_error(self): + """CHANNELS=2 -> the stereo->mono branch runs and the loop completes.""" + self._drive(probs=[0.05] * 12, vad_sensitivity=3, channels=2, chunk_bytes=1024 * 2 * 2) + # No assertion on enqueue count -- just verify the path runs cleanly. + + def test_resample_branch_runs_at_48khz(self): + """rate=48000 -> the resample branch in _record_audio runs.""" + self._drive(probs=[0.05] * 12, vad_sensitivity=3, rate=48000) + + +class TestRecordAudioAmplitudeFallback(unittest.TestCase): + """When onnxruntime is unavailable, _record_audio falls back to an amplitude + threshold. These tests drive that path with `_silero_vad = None`. + """ + + def setUp(self): + self.mgr = _make_manager() + self.mgr.state = RecognitionState.LISTENING + self.mgr.should_record = True + self.mgr.audio_buffer = [] + self.mgr.silence_timeout = 0.5 + self.mgr.vad_sensitivity = 3 + self.mgr._silero_vad = None + + self.enqueued = [] + self.mgr._enqueue_audio_segment = lambda buf: self.enqueued.append(list(buf)) + + def _drive(self, payload, max_iters): + stream = MagicMock() + counter = {"n": 0} + + def _read(*a, **kw): + counter["n"] += 1 + if counter["n"] >= max_iters: + self.mgr.should_record = False + return payload + + stream.read.side_effect = _read + pyaudio_mod, _ = _make_pyaudio_module(stream) + with ( + patch.dict(sys.modules, {"pyaudio": pyaudio_mod, "numpy": np}), + patch( + "vocalinux.speech_recognition.recognition_manager._get_supported_channels", + return_value=1, + ), + patch( + "vocalinux.speech_recognition.recognition_manager._get_supported_sample_rate", + return_value=16000, + ), + ): + self.mgr._record_audio() + + def test_silence_drops_buffer(self): + """All-zero samples -> volume below threshold -> no transcription segment.""" + self._drive(payload=b"\x00" * (1024 * 2), max_iters=20) + self.assertEqual(len(self.enqueued), 0) + + def test_invalid_sensitivity_uses_default_amplitude_threshold(self): + """Bad sensitivity values should fall back to the default amplitude threshold.""" + self.mgr.vad_sensitivity = "bad" + self._drive(payload=b"\x00" * (1024 * 2), max_iters=20) + self.assertEqual(len(self.enqueued), 0) + + def test_loud_audio_is_speech(self): + """Loud samples -> volume above threshold -> speech, no flush.""" + loud = (np.full(1024, 20000, dtype=np.int16)).tobytes() + self._drive(payload=loud, max_iters=20) + self.assertEqual(len(self.enqueued), 0) + + +class TestAudioLevelCallback(unittest.TestCase): + """The audio-level callback list is iterated every chunk with normalized + level in [0, 100]. Verify a registered callback receives values. + """ + + def test_callback_receives_levels(self): + mgr = _make_manager() + mgr.state = RecognitionState.LISTENING + mgr.should_record = True + mgr.audio_buffer = [] + mgr.silence_timeout = 0.5 + mgr.vad_sensitivity = 3 + mgr._silero_vad = None + mgr._enqueue_audio_segment = lambda buf: None + + levels = [] + mgr._audio_level_callbacks = [levels.append] + + stream = MagicMock() + counter = {"n": 0} + + def _read(*a, **kw): + counter["n"] += 1 + if counter["n"] >= 5: + mgr.should_record = False + return b"\x00" * (1024 * 2) + + stream.read.side_effect = _read + pyaudio_mod, _ = _make_pyaudio_module(stream) + with ( + patch.dict(sys.modules, {"pyaudio": pyaudio_mod, "numpy": np}), + patch( + "vocalinux.speech_recognition.recognition_manager._get_supported_channels", + return_value=1, + ), + patch( + "vocalinux.speech_recognition.recognition_manager._get_supported_sample_rate", + return_value=16000, + ), + ): + mgr._record_audio() + + self.assertGreaterEqual(len(levels), 1) + for v in levels: + self.assertGreaterEqual(v, 0.0) + self.assertLessEqual(v, 100.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_silero_vad.py b/tests/test_silero_vad.py new file mode 100644 index 0000000..8d0380f --- /dev/null +++ b/tests/test_silero_vad.py @@ -0,0 +1,243 @@ +""" +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