Files
voculinux/tests/test_recognition_manager.py
T
xavierk 80a3792576
CI / security (pull_request) Failing after 1m25s
CI / lint (pull_request) Failing after 34s
CI / ai-review (pull_request) Successful in 25s
chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/,
packaging, installers, docs and tests) on top of the Gitea template
commit. The project README replaces the template README, and the
template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
2026-11-06 11:51:44 +05:30

1380 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()
# os.makedirs is already patched above as mockMakeDirs. Patching it a
# second time here and then stopping the two out of order (this one last)
# restored the *first* mock into the os module, leaking a fake makedirs
# into every test that ran after this class.
self.mock_makedirs = self.makeDirsMock
# 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_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) -> None:
"""Test starting and stopping recognition."""
manager = SpeechRecognitionManager(engine="vosk")
initial_gate = manager._key_release_gate
# Start recognition
manager.start_recognition()
session_gate = manager._key_release_gate
self.assertIsNot(session_gate, initial_gate)
self.assertFalse(session_gate.is_set())
# 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
self.threadInstance.is_alive.return_value = True
self.threadInstance.join.side_effect = lambda *_args, **_kwargs: setattr(
self.threadInstance.is_alive, "return_value", False
)
manager._worker_exited.set()
manager.stop_recognition()
# Verify stopped state
self.assertEqual(manager.state, RecognitionState.IDLE)
self.assertFalse(manager.should_record)
self.assertTrue(session_gate.is_set())
mock_audio_feedback.play_stop_sound.assert_called_once()
self.threadInstance.join.assert_called()
manager.start_recognition()
self.assertIsNot(manager._key_release_gate, session_gate)
self.assertFalse(manager._key_release_gate.is_set())
manager.stop_recognition()
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, 1.2)
# 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.audio.capture._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.audio.capture._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.audio.capture._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.audio.capture._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.audio.capture._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.audio.capture._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.audio.capture._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.audio.capture._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.get_read_available.return_value = 1024
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)