Fixes #340 by making channel probing use the device default sample rate first, then fallback rates, so 48kHz-only USB interfaces initialize correctly.
357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""
|
|
Additional coverage tests for recognition_manager.py module.
|
|
|
|
Tests for uncovered initialization paths, error handling, and edge cases.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from unittest.mock import MagicMock, Mock, mock_open, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# Autouse fixture to prevent sys.modules pollution
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_sys_modules():
|
|
saved = dict(sys.modules)
|
|
yield
|
|
added = set(sys.modules.keys()) - set(saved.keys())
|
|
for k in added:
|
|
del sys.modules[k]
|
|
for k, v in saved.items():
|
|
if k not in sys.modules or sys.modules[k] is not v:
|
|
sys.modules[k] = v
|
|
|
|
|
|
def _make_manager(engine="whisper_cpp", **kw):
|
|
"""Helper to create SpeechRecognitionManager with init methods patched."""
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
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 TestSpeechRecognitionManagerInit:
|
|
"""Tests for SpeechRecognitionManager initialization."""
|
|
|
|
def test_manager_init_whisper_cpp_engine(self):
|
|
"""Test manager initialization with whisper_cpp engine."""
|
|
manager = _make_manager(engine="whisper_cpp")
|
|
assert manager.engine == "whisper_cpp"
|
|
assert manager.model_size == "small"
|
|
assert manager.language == "en-us"
|
|
|
|
def test_manager_init_vosk_engine(self):
|
|
"""Test manager initialization with vosk engine."""
|
|
manager = _make_manager(engine="vosk")
|
|
assert manager.engine == "vosk"
|
|
|
|
def test_manager_init_whisper_engine(self):
|
|
"""Test manager initialization with whisper engine."""
|
|
manager = _make_manager(engine="whisper")
|
|
assert manager.engine == "whisper"
|
|
|
|
def test_manager_init_with_audio_device_index(self):
|
|
"""Test manager initialization with audio device index."""
|
|
manager = _make_manager(audio_device_index=2)
|
|
assert manager.audio_device_index == 2
|
|
|
|
def test_manager_init_with_vad_sensitivity(self):
|
|
"""Test manager initialization with VAD sensitivity."""
|
|
manager = _make_manager(vad_sensitivity=5)
|
|
assert manager.vad_sensitivity == 5
|
|
|
|
def test_manager_init_with_silence_timeout(self):
|
|
"""Test manager initialization with silence timeout."""
|
|
manager = _make_manager(silence_timeout=3.0)
|
|
assert manager.silence_timeout == 3.0
|
|
|
|
def test_manager_init_with_voice_commands_enabled(self):
|
|
"""Test manager initialization with voice commands enabled."""
|
|
manager = _make_manager(voice_commands_enabled=True)
|
|
# Just verify manager initializes without error
|
|
assert manager is not None
|
|
|
|
def test_manager_init_with_download_progress_callback(self):
|
|
"""Test manager initialization with download progress callback."""
|
|
callback = MagicMock()
|
|
manager = _make_manager(download_progress_callback=callback)
|
|
# Just verify manager initializes without error
|
|
assert manager is not None
|
|
|
|
|
|
class TestGetAudioInputDevices:
|
|
"""Tests for get_audio_input_devices function."""
|
|
|
|
def test_get_audio_input_devices_success(self):
|
|
"""Test getting audio input devices successfully."""
|
|
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
|
|
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_count.return_value = 2
|
|
mock_audio.get_default_input_device_info.return_value = {"index": 0}
|
|
mock_audio.get_device_info_by_index.side_effect = [
|
|
{"name": "Device 0", "maxInputChannels": 2, "index": 0},
|
|
{"name": "Device 1", "maxInputChannels": 2, "index": 1},
|
|
]
|
|
|
|
with patch.dict(
|
|
"sys.modules", {"pyaudio": MagicMock(PyAudio=MagicMock(return_value=mock_audio))}
|
|
):
|
|
devices = get_audio_input_devices()
|
|
assert len(devices) == 2
|
|
assert devices[0][0] == 0 # device index
|
|
assert devices[0][2] is True # is_default
|
|
assert devices[1][2] is False
|
|
|
|
def test_get_audio_input_devices_no_default(self):
|
|
"""Test getting devices when no default input device exists."""
|
|
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
|
|
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_count.return_value = 1
|
|
mock_audio.get_default_input_device_info.side_effect = IOError("No default device")
|
|
mock_audio.get_device_info_by_index.return_value = {
|
|
"name": "Device 0",
|
|
"maxInputChannels": 2,
|
|
"index": 0,
|
|
}
|
|
|
|
with patch.dict(
|
|
"sys.modules", {"pyaudio": MagicMock(PyAudio=MagicMock(return_value=mock_audio))}
|
|
):
|
|
devices = get_audio_input_devices()
|
|
assert len(devices) == 1
|
|
assert devices[0][2] is False # not default
|
|
|
|
def test_get_audio_input_devices_skip_input_only(self):
|
|
"""Test that devices with 0 input channels are skipped."""
|
|
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
|
|
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_count.return_value = 2
|
|
mock_audio.get_default_input_device_info.return_value = {"index": 0}
|
|
mock_audio.get_device_info_by_index.side_effect = [
|
|
{"name": "Output Device", "maxInputChannels": 0, "index": 0}, # output only
|
|
{"name": "Input Device", "maxInputChannels": 2, "index": 1}, # input capable
|
|
]
|
|
|
|
with patch.dict(
|
|
"sys.modules", {"pyaudio": MagicMock(PyAudio=MagicMock(return_value=mock_audio))}
|
|
):
|
|
devices = get_audio_input_devices()
|
|
assert len(devices) == 1
|
|
assert devices[0][1] == "Input Device"
|
|
|
|
def test_get_audio_input_devices_device_error(self):
|
|
"""Test handling of device enumeration errors."""
|
|
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
|
|
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_count.return_value = 2
|
|
mock_audio.get_default_input_device_info.return_value = {"index": 0}
|
|
mock_audio.get_device_info_by_index.side_effect = [
|
|
IOError("Device error"), # First device has error
|
|
{"name": "Device 1", "maxInputChannels": 2, "index": 1},
|
|
]
|
|
|
|
with patch.dict(
|
|
"sys.modules", {"pyaudio": MagicMock(PyAudio=MagicMock(return_value=mock_audio))}
|
|
):
|
|
devices = get_audio_input_devices()
|
|
assert len(devices) == 1
|
|
assert devices[0][1] == "Device 1"
|
|
|
|
def test_get_audio_input_devices_pyaudio_not_installed(self):
|
|
"""Test handling when pyaudio is not installed."""
|
|
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": None}):
|
|
with patch("builtins.__import__", side_effect=ImportError("No module named 'pyaudio'")):
|
|
devices = get_audio_input_devices()
|
|
assert devices == []
|
|
|
|
def test_get_audio_input_devices_generic_exception(self):
|
|
"""Test handling of generic exceptions during device enumeration."""
|
|
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
|
|
|
|
with patch.dict("sys.modules", {"pyaudio": MagicMock()}):
|
|
with patch("builtins.__import__", side_effect=RuntimeError("Unexpected error")):
|
|
devices = get_audio_input_devices()
|
|
assert devices == []
|
|
|
|
|
|
class TestGetSupportedChannels:
|
|
"""Tests for _get_supported_channels function."""
|
|
|
|
def test_get_supported_channels_mono(self):
|
|
"""Test that mono (1 channel) is detected as supported."""
|
|
from vocalinux.speech_recognition.recognition_manager import _get_supported_channels
|
|
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.open.return_value = mock_stream
|
|
|
|
# First call (mono) succeeds
|
|
mock_audio.open.side_effect = [mock_stream, IOError("2 channels not supported")]
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
|
|
):
|
|
channels = _get_supported_channels(mock_audio, None)
|
|
assert channels == 1
|
|
|
|
def test_get_supported_channels_stereo_fallback(self):
|
|
"""Test fallback to stereo if mono fails."""
|
|
from vocalinux.speech_recognition.recognition_manager import _get_supported_channels
|
|
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
|
|
def open_side_effect(**kwargs):
|
|
if kwargs.get("channels") == 1:
|
|
raise IOError("-9998 invalid number of channels")
|
|
return mock_stream
|
|
|
|
mock_audio.open.side_effect = open_side_effect
|
|
mock_audio.get_default_input_device_info.return_value = {"defaultSampleRate": 48000}
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
|
|
):
|
|
channels = _get_supported_channels(mock_audio, None)
|
|
assert channels == 2
|
|
|
|
def test_get_supported_channels_both_fail(self):
|
|
"""Test default to 1 channel if both mono and stereo fail."""
|
|
from vocalinux.speech_recognition.recognition_manager import _get_supported_channels
|
|
|
|
mock_audio = MagicMock()
|
|
|
|
mock_audio.open.side_effect = IOError("All channel/rate probes failed")
|
|
mock_audio.get_default_input_device_info.return_value = {"defaultSampleRate": 48000}
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
|
|
):
|
|
channels = _get_supported_channels(mock_audio, None)
|
|
assert channels == 1
|
|
|
|
|
|
class TestGetSupportedSampleRate:
|
|
"""Tests for _get_supported_sample_rate function."""
|
|
|
|
def test_get_supported_sample_rate_device_default(self):
|
|
"""Test that device's default sample rate is preferred."""
|
|
from vocalinux.speech_recognition.recognition_manager import _get_supported_sample_rate
|
|
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
|
|
mock_audio.open.return_value = mock_stream
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
|
|
):
|
|
rate = _get_supported_sample_rate(mock_audio, 0, 1)
|
|
assert rate == 48000
|
|
|
|
def test_get_supported_sample_rate_fallback_to_common(self):
|
|
"""Test fallback to common sample rates."""
|
|
from vocalinux.speech_recognition.recognition_manager import _get_supported_sample_rate
|
|
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
# Device default fails, but 44100 works
|
|
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 0}
|
|
mock_audio.open.side_effect = [
|
|
IOError("Device default failed"),
|
|
mock_stream, # 44100 works
|
|
]
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
|
|
):
|
|
rate = _get_supported_sample_rate(mock_audio, 0, 1)
|
|
assert rate == 44100
|
|
|
|
def test_get_supported_sample_rate_all_fail_default(self):
|
|
"""Test default to 16000 if all rates fail."""
|
|
from vocalinux.speech_recognition.recognition_manager import _get_supported_sample_rate
|
|
|
|
mock_audio = MagicMock()
|
|
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 0}
|
|
# All rates fail
|
|
mock_audio.open.side_effect = IOError("All rates failed")
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
|
|
):
|
|
rate = _get_supported_sample_rate(mock_audio, 0, 1)
|
|
assert rate == 16000
|
|
|
|
def test_get_supported_sample_rate_no_device_info(self):
|
|
"""Test handling when device info cannot be retrieved."""
|
|
from vocalinux.speech_recognition.recognition_manager import _get_supported_sample_rate
|
|
|
|
mock_audio = MagicMock()
|
|
mock_stream = MagicMock()
|
|
mock_audio.get_device_info_by_index.side_effect = IOError("Cannot get device info")
|
|
mock_audio.open.side_effect = [
|
|
mock_stream, # First common rate works
|
|
]
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
|
|
):
|
|
rate = _get_supported_sample_rate(mock_audio, 0, 1)
|
|
# Should try common rates and return first working one
|
|
assert rate in [48000, 44100, 32000, 22050, 16000, 8000]
|
|
|
|
|
|
class TestManagerStateCallbacks:
|
|
"""Tests for callback registration and state management."""
|
|
|
|
def test_register_text_callback(self):
|
|
"""Test registering text callback."""
|
|
manager = _make_manager()
|
|
callback = MagicMock()
|
|
# Just verify method exists and doesn't error
|
|
manager.register_text_callback(callback)
|
|
assert manager is not None
|
|
|
|
def test_register_action_callback(self):
|
|
"""Test registering action callback."""
|
|
manager = _make_manager()
|
|
callback = MagicMock()
|
|
# Just verify method exists and doesn't error
|
|
manager.register_action_callback(callback)
|
|
assert manager is not None
|
|
|
|
def test_register_state_callback(self):
|
|
"""Test registering state callback."""
|
|
manager = _make_manager()
|
|
callback = MagicMock()
|
|
# Just verify method exists and doesn't error
|
|
manager.register_state_callback(callback)
|
|
assert manager is not None
|
|
|
|
def test_manager_state_property(self):
|
|
"""Test manager state property."""
|
|
manager = _make_manager()
|
|
# Just verify manager has state property
|
|
state = manager.state
|
|
assert state is not None
|