* fix(recognition): queue long dictation segments before the buffer cap A held push-to-talk key (or a toggle session with no pause) keeps a single segment growing until the 5000-chunk safety cap discards its oldest 25%. The cap is a chunk count, not a duration, so the effective limit depends on the capture format: at 16 kHz a chunk is 64 ms (5 min 20 s), but a device that negotiates 44.1 kHz feeds 23 ms chunks and the same cap is 1 min 56 s. A 3-minute hold therefore lost ~87 s of speech before transcription, with only a log warning to show for it. Flush the current segment onto the existing transcription queue once it reaches _max_segment_seconds (90 s), or 75% of the chunk budget when chunks are shorter, so the destructive trim can never fire on buffered speech. Recording continues while queued segments are transcribed in order; reload sessions keep the existing hold-until-reload behaviour. Adds tests for the mid-hold flush, the buffer headroom, and the reload path. * fix(recognition): hold push-to-talk text until the key is released Text injection while PTT is held can release or modify the key. * fix(recognition): hold PTT text without blocking the recognition worker c7b9bd6 held transcribed text by waiting on the release gate inside _process_audio_buffer, on the single recognition worker. Later segments then sat in the 32-slot queue for the rest of the hold instead of being transcribed: memory grows with every queued segment, and once 32 are waiting (~48 min of 90 s segments) _enqueue_audio_segment starts dropping the oldest speech. Buffer the processed text instead. Every segment is transcribed as it arrives; while the key is down its result is appended to a held list, and the held results are delivered in capture order once the gate opens. The final buffer carries the session gate too, so it cannot overtake text that was transcribed before release. Held text is flushed when the worker exits, at the end of stop_recognition, and after the reload drain. An empty mid-hold result no longer flushes the held text early. Tests assert that every queued segment is transcribed before release and delivered in capture order afterwards. * fix(recognition): transcribe held push-to-talk segments ahead of release Keep transcription running during long push-to-talk holds while retaining each result until key release. Text injection during a hold can release or modify the PTT key. * test(recognition): cover cross-session held text isolation The per-session release gate must release only its own held text: a slow worker from an earlier session finishing after a new push-to-talk session starts must not deliver the new session's text while its key is down. --------- Co-authored-by: Muhannad Abo Hajia <muhannadabohajia@gmail.com> Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
1374 lines
54 KiB
Python
1374 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
|
|
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.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)
|