Files
voculinux/tests/test_recognition_manager_device_config.py
T
xavierk 0c48db5b5e
CI / security (push) Successful in 1m9s
CI / lint (push) Successful in 23s
CI / ai-review (push) Skipped
fix(void): report pywhispercpp 1.5.0 and load CPU variants at runtime (#49)
Fixes #43
Fixes #44
2026-11-06 18:51:30 +00:00

2785 lines
120 KiB
Python

"""
Extra tests for recognition_manager.py to increase coverage.
Focuses on uncovered lines in:
- _init_vosk() error handling and model path resolution
- _init_whisper() model validation and error paths
- _init_whispercpp() backend detection and GPU fallback
- Download functions with progress tracking and error handling
- Transcription with model lock and error conditions
- start_recognition() and stop_recognition() flows
- Audio device detection and sample rate negotiation
"""
import json
import os
import queue
import struct
import sys
import tempfile
import threading
import time
import unittest
from typing import Any, Optional
from unittest import mock
from unittest.mock import MagicMock, Mock, call, patch
import pytest
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import (
_STICKY_LOCK_MIN_MEAN_SQUARE,
SpeechRecognitionManager,
_downmix_to_mono,
_filter_non_speech,
_get_supported_channels,
_get_supported_sample_rate,
_is_bluetooth_device,
_is_virtual_device,
_open_capture_stream,
_resolve_valid_input_device,
_safe_close_stream,
get_audio_input_devices,
)
from vocalinux.speech_recognition.recognition_manager import (
test_audio_input as _run_test_audio_input,
)
def _make_manager(engine="whisper_cpp", **kw):
"""Helper to create a manager with all init methods patched."""
with patch.object(SpeechRecognitionManager, "_init_vosk"):
with patch.object(SpeechRecognitionManager, "_init_whisper"):
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
return SpeechRecognitionManager(
engine=engine, model_size="small", language="en-us", defer_download=True, **kw
)
class TestAudioDeviceDetection(unittest.TestCase):
"""Test audio device enumeration functions."""
def test_is_virtual_device_filters_pipewire_pseudo_sources(self):
"""PipeWire pseudo sources from issue #624 should not be opened by index."""
unsafe_names = [
"default",
"DeepFilterNet Source",
"paplay",
"pipewire",
"PipeWire filter-chain source",
"Monitor of Built-in Audio Analog Stereo",
"alsa_output.pci-0000_00_1f.3.analog-stereo.monitor",
"Null Sink",
"dummy",
"speech-dispatcher-dummy",
]
for name in unsafe_names:
assert _is_virtual_device(name), name
assert not _is_virtual_device("USB Microphone")
assert not _is_virtual_device("Built-in Audio Analog Stereo")
assert not _is_virtual_device("Built-in Mic (PipeWire)")
assert not _is_virtual_device("")
def test_get_audio_input_devices_filters_unsafe_virtual_sources(self):
"""Device enumeration should hide unsafe virtual sources but keep real mics."""
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 4
mock_audio.get_default_input_device_info.return_value = {"index": 2}
devices = [
{"index": 0, "name": "DeepFilterNet Source", "maxInputChannels": 2},
{"index": 1, "name": "Monitor of Built-in Audio Analog Stereo", "maxInputChannels": 2},
{"index": 2, "name": "USB Microphone", "maxInputChannels": 1},
{"index": 3, "name": "default", "maxInputChannels": 32},
]
mock_audio.get_device_info_by_index.side_effect = lambda i: devices[i]
mock_pyaudio = MagicMock(PyAudio=MagicMock(return_value=mock_audio))
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
assert get_audio_input_devices() == [(2, "USB Microphone", True)]
def test_resolve_valid_input_device_skips_stale_virtual_index(self):
"""A saved virtual source index should fall back to a physical microphone."""
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 3
mock_audio.get_default_input_device_info.return_value = {"index": 1}
devices = [
{"index": 0, "name": "DeepFilterNet Source", "maxInputChannels": 2},
{"index": 1, "name": "default", "maxInputChannels": 32},
{"index": 2, "name": "USB Microphone", "maxInputChannels": 1},
]
mock_audio.get_device_info_by_index.side_effect = lambda i: devices[i]
assert _resolve_valid_input_device(mock_audio, preferred_index=0) == 2
def test_system_default_selection_uses_no_explicit_device_index(self):
"""System Default should still open through PortAudio's default device path."""
manager = _make_manager(
engine="whisper_cpp", audio_device_index=None, audio_device_name=None
)
manager.should_record = True
manager.state = RecognitionState.LISTENING
manager.silence_timeout = 0.05
manager._silero_vad = None
mock_stream = MagicMock()
def _read_once(*_args, **_kwargs):
manager.should_record = False
return b"\x00" * 2048
mock_stream.get_read_available.return_value = 1024
mock_stream.read.side_effect = _read_once
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 1
mock_audio.get_device_info_by_index.return_value = {
"index": 0,
"name": "default",
"maxInputChannels": 32,
}
mock_audio.get_default_input_device_info.return_value = {"index": 0, "name": "default"}
mock_pyaudio = MagicMock(paInt16=8)
mock_pyaudio.PyAudio.return_value = mock_audio
with (
patch.dict("sys.modules", {"pyaudio": mock_pyaudio}),
patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, mock_stream),
) as mock_open,
patch(
"vocalinux.speech_recognition.recognition_manager.play_error_sound",
),
):
if isinstance(sys.modules.get("numpy"), MagicMock):
del sys.modules["numpy"]
manager._record_audio()
mock_open.assert_called_once_with(mock_audio, None)
def test_record_audio_refuses_system_default_for_unusable_configured_device(self):
"""An unusable configured mic must not reopen the system default (issue #42)."""
manager = _make_manager(
engine="whisper_cpp",
audio_device_index=0,
audio_device_name="DeepFilterNet Source",
)
manager.should_record = True
manager.state = RecognitionState.LISTENING
manager.silence_timeout = 0.05
manager._silero_vad = None
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 1
mock_audio.get_device_info_by_index.return_value = {
"index": 0,
"name": "DeepFilterNet Source",
"maxInputChannels": 2,
}
mock_audio.get_default_input_device_info.return_value = {
"index": 0,
"name": "default",
}
mock_pyaudio = MagicMock(paInt16=8)
mock_pyaudio.PyAudio.return_value = mock_audio
with (
patch.dict("sys.modules", {"pyaudio": mock_pyaudio}),
patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, MagicMock()),
) as mock_open,
patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"),
):
if isinstance(sys.modules.get("numpy"), MagicMock):
del sys.modules["numpy"]
manager._record_audio()
mock_open.assert_not_called()
mock_audio.open.assert_not_called()
assert manager._buffered_capture_failed
def test_test_audio_input_system_default_fallback_open_omits_device_index(self):
"""Fallback mic-test open for System Default must omit input_device_index."""
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\x01" * 1024
mock_audio = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_default_input_device_info.return_value = {
"index": 7,
"name": "default",
"defaultSampleRate": 48000,
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock(paInt16=8)
mock_pyaudio.PyAudio.return_value = mock_audio
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
if isinstance(sys.modules.get("numpy"), MagicMock):
del sys.modules["numpy"]
with patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 48000, None),
):
result = _run_test_audio_input(device_index=None, duration=0.1)
assert result["success"] is True
kwargs = mock_audio.open.call_args.kwargs
assert "input_device_index" not in kwargs
def test_settings_persist_raw_audio_device_name(self):
"""Settings should persist raw PortAudio names, not UI-only suffixes."""
from vocalinux.ui.settings_dialog import (
_raw_audio_device_name,
_resolve_audio_device_selection,
)
assert _raw_audio_device_name("USB Microphone (default)") == "USB Microphone"
assert _raw_audio_device_name("USB Microphone") == "USB Microphone"
assert _raw_audio_device_name(None) is None
devices = [(3, "USB Microphone", True), (5, "Webcam Mic", False)]
# Legacy configs may still store the UI-only "(default)" suffix.
assert _resolve_audio_device_selection(devices, 3, "USB Microphone (default)") == 3
assert _resolve_audio_device_selection(devices, 99, "Webcam Mic") == 5
# Filtered-out / missing devices resolve to System Default.
assert _resolve_audio_device_selection(devices, 1, "DeepFilterNet") is None
assert _resolve_audio_device_selection(devices, 1, None) is None
def test_is_bluetooth_device_detection(self):
"""Bluetooth headset/mic names should be recognized (Issue #567)."""
assert _is_bluetooth_device("Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3")
assert _is_bluetooth_device("bluez_source.XX_XX_XX")
assert _is_bluetooth_device("Hands-Free AG Speech")
assert not _is_bluetooth_device("WH-1000XM5 Headset") # no bluetooth marker
assert not _is_bluetooth_device("USB Audio Device")
assert not _is_bluetooth_device(None)
assert not _is_bluetooth_device("")
def test_safe_close_stream_stops_before_close(self):
"""PortAudio streams must be stopped before close to avoid heap corruption."""
mock_stream = MagicMock()
_safe_close_stream(mock_stream)
mock_stream.stop_stream.assert_called_once()
mock_stream.close.assert_called_once()
# stop must happen before close
assert mock_stream.mock_calls.index(mock.call.stop_stream()) < mock_stream.mock_calls.index(
mock.call.close()
)
def test_safe_close_stream_tolerates_errors(self):
"""Cleanup must not raise even if stop/close fail."""
mock_stream = MagicMock()
mock_stream.stop_stream.side_effect = OSError("already stopped")
mock_stream.close.side_effect = OSError("already closed")
_safe_close_stream(mock_stream) # should not raise
_safe_close_stream(None) # should not raise
def test_open_capture_stream_returns_live_stream(self):
"""Negotiation must return the opened stream, never close-and-reopen."""
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_device_info_by_index.return_value = {
"name": "USB Mic",
"defaultSampleRate": 48000,
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 0)
assert channels == 1
assert rate == 48000
assert stream is mock_stream
assert mock_audio.open.call_count == 1
# The negotiated stream is handed to the caller live.
mock_stream.stop_stream.assert_not_called()
mock_stream.close.assert_not_called()
def test_open_capture_stream_bluetooth_single_open(self):
"""Bluetooth SCO capture must be opened exactly once (Issue #567).
The previous channels-then-rate-then-real-open flow opened the SCO
capture stream three times in under a second, which aborts with
malloc heap corruption on devices like the HUAWEI FreeBuds Pro 3.
"""
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_device_info_by_index.return_value = {
"name": "Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3",
"defaultSampleRate": 16000,
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 14)
assert channels == 1
assert rate == 16000
assert stream is mock_stream
assert mock_audio.open.call_count == 1
def test_open_capture_stream_stereo_device_prefers_native_channels(self):
"""Stereo devices must be opened 2ch-first, not converted mono (#666).
Intel SOF digital mics report maxInputChannels=2. Opening them as
1ch can succeed then abort in PortAudio's callback with
``free(): corrupted unsorted chunks``.
"""
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_device_info_by_index.return_value = {
"name": "Raptor Lake-P/U/H cAVS Digital Microphone",
"defaultSampleRate": 48000,
"maxInputChannels": 2,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 18)
assert channels == 2
assert rate == 48000
assert stream is mock_stream
assert mock_audio.open.call_count == 1
assert mock_audio.open.call_args.kwargs.get("channels") == 2
def test_open_capture_stream_stereo_device_falls_back_to_mono(self):
"""If native stereo open fails, still try mono on a 2ch-reporting device."""
mock_audio = MagicMock()
mock_stream = MagicMock()
def open_side_effect(**kwargs):
if kwargs.get("channels") == 2:
raise IOError("[Errno -9998] Invalid number of channels")
return mock_stream
mock_audio.open.side_effect = open_side_effect
mock_audio.get_device_info_by_index.return_value = {
"name": "USB Microphone",
"defaultSampleRate": 48000,
"maxInputChannels": 2,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 0)
assert channels == 1
assert rate == 48000
assert stream is mock_stream
assert mock_audio.open.call_args.kwargs.get("channels") == 1
assert any(call.kwargs.get("channels") == 2 for call in mock_audio.open.call_args_list)
def test_open_capture_stream_hda_4ch_prefers_native_channels(self):
"""HDA mics reporting 4 capture channels must open 4ch-first (#813).
Built-in analog HDA devices often expose 4ch (front/rear). Opening
at 2ch can succeed then abort in PortAudio CleanUpStream with
``free(): corrupted unsorted chunks``.
"""
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_device_info_by_index.return_value = {
"name": "HDA Intel PCH Analog",
"defaultSampleRate": 48000,
"maxInputChannels": 4,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 0)
assert channels == 4
assert rate == 48000
assert stream is mock_stream
assert mock_audio.open.call_count == 1
assert mock_audio.open.call_args.kwargs.get("channels") == 4
def test_open_capture_stream_pulse_32ch_opens_stereo_never_native(self):
"""Pulse devices reporting 32 channels must try 2ch first, never 32."""
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_device_info_by_index.return_value = {
"name": "pulse",
"defaultSampleRate": 48000,
"maxInputChannels": 32,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 0)
assert channels == 2
assert rate == 48000
assert stream is mock_stream
assert mock_audio.open.call_args.kwargs.get("channels") == 2
assert all(call.kwargs.get("channels") != 32 for call in mock_audio.open.call_args_list)
def test_open_capture_stream_hda_4ch_falls_back_to_stereo(self):
"""If native 4ch open fails, still try 2ch on a 4ch-reporting device."""
mock_audio = MagicMock()
mock_stream = MagicMock()
def open_side_effect(**kwargs):
if kwargs.get("channels") == 4:
raise IOError("[Errno -9998] Invalid number of channels")
return mock_stream
mock_audio.open.side_effect = open_side_effect
mock_audio.get_device_info_by_index.return_value = {
"name": "HDA Intel PCH Analog",
"defaultSampleRate": 48000,
"maxInputChannels": 4,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 0)
assert channels == 2
assert rate == 48000
assert stream is mock_stream
assert mock_audio.open.call_args.kwargs.get("channels") == 2
assert any(call.kwargs.get("channels") == 4 for call in mock_audio.open.call_args_list)
def test_open_capture_stream_mono_device_never_probes_stereo(self):
"""Mono-only devices must never be opened with 2 channels.
Opening with more channels than the device supports is itself a
PortAudio/ALSA heap-corruption trigger.
"""
mock_audio = MagicMock()
mock_audio.open.side_effect = IOError("cannot open")
mock_audio.get_device_info_by_index.return_value = {
"name": "Bluetooth internal capture stream",
"defaultSampleRate": 16000,
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
with patch("vocalinux.speech_recognition.recognition_manager.time.sleep"):
channels, rate, stream = _open_capture_stream(mock_audio, 14)
assert stream is None
assert (channels, rate) == (1, 16000)
assert all(call.kwargs.get("channels") == 1 for call in mock_audio.open.call_args_list)
def test_open_capture_stream_failure_returns_none_stream(self):
"""Total negotiation failure returns (1, 16000, None) for caller fallback."""
mock_audio = MagicMock()
mock_audio.open.side_effect = IOError("no device")
mock_audio.get_device_info_by_index.side_effect = IOError("gone")
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 3)
assert (channels, rate, stream) == (1, 16000, None)
def test_test_audio_input_single_portaudio_open(self):
"""The mic test must perform exactly ONE PortAudio open (Issue #567)."""
from vocalinux.speech_recognition.recognition_manager import test_audio_input
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\x01" * 1024
mock_audio = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_device_info_by_index.return_value = {
"index": 14,
"name": "Bluetooth internal capture stream for HUAWEI FreeBuds Pro 3",
"defaultSampleRate": 16000,
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock(paInt16=8)
mock_pyaudio.PyAudio.return_value = mock_audio
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
# Other test modules poison sys.modules["numpy"] with a MagicMock
# at import time; drop it so test_audio_input gets real numpy.
# patch.dict restores the original sys.modules afterwards.
if isinstance(sys.modules.get("numpy"), MagicMock):
del sys.modules["numpy"]
result = test_audio_input(device_index=14, duration=0.1)
assert result["success"] is True
assert mock_audio.open.call_count == 1
# The stream that was read from is the negotiated stream itself.
assert mock_stream.read.called
mock_stream.stop_stream.assert_called_once()
mock_stream.close.assert_called_once()
def test_test_audio_input_system_default_keeps_null_device_index(self):
"""System Default must open PortAudio without an explicit device index."""
from vocalinux.speech_recognition.recognition_manager import test_audio_input
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\x01" * 1024
mock_audio = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_default_input_device_info.return_value = {
"index": 7,
"name": "default",
"defaultSampleRate": 48000,
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock(paInt16=8)
mock_pyaudio.PyAudio.return_value = mock_audio
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
if isinstance(sys.modules.get("numpy"), MagicMock):
del sys.modules["numpy"]
with patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 48000, mock_stream),
) as mock_open:
result = test_audio_input(device_index=None, duration=0.1)
assert result["success"] is True
mock_open.assert_called_once_with(mock_audio, None)
# Display metadata can still report the host default index/name.
assert result["device_index"] == 7
assert result["device_name"] == "default"
def test_test_audio_input_negotiation_fallback_open(self):
"""When negotiation returns no stream, mic test falls back to a plain open."""
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\x01" * 1024
mock_audio = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_device_info_by_index.return_value = {
"index": 14,
"name": "Bluetooth internal capture stream",
"defaultSampleRate": 16000,
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock(paInt16=8)
mock_pyaudio.PyAudio.return_value = mock_audio
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
if isinstance(sys.modules.get("numpy"), MagicMock):
del sys.modules["numpy"]
with patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, None),
):
result = _run_test_audio_input(device_index=14, duration=0.1)
assert result["success"] is True
mock_audio.open.assert_called_once()
def test_test_audio_input_negotiation_and_fallback_both_fail(self):
"""When negotiation and fallback open both fail, return a clear error."""
mock_audio = MagicMock()
mock_audio.open.side_effect = IOError("device busy")
mock_audio.get_device_info_by_index.return_value = {
"index": 14,
"name": "Bluetooth internal capture stream",
"defaultSampleRate": 16000,
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock(paInt16=8)
mock_pyaudio.PyAudio.return_value = mock_audio
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
with patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, None),
):
result = _run_test_audio_input(device_index=14, duration=0.1)
assert result["success"] is False
assert "Cannot open audio stream" in result["error"]
def test_get_supported_sample_rate_bluetooth_settle_on_failure(self):
"""Bluetooth devices should pause between failed sample-rate probes."""
mock_audio = MagicMock()
mock_audio.open.side_effect = IOError("busy")
mock_audio.get_device_info_by_index.return_value = {
"name": "Bluetooth internal capture stream",
"defaultSampleRate": 48000,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
with patch("vocalinux.speech_recognition.recognition_manager.time.sleep") as mock_sleep:
rate = _get_supported_sample_rate(mock_audio, 0, channels=1)
assert rate == 16000
assert mock_sleep.called
def test_open_capture_stream_logs_channel_rejection(self):
"""Invalid-channel errors during negotiation should be logged distinctly."""
mock_audio = MagicMock()
mock_audio.open.side_effect = IOError("[Errno -9998] Invalid number of channels")
mock_audio.get_device_info_by_index.return_value = {
"name": "USB Mic",
"defaultSampleRate": 48000,
"maxInputChannels": 2,
}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels, rate, stream = _open_capture_stream(mock_audio, 0)
assert (channels, rate, stream) == (1, 16000, None)
assert mock_audio.open.call_count >= 1
def test_get_supported_channels_mono_success(self):
"""Test mono channel support detection."""
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels = _get_supported_channels(mock_audio, 0)
assert channels == 1
def test_get_supported_channels_stereo_fallback(self):
"""Test fallback to stereo when mono fails."""
mock_audio = MagicMock()
mock_stream = MagicMock()
# First call (mono) fails, second (stereo) succeeds
def open_side_effect(**kwargs):
if kwargs.get("channels") == 1:
raise IOError("invalid number of channels")
return mock_stream
mock_audio.open.side_effect = open_side_effect
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels = _get_supported_channels(mock_audio, 0)
assert channels == 2
def test_get_supported_channels_all_fail(self):
"""Test fallback to mono when all channels fail."""
mock_audio = MagicMock()
mock_audio.open.side_effect = IOError("unsupported operation")
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels = _get_supported_channels(mock_audio, None)
assert channels == 1
def test_get_supported_channels_48khz_only_device(self):
"""Test channel detection on 48kHz-only pro audio devices (Issue #340).
Professional audio interfaces (MUPRO, Vocaster, etc.) only support 48kHz
and reject 16kHz probes with misleading "Invalid number of channels" error.
The fix should use the device's defaultSampleRate for channel probing.
"""
mock_audio = MagicMock()
mock_stream = MagicMock()
def open_side_effect(**kwargs):
rate = kwargs.get("rate")
channels = kwargs.get("channels")
# Simulate 48kHz-only device: 16kHz fails, 48kHz succeeds
if rate == 16000:
raise IOError("[Errno -9998] Invalid number of channels")
elif rate == 48000 and channels == 1:
return mock_stream
else:
raise IOError("Unsupported configuration")
mock_audio.open.side_effect = open_side_effect
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels = _get_supported_channels(mock_audio, 0)
assert channels == 1
# Verify that it tried using the device's default rate (48000)
assert any(
call[1].get("rate") == 48000 for call in mock_audio.open.call_args_list
), "Should probe using device's defaultSampleRate (48000)"
def test_get_supported_channels_default_rate_fails_fallback(self):
"""Test fallback when device's default rate fails during channel probing."""
mock_audio = MagicMock()
mock_stream = MagicMock()
def open_side_effect(**kwargs):
rate = kwargs.get("rate")
channels = kwargs.get("channels")
# Default rate (48000) fails, but 44100 works
if rate == 48000:
raise IOError("Device busy")
elif rate == 44100 and channels == 1:
return mock_stream
else:
raise IOError("Unsupported")
mock_audio.open.side_effect = open_side_effect
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels = _get_supported_channels(mock_audio, 0)
assert channels == 1
def test_get_supported_channels_no_device_info(self):
"""Test channel probing when device info is unavailable."""
mock_audio = MagicMock()
mock_stream = MagicMock()
def open_side_effect(**kwargs):
# Works with 44100Hz mono
if kwargs.get("rate") == 44100 and kwargs.get("channels") == 1:
return mock_stream
raise IOError("Unsupported")
mock_audio.open.side_effect = open_side_effect
mock_audio.get_device_info_by_index.side_effect = IOError("Device not found")
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
channels = _get_supported_channels(mock_audio, 0)
assert channels == 1 # Should fallback through common rates and find 44100
def test_get_supported_sample_rate_default_rate_works(self):
"""Test using device's default sample rate."""
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
rate = _get_supported_sample_rate(mock_audio, 0, 1)
assert rate == 48000
def test_get_supported_sample_rate_default_rate_fails(self):
"""Test fallback from device default rate."""
mock_audio = MagicMock()
mock_stream = MagicMock()
def open_side_effect(**kwargs):
if kwargs.get("rate") == 48000:
raise IOError("unsupported rate")
return mock_stream
mock_audio.open.side_effect = open_side_effect
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
rate = _get_supported_sample_rate(mock_audio, 0, 1)
assert rate == 44100 # First fallback rate
def test_get_supported_sample_rate_all_fail(self):
"""Test fallback to default rate when all fail."""
mock_audio = MagicMock()
mock_audio.open.side_effect = IOError("all fail")
mock_audio.get_device_info_by_index.side_effect = IOError("no device info")
mock_pyaudio = MagicMock(paInt16=8)
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
rate = _get_supported_sample_rate(mock_audio, None, 1)
assert rate == 16000 # Default fallback
class TestDownmixToMono(unittest.TestCase):
"""Energy-aware loudest-channel downmix policy for HDA capture (#813 / PR #829)."""
@classmethod
def setUpClass(cls):
# Other test modules may have left sys.modules["numpy"] as MagicMock.
if isinstance(sys.modules.get("numpy"), MagicMock):
real = getattr(sys, "_voculinux_real_numpy", None)
if real is not None:
sys.modules["numpy"] = real
else:
del sys.modules["numpy"]
import numpy as np
cls.np = np
def test_mono_passthrough_unchanged(self):
np = self.np
audio = np.array([100, 200, 300], dtype=np.int16)
out, sticky = _downmix_to_mono(audio, 1)
np.testing.assert_array_equal(out, audio)
assert out is audio
assert sticky is None
def test_stereo_mean_of_both_channels(self):
np = self.np
audio = np.array([100, 200], dtype=np.int16)
out, sticky = _downmix_to_mono(audio, 2)
np.testing.assert_array_equal(out, np.array([150], dtype=np.int16))
assert sticky is None
def test_quad_loudest_channel_does_not_halve_speech_level(self):
"""4ch (speech, speech, silence, silence) keeps the loudest channel at full level."""
np = self.np
audio = np.array([1000, 1000, 0, 0], dtype=np.int16)
out, sticky = _downmix_to_mono(audio, 4)
np.testing.assert_array_equal(out, np.array([1000], dtype=np.int16))
# Averaging all 4 channels would have produced 500.
assert out[0] != 500
assert sticky == 0
def test_quad_recovers_speech_on_channel_2(self):
"""4ch (silence, silence, speech, silence) selects the loud rear channel."""
np = self.np
audio = np.array([0, 0, 1000, 0], dtype=np.int16)
out, sticky = _downmix_to_mono(audio, 4)
np.testing.assert_array_equal(out, np.array([1000], dtype=np.int16))
assert sticky == 2
def test_quad_recovers_speech_on_channel_3(self):
"""4ch (silence, silence, silence, speech) selects the loud rear channel."""
np = self.np
audio = np.array([0, 0, 0, 1000], dtype=np.int16)
out, sticky = _downmix_to_mono(audio, 4)
np.testing.assert_array_equal(out, np.array([1000], dtype=np.int16))
assert sticky == 3
def test_quad_leftover_truncation(self):
"""Incomplete trailing frame is dropped using full N-channel width, then loudest channel."""
np = self.np
audio = np.array([1000, 1000, 0, 0, 2000, 2000, 0, 0, 99], dtype=np.int16)
out, sticky = _downmix_to_mono(audio, 4)
np.testing.assert_array_equal(out, np.array([1000, 2000], dtype=np.int16))
assert sticky == 0
def test_quad_leftover_truncation_then_loudest_rear_channel(self):
"""Truncate leftover at full N-channel width, then pick speech on ch2 (not first pair)."""
np = self.np
audio = np.array([0, 0, 1000, 0, 0, 0, 2000, 0, 99], dtype=np.int16)
out, sticky = _downmix_to_mono(audio, 4)
np.testing.assert_array_equal(out, np.array([1000, 2000], dtype=np.int16))
assert sticky == 2
def test_quad_sticky_channel_ignores_later_noise_burst(self):
"""First buffer speech on ch2 sticks; later louder noise on ch0 still returns ch2."""
np = self.np
first = np.array([0, 0, 1000, 0], dtype=np.int16)
out1, sticky = _downmix_to_mono(first, 4)
np.testing.assert_array_equal(out1, np.array([1000], dtype=np.int16))
assert sticky == 2
# Noise burst on ch0 is louder than sticky mic speech on ch2.
second = np.array([5000, 0, 800, 0], dtype=np.int16)
out2, sticky2 = _downmix_to_mono(second, 4, sticky)
np.testing.assert_array_equal(out2, np.array([800], dtype=np.int16))
assert sticky2 == 2
def test_quad_front_pair_full_level_with_sticky(self):
"""Front-pair speech still comes through at full level once sticky is set."""
np = self.np
first = np.array([1200, 1100, 0, 0], dtype=np.int16)
out1, sticky = _downmix_to_mono(first, 4)
np.testing.assert_array_equal(out1, np.array([1200], dtype=np.int16))
assert sticky == 0
second = np.array([900, 850, 0, 0], dtype=np.int16)
out2, sticky2 = _downmix_to_mono(second, 4, sticky)
np.testing.assert_array_equal(out2, np.array([900], dtype=np.int16))
assert sticky2 == 0
def test_quad_silent_first_buffer_does_not_lock_sticky(self):
"""Idle/ambient first buffer must not permanently pin ch0 before speech."""
np = self.np
# ch0 has tiny ambient (mean-square << lock floor); others silent.
ambient = 20 # MS = 400 << _STICKY_LOCK_MIN_MEAN_SQUARE (10000)
assert ambient * ambient < _STICKY_LOCK_MIN_MEAN_SQUARE
first = np.array([ambient, 0, 0, 0], dtype=np.int16)
out1, sticky = _downmix_to_mono(first, 4)
np.testing.assert_array_equal(out1, np.array([ambient], dtype=np.int16))
assert sticky is None
# Second buffer: real speech on ch2 — lock there, not ch0.
second = np.array([0, 0, 1000, 0], dtype=np.int16)
out2, sticky2 = _downmix_to_mono(second, 4, sticky)
np.testing.assert_array_equal(out2, np.array([1000], dtype=np.int16))
assert sticky2 == 2
def test_quad_near_silence_noise_then_speech_locks_speech_channel(self):
"""Noise-floor ch0 then speech on ch2: return speech and pin ch2."""
np = self.np
# Multi-frame idle: faint noise on ch0 only.
idle = np.array(
[30, 0, 0, 0, 40, 0, 0, 0, 25, 0, 0, 0],
dtype=np.int16,
)
out1, sticky = _downmix_to_mono(idle, 4)
assert sticky is None
assert out1.tolist() == [30, 40, 25]
speech = np.array([10, 0, 2000, 0, 5, 0, 1800, 0], dtype=np.int16)
out2, sticky2 = _downmix_to_mono(speech, 4, sticky)
np.testing.assert_array_equal(out2, np.array([2000, 1800], dtype=np.int16))
assert sticky2 == 2
# Once locked, later louder noise on another channel stays ignored.
burst = np.array([8000, 0, 900, 0], dtype=np.int16)
out3, sticky3 = _downmix_to_mono(burst, 4, sticky2)
np.testing.assert_array_equal(out3, np.array([900], dtype=np.int16))
assert sticky3 == 2
class TestExplicitMicrophoneSelection(unittest.TestCase):
"""A configured microphone is recorded exactly, or not at all (issue #42)."""
def _audio(self, devices):
audio = MagicMock()
audio.get_device_count.return_value = len(devices)
audio.get_device_info_by_index.side_effect = lambda i: devices[i]
audio.get_default_input_device_info.return_value = {"index": 2, "name": "Built-in Mic"}
return audio
def _pyaudio_module(self):
module = MagicMock(paInt16=8)
module.PyAudio.return_value = MagicMock()
return module
def _devices(self):
return [
{"index": 0, "name": "pipewire", "maxInputChannels": 32},
{"index": 1, "name": "USB Microphone", "maxInputChannels": 1},
{"index": 2, "name": "Built-in Mic", "maxInputChannels": 2},
]
def test_virtual_configured_device_is_refused_not_replaced(self):
"""A configured virtual device must not open the hardware default instead."""
from vocalinux.audio.capture import PortAudioCaptureSource
from vocalinux.audio.errors import AudioDeviceUnavailableError
audio = self._audio(self._devices())
source = PortAudioCaptureSource(device_index=0, device_name="pipewire")
with (
patch.dict("sys.modules", {"pyaudio": self._pyaudio_module()}),
patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, MagicMock()),
) as opener,
):
with self.assertRaises(AudioDeviceUnavailableError) as ctx:
source.open(audio)
opener.assert_not_called()
audio.open.assert_not_called()
assert "'pipewire'" in str(ctx.exception)
def test_configured_device_that_disappeared_is_not_switched(self):
"""A missing configured mic must not reuse the stored index for another input."""
from vocalinux.audio.capture import PortAudioCaptureSource
from vocalinux.audio.errors import AudioDeviceUnavailableError
devices = self._devices()
devices[1] = {"index": 1, "name": "Built-in Mic", "maxInputChannels": 2}
devices[2] = {"index": 2, "name": "Other Mic", "maxInputChannels": 1}
audio = self._audio(devices)
source = PortAudioCaptureSource(device_index=1, device_name="USB Microphone")
with (
patch.dict("sys.modules", {"pyaudio": self._pyaudio_module()}),
patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, MagicMock()),
) as opener,
):
with self.assertRaises(AudioDeviceUnavailableError):
source.open(audio)
opener.assert_not_called()
def test_configured_usable_device_opens_by_name(self):
"""A configured microphone that is present still opens at its index."""
from vocalinux.audio.capture import PortAudioCaptureSource
audio = self._audio(self._devices())
source = PortAudioCaptureSource(device_index=1, device_name="USB Microphone")
with (
patch.dict("sys.modules", {"pyaudio": self._pyaudio_module()}),
patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, MagicMock()),
) as opener,
):
source.open(audio)
opener.assert_called_once_with(audio, 1)
def test_system_default_sentinel_still_opens_the_default_device(self):
"""A stored -1 is PortAudio's System Default, not a device to look up."""
from vocalinux.audio.capture import PortAudioCaptureSource
audio = self._audio(self._devices())
source = PortAudioCaptureSource(device_index=-1, device_name=None)
with (
patch.dict("sys.modules", {"pyaudio": self._pyaudio_module()}),
patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, MagicMock()),
) as opener,
):
source.open(audio)
opener.assert_called_once_with(audio, None)
def test_reopen_refuses_a_virtual_configured_device(self):
"""Reconnection reports the refusal through last_error and opens nothing."""
from vocalinux.audio.capture import PortAudioCaptureSource
from vocalinux.audio.errors import AudioDeviceUnavailableError
audio = self._audio(self._devices())
source = PortAudioCaptureSource(device_index=0, device_name="pipewire")
with (
patch.dict("sys.modules", {"pyaudio": self._pyaudio_module()}),
patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, MagicMock()),
) as opener,
):
assert source.reopen(audio) is False
opener.assert_not_called()
assert isinstance(source.last_error, AudioDeviceUnavailableError)
class TestRecordAudioNegotiationFallback(unittest.TestCase):
"""Cover _record_audio fallback when stream negotiation returns None."""
def test_record_audio_falls_back_when_negotiation_returns_none(self):
"""Negotiation failure must fall back to a plain PortAudio open."""
manager = _make_manager(engine="whisper_cpp")
manager.should_record = True
manager.state = RecognitionState.LISTENING
manager.silence_timeout = 0.05
manager._silero_vad = None
mock_stream = MagicMock()
def _read_once(*_args, **_kwargs):
manager.should_record = False
return b"\x00" * 2048
mock_stream.get_read_available.return_value = 1024
mock_stream.read.side_effect = _read_once
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 1
mock_audio.get_device_info_by_index.return_value = {
"index": 0,
"name": "test mic",
"maxInputChannels": 1,
}
mock_audio.get_default_input_device_info.return_value = {"index": 0}
mock_audio.open.return_value = mock_stream
mock_pyaudio = MagicMock(paInt16=8)
mock_pyaudio.PyAudio.return_value = mock_audio
with (
patch.dict("sys.modules", {"pyaudio": mock_pyaudio}),
patch(
"vocalinux.audio.capture._resolve_device_by_name",
return_value=0,
),
patch(
"vocalinux.audio.capture._open_capture_stream",
return_value=(1, 16000, None),
),
patch(
"vocalinux.speech_recognition.recognition_manager.play_error_sound",
),
):
if isinstance(sys.modules.get("numpy"), MagicMock):
del sys.modules["numpy"]
manager._record_audio()
mock_audio.open.assert_called_once()
class TestFilterNonSpeech(unittest.TestCase):
"""Test the _filter_non_speech function."""
def test_filter_non_speech_empty(self):
"""Test filtering empty string."""
result = _filter_non_speech("")
assert result == ""
def test_filter_non_speech_normal(self):
"""Test filtering normal text."""
result = _filter_non_speech("hello world")
assert result == "hello world"
def test_filter_non_speech_with_special_tokens(self):
"""Test filtering text with special tokens."""
result = _filter_non_speech("[BLANK_AUDIO]")
assert result == ""
def test_filter_non_speech_mixed(self):
"""Test filtering mixed content."""
result = _filter_non_speech("hello [BLANK_AUDIO] world")
assert "hello" in result or result == ""
class TestVoskInitialization(unittest.TestCase):
"""Test VOSK engine initialization."""
def test_init_vosk_model_not_found_deferred(self):
"""Test VOSK initialization with deferred download."""
manager = _make_manager(engine="vosk")
manager.language = "en-us"
manager._defer_download = True
manager.model_size = "small"
mock_vosk = MagicMock()
with patch(
"vocalinux.speech_recognition.recognition_manager.VOSK_MODEL_INFO",
{
"small": {"languages": {"en-us": "model-name"}},
"medium": {"languages": {"en-us": "model-name"}},
"large": {"languages": {"en-us": "model-name"}},
},
):
with patch("os.path.exists", return_value=False):
with patch.object(manager, "_get_vosk_model_path", return_value="/fake/path"):
with patch.dict("sys.modules", {"vosk": mock_vosk}):
manager._init_vosk()
assert manager._model_initialized is False
def test_init_vosk_import_error(self):
"""Test VOSK initialization when import fails."""
manager = _make_manager(engine="vosk")
manager.language = "en-us"
manager.model_size = "small"
with patch(
"vocalinux.speech_recognition.recognition_manager.VOSK_MODEL_INFO",
{
"small": {"languages": {"en-us": "model-name"}},
"medium": {"languages": {"en-us": "model-name"}},
"large": {"languages": {"en-us": "model-name"}},
},
):
with patch.object(manager, "_get_vosk_model_path", return_value="/fake/path"):
with patch("os.path.exists", return_value=False):
with patch.dict("sys.modules", {"vosk": None}):
with pytest.raises(ImportError):
manager._init_vosk()
assert manager.state == RecognitionState.ERROR
def test_init_vosk_preinstalled_model(self):
"""Test VOSK initialization with pre-installed model."""
manager = _make_manager(engine="vosk")
manager.language = "en-us"
manager.model_size = "small"
mock_vosk = MagicMock()
mock_model = MagicMock()
mock_recognizer = MagicMock()
mock_vosk.Model = MagicMock(return_value=mock_model)
mock_vosk.KaldiRecognizer = MagicMock(return_value=mock_recognizer)
with patch(
"vocalinux.speech_recognition.recognition_manager.VOSK_MODEL_INFO",
{
"small": {"languages": {"en-us": "model-name"}},
"medium": {"languages": {"en-us": "model-name"}},
"large": {"languages": {"en-us": "model-name"}},
},
):
with patch(
"vocalinux.speech_recognition.recognition_manager.SYSTEM_MODELS_DIRS",
["/usr/share/voculinux"],
):
with patch.object(
manager, "_get_vosk_model_path", return_value="/usr/share/voculinux/model"
):
with patch("os.path.exists", return_value=True):
with patch.dict("sys.modules", {"vosk": mock_vosk}):
manager._init_vosk()
assert manager._model_initialized is True
assert manager.model == mock_model
class TestWhisperInitialization(unittest.TestCase):
"""Test Whisper engine initialization."""
def test_init_whisper_invalid_model_size(self):
"""Test Whisper with invalid model size."""
manager = _make_manager(engine="whisper")
manager.model_size = "invalid"
manager._defer_download = True
mock_whisper = MagicMock()
mock_torch = MagicMock()
with patch.dict("sys.modules", {"whisper": mock_whisper, "torch": mock_torch}):
with patch("os.path.exists", return_value=False):
manager._init_whisper()
assert manager.model_size == "base" # Should be corrected
assert manager._model_initialized is False
def test_init_whisper_model_exists(self):
"""Test Whisper when model already exists."""
manager = _make_manager(engine="whisper")
manager.model_size = "tiny"
mock_whisper = MagicMock()
mock_torch = MagicMock()
mock_model = MagicMock()
mock_whisper.load_model.return_value = mock_model
mock_torch.cuda.is_available.return_value = False
with patch.dict("sys.modules", {"whisper": mock_whisper, "torch": mock_torch}):
with patch("os.path.exists", return_value=True):
manager._init_whisper()
assert manager._model_initialized is True
assert manager.model == mock_model
def test_init_whisper_import_error(self):
"""Test Whisper initialization when import fails."""
manager = _make_manager(engine="whisper")
with patch.dict("sys.modules", {"whisper": None, "torch": None}):
with pytest.raises(ImportError):
manager._init_whisper()
assert manager.state == RecognitionState.ERROR
def test_init_whisper_runtime_error(self):
"""Test Whisper initialization with runtime error."""
manager = _make_manager(engine="whisper")
manager._defer_download = False
mock_whisper = MagicMock()
mock_torch = MagicMock()
with patch.dict("sys.modules", {"whisper": mock_whisper, "torch": mock_torch}):
with patch("os.path.exists", return_value=False):
with patch.object(
manager, "_download_whisper_model", side_effect=RuntimeError("Download failed")
):
with pytest.raises(RuntimeError):
manager._init_whisper()
class TestWhispercppInitialization(unittest.TestCase):
"""Test whisper.cpp engine initialization."""
def setUp(self):
# These cover device/backend selection, not integrity. _init_whispercpp
# now hashes an existing model before handing it to ctypes, and the model
# here is a mocked path with no bytes behind it. Verification itself is
# covered by tests/test_model_checksums.py.
patcher = patch.object(
SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True
)
self.addCleanup(patcher.stop)
patcher.start()
def test_init_whispercpp_invalid_model_size(self):
"""Test whisper.cpp with invalid model size."""
manager = _make_manager(engine="whisper_cpp")
manager.model_size = "invalid"
manager._defer_download = True
mock_pywhispercpp = MagicMock()
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO",
{"tiny": {}, "base": {}},
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/path",
):
with patch("os.path.exists", return_value=False):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
},
):
manager._init_whispercpp()
assert manager.model_size == "tiny" # Should be corrected
assert manager._model_initialized is False
def test_init_whispercpp_gpu_fallback(self) -> None:
"""Test whisper.cpp GPU fallback to CPU."""
import vocalinux.utils.whispercpp_model_info as whispercpp_info
for thread_override, expected_threads in ((None, (2, 6)), (3, (3, 3))):
with self.subTest(thread_override=thread_override):
manager = _make_manager(
engine="whisper_cpp",
whispercpp_n_threads=thread_override,
)
manager.model_size = "tiny"
manager.whispercpp_gpu_device = 0
mock_pywhispercpp = MagicMock()
model_class = MagicMock(
side_effect=[
RuntimeError("16-bit storage not supported"),
MagicMock(),
]
)
mock_pywhispercpp.Model = model_class
mock_pywhispercpp.model.Model = model_class
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO",
{"tiny": {}},
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.getsize", return_value=100000000):
with patch("os.path.exists", return_value=True):
with patch.object(
manager,
"_whispercpp_model_is_verified",
return_value=True,
):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
"psutil": mock_psutil,
},
):
with patch.object(
whispercpp_info,
"detect_compute_backend",
return_value=(MagicMock(), "test"),
):
with patch.object(
whispercpp_info,
"detect_cpu_info",
return_value="test CPU",
):
with patch.object(
whispercpp_info,
"detect_vulkan_devices",
return_value=[{"index": 0, "name": "test GPU"}],
):
with patch.object(
whispercpp_info,
"get_backend_display_name",
return_value="Vulkan",
):
with patch.object(
manager,
"_detect_pywhispercpp_gpu_backend",
return_value="vulkan",
):
with patch(
"vocalinux.speech_recognition.recognition_manager.multiprocessing.cpu_count",
return_value=8,
):
with patch(
"vocalinux.speech_recognition.recognition_manager._show_notification"
):
with patch.dict(
"os.environ",
{},
clear=True,
):
manager._init_whispercpp()
self.assertTrue(manager._model_initialized)
self.assertEqual(model_class.call_count, 2)
self.assertEqual(
tuple(call.kwargs["n_threads"] for call in model_class.call_args_list),
expected_threads,
)
self.assertEqual(manager._whispercpp_effective_threads, expected_threads[-1])
def test_init_whispercpp_import_error(self):
"""Test whisper.cpp when import fails."""
manager = _make_manager(engine="whisper_cpp")
with patch.dict("sys.modules", {"pywhispercpp": None, "pywhispercpp.model": None}):
with pytest.raises(ImportError):
manager._init_whispercpp()
assert manager.state == RecognitionState.ERROR
def test_init_whispercpp_model_file_not_found(self):
"""Test whisper.cpp when model file is missing."""
manager = _make_manager(engine="whisper_cpp")
manager._defer_download = False
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO",
{"tiny": {"url": "http://example.com/model"}},
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.exists", return_value=False):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
},
):
with patch.object(
manager,
"_download_whispercpp_model",
side_effect=Exception("Download failed"),
):
with pytest.raises(Exception):
manager._init_whispercpp()
class TestWhispercppGpuDeviceSelection(unittest.TestCase):
"""Test whisper.cpp GPU device selection logic."""
def setUp(self):
# These cover device/backend selection, not integrity. _init_whispercpp
# now hashes an existing model before handing it to ctypes, and the model
# here is a mocked path with no bytes behind it. Verification itself is
# covered by tests/test_model_checksums.py.
patcher = patch.object(
SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True
)
self.addCleanup(patcher.stop)
patcher.start()
class ContextParamsModel:
calls = []
def __init__(self, model_path, context_params=None, **kwargs):
call_kwargs = dict(kwargs)
if context_params is not None:
call_kwargs["context_params"] = context_params
self.__class__.calls.append((model_path, call_kwargs))
class OldSignatureModel:
calls = []
def __init__(self, model_path, **kwargs):
self.__class__.calls.append((model_path, dict(kwargs)))
class AttributeErrorContextParamsModel:
calls = []
def __init__(self, model_path, context_params=None, **kwargs):
call_kwargs = dict(kwargs)
if context_params is not None:
call_kwargs["context_params"] = context_params
self.__class__.calls.append((model_path, call_kwargs))
if context_params is not None:
raise AttributeError(
"'whisper_full_params' object has no attribute 'context_params'"
)
def _init_with_backends(
self,
manager,
*,
host_backend,
host_info,
actual_backend,
vulkan_devices=None,
prefer_index=None,
):
mock_pywhispercpp = MagicMock()
self.ContextParamsModel.calls = []
mock_pywhispercpp.Model = self.ContextParamsModel
mock_pywhispercpp.model.Model = self.ContextParamsModel
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
vulkan_devices = vulkan_devices or []
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.getsize", return_value=100000000):
with patch("os.path.exists", return_value=True):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
"psutil": mock_psutil,
},
):
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with patch.object(
whispercpp_info,
"detect_compute_backend",
return_value=(host_backend, host_info),
):
with patch.object(
whispercpp_info,
"get_backend_display_name",
side_effect=lambda backend: backend,
):
with patch.object(
whispercpp_info,
"detect_cpu_info",
return_value="Test CPU",
):
with patch.object(
whispercpp_info,
"detect_vulkan_devices",
return_value=vulkan_devices,
):
with patch.object(
whispercpp_info,
"_prefer_discrete_vulkan_device",
return_value=prefer_index,
):
with patch.object(
manager,
"_detect_pywhispercpp_gpu_backend",
return_value=actual_backend,
):
manager._init_whispercpp()
return self.ContextParamsModel.calls
def test_gpu_device_auto_select_discrete(self):
"""Test auto-selection of discrete GPU when configured as None."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.ContextParamsModel.calls = []
mock_pywhispercpp.Model = self.ContextParamsModel
mock_pywhispercpp.model.Model = self.ContextParamsModel
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
vulkan_devices = [
{"index": 0, "name": "Intel UHD 630", "device_type": "integrated"},
{"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"},
]
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.getsize", return_value=100000000):
with patch("os.path.exists", return_value=True):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
"psutil": mock_psutil,
},
):
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with patch.object(
whispercpp_info,
"detect_compute_backend",
return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"),
):
with patch.object(
whispercpp_info,
"get_backend_display_name",
return_value="Vulkan",
):
with patch.object(
whispercpp_info,
"detect_vulkan_devices",
return_value=vulkan_devices,
):
with patch.object(
whispercpp_info,
"_prefer_discrete_vulkan_device",
return_value=1,
):
with patch.object(
manager,
"_detect_pywhispercpp_gpu_backend",
return_value="vulkan",
):
manager._init_whispercpp()
assert self.ContextParamsModel.calls[-1][1].get(
"context_params"
) == {"gpu_device": 1}
def test_gpu_device_auto_select_cuda_backend_uses_device_zero(self):
"""CUDA pywhispercpp must use CUDA ordinals, not Vulkan GPU indices."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.ContextParamsModel.calls = []
mock_pywhispercpp.Model = self.ContextParamsModel
mock_pywhispercpp.model.Model = self.ContextParamsModel
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
vulkan_devices = [
{"index": 0, "name": "Intel UHD 630", "device_type": "integrated"},
{"index": 1, "name": "NVIDIA RTX 4070", "device_type": "discrete"},
]
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.getsize", return_value=100000000):
with patch("os.path.exists", return_value=True):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
"psutil": mock_psutil,
},
):
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with patch.object(
whispercpp_info,
"detect_compute_backend",
return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"),
):
with patch.object(
whispercpp_info,
"get_backend_display_name",
return_value="Vulkan",
):
with patch.object(
whispercpp_info,
"detect_vulkan_devices",
return_value=vulkan_devices,
):
with patch.object(
whispercpp_info,
"_prefer_discrete_vulkan_device",
return_value=1,
):
with patch.object(
manager,
"_detect_pywhispercpp_gpu_backend",
return_value="cuda",
):
manager._init_whispercpp()
assert self.ContextParamsModel.calls[-1][1].get(
"context_params"
) == {"gpu_device": 0}
def test_gpu_device_cuda_backend_explicit_device_zero(self):
"""CUDA backend with Vulkan index 0 uses CUDA device 0 without remapping log."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=0)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.ContextParamsModel.calls = []
mock_pywhispercpp.Model = self.ContextParamsModel
mock_pywhispercpp.model.Model = self.ContextParamsModel
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.getsize", return_value=100000000):
with patch("os.path.exists", return_value=True):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
"psutil": mock_psutil,
},
):
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with patch.object(
whispercpp_info,
"detect_compute_backend",
return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"),
):
with patch.object(
whispercpp_info,
"get_backend_display_name",
return_value="Vulkan",
):
with patch.object(
whispercpp_info,
"_prefer_discrete_vulkan_device",
) as prefer_mock:
with patch.object(
manager,
"_detect_pywhispercpp_gpu_backend",
return_value="cuda",
):
manager._init_whispercpp()
prefer_mock.assert_not_called()
assert self.ContextParamsModel.calls[-1][1].get(
"context_params"
) == {"gpu_device": 0}
def test_gpu_device_cuda_backend_remaps_explicit_vulkan_index(self):
"""CUDA backend remaps explicit Vulkan dGPU index to CUDA device 0."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.ContextParamsModel.calls = []
mock_pywhispercpp.Model = self.ContextParamsModel
mock_pywhispercpp.model.Model = self.ContextParamsModel
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.getsize", return_value=100000000):
with patch("os.path.exists", return_value=True):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
"psutil": mock_psutil,
},
):
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with patch.object(
whispercpp_info,
"detect_compute_backend",
return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"),
):
with patch.object(
whispercpp_info,
"get_backend_display_name",
return_value="Vulkan",
):
with patch.object(
whispercpp_info,
"_prefer_discrete_vulkan_device",
) as prefer_mock:
with patch.object(
manager,
"_detect_pywhispercpp_gpu_backend",
return_value="cuda",
):
manager._init_whispercpp()
prefer_mock.assert_not_called()
assert self.ContextParamsModel.calls[-1][1].get(
"context_params"
) == {"gpu_device": 0}
def test_gpu_device_explicit_index(self):
"""Test explicit GPU device index selection."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=0)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.ContextParamsModel.calls = []
mock_pywhispercpp.Model = self.ContextParamsModel
mock_pywhispercpp.model.Model = self.ContextParamsModel
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
vulkan_devices = [
{"index": 0, "name": "Intel UHD 630", "device_type": "integrated"},
{"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"},
]
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.getsize", return_value=100000000):
with patch("os.path.exists", return_value=True):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
"psutil": mock_psutil,
},
):
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with patch.object(
whispercpp_info,
"detect_compute_backend",
return_value=(whispercpp_info.ComputeBackend.VULKAN, "Vulkan GPU"),
):
with patch.object(
whispercpp_info,
"get_backend_display_name",
return_value="Vulkan",
):
with patch.object(
whispercpp_info,
"detect_vulkan_devices",
return_value=vulkan_devices,
):
with patch.object(
manager,
"_detect_pywhispercpp_gpu_backend",
return_value="vulkan",
):
manager._init_whispercpp()
assert self.ContextParamsModel.calls[-1][1].get(
"context_params"
) == {"gpu_device": 0}
def test_gpu_device_skips_context_params_for_old_pywhispercpp_signature(self):
"""Old pywhispercpp signatures must not receive context_params."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.OldSignatureModel.calls = []
mock_pywhispercpp.Model = self.OldSignatureModel
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
},
):
result = manager._load_model_with_compatible_params("/fake/model.bin", {}, gpu_device=1)
assert isinstance(result, self.OldSignatureModel)
assert len(self.OldSignatureModel.calls) == 1
assert "context_params" not in self.OldSignatureModel.calls[0][1]
def test_gpu_device_skips_context_params_when_signature_inspection_fails(self):
"""If Model.__init__ cannot be inspected, skip context_params safely."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.OldSignatureModel.calls = []
mock_pywhispercpp.Model = self.OldSignatureModel
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
},
):
with patch(
"vocalinux.speech_recognition.recognition_manager.inspect.signature",
side_effect=ValueError("no signature"),
):
result = manager._load_model_with_compatible_params(
"/fake/model.bin", {}, gpu_device=1
)
assert isinstance(result, self.OldSignatureModel)
assert len(self.OldSignatureModel.calls) == 1
assert "context_params" not in self.OldSignatureModel.calls[0][1]
def test_gpu_device_context_params_fallback_on_attribute_error(self):
"""AttributeError from pywhispercpp context_params is retried without it."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.AttributeErrorContextParamsModel.calls = []
mock_pywhispercpp.Model = self.AttributeErrorContextParamsModel
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
},
):
result = manager._load_model_with_compatible_params("/fake/model.bin", {}, gpu_device=1)
assert isinstance(result, self.AttributeErrorContextParamsModel)
assert len(self.AttributeErrorContextParamsModel.calls) == 2
assert self.AttributeErrorContextParamsModel.calls[0][1].get("context_params") == {
"gpu_device": 1
}
assert "context_params" not in self.AttributeErrorContextParamsModel.calls[1][1]
def test_gpu_device_skips_context_params_on_pywhispercpp_1_4(self):
"""AUR 1.4 wheels advertise context_params then crash; skip them (#625)."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.ContextParamsModel.calls = []
mock_pywhispercpp.Model = self.ContextParamsModel
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
},
):
with patch(
"vocalinux.speech_recognition.recognition_manager._pywhispercpp_distribution_version",
return_value=(1, 4, 0),
):
result = manager._load_model_with_compatible_params(
"/fake/model.bin", {}, gpu_device=1
)
assert isinstance(result, self.ContextParamsModel)
assert len(self.ContextParamsModel.calls) == 1
assert "context_params" not in self.ContextParamsModel.calls[0][1]
def test_placeholder_distribution_version_is_unknown_not_old(self):
"""setuptools writes 0.0.0 when a build had no version; that is not 'too old' (#43)."""
from vocalinux.speech_recognition import recognition_manager
with patch("importlib.metadata.version", return_value="0.0.0"):
assert recognition_manager._pywhispercpp_distribution_version() is None
with patch("importlib.metadata.version", return_value="1.5.0"):
assert recognition_manager._pywhispercpp_distribution_version() == (1, 5, 0)
def test_gpu_device_kept_when_distribution_version_is_placeholder(self):
"""A 0.0.0 build still has context_params, so device selection must stay on (#43)."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
self.ContextParamsModel.calls = []
mock_pywhispercpp.Model = self.ContextParamsModel
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
},
):
with patch("importlib.metadata.version", return_value="0.0.0"):
result = manager._load_model_with_compatible_params(
"/fake/model.bin", {}, gpu_device=1
)
assert isinstance(result, self.ContextParamsModel)
assert len(self.ContextParamsModel.calls) == 1
assert self.ContextParamsModel.calls[0][1].get("context_params") == {"gpu_device": 1}
def test_host_cuda_label_runtime_vulkan_selects_discrete(self):
"""Device selection follows bundled Vulkan libs, not a host CUDA label (#604)."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
manager.model_size = "tiny"
vulkan_devices = [
{"index": 0, "name": "Intel UHD 630", "device_type": "integrated"},
{"index": 1, "name": "NVIDIA RTX 2060", "device_type": "discrete"},
]
import vocalinux.utils.whispercpp_model_info as whispercpp_info
calls = self._init_with_backends(
manager,
host_backend=whispercpp_info.ComputeBackend.CUDA,
host_info="NVIDIA GeForce RTX 2060 (6 GiB)",
actual_backend="vulkan",
vulkan_devices=vulkan_devices,
prefer_index=1,
)
assert calls[-1][1].get("context_params") == {"gpu_device": 1}
def test_cpu_wheel_skips_device_when_host_reports_vulkan(self):
"""CPU-only pywhispercpp must not pass gpu_device even if vulkaninfo sees a GPU."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
manager.model_size = "tiny"
vulkan_devices = [
{"index": 1, "name": "NVIDIA GeForce GTX 950M", "device_type": "discrete"},
]
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with self.assertLogs(
"vocalinux.speech_recognition.recognition_manager", level="WARNING"
) as logs:
calls = self._init_with_backends(
manager,
host_backend=whispercpp_info.ComputeBackend.VULKAN,
host_info="NVIDIA GeForce GTX 950M",
actual_backend="cpu",
vulkan_devices=vulkan_devices,
prefer_index=1,
)
assert "context_params" not in calls[-1][1]
assert any("no GPU libraries" in message for message in logs.output)
def test_vulkan_runtime_without_enumerated_devices_skips_context_params(self):
"""Vulkan libs with no vulkaninfo devices leave gpu_device unset."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
manager.model_size = "tiny"
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with self.assertLogs(
"vocalinux.speech_recognition.recognition_manager", level="WARNING"
) as logs:
calls = self._init_with_backends(
manager,
host_backend=whispercpp_info.ComputeBackend.CUDA,
host_info="NVIDIA GeForce RTX 2060 (6 GiB)",
actual_backend="vulkan",
vulkan_devices=[],
prefer_index=None,
)
assert "context_params" not in calls[-1][1]
assert any("vulkaninfo found no devices" in message for message in logs.output)
def test_gpu_device_not_set_for_cpu_backend(self):
"""Test that GPU device selection is skipped on CPU backend."""
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
manager.model_size = "tiny"
mock_pywhispercpp = MagicMock()
mock_model = MagicMock()
mock_pywhispercpp.Model = MagicMock(return_value=mock_model)
mock_pywhispercpp.model.Model = MagicMock(return_value=mock_model)
mock_psutil = MagicMock()
mock_psutil.virtual_memory.return_value.total = 8 * 1024 * 1024 * 1024
with patch(
"vocalinux.speech_recognition.recognition_manager.WHISPERCPP_MODEL_INFO", {"tiny": {}}
):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value="/fake/model.bin",
):
with patch("os.path.getsize", return_value=100000000):
with patch("os.path.exists", return_value=True):
with patch.dict(
"sys.modules",
{
"pywhispercpp": mock_pywhispercpp,
"pywhispercpp.model": mock_pywhispercpp,
"psutil": mock_psutil,
},
):
import vocalinux.utils.whispercpp_model_info as whispercpp_info
with patch.object(
whispercpp_info,
"detect_compute_backend",
return_value=(whispercpp_info.ComputeBackend.CPU, "CPU"),
):
with patch.object(
whispercpp_info,
"get_backend_display_name",
return_value="CPU",
):
with patch.object(
manager,
"_detect_pywhispercpp_gpu_backend",
return_value="cpu",
):
manager._init_whispercpp()
assert (
"context_params"
not in mock_pywhispercpp.Model.call_args.kwargs
)
class TestWhispercppGgmlDeviceSelection(unittest.TestCase):
"""Test manager selection against whisper.cpp's native GPU registry."""
def setUp(self: "TestWhispercppGgmlDeviceSelection") -> None:
verifier = patch.object(
SpeechRecognitionManager, "_whispercpp_model_is_verified", return_value=True
)
verifier.start()
self.addCleanup(verifier.stop)
self.device_helper = TestWhispercppGpuDeviceSelection()
def _load_with_ggml_devices(
self: "TestWhispercppGgmlDeviceSelection",
manager: SpeechRecognitionManager,
ggml_devices: list[dict[str, Any]],
vulkan_devices: list[dict[str, Any]],
actual_backend: str = "vulkan",
host_backend: Optional[str] = None,
) -> list[tuple[str, dict[str, Any]]]:
import vocalinux.utils.whispercpp_model_info as whispercpp_info
if host_backend is None:
host_backend = (
whispercpp_info.ComputeBackend.CUDA
if actual_backend == "cuda"
else whispercpp_info.ComputeBackend.VULKAN
)
with (
patch.object(
whispercpp_info,
"detect_ggml_gpu_devices",
return_value=ggml_devices,
),
patch(
"vocalinux.speech_recognition.recognition_manager.multiprocessing.cpu_count",
return_value=8,
),
):
return self.device_helper._init_with_backends(
manager,
host_backend=host_backend,
host_info="Vulkan or CUDA test device",
actual_backend=actual_backend,
vulkan_devices=vulkan_devices,
prefer_index=1,
)
def test_auto_selection_uses_ggml_ordinal_not_vulkaninfo_index(
self: "TestWhispercppGgmlDeviceSelection",
) -> None:
import vocalinux.utils.whispercpp_model_info as whispercpp_info
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
manager.model_size = "tiny"
vulkan_devices = [
{
"index": 0,
"name": "Intel(R) HD Graphics 4600 (HSW GT2)",
"device_type": "integrated",
},
{
"index": 1,
"name": "NVIDIA GeForce GTX 1050",
"device_type": "discrete",
},
]
ggml_devices = [
{
"index": 0,
"name": "NVIDIA GeForce GTX 1050",
"backend_name": "Vulkan0",
"device_type": "discrete",
}
]
with self.assertLogs(
"vocalinux.speech_recognition.recognition_manager", level="INFO"
) as logs:
calls = self._load_with_ggml_devices(
manager,
ggml_devices,
vulkan_devices,
host_backend=whispercpp_info.ComputeBackend.VULKAN,
)
self.assertEqual(calls[-1][1]["context_params"], {"gpu_device": 0})
self.assertEqual(calls[-1][1]["n_threads"], 2)
self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output))
def test_stale_explicit_index_falls_back_to_ggml_auto_selection(
self: "TestWhispercppGgmlDeviceSelection",
) -> None:
import vocalinux.utils.whispercpp_model_info as whispercpp_info
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=1)
manager.model_size = "tiny"
ggml_devices = [
{
"index": 0,
"name": "NVIDIA GeForce GTX 1050",
"backend_name": "Vulkan0",
"device_type": "discrete",
}
]
vulkan_devices = [
{"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"},
{"index": 1, "name": "NVIDIA GeForce GTX 1050", "device_type": "discrete"},
]
with self.assertLogs(
"vocalinux.speech_recognition.recognition_manager", level="WARNING"
) as logs:
calls = self._load_with_ggml_devices(
manager,
ggml_devices,
vulkan_devices,
host_backend=whispercpp_info.ComputeBackend.VULKAN,
)
self.assertEqual(calls[-1][1]["context_params"], {"gpu_device": 0})
self.assertTrue(any("index 1 is unavailable" in entry for entry in logs.output))
self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output))
def test_empty_ggml_registry_uses_cpu_and_lists_skipped_devices(
self: "TestWhispercppGgmlDeviceSelection",
) -> None:
import vocalinux.utils.whispercpp_model_info as whispercpp_info
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
manager.model_size = "tiny"
vulkan_devices = [
{
"index": 0,
"name": "Intel(R) HD Graphics 4600 (HSW GT2)",
"device_type": "integrated",
},
{
"index": 1,
"name": "NVIDIA GeForce GTX 1050",
"device_type": "discrete",
},
]
with self.assertLogs(
"vocalinux.speech_recognition.recognition_manager", level="INFO"
) as logs:
calls = self._load_with_ggml_devices(
manager,
[],
vulkan_devices,
host_backend=whispercpp_info.ComputeBackend.VULKAN,
)
self.assertEqual(calls[-1][1]["context_params"], {"use_gpu": False})
self.assertEqual(calls[-1][1]["n_threads"], 6)
self.assertTrue(any("effective cpu backend" in entry.lower() for entry in logs.output))
self.assertTrue(any("Intel(R) HD Graphics 4600" in entry for entry in logs.output))
self.assertTrue(any("NVIDIA GeForce GTX 1050" in entry for entry in logs.output))
def test_positive_thread_override_applies_to_cpu_and_gpu(
self: "TestWhispercppGgmlDeviceSelection",
) -> None:
manager = _make_manager(
engine="whisper_cpp",
whispercpp_gpu_device=None,
whispercpp_n_threads=3,
)
manager.model_size = "tiny"
vulkan_devices = [
{"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"},
{"index": 1, "name": "NVIDIA GPU", "device_type": "discrete"},
]
ggml_devices = [
{
"index": 0,
"name": "NVIDIA GPU",
"backend_name": "Vulkan0",
"device_type": "discrete",
}
]
gpu_calls = self._load_with_ggml_devices(manager, ggml_devices, vulkan_devices)
self.assertEqual(gpu_calls[-1][1]["n_threads"], 3)
self.assertEqual(gpu_calls[-1][1]["context_params"], {"gpu_device": 0})
cpu_manager = _make_manager(
engine="whisper_cpp",
whispercpp_gpu_device=None,
whispercpp_n_threads=3,
)
cpu_manager.model_size = "tiny"
cpu_calls = self._load_with_ggml_devices(cpu_manager, [], vulkan_devices)
self.assertEqual(cpu_calls[-1][1]["n_threads"], 3)
self.assertEqual(cpu_calls[-1][1]["context_params"], {"use_gpu": False})
def test_cuda_runtime_with_empty_ggml_registry_uses_cpu_threads(
self: "TestWhispercppGgmlDeviceSelection",
) -> None:
import vocalinux.utils.whispercpp_model_info as whispercpp_info
manager = _make_manager(engine="whisper_cpp", whispercpp_gpu_device=None)
manager.model_size = "tiny"
vulkan_devices = [
{"index": 0, "name": "Intel HD Graphics", "device_type": "integrated"},
{"index": 1, "name": "NVIDIA GeForce GTX 1050", "device_type": "discrete"},
]
with self.assertLogs(
"vocalinux.speech_recognition.recognition_manager", level="INFO"
) as logs:
calls = self._load_with_ggml_devices(
manager,
[],
vulkan_devices,
actual_backend="cuda",
host_backend=whispercpp_info.ComputeBackend.CUDA,
)
self.assertEqual(calls[-1][1]["n_threads"], 6)
self.assertEqual(calls[-1][1]["context_params"], {"use_gpu": False})
self.assertTrue(any("effective cpu backend" in entry.lower() for entry in logs.output))
class TestTranscription(unittest.TestCase):
"""Test transcription methods."""
def test_transcribe_with_whisper_empty_buffer(self):
"""Test Whisper transcription with empty buffer."""
manager = _make_manager(engine="whisper")
manager.model = MagicMock()
result = manager._transcribe_with_whisper([])
assert result == ""
def test_transcribe_with_whisper_none_model(self):
"""Test Whisper transcription when model is None."""
manager = _make_manager(engine="whisper")
manager.model = None
audio_buffer = [b"\x00\x00\x00\x00"]
result = manager._transcribe_with_whisper(audio_buffer)
assert result == ""
def test_transcribe_with_whisper_success(self):
"""Test successful Whisper transcription."""
manager = _make_manager(engine="whisper")
# Create mock that behaves like a torch device
mock_device = MagicMock()
mock_device.__ne__ = MagicMock(return_value=True) # device != torch.device("cpu")
mock_model = MagicMock()
mock_model.device = mock_device
mock_model.transcribe.return_value = {"text": "hello world"}
manager.model = mock_model
manager.language = "en-us"
audio_buffer = [b"\x00\x00" for _ in range(16000)] # 1 second of audio
mock_np = MagicMock()
mock_audio_data = MagicMock()
mock_audio_float = MagicMock()
mock_np.frombuffer.return_value = mock_audio_data
mock_audio_data.astype.return_value = mock_audio_float
mock_torch = MagicMock()
with patch.dict("sys.modules", {"numpy": mock_np, "torch": mock_torch}):
result = manager._transcribe_with_whisper(audio_buffer)
assert result == "hello world"
def test_transcribe_with_whispercpp_empty_buffer(self):
"""Test whisper.cpp transcription with empty buffer."""
manager = _make_manager(engine="whisper_cpp")
manager.model = MagicMock()
result = manager._transcribe_with_whispercpp([])
assert result == ""
def test_transcribe_with_whispercpp_none_model(self):
"""Test whisper.cpp transcription when model is None."""
manager = _make_manager(engine="whisper_cpp")
manager.model = None
audio_buffer = [b"\x00\x00\x00\x00"]
result = manager._transcribe_with_whispercpp(audio_buffer)
assert result == ""
def test_transcribe_with_whispercpp_success(self):
"""Test successful whisper.cpp transcription."""
manager = _make_manager(engine="whisper_cpp")
# Mock segment with text attribute
mock_segment = MagicMock()
mock_segment.text = "test result"
mock_model = MagicMock()
mock_model.transcribe.return_value = [mock_segment]
manager.model = mock_model
manager.language = "en-us"
audio_buffer = [b"\x00\x00\x00\x00" * 16000]
mock_np = MagicMock()
mock_np.frombuffer.return_value = MagicMock()
mock_np.frombuffer.return_value.astype.return_value = MagicMock()
with patch.dict("sys.modules", {"numpy": mock_np, "np": mock_np}):
result = manager._transcribe_with_whispercpp(audio_buffer)
assert result == "test result"
class TestStartStopRecognition(unittest.TestCase):
"""Test start_recognition and stop_recognition flows."""
def test_start_recognition_invalid_state(self):
"""Test starting recognition when not IDLE."""
manager = _make_manager()
manager.state = RecognitionState.LISTENING
with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"):
assert manager.start_recognition() is False
# Should return early without starting threads
def test_start_recognition_model_not_ready(self):
"""Test starting recognition when model not ready."""
manager = _make_manager()
manager.state = RecognitionState.IDLE
manager._model_initialized = False
with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"):
with patch("vocalinux.speech_recognition.recognition_manager._show_notification"):
assert manager.start_recognition() is False
assert manager.state == RecognitionState.IDLE # Should not change
def test_start_recognition_success(self):
"""Test successful recognition start."""
manager = _make_manager()
manager.state = RecognitionState.IDLE
manager._model_initialized = True
manager.model = MagicMock()
with patch("vocalinux.speech_recognition.recognition_manager.play_start_sound"):
with patch.object(manager, "_record_audio"):
with patch.object(manager, "_perform_recognition"):
assert manager.start_recognition() is True
assert manager.state == RecognitionState.LISTENING
assert manager.should_record is True
def test_stop_recognition_when_idle(self):
"""Test stopping recognition when already idle."""
manager = _make_manager()
manager.state = RecognitionState.IDLE
manager.stop_recognition()
# Should return early
assert manager.state == RecognitionState.IDLE
def test_stop_recognition_with_threads(self):
"""Test stopping recognition with active threads."""
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 = True
# Create dummy threads
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()
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):
"""Test model download functions."""
def test_download_whispercpp_model_success(self):
"""Test successful whisper.cpp model download - skipped (requires requests)."""
# This test requires requests library which may not be available
# The actual download logic is tested through integration tests
pass
def test_download_whispercpp_model_cancelled(self):
"""Test cancelled whisper.cpp model download - skipped (requires requests)."""
# This test requires requests library which may not be available
# The actual download logic is tested through integration tests
pass
def test_download_vosk_model_success(self):
"""Test successful VOSK model download - skipped (requires requests)."""
# This test requires requests library which may not be available
# The actual download logic is tested through integration tests
pass
def test_download_vosk_model_bad_zip(self):
"""Test VOSK download with corrupted zip file - skipped (requires requests)."""
# This test requires requests library which may not be available
# The actual download logic is tested through integration tests
pass
class TestReconfiguration(unittest.TestCase):
"""Test reconfiguration and model switching."""
def test_reconfigure_engine(self):
"""Test reconfiguring to a different engine."""
manager = _make_manager(engine="vosk")
manager.engine = "vosk"
with patch.object(manager, "_init_whisper"):
with patch.object(manager, "stop_recognition"):
manager.reconfigure(engine="whisper", force_download=False)
assert manager.engine == "whisper"
class TestEnqueueAudioSegment(unittest.TestCase):
"""Direct tests for _enqueue_audio_segment (not mocked)."""
def test_enqueue_empty_buffer_returns_early(self):
"""Empty buffer should return without enqueuing anything."""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager._enqueue_audio_segment([])
assert manager._segment_queue.empty()
def test_enqueue_puts_segment(self):
"""Non-empty buffer should be copied and enqueued."""
manager = _make_manager()
manager._segment_queue = queue.Queue()
buf = [b"\x00\x01", b"\x02\x03"]
manager._enqueue_audio_segment(buf)
assert not manager._segment_queue.empty()
# Segments carry the session language they were recorded under (#805).
assert manager._segment_queue.get_nowait() == (buf, "en-us")
def test_enqueue_drops_oldest_when_queue_full(self):
"""When queue is full, oldest item is dropped and new one inserted."""
manager = _make_manager()
manager._segment_queue = queue.Queue(maxsize=1)
manager._segment_queue.put_nowait(([b"old"], "en-us"))
manager._enqueue_audio_segment([b"new"])
assert manager._segment_queue.get_nowait() == ([b"new"], "en-us")
class TestPerformRecognition(unittest.TestCase):
"""Direct tests for _perform_recognition (not mocked)."""
def _run_recognition(self, manager, pre_hook=None):
"""Start _perform_recognition in a thread and return the thread."""
t = threading.Thread(target=manager._perform_recognition)
t.start()
if pre_hook:
pre_hook(manager)
return t
def test_processes_segment_and_exits_on_none_signal(self):
"""Feed a segment, then a None signal; thread processes and exits."""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager.should_record = True
with patch.object(manager, "_process_audio_buffer") as mock_proc:
with patch.object(manager, "_update_state"):
t = self._run_recognition(manager)
manager._segment_queue.put(([b"\x00\x01"], "en-us"))
manager.should_record = False
manager._segment_queue.put(None)
t.join(timeout=2)
assert not t.is_alive()
assert mock_proc.called
def test_drains_remaining_items_on_none_signal(self):
"""None signal drains any remaining queued segments before exit."""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager.should_record = True
with patch.object(manager, "_process_audio_buffer") as mock_proc:
with patch.object(manager, "_update_state"):
t = self._run_recognition(manager)
manager._segment_queue.put(([b"first"], "en-us"))
manager._segment_queue.put(([b"second"], "en-us"))
manager.should_record = False
manager._segment_queue.put(None)
t.join(timeout=2)
assert not t.is_alive()
assert mock_proc.call_count == 2
def test_exits_when_idle_and_queue_empty(self):
"""When not recording and queue empty, thread exits after brief wait."""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager.should_record = False
with patch.object(manager, "_process_audio_buffer") as mock_proc:
with patch.object(manager, "_update_state"):
t = self._run_recognition(manager)
t.join(timeout=2)
assert not t.is_alive()
assert not mock_proc.called
def test_queue_timeout_while_recording(self):
"""While recording, an empty-queue timeout causes the loop to continue."""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager.should_record = True
with patch.object(manager, "_process_audio_buffer") as mock_proc:
with patch.object(manager, "_update_state"):
t = self._run_recognition(manager)
# Allow at least one 0.1 s timeout to fire
time.sleep(0.25)
manager.should_record = False
manager._segment_queue.put(None)
t.join(timeout=2)
assert not t.is_alive()
def _track_states(self, manager):
states = []
original = manager._update_state
def _tracked(new_state):
states.append(new_state)
original(new_state)
manager._update_state = _tracked
return states
def test_leftover_after_stop_does_not_stick_in_processing(self):
"""After stop, leftover audio is transcribed but state returns to IDLE.
Reproduces #739: stop_recognition can time out while the recognition
thread still marks leftover drain as PROCESSING and never goes idle.
The tray then stays on the active/processing icon until another
Alt+F12 toggle.
"""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager.should_record = False
manager.state = RecognitionState.IDLE
states = self._track_states(manager)
with patch.object(manager, "_process_audio_buffer") as mock_proc:
manager._segment_queue.put(([b"leftover"], "en-us"))
manager._segment_queue.put(None)
t = threading.Thread(target=manager._perform_recognition)
t.start()
t.join(timeout=2)
assert not t.is_alive()
mock_proc.assert_called_once_with([b"leftover"], "en-us")
assert RecognitionState.PROCESSING not in states
assert RecognitionState.LISTENING not in states
assert manager.state == RecognitionState.IDLE
def test_none_signal_drain_after_stop_returns_idle(self):
"""Segments still queued behind the stop sentinel must not leave PROCESSING."""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager.should_record = False
manager.state = RecognitionState.IDLE
states = self._track_states(manager)
with patch.object(manager, "_process_audio_buffer") as mock_proc:
# None is consumed first; leftover is drained from the remaining queue.
manager._segment_queue.put(None)
manager._segment_queue.put(([b"leftover"], "en-us"))
t = threading.Thread(target=manager._perform_recognition)
t.start()
t.join(timeout=2)
assert not t.is_alive()
mock_proc.assert_called_once_with([b"leftover"], "en-us")
assert RecognitionState.PROCESSING not in states
assert manager.state == RecognitionState.IDLE
def test_processing_still_set_while_recording(self):
"""Live dictation still flips PROCESSING then LISTENING."""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager.should_record = True
manager.state = RecognitionState.LISTENING
states = self._track_states(manager)
processed = threading.Event()
def _after_process(_segment, _language):
processed.set()
with patch.object(manager, "_process_audio_buffer", side_effect=_after_process):
t = threading.Thread(target=manager._perform_recognition)
t.start()
manager._segment_queue.put(([b"live"], "en-us"))
assert processed.wait(timeout=2)
deadline = time.time() + 1
while RecognitionState.LISTENING not in states and time.time() < deadline:
time.sleep(0.01)
manager.should_record = False
manager._segment_queue.put(None)
t.join(timeout=2)
assert not t.is_alive()
assert RecognitionState.PROCESSING in states
assert RecognitionState.LISTENING in states
assert manager.state == RecognitionState.LISTENING
def test_leftover_exit_does_not_clobber_new_listening_session(self):
"""Leftover drain finishing after a new start must not force IDLE.
stop_recognition() join can time out, then Alt+F12 starts again.
start_recognition() sets LISTENING before should_record. If the
leftover worker forced IDLE on exit, the tray would go idle and
the next hotkey would start a second session.
"""
manager = _make_manager()
manager._segment_queue = queue.Queue()
manager.should_record = False
manager.state = RecognitionState.IDLE
states = self._track_states(manager)
def _start_new_session(_segment, _language):
manager.state = RecognitionState.LISTENING
with patch.object(manager, "_process_audio_buffer", side_effect=_start_new_session):
manager._segment_queue.put(([b"leftover"], "en-us"))
manager._segment_queue.put(None)
t = threading.Thread(target=manager._perform_recognition)
t.start()
t.join(timeout=2)
assert not t.is_alive()
assert manager.state == RecognitionState.LISTENING
assert RecognitionState.IDLE not in states
if __name__ == "__main__":
pytest.main([__file__, "-v"])