Files
voculinux/tests/test_recognition_manager.py
T
Jatin K MalikandGitHub d8ff7052cd fix(settings): stop Test Dictation calling a missing model "no speech" (#702)
* fix(settings): stop Test Dictation calling a missing model "no speech"

start_recognition now returns whether listening actually began. Test
Dictation checks that before starting its timer, so a missing or
auto-paused model gets a real error instead of "No speech detected".

* fix(settings): reconfigure Test Dictation against the live engine

UI matching config.json is not enough when speech_engine is still on
another engine or model size. Compare those too before the listen timer.

* fix(tests): keep settings_dialog lookups on one module

_load_settings_dialog() restored sys.modules but left
vocalinux.ui.settings_dialog pointing at the private reload.
Python 3.9/3.10 mock follows that attribute, so
test_whisper_list_and_delete patched a different copy than it
imported and the expanduser stub hid the real cache.

Point the package attribute back at the restored module, and
patch the imported module object in the deletion test.
2026-08-18 17:59:57 -07:00

1363 lines
54 KiB
Python

"""
Tests for the speech recognition manager.
"""
import sys
import unittest
from unittest.mock import MagicMock, patch
# Mock modules before importing any modules that might use them
sys.modules["vosk"] = MagicMock()
sys.modules["whisper"] = MagicMock()
sys.modules["requests"] = MagicMock()
sys.modules["pyaudio"] = MagicMock()
sys.modules["wave"] = MagicMock()
sys.modules["tempfile"] = MagicMock()
sys.modules["tqdm"] = MagicMock()
sys.modules["numpy"] = MagicMock()
sys.modules["zipfile"] = MagicMock()
# Import the shared mock from conftest
from conftest import mock_audio_feedback # noqa: E402
# Update import paths to use the new package structure
from vocalinux.common_types import RecognitionState # noqa: E402
from vocalinux.speech_recognition.command_processor import CommandProcessor # noqa: E402
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager # noqa: E402
class TestSpeechRecognition(unittest.TestCase):
"""Test cases for the speech recognition functionality."""
def setUp(self):
"""Set up for tests."""
# Create patches for our mocks
self.mockKaldi = patch.object(sys.modules["vosk"], "KaldiRecognizer")
self.mockModel = patch.object(sys.modules["vosk"], "Model")
self.mockMakeDirs = patch("os.makedirs")
self.mockThread = patch("threading.Thread")
self.mockPath = patch.object(SpeechRecognitionManager, "_get_vosk_model_path")
self.mockDownload = patch.object(SpeechRecognitionManager, "_download_vosk_model")
self.mockCmdProcessor = patch.object(CommandProcessor, "process_text")
# Start all patches
self.kaldiMock = self.mockKaldi.start()
self.modelMock = self.mockModel.start()
self.makeDirsMock = self.mockMakeDirs.start()
self.threadMock = self.mockThread.start()
self.pathMock = self.mockPath.start()
self.downloadMock = self.mockDownload.start()
self.cmdProcessorMock = self.mockCmdProcessor.start()
# Set up return values
self.recognizerMock = MagicMock()
self.kaldiMock.return_value = self.recognizerMock
self.pathMock.return_value = "/mock/path/vosk-model"
self.threadInstance = MagicMock()
self.threadMock.return_value = self.threadInstance
self.cmdProcessorMock.return_value = ("processed text", ["action1"])
# Critical: Set FinalResult to return valid JSON string
self.recognizerMock.FinalResult.return_value = '{"text": "test transcription"}'
# Reset audio feedback mocks before each test
mock_audio_feedback.play_start_sound.reset_mock()
mock_audio_feedback.play_stop_sound.reset_mock()
mock_audio_feedback.play_error_sound.reset_mock()
# Patch os.makedirs to avoid creating directories
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
# Patch os.path.exists to return True for any path
self.patcher_exists = patch("os.path.exists", return_value=True)
self.mock_exists = self.patcher_exists.start()
# Patch os.unlink to avoid removing files
self.patcher_unlink = patch("os.unlink")
self.mock_unlink = self.patcher_unlink.start()
# Patch tempfile.NamedTemporaryFile for whisper tests
self.patcher_temp = patch("tempfile.NamedTemporaryFile")
self.mock_temp = self.patcher_temp.start()
self.mock_temp_file = MagicMock()
self.mock_temp_file.__enter__ = MagicMock(return_value=self.mock_temp_file)
self.mock_temp_file.__exit__ = MagicMock(return_value=None)
self.mock_temp_file.name = "/tmp/test.wav"
self.mock_temp.return_value = self.mock_temp_file
# Patch wave.open for whisper tests
self.patcher_wave = patch("wave.open")
self.mock_wave = self.patcher_wave.start()
self.mock_wave_file = MagicMock()
self.mock_wave_file.__enter__ = MagicMock(return_value=self.mock_wave_file)
self.mock_wave_file.__exit__ = MagicMock(return_value=None)
self.mock_wave.return_value = self.mock_wave_file
def tearDown(self):
"""Clean up after tests."""
# Stop all patches
self.mockKaldi.stop()
self.mockModel.stop()
self.mockMakeDirs.stop()
self.mockThread.stop()
self.mockPath.stop()
self.mockDownload.stop()
self.mockCmdProcessor.stop()
self.patcher_makedirs.stop()
self.patcher_exists.stop()
self.patcher_unlink.stop()
self.patcher_temp.stop()
self.patcher_wave.stop()
def test_init(self):
"""Test initialization with different engines."""
# Test VOSK initialization
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
# Verify initial state
self.assertEqual(manager.state, RecognitionState.IDLE)
self.assertEqual(manager.engine, "vosk")
self.assertEqual(manager.model_size, "small")
self.assertFalse(manager.should_record)
self.assertEqual(manager.audio_buffer, [])
# Verify VOSK model was initialized
self.modelMock.assert_called_once()
self.kaldiMock.assert_called_once()
# Test invalid engine
with self.assertRaises(ValueError):
SpeechRecognitionManager(engine="invalid")
def test_register_callbacks(self):
"""Test callback registration."""
manager = SpeechRecognitionManager(engine="vosk")
# Create mock callbacks
text_callback = MagicMock()
state_callback = MagicMock()
action_callback = MagicMock()
# Register callbacks
manager.register_text_callback(text_callback)
manager.register_state_callback(state_callback)
manager.register_action_callback(action_callback)
# Verify callbacks were registered
self.assertEqual(manager.text_callbacks, [text_callback])
self.assertEqual(manager.state_callbacks, [state_callback])
self.assertEqual(manager.action_callbacks, [action_callback])
# Test state update
manager._update_state(RecognitionState.LISTENING)
self.assertEqual(manager.state, RecognitionState.LISTENING)
state_callback.assert_called_once_with(RecognitionState.LISTENING)
def test_process_buffer(self):
"""Test processing audio buffer."""
# Setup for test
manager = SpeechRecognitionManager(engine="vosk")
# Register callbacks
text_callback = MagicMock()
action_callback = MagicMock()
manager.register_text_callback(text_callback)
manager.register_action_callback(action_callback)
# Setup audio buffer
manager.audio_buffer = [b"data1", b"data2"]
# Process buffer
manager._process_final_buffer()
# Verify Vosk methods were called
self.recognizerMock.AcceptWaveform.assert_any_call(b"data1")
self.recognizerMock.AcceptWaveform.assert_any_call(b"data2")
self.recognizerMock.FinalResult.assert_called_once()
# Verify command processor was called
self.cmdProcessorMock.assert_called_once_with("test transcription")
# Verify callbacks were called
text_callback.assert_called_once_with("processed text")
action_callback.assert_called_once_with("action1")
def test_start_stop_recognition(self):
"""Test starting and stopping recognition."""
manager = SpeechRecognitionManager(engine="vosk")
# Start recognition
manager.start_recognition()
# Verify state and calls
self.assertEqual(manager.state, RecognitionState.LISTENING)
self.assertTrue(manager.should_record)
mock_audio_feedback.play_start_sound.assert_called_once()
self.assertEqual(self.threadMock.call_count, 2)
self.threadInstance.start.assert_called()
# Reset mocks
self.threadMock.reset_mock()
self.threadInstance.reset_mock()
# Already listening, should not start again
manager.start_recognition()
self.threadMock.assert_not_called()
# Stop recognition
manager.audio_thread = self.threadInstance
manager.recognition_thread = self.threadInstance
manager.stop_recognition()
# Verify stopped state
self.assertEqual(manager.state, RecognitionState.IDLE)
self.assertFalse(manager.should_record)
mock_audio_feedback.play_stop_sound.assert_called_once()
self.threadInstance.join.assert_called()
def test_whisper_engine(self):
"""Test initialization and usage with Whisper engine."""
# Setup Whisper and torch mocks
whisper_mock = MagicMock()
torch_mock = MagicMock()
whisper_mock.load_model = MagicMock()
torch_mock.cuda.is_available.return_value = False
model_mock = MagicMock()
whisper_mock.load_model.return_value = model_mock
# Patch both whisper and torch modules
with patch.dict("sys.modules", {"whisper": whisper_mock, "torch": torch_mock}):
# Create manager with Whisper engine
manager = SpeechRecognitionManager(engine="whisper", model_size="medium")
# Verify Whisper was initialized
self.assertEqual(manager.engine, "whisper")
self.assertEqual(manager.model_size, "medium")
whisper_mock.load_model.assert_called_once()
# Instead of calling the actual _process_final_buffer method which does file operations,
# let's test the Whisper functionality by directly mocking that method
original_process = manager._process_final_buffer
# Replace _process_final_buffer with our own implementation for testing
def mock_process():
# Skip file operations, just simulate the Whisper transcription directly
# Mock the Whisper result and process it
processed_text, actions = self.cmdProcessorMock("whisper test")
# Call the callbacks
for callback in manager.text_callbacks:
callback(processed_text)
for callback in manager.action_callbacks:
for action in actions:
callback(action)
# Replace the method with our mock implementation
manager._process_final_buffer = mock_process
# Register callbacks
text_callback = MagicMock()
manager.register_text_callback(text_callback)
# Mock command processor
self.cmdProcessorMock.return_value = ("processed whisper", [])
# Call the mocked process method
manager._process_final_buffer()
# Verify callback was called
text_callback.assert_called_with("processed whisper")
# Restore the original method
manager._process_final_buffer = original_process
def test_vosk_model_path(self):
"""Test model path generation."""
# Disable our path mock to test actual implementation
self.mockPath.stop()
# Test with different model sizes
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
path = manager._get_vosk_model_path()
self.assertIn("small", path)
manager = SpeechRecognitionManager(engine="vosk", model_size="medium")
path = manager._get_vosk_model_path()
self.assertIn("0.22", path)
manager = SpeechRecognitionManager(engine="vosk", model_size="large")
path = manager._get_vosk_model_path()
self.assertIn("0.22", path) # Updated to match actual implementation
# Restart our mock
self.pathMock = self.mockPath.start()
self.pathMock.return_value = "/mock/path/vosk-model"
def test_configure(self):
"""Test configuration method."""
manager = SpeechRecognitionManager(engine="vosk")
# Default values
self.assertEqual(manager.vad_sensitivity, 3)
self.assertEqual(manager.silence_timeout, 2.0)
# Configure with valid values
manager.reconfigure(vad_sensitivity=4, silence_timeout=1.5)
self.assertEqual(manager.vad_sensitivity, 4)
self.assertEqual(manager.silence_timeout, 1.5)
# Test bounds checking
manager.reconfigure(vad_sensitivity=10, silence_timeout=10.0)
self.assertEqual(manager.vad_sensitivity, 5) # Max is 5
self.assertEqual(manager.silence_timeout, 5.0) # Max is 5.0
manager.reconfigure(vad_sensitivity=0, silence_timeout=0.0)
self.assertEqual(manager.vad_sensitivity, 1) # Min is 1
self.assertEqual(manager.silence_timeout, 0.5) # Min is 0.5
def test_unregister_text_callback(self):
"""Test unregistering text callbacks."""
manager = SpeechRecognitionManager(engine="vosk")
callback = MagicMock()
manager.register_text_callback(callback)
self.assertIn(callback, manager.text_callbacks)
manager.unregister_text_callback(callback)
self.assertNotIn(callback, manager.text_callbacks)
# Unregistering non-existent callback should not raise
manager.unregister_text_callback(callback)
def test_get_set_text_callbacks(self):
"""Test getting and setting text callbacks."""
manager = SpeechRecognitionManager(engine="vosk")
callback1 = MagicMock()
callback2 = MagicMock()
manager.register_text_callback(callback1)
callbacks = manager.get_text_callbacks()
self.assertEqual(callbacks, [callback1])
# Verify it's a copy
callbacks.append(callback2)
self.assertEqual(len(manager.text_callbacks), 1)
# Set callbacks
manager.set_text_callbacks([callback1, callback2])
self.assertEqual(len(manager.text_callbacks), 2)
def test_audio_level_callbacks(self):
"""Test audio level callback registration."""
manager = SpeechRecognitionManager(engine="vosk")
callback = MagicMock()
manager.register_audio_level_callback(callback)
self.assertIn(callback, manager._audio_level_callbacks)
manager.unregister_audio_level_callback(callback)
self.assertNotIn(callback, manager._audio_level_callbacks)
# Unregistering non-existent callback should not raise
manager.unregister_audio_level_callback(callback)
def test_audio_device_management(self):
"""Test audio device get/set."""
manager = SpeechRecognitionManager(engine="vosk")
# Default is None
self.assertIsNone(manager.get_audio_device())
# Set device
manager.set_audio_device(2)
self.assertEqual(manager.get_audio_device(), 2)
# Set back to None
manager.set_audio_device(None)
self.assertIsNone(manager.get_audio_device())
def test_get_last_audio_level(self):
"""Test getting last audio level."""
manager = SpeechRecognitionManager(engine="vosk")
# Initially 0
self.assertEqual(manager.get_last_audio_level(), 0.0)
# Set a value directly
manager._last_audio_level = 50.5
self.assertEqual(manager.get_last_audio_level(), 50.5)
def test_model_ready_property(self):
"""Test model_ready property."""
manager = SpeechRecognitionManager(engine="vosk")
# Should be ready after init with mocked model
self.assertTrue(manager.model_ready)
# Set model to None
manager.model = None
self.assertFalse(manager.model_ready)
# Set initialized to False
manager.model = MagicMock()
manager._model_initialized = False
self.assertFalse(manager.model_ready)
def test_download_progress_callback(self):
"""Test download progress callback setting."""
manager = SpeechRecognitionManager(engine="vosk")
callback = MagicMock()
manager.set_download_progress_callback(callback)
self.assertEqual(manager._download_progress_callback, callback)
# Clear callback
manager.set_download_progress_callback(None)
self.assertIsNone(manager._download_progress_callback)
def test_cancel_download(self):
"""Test download cancellation."""
manager = SpeechRecognitionManager(engine="vosk")
self.assertFalse(manager._download_cancelled)
manager.cancel_download()
self.assertTrue(manager._download_cancelled)
def test_reconfigure_audio_device(self):
"""Test reconfiguring audio device index."""
manager = SpeechRecognitionManager(engine="vosk")
# Set device via reconfigure
manager.reconfigure(audio_device_index=1)
self.assertEqual(manager.audio_device_index, 1)
# Clear with -1
manager.reconfigure(audio_device_index=-1)
self.assertIsNone(manager.audio_device_index)
def test_reconfigure_engine_change(self):
"""Test reconfiguring to a different engine."""
# Setup Whisper and torch mocks
whisper_mock = MagicMock()
torch_mock = MagicMock()
whisper_mock.load_model = MagicMock()
torch_mock.cuda.is_available.return_value = False
manager = SpeechRecognitionManager(engine="vosk")
self.assertEqual(manager.engine, "vosk")
with patch.dict("sys.modules", {"whisper": whisper_mock, "torch": torch_mock}):
manager.reconfigure(engine="whisper", force_download=False)
self.assertEqual(manager.engine, "whisper")
def test_reconfigure_model_size_change(self):
"""Test reconfiguring model size."""
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
self.assertEqual(manager.model_size, "small")
manager.reconfigure(model_size="medium", force_download=False)
self.assertEqual(manager.model_size, "medium")
def test_reconfigure_language_change(self):
"""Test reconfiguring language."""
manager = SpeechRecognitionManager(engine="vosk", language="en-us")
self.assertEqual(manager.language, "en-us")
manager.reconfigure(language="de", force_download=False)
self.assertEqual(manager.language, "de")
def test_start_recognition_not_ready(self):
"""Test starting recognition when model is not ready."""
manager = SpeechRecognitionManager(engine="vosk")
manager._model_initialized = False
manager.model = None
# Should not start and should play error sound
self.assertFalse(manager.start_recognition())
self.assertEqual(manager.state, RecognitionState.IDLE)
mock_audio_feedback.play_error_sound.assert_called()
def test_stop_recognition_when_idle(self):
"""Test stopping recognition when already idle."""
manager = SpeechRecognitionManager(engine="vosk")
self.assertEqual(manager.state, RecognitionState.IDLE)
# Should do nothing
manager.stop_recognition()
self.assertEqual(manager.state, RecognitionState.IDLE)
def test_process_empty_buffer(self):
"""Test processing empty buffer does nothing."""
manager = SpeechRecognitionManager(engine="vosk")
manager.audio_buffer = []
# Should return without error
manager._process_final_buffer()
# Recognizer should not be called
self.recognizerMock.AcceptWaveform.assert_not_called()
def test_process_buffer_unknown_engine(self):
"""Test processing buffer with unknown engine."""
manager = SpeechRecognitionManager(engine="vosk")
manager.engine = "unknown"
manager.audio_buffer = [b"data"]
# Should log error but not crash
manager._process_final_buffer()
def test_init_with_kwargs(self):
"""Test initialization with additional kwargs."""
manager = SpeechRecognitionManager(
engine="vosk", vad_sensitivity=4, silence_timeout=1.5, audio_device_index=2
)
self.assertEqual(manager.vad_sensitivity, 4)
self.assertEqual(manager.silence_timeout, 1.5)
self.assertEqual(manager.audio_device_index, 2)
def test_init_with_device_name(self):
"""Test initialization with audio_device_name kwarg."""
manager = SpeechRecognitionManager(
engine="vosk", audio_device_index=3, audio_device_name="USB Mic"
)
self.assertEqual(manager.audio_device_index, 3)
self.assertEqual(manager.get_audio_device_name(), "USB Mic")
def test_set_audio_device_with_name(self):
"""Test set_audio_device stores both index and name."""
manager = SpeechRecognitionManager(engine="vosk")
manager.set_audio_device(5, "BRIO Webcam")
self.assertEqual(manager.get_audio_device(), 5)
self.assertEqual(manager.get_audio_device_name(), "BRIO Webcam")
def test_set_audio_device_clears_name(self):
"""Test set_audio_device(None, None) clears both."""
manager = SpeechRecognitionManager(engine="vosk", audio_device_name="Mic")
manager.set_audio_device(None, None)
self.assertIsNone(manager.get_audio_device())
self.assertIsNone(manager.get_audio_device_name())
def test_reconfigure_with_device_name(self):
"""Test reconfigure accepts audio_device_name."""
manager = SpeechRecognitionManager(engine="vosk")
manager.reconfigure(audio_device_index=2, audio_device_name="Headset")
self.assertEqual(manager.audio_device_index, 2)
self.assertEqual(manager.get_audio_device_name(), "Headset")
class TestModuleLevelFunctions(unittest.TestCase):
"""Test module-level functions in recognition_manager."""
def setUp(self):
"""Set up patches."""
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
def tearDown(self):
"""Clean up patches."""
self.patcher_makedirs.stop()
def test_get_audio_input_devices(self):
"""Test getting audio input devices."""
from vocalinux.speech_recognition import recognition_manager
# Mock PyAudio
mock_pyaudio_instance = MagicMock()
mock_pyaudio_instance.get_default_input_device_info.return_value = {"index": 0}
mock_pyaudio_instance.get_device_count.return_value = 2
mock_pyaudio_instance.get_device_info_by_index.side_effect = [
{"name": "Built-in Mic", "maxInputChannels": 1},
{"name": "USB Mic", "maxInputChannels": 2},
]
mock_pyaudio = MagicMock()
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
# Need to reload to pick up the mock
devices = recognition_manager.get_audio_input_devices()
# With mocking, it may return empty if pyaudio was already imported
# The important thing is it doesn't crash
def test_get_audio_input_devices_no_default(self):
"""Test getting audio devices when no default is set."""
from vocalinux.speech_recognition import recognition_manager
mock_pyaudio_instance = MagicMock()
mock_pyaudio_instance.get_default_input_device_info.side_effect = IOError("No default")
mock_pyaudio_instance.get_device_count.return_value = 1
mock_pyaudio_instance.get_device_info_by_index.return_value = {
"name": "Mic",
"maxInputChannels": 1,
}
mock_pyaudio = MagicMock()
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
devices = recognition_manager.get_audio_input_devices()
def test_get_audio_input_devices_skips_unreadable_device(self):
"""Test unreadable devices are skipped during enumeration."""
from vocalinux.speech_recognition import recognition_manager
mock_pyaudio_instance = MagicMock()
mock_pyaudio_instance.get_default_input_device_info.return_value = {"index": 1}
mock_pyaudio_instance.get_device_count.return_value = 2
mock_pyaudio_instance.get_device_info_by_index.side_effect = [
IOError("device disappeared"),
{"name": "USB Mic", "maxInputChannels": 1},
]
mock_pyaudio = MagicMock()
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
devices = recognition_manager.get_audio_input_devices()
self.assertEqual(devices, [(1, "USB Mic", True)])
def test_get_audio_input_devices_skips_output_only_device(self):
"""Test output-only devices are skipped during enumeration."""
from vocalinux.speech_recognition import recognition_manager
mock_pyaudio_instance = MagicMock()
mock_pyaudio_instance.get_default_input_device_info.return_value = {"index": 1}
mock_pyaudio_instance.get_device_count.return_value = 2
mock_pyaudio_instance.get_device_info_by_index.side_effect = [
{"name": "HDMI Output", "maxInputChannels": 0},
{"name": "USB Mic", "maxInputChannels": 1},
]
mock_pyaudio = MagicMock()
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
devices = recognition_manager.get_audio_input_devices()
self.assertEqual(devices, [(1, "USB Mic", True)])
def test_get_audio_input_devices_import_error(self):
"""Test missing PyAudio returns an empty device list."""
from vocalinux.speech_recognition import recognition_manager
with patch.dict(sys.modules, {"pyaudio": None}):
devices = recognition_manager.get_audio_input_devices()
self.assertEqual(devices, [])
def test_get_audio_input_devices_pyaudio_error(self):
"""Test PyAudio enumeration errors return an empty device list."""
from vocalinux.speech_recognition import recognition_manager
mock_pyaudio = MagicMock()
mock_pyaudio.PyAudio.side_effect = OSError("boom")
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
devices = recognition_manager.get_audio_input_devices()
self.assertEqual(devices, [])
def test_is_virtual_device(self):
"""Test _is_virtual_device detects known virtual device patterns."""
from vocalinux.speech_recognition.recognition_manager import _is_virtual_device
# Virtual devices
self.assertTrue(_is_virtual_device("speech-dispatcher-dummy"))
self.assertTrue(_is_virtual_device("speech-dispatcher-generic"))
self.assertTrue(_is_virtual_device("Dummy Output"))
self.assertTrue(_is_virtual_device("Null Sink"))
self.assertTrue(_is_virtual_device("Monitor of Built-in Audio"))
self.assertTrue(_is_virtual_device("alsa_output.pci-0000"))
# Real devices
self.assertFalse(_is_virtual_device("BRIO Ultra HD Webcam"))
self.assertFalse(_is_virtual_device("Built-in Microphone"))
self.assertFalse(_is_virtual_device("USB Headset"))
# Edge cases
self.assertFalse(_is_virtual_device(""))
self.assertFalse(_is_virtual_device(None))
def test_is_virtual_device_case_insensitive(self):
"""Test _is_virtual_device is case-insensitive."""
from vocalinux.speech_recognition.recognition_manager import _is_virtual_device
self.assertTrue(_is_virtual_device("SPEECH-DISPATCHER-DUMMY"))
self.assertTrue(_is_virtual_device("NULL SINK"))
self.assertTrue(_is_virtual_device("Monitor Of Something"))
def test_get_audio_input_devices_filters_virtual(self):
"""Test that get_audio_input_devices excludes virtual devices."""
from vocalinux.speech_recognition import recognition_manager
mock_pyaudio_instance = MagicMock()
mock_pyaudio_instance.get_default_input_device_info.return_value = {"index": 0}
mock_pyaudio_instance.get_device_count.return_value = 3
mock_pyaudio_instance.get_device_info_by_index.side_effect = [
{"name": "Real Mic", "maxInputChannels": 2},
{"name": "speech-dispatcher-dummy", "maxInputChannels": 2},
{"name": "USB Headset", "maxInputChannels": 1},
]
mock_pyaudio = MagicMock()
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}):
devices = recognition_manager.get_audio_input_devices()
# Should only have 2 devices (virtual one filtered out)
device_names = [d[1] for d in devices]
self.assertIn("Real Mic", device_names)
self.assertIn("USB Headset", device_names)
self.assertNotIn("speech-dispatcher-dummy", device_names)
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
def test_resolve_device_by_name_found(self, mock_valid):
"""Test _resolve_device_by_name finds a matching device and delegates validation."""
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
mock_valid.return_value = 1
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 3
mock_audio.get_device_info_by_index.side_effect = [
{"name": "Built-in Mic", "maxInputChannels": 1},
{"name": "BRIO Webcam", "maxInputChannels": 2},
{"name": "speech-dispatcher-dummy", "maxInputChannels": 2},
]
result = _resolve_device_by_name(mock_audio, "BRIO Webcam")
self.assertEqual(result, 1)
mock_valid.assert_called_once_with(mock_audio, 1)
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
def test_resolve_device_by_name_not_found_with_fallback(self, mock_valid):
"""Test _resolve_device_by_name falls back to index when name not found."""
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
mock_valid.return_value = 0
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 2
mock_audio.get_device_info_by_index.return_value = {
"name": "Mic",
"maxInputChannels": 1,
}
result = _resolve_device_by_name(mock_audio, "Missing Device", fallback_index=0)
self.assertEqual(result, 0)
mock_valid.assert_called_once_with(mock_audio, 0)
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
def test_resolve_device_by_name_skips_unreadable_device(self, mock_valid):
"""Test _resolve_device_by_name skips devices that cannot be read."""
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
mock_valid.return_value = 1
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 2
mock_audio.get_device_info_by_index.side_effect = [
OSError("device disappeared"),
{"name": "USB Mic", "maxInputChannels": 1},
]
result = _resolve_device_by_name(mock_audio, "USB Mic")
self.assertEqual(result, 1)
mock_valid.assert_called_once_with(mock_audio, 1)
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
def test_resolve_device_by_name_ignores_non_dict_device_info(self, mock_valid):
"""Test _resolve_device_by_name ignores malformed device info."""
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
mock_valid.return_value = 0
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 1
mock_audio.get_device_info_by_index.return_value = None
result = _resolve_device_by_name(mock_audio, "USB Mic", fallback_index=0)
self.assertEqual(result, 0)
mock_valid.assert_called_once_with(mock_audio, 0)
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
def test_resolve_device_by_name_not_found_no_fallback(self, mock_valid):
"""Test _resolve_device_by_name returns None when name not found and no fallback."""
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
mock_valid.return_value = None
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 1
mock_audio.get_device_info_by_index.return_value = {
"name": "Mic",
"maxInputChannels": 1,
}
result = _resolve_device_by_name(mock_audio, "Missing Device")
self.assertIsNone(result)
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
def test_resolve_device_by_name_empty_name_with_fallback(self, mock_valid):
"""Test _resolve_device_by_name with empty name uses fallback index."""
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
mock_valid.return_value = 0
mock_audio = MagicMock()
result = _resolve_device_by_name(mock_audio, None, fallback_index=0)
self.assertEqual(result, 0)
mock_valid.assert_called_once_with(mock_audio, 0)
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
def test_resolve_device_by_name_empty_name_no_fallback(self, mock_valid):
"""Test _resolve_device_by_name with empty name and no fallback returns None."""
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
mock_valid.return_value = None
mock_audio = MagicMock()
result = _resolve_device_by_name(mock_audio, None)
self.assertIsNone(result)
@patch("vocalinux.speech_recognition.recognition_manager._resolve_valid_input_device")
def test_resolve_device_by_name_get_count_error(self, mock_valid):
"""Test _resolve_device_by_name handles get_device_count errors."""
from vocalinux.speech_recognition.recognition_manager import _resolve_device_by_name
mock_valid.return_value = 0
mock_audio = MagicMock()
mock_audio.get_device_count.side_effect = IOError("boom")
result = _resolve_device_by_name(mock_audio, "Some Device", fallback_index=0)
self.assertEqual(result, 0)
mock_valid.assert_called_once_with(mock_audio, 0)
def test_show_notification(self):
"""Test _show_notification helper function."""
from vocalinux.speech_recognition.recognition_manager import _show_notification
with patch("subprocess.Popen") as mock_popen:
_show_notification("Test Title", "Test Message")
mock_popen.assert_called_once()
def test_show_notification_error(self):
"""Test _show_notification handles errors gracefully."""
from vocalinux.speech_recognition.recognition_manager import _show_notification
with patch("subprocess.Popen", side_effect=FileNotFoundError("notify-send not found")):
# Should not raise
_show_notification("Test Title", "Test Message")
def test_test_audio_input_success(self):
"""Test test_audio_input with successful recording."""
from vocalinux.speech_recognition import recognition_manager
# Create mock for numpy
mock_np = MagicMock()
mock_np.frombuffer.return_value = MagicMock()
mock_np.frombuffer.return_value.max.return_value = 1000
mock_np.frombuffer.return_value.mean.return_value = 500
mock_np.abs.return_value = mock_np.frombuffer.return_value
mock_np.int16 = "int16"
# Create mock for PyAudio
mock_stream = MagicMock()
mock_stream.read.return_value = b"\x00" * 2048
mock_pyaudio_instance = MagicMock()
mock_pyaudio_instance.get_device_info_by_index.return_value = {
"name": "Test Mic",
"defaultSampleRate": 16000,
}
mock_pyaudio_instance.open.return_value = mock_stream
mock_pyaudio_instance.paInt16 = 8
mock_pyaudio = MagicMock()
mock_pyaudio.PyAudio.return_value = mock_pyaudio_instance
mock_pyaudio.paInt16 = 8
with patch.dict(sys.modules, {"pyaudio": mock_pyaudio, "numpy": mock_np}):
result = recognition_manager.test_audio_input(device_index=0, duration=0.1)
# The function may return early due to mocking complexity
# Just verify no crash
def test_test_audio_input_import_error(self):
"""Test test_audio_input when pyaudio is not available."""
from vocalinux.speech_recognition import recognition_manager
# Save original
original_pyaudio = sys.modules.get("pyaudio")
# Remove pyaudio from modules to simulate import error
# This is tricky because the import happens inside the function
# We'll patch the import itself
with patch.dict(sys.modules, {"pyaudio": None}):
# Force import error by making pyaudio None
pass # The actual test would need to reload the module
class TestALSAErrorHandler(unittest.TestCase):
"""Tests for ALSA error handler setup."""
def test_setup_alsa_error_handler_success(self):
"""Test ALSA error handler setup when ALSA is available."""
from vocalinux.speech_recognition import recognition_manager
# The function is called at module load, so it was already executed
# Just verify the module loaded without crashing
self.assertTrue(hasattr(recognition_manager, "_alsa_handler"))
def test_setup_alsa_error_handler_oserror(self):
"""Test ALSA error handler returns None when ALSA not available."""
from vocalinux.speech_recognition.recognition_manager import _setup_alsa_error_handler
with patch("ctypes.CDLL", side_effect=OSError("ALSA not available")):
result = _setup_alsa_error_handler()
self.assertIsNone(result)
def test_setup_alsa_error_handler_attribute_error(self):
"""Test ALSA error handler returns None on AttributeError."""
from vocalinux.speech_recognition.recognition_manager import _setup_alsa_error_handler
mock_lib = MagicMock()
del mock_lib.snd_lib_error_set_handler # Remove the attribute
with patch("ctypes.CDLL", return_value=mock_lib):
result = _setup_alsa_error_handler()
self.assertIsNone(result)
class TestVoskModelPath(unittest.TestCase):
"""Tests for VOSK model path resolution."""
def setUp(self):
"""Set up patches for VOSK tests."""
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
def tearDown(self):
"""Clean up patches."""
self.patcher_makedirs.stop()
def test_get_vosk_model_path_from_system_dirs(self):
"""Test finding VOSK model in system directories."""
from vocalinux.speech_recognition.recognition_manager import (
MODELS_DIR,
SYSTEM_MODELS_DIRS,
SpeechRecognitionManager,
)
# Mock sys.modules for vosk
mock_vosk = MagicMock()
mock_vosk.Model = MagicMock()
mock_vosk.KaldiRecognizer = MagicMock()
# We need to mock os.path.exists to return True for system paths
with patch.dict(sys.modules, {"vosk": mock_vosk}):
with patch("os.path.exists") as mock_exists:
# Make user dir not exist but system dir exist
def exists_side_effect(path):
if SYSTEM_MODELS_DIRS[0] in path:
return True
return False
mock_exists.side_effect = exists_side_effect
manager = SpeechRecognitionManager(engine="vosk")
# Method already called in init, test passes if no exception
class TestInitializationEdgeCases(unittest.TestCase):
"""Test initialization edge cases."""
def setUp(self):
"""Set up patches."""
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
self.patcher_exists = patch("os.path.exists", return_value=True)
self.mock_exists = self.patcher_exists.start()
def tearDown(self):
"""Clean up patches."""
self.patcher_makedirs.stop()
self.patcher_exists.stop()
def test_init_vosk_import_error(self):
"""Test VOSK initialization when vosk module cannot be imported."""
# This is tricky to test since vosk is already mocked at module level
# We test that the error handling code path exists
pass
def test_init_vosk_with_preinstalled_marker(self):
"""Test detecting installer-provided model."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
mock_vosk = MagicMock()
mock_vosk.Model = MagicMock()
mock_vosk.KaldiRecognizer = MagicMock()
with patch.dict(sys.modules, {"vosk": mock_vosk}):
with patch("os.path.exists") as mock_exists:
def exists_check(path):
if ".vocalinux_preinstalled" in path:
return True
return True
mock_exists.side_effect = exists_check
manager = SpeechRecognitionManager(engine="vosk")
self.assertTrue(manager._model_initialized)
def test_init_vosk_model_not_found_deferred(self):
"""Test VOSK init with missing model and deferred download."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
mock_vosk = MagicMock()
mock_vosk.Model = MagicMock()
mock_vosk.KaldiRecognizer = MagicMock()
with patch.dict(sys.modules, {"vosk": mock_vosk}):
with patch("os.path.exists", return_value=False):
manager = SpeechRecognitionManager(engine="vosk", defer_download=True)
self.assertFalse(manager._model_initialized)
class TestRecognitionManagerMethods(unittest.TestCase):
"""Test additional SpeechRecognitionManager methods."""
def setUp(self):
"""Set up patches."""
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
self.patcher_exists = patch("os.path.exists", return_value=True)
self.mock_exists = self.patcher_exists.start()
mock_vosk = MagicMock()
mock_vosk.Model = MagicMock()
mock_vosk.KaldiRecognizer = MagicMock()
self.patcher_vosk = patch.dict(sys.modules, {"vosk": mock_vosk})
self.patcher_vosk.start()
def tearDown(self):
"""Clean up patches."""
self.patcher_makedirs.stop()
self.patcher_exists.stop()
self.patcher_vosk.stop()
def test_model_ready_property_true(self):
"""Test model_ready property when model is initialized."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager._model_initialized = True
manager.model = MagicMock()
self.assertTrue(manager.model_ready)
def test_model_ready_property_false_not_initialized(self):
"""Test model_ready property when not initialized."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager._model_initialized = False
self.assertFalse(manager.model_ready)
def test_model_ready_property_false_no_model(self):
"""Test model_ready property when model is None."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager._model_initialized = True
manager.model = None
self.assertFalse(manager.model_ready)
def test_update_state(self):
"""Test _update_state method."""
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
callback = MagicMock()
manager.register_state_callback(callback)
manager._update_state(RecognitionState.LISTENING)
self.assertEqual(manager.state, RecognitionState.LISTENING)
callback.assert_called_once_with(RecognitionState.LISTENING)
def test_update_state_multiple_callbacks(self):
"""Test _update_state with multiple callbacks."""
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
callback1 = MagicMock()
callback2 = MagicMock()
manager.register_state_callback(callback1)
manager.register_state_callback(callback2)
manager._update_state(RecognitionState.PROCESSING)
callback1.assert_called_once_with(RecognitionState.PROCESSING)
callback2.assert_called_once_with(RecognitionState.PROCESSING)
def test_set_download_progress_callback(self):
"""Test setting download progress callback."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
callback = MagicMock()
manager.set_download_progress_callback(callback)
self.assertEqual(manager._download_progress_callback, callback)
def test_cancel_download(self):
"""Test cancel download sets flag."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
self.assertFalse(manager._download_cancelled)
manager.cancel_download()
self.assertTrue(manager._download_cancelled)
def test_get_vosk_model_path_small(self):
"""Test _get_vosk_model_path for small model."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
path = manager._get_vosk_model_path()
self.assertIn("small", path.lower())
def test_get_vosk_model_path_medium(self):
"""Test _get_vosk_model_path for medium model."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk", model_size="medium")
path = manager._get_vosk_model_path()
# Medium model path should be valid
self.assertIsNotNone(path)
def test_get_text_callbacks(self):
"""Test getting text callbacks returns a copy."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
callback = MagicMock()
manager.register_text_callback(callback)
callbacks = manager.get_text_callbacks()
self.assertEqual(callbacks, [callback])
# Modifying returned list should not affect original
callbacks.append(MagicMock())
self.assertEqual(len(manager.text_callbacks), 1)
def test_set_text_callbacks(self):
"""Test setting text callbacks replaces all."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
callback1 = MagicMock()
callback2 = MagicMock()
manager.set_text_callbacks([callback1, callback2])
self.assertEqual(len(manager.text_callbacks), 2)
self.assertIn(callback1, manager.text_callbacks)
self.assertIn(callback2, manager.text_callbacks)
class TestProcessFinalBuffer(unittest.TestCase):
"""Test _process_final_buffer method."""
def setUp(self):
"""Set up patches."""
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
self.patcher_exists = patch("os.path.exists", return_value=True)
self.mock_exists = self.patcher_exists.start()
# Mock vosk
self.mock_vosk = MagicMock()
self.mock_recognizer = MagicMock()
self.mock_vosk.Model.return_value = MagicMock()
self.mock_vosk.KaldiRecognizer.return_value = self.mock_recognizer
self.patcher_vosk = patch.dict(sys.modules, {"vosk": self.mock_vosk})
self.patcher_vosk.start()
def tearDown(self):
"""Clean up patches."""
self.patcher_makedirs.stop()
self.patcher_exists.stop()
self.patcher_vosk.stop()
def test_process_final_buffer_empty(self):
"""Test processing empty buffer."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager.audio_buffer = []
# Should return without error
manager._process_final_buffer()
# Recognizer should not be called
self.mock_recognizer.AcceptWaveform.assert_not_called()
def test_process_final_buffer_vosk(self):
"""Test processing buffer with vosk."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager.audio_buffer = [b"data1", b"data2"]
# Mock recognizer result
self.mock_recognizer.FinalResult.return_value = '{"text": "hello world"}'
# Register callbacks
text_callback = MagicMock()
action_callback = MagicMock()
manager.register_text_callback(text_callback)
manager.register_action_callback(action_callback)
# Mock command processor
with patch.object(manager.command_processor, "process_text") as mock_process:
mock_process.return_value = ("processed hello world", [])
manager._process_final_buffer()
text_callback.assert_called_once_with("processed hello world")
def test_process_final_buffer_unknown_engine(self):
"""Test processing buffer with unknown engine."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager.engine = "unknown"
manager.audio_buffer = [b"data"]
# Should log error but not crash
manager._process_final_buffer()
def test_process_final_buffer_with_actions(self):
"""Test processing buffer returns actions."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager.audio_buffer = [b"data"]
# Mock recognizer result
self.mock_recognizer.FinalResult.return_value = '{"text": "delete that"}'
# Register callbacks
action_callback = MagicMock()
manager.register_action_callback(action_callback)
# Mock command processor to return an action
with patch.object(manager.command_processor, "process_text") as mock_process:
mock_process.return_value = ("", ["delete_last"])
manager._process_final_buffer()
action_callback.assert_called_once_with("delete_last")
def test_process_final_buffer_empty_processed_text(self):
"""Test processing when processed text is empty."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager.audio_buffer = [b"data"]
# Mock recognizer result
self.mock_recognizer.FinalResult.return_value = '{"text": "silence"}'
# Register callbacks
text_callback = MagicMock()
manager.register_text_callback(text_callback)
# Mock command processor to return empty text
with patch.object(manager.command_processor, "process_text") as mock_process:
mock_process.return_value = ("", [])
manager._process_final_buffer()
# Text callback should not be called for empty text
text_callback.assert_not_called()
class TestReconfigureMethod(unittest.TestCase):
"""Test reconfigure method."""
def setUp(self):
"""Set up patches."""
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
self.patcher_exists = patch("os.path.exists", return_value=True)
self.mock_exists = self.patcher_exists.start()
mock_vosk = MagicMock()
mock_vosk.Model = MagicMock()
mock_vosk.KaldiRecognizer = MagicMock()
self.patcher_vosk = patch.dict(sys.modules, {"vosk": mock_vosk})
self.patcher_vosk.start()
def tearDown(self):
"""Clean up patches."""
self.patcher_makedirs.stop()
self.patcher_exists.stop()
self.patcher_vosk.stop()
def test_reconfigure_vad_sensitivity_bounds(self):
"""Test VAD sensitivity is bounded to valid range."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
# Test upper bound
manager.reconfigure(vad_sensitivity=10)
self.assertEqual(manager.vad_sensitivity, 5)
# Test lower bound
manager.reconfigure(vad_sensitivity=0)
self.assertEqual(manager.vad_sensitivity, 1)
def test_reconfigure_silence_timeout_bounds(self):
"""Test silence timeout is bounded to valid range."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
# Test upper bound
manager.reconfigure(silence_timeout=10.0)
self.assertEqual(manager.silence_timeout, 5.0)
# Test lower bound
manager.reconfigure(silence_timeout=0.1)
self.assertEqual(manager.silence_timeout, 0.5)
def test_reconfigure_audio_device_clear(self):
"""Test clearing audio device with -1."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(engine="vosk")
manager.audio_device_index = 1
manager.reconfigure(audio_device_index=-1)
self.assertIsNone(manager.audio_device_index)