* fix(audio): bound capture reads and support cancellation * fix(recognition): bound and serialize capture shutdown * fix(recognition): cancel retries and report stalled microphones * fix(audio): prefer pulse when the ALSA default is unavailable * fix(recognition): guard late callbacks and finalize capture tests * docs(audio): explain input selection and stall recovery * fix(speech): enforce auto-pause across buffered reload timeouts * fix(speech): recover auto-resume after reload timeout * test(speech): fix auto-pause follow-up test regressions * fix(speech): retain auto-resume after reconfigure timeout * fix(tray): preserve auto-pause recovery across suspend * test(capture-shutdown): give the bare tray the suspend path's shortcut managers _on_system_suspend now releases the main and per-language hotkey listeners before sleep (#1025); the fixture builds TrayIndicator without __init__, so it needs the two attributes present. * fix(tray): release hotkey listeners on suspend even while auto-paused Merging #1025's suspend cleanup with this branch's auto-pause handoff left an early return that skipped shortcut_manager.stop() whenever a configured game was active, keeping EVIOCGRABs held through sleep. The paused branch now only owns the model-lifecycle work; listener release and stopping active recognition run for every suspend. * fix(tray): hold the apply lock only around the download's verify and save Wrapping the whole recommended-model download in _apply_settings_lock blocked every GTK-thread settings apply (e.g. Test Dictation) for the length of the transfer. The engine already serializes reconfigures, so the lock only needs to cover verifying the download still owns the receipt and saving it; an apply that lands mid-download now proceeds instead of freezing the app. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
583 lines
23 KiB
Python
583 lines
23 KiB
Python
"""
|
|
Tests for speech recognition functionality.
|
|
"""
|
|
|
|
import sys # noqa: F401
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# Create proper mock responses that can be safely JSON serialized
|
|
MOCK_VOSK_RESULT = '{"text": "test transcription"}'
|
|
|
|
|
|
# We need to patch the entire vosk Model class and KaldiRecognizer
|
|
class MockModel:
|
|
def __init__(self, path):
|
|
pass
|
|
|
|
|
|
class MockKaldiRecognizer:
|
|
def __init__(self, model, rate):
|
|
pass
|
|
|
|
def AcceptWaveform(self, data):
|
|
return True
|
|
|
|
def FinalResult(self):
|
|
return MOCK_VOSK_RESULT
|
|
|
|
def PartialResult(self):
|
|
return '{"partial": "test"}'
|
|
|
|
|
|
# Create a proper mock vosk module that uses our custom classes
|
|
class MockVoskModule:
|
|
Model = MockModel
|
|
KaldiRecognizer = MockKaldiRecognizer
|
|
|
|
|
|
# Path modules before importing the recognition_manager
|
|
sys.modules["vosk"] = MockVoskModule
|
|
|
|
# Mock other required modules
|
|
sys.modules["pyaudio"] = MagicMock()
|
|
sys.modules["numpy"] = MagicMock()
|
|
sys.modules["whisper"] = MagicMock()
|
|
sys.modules["whisper"].load_model = MagicMock(return_value=MagicMock())
|
|
sys.modules["torch"] = MagicMock()
|
|
sys.modules["torch"].cuda.is_available = MagicMock(return_value=False)
|
|
|
|
# Mock tempfile and wave modules to avoid file system issues
|
|
mock_tempfile = MagicMock()
|
|
mock_temp_file = MagicMock()
|
|
mock_temp_file.name = "/tmp/tmpfile.wav"
|
|
mock_temp_file.__enter__ = MagicMock(return_value=mock_temp_file)
|
|
mock_temp_file.__exit__ = MagicMock(return_value=None)
|
|
mock_tempfile.NamedTemporaryFile = MagicMock(return_value=mock_temp_file)
|
|
sys.modules["tempfile"] = mock_tempfile
|
|
|
|
mock_wave = MagicMock()
|
|
mock_wave_file = MagicMock()
|
|
mock_wave_file.__enter__ = MagicMock(return_value=mock_wave_file)
|
|
mock_wave_file.__exit__ = MagicMock(return_value=None)
|
|
mock_wave.open = MagicMock(return_value=mock_wave_file)
|
|
sys.modules["wave"] = mock_wave
|
|
|
|
# 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.recognition_manager import SpeechRecognitionManager # noqa: E402
|
|
from vocalinux.utils.vosk_model_info import VOSK_MODEL_INFO # noqa: E402
|
|
|
|
|
|
class TestSpeechRecognition(unittest.TestCase):
|
|
"""Test cases for the speech recognition functionality."""
|
|
|
|
def setUp(self):
|
|
"""Set up for tests."""
|
|
# Patch os.makedirs to avoid creating directories
|
|
self.patcher_makedirs = patch("os.makedirs")
|
|
self.mock_makedirs = self.patcher_makedirs.start()
|
|
|
|
# Patch os.path.exists to return True for any path
|
|
self.patcher_exists = patch("os.path.exists", return_value=True)
|
|
self.mock_exists = self.patcher_exists.start()
|
|
|
|
# Mock the command processor
|
|
self.patcher_cmd = patch(
|
|
"vocalinux.speech_recognition.recognition_manager.CommandProcessor"
|
|
)
|
|
self.mock_cmd_class = self.patcher_cmd.start()
|
|
self.mock_cmd = MagicMock()
|
|
self.mock_cmd_class.return_value = self.mock_cmd
|
|
|
|
# Mock threading to avoid thread creation
|
|
self.patcher_thread = patch(
|
|
"vocalinux.speech_recognition.recognition_manager.threading.Thread"
|
|
)
|
|
self.mock_thread_class = self.patcher_thread.start()
|
|
self.mock_thread = MagicMock()
|
|
self.mock_thread_class.return_value = self.mock_thread
|
|
|
|
# Use the module-level audio feedback mock
|
|
self.mock_play_start = mock_audio_feedback.play_start_sound
|
|
self.mock_play_stop = mock_audio_feedback.play_stop_sound
|
|
self.mock_play_error = mock_audio_feedback.play_error_sound
|
|
|
|
# Patch the download method for vosk models
|
|
self.patcher_download = patch.object(SpeechRecognitionManager, "_download_vosk_model")
|
|
self.mock_download = self.patcher_download.start()
|
|
|
|
# Patch os.unlink to avoid file removal errors
|
|
self.patcher_unlink = patch("os.unlink")
|
|
self.mock_unlink = self.patcher_unlink.start()
|
|
|
|
def tearDown(self):
|
|
"""Clean up after tests."""
|
|
self.patcher_makedirs.stop()
|
|
self.patcher_exists.stop()
|
|
self.patcher_cmd.stop()
|
|
self.patcher_thread.stop()
|
|
self.patcher_download.stop()
|
|
self.patcher_unlink.stop()
|
|
|
|
# Reset the audio feedback mocks for the next test
|
|
mock_audio_feedback.reset_mock()
|
|
|
|
def test_init_state(self):
|
|
"""Test the initial state of the speech recognition manager."""
|
|
# 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, [])
|
|
self.assertEqual(manager.text_callbacks, [])
|
|
self.assertEqual(manager.state_callbacks, [])
|
|
self.assertEqual(manager.action_callbacks, [])
|
|
|
|
# Test Whisper initialization with different model size
|
|
manager = SpeechRecognitionManager(engine="whisper", model_size="medium")
|
|
|
|
# Verify Whisper initialization
|
|
self.assertEqual(manager.engine, "whisper")
|
|
self.assertEqual(manager.model_size, "medium")
|
|
|
|
# Test invalid engine
|
|
with self.assertRaises(ValueError):
|
|
SpeechRecognitionManager(engine="invalid")
|
|
|
|
def test_voice_commands_auto_mode_follows_engine(self):
|
|
vosk_manager = SpeechRecognitionManager(
|
|
engine="vosk", model_size="small", voice_commands_enabled=None
|
|
)
|
|
self.assertTrue(getattr(vosk_manager, "_voice_commands_enabled"))
|
|
|
|
whisper_manager = SpeechRecognitionManager(
|
|
engine="whisper", model_size="small", voice_commands_enabled=None
|
|
)
|
|
self.assertFalse(getattr(whisper_manager, "_voice_commands_enabled"))
|
|
|
|
def test_voice_commands_auto_mode_updates_on_engine_reconfigure(self):
|
|
manager = SpeechRecognitionManager(engine="whisper", voice_commands_enabled=None)
|
|
self.assertFalse(getattr(manager, "_voice_commands_enabled"))
|
|
|
|
manager.reconfigure(engine="vosk", force_download=False)
|
|
self.assertTrue(getattr(manager, "_voice_commands_enabled"))
|
|
|
|
def test_voice_commands_toggle_updates_on_reconfigure(self):
|
|
manager = SpeechRecognitionManager(engine="vosk", voice_commands_enabled=True)
|
|
self.assertTrue(getattr(manager, "_voice_commands_enabled"))
|
|
|
|
manager.reconfigure(voice_commands_enabled=False, force_download=False)
|
|
self.assertFalse(getattr(manager, "_voice_commands_enabled"))
|
|
|
|
def test_callbacks(self):
|
|
"""Test registering and using callbacks."""
|
|
manager = SpeechRecognitionManager(engine="vosk")
|
|
|
|
# Replace recognizer with a mock that returns proper JSON
|
|
mock_recognizer = MagicMock()
|
|
mock_recognizer.FinalResult.return_value = MOCK_VOSK_RESULT
|
|
mock_recognizer.AcceptWaveform.return_value = True
|
|
manager.recognizer = mock_recognizer
|
|
|
|
# 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)
|
|
|
|
# Test text processing with callbacks
|
|
# Setup command processor mock return values
|
|
self.mock_cmd.process_text.return_value = ("processed text", ["action1"])
|
|
|
|
# Prepare audio buffer for processing
|
|
manager.audio_buffer = [b"audio_data"]
|
|
|
|
# Process the buffer
|
|
manager._process_final_buffer()
|
|
|
|
# Verify command processor was called
|
|
self.mock_cmd.process_text.assert_called_once_with("test transcription")
|
|
|
|
# Verify text callback was called
|
|
text_callback.assert_called_once_with("processed text")
|
|
|
|
# Verify action callback was called
|
|
action_callback.assert_called_once_with("action1")
|
|
|
|
def test_vosk_model_path(self):
|
|
"""Test getting the VOSK model path based on size."""
|
|
with patch.object(SpeechRecognitionManager, "_get_vosk_model_path") as mock_get_path:
|
|
# Test small model size
|
|
mock_get_path.return_value = "/path/to/vosk-model-small-en-us-0.15"
|
|
_ = SpeechRecognitionManager(
|
|
engine="vosk", model_size="small", voice_commands_enabled=True
|
|
)
|
|
|
|
# Verify the small model path is constructed correctly
|
|
mock_get_path.assert_called_with()
|
|
|
|
# Test medium model size
|
|
mock_get_path.return_value = "/path/to/vosk-model-en-us-0.22"
|
|
_ = SpeechRecognitionManager(engine="vosk", model_size="medium")
|
|
|
|
# Verify the medium model path is constructed correctly
|
|
mock_get_path.assert_called_with()
|
|
|
|
# Test large model size
|
|
mock_get_path.return_value = "/path/to/vosk-model-en-us-0.42"
|
|
_ = SpeechRecognitionManager(engine="vosk", model_size="large")
|
|
|
|
# Verify the large model path is constructed correctly
|
|
mock_get_path.assert_called_with()
|
|
|
|
def test_vosk_medium_and_large_cover_all_small_languages(self):
|
|
"""Regression test for issue #550.
|
|
|
|
The 'medium' and 'large' VOSK model tables must map every language
|
|
that 'small' supports. Italian ('it') and English-India ('en-in')
|
|
were previously missing from 'medium'/'large', which caused
|
|
vosk_model_map[size] to resolve to None and crash later with
|
|
"join() argument must be str, bytes, or os.PathLike object, not
|
|
'NoneType'" instead of downloading the (existing) big model.
|
|
"""
|
|
small_languages = set(VOSK_MODEL_INFO["small"]["languages"])
|
|
for size in ("medium", "large"):
|
|
size_languages = set(VOSK_MODEL_INFO[size]["languages"])
|
|
missing = small_languages - size_languages
|
|
self.assertEqual(
|
|
missing,
|
|
set(),
|
|
f"VOSK_MODEL_INFO['{size}']['languages'] is missing languages "
|
|
f"that 'small' supports: {missing}. Any language present in "
|
|
"'small' must also resolve to a model for 'medium'/'large', "
|
|
"otherwise initialization crashes with a NoneType path error.",
|
|
)
|
|
|
|
def test_supported_languages_vosk_entries_match_model_tables(self):
|
|
"""Every catalog language with a VOSK model must appear in size tables."""
|
|
from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES
|
|
|
|
for lang_code, lang_info in SUPPORTED_LANGUAGES.items():
|
|
vosk_model = lang_info.get("vosk")
|
|
if not vosk_model:
|
|
continue
|
|
self.assertEqual(
|
|
VOSK_MODEL_INFO["small"]["languages"].get(lang_code),
|
|
vosk_model,
|
|
f"SUPPORTED_LANGUAGES['{lang_code}']['vosk'] must match "
|
|
f"VOSK_MODEL_INFO['small']['languages']['{lang_code}']",
|
|
)
|
|
for size in ("medium", "large"):
|
|
self.assertIn(
|
|
lang_code,
|
|
VOSK_MODEL_INFO[size]["languages"],
|
|
f"VOSK language '{lang_code}' missing from '{size}' table",
|
|
)
|
|
|
|
def test_hungarian_is_whisper_only_in_catalog(self):
|
|
"""Hungarian (issue #565) is exposed for Whisper engines, not VOSK."""
|
|
from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES
|
|
|
|
hu = SUPPORTED_LANGUAGES["hu"]
|
|
self.assertEqual(hu["whisper"], "hu")
|
|
self.assertIsNone(hu["vosk"])
|
|
self.assertNotIn("hu", VOSK_MODEL_INFO["small"]["languages"])
|
|
|
|
def test_slovenian_is_whisper_only_in_catalog(self) -> None:
|
|
"""Slovenian (issue #970) is exposed for Whisper engines, not VOSK or Slovak."""
|
|
from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES
|
|
|
|
slovenian = SUPPORTED_LANGUAGES["sl"]
|
|
self.assertEqual(slovenian["name"], "Slovenian")
|
|
self.assertEqual(slovenian["whisper"], "sl")
|
|
self.assertIsNone(slovenian["vosk"])
|
|
for size in ("small", "medium", "large"):
|
|
self.assertNotIn("sl", VOSK_MODEL_INFO[size]["languages"])
|
|
self.assertNotIn("sk", SUPPORTED_LANGUAGES)
|
|
|
|
def test_resolve_whisper_language_maps_english_variants(self):
|
|
"""Catalog ids like en-us/en-in must become Whisper's 'en' code."""
|
|
from unittest.mock import patch
|
|
|
|
from vocalinux.speech_recognition.recognition_manager import resolve_whisper_language
|
|
|
|
self.assertEqual(resolve_whisper_language("en-us"), "en")
|
|
self.assertEqual(resolve_whisper_language("en-in"), "en")
|
|
self.assertEqual(resolve_whisper_language("hu"), "hu")
|
|
self.assertEqual(resolve_whisper_language("sl"), "sl")
|
|
self.assertEqual(resolve_whisper_language("ja"), "ja")
|
|
self.assertIsNone(resolve_whisper_language("auto"))
|
|
# Unknown catalog ids pass through unchanged.
|
|
self.assertEqual(resolve_whisper_language("zz-unknown"), "zz-unknown")
|
|
|
|
# Legacy fallback when a code is missing from the catalog entirely.
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.SUPPORTED_LANGUAGES",
|
|
{},
|
|
):
|
|
self.assertEqual(resolve_whisper_language("en-us"), "en")
|
|
self.assertEqual(resolve_whisper_language("fr"), "fr")
|
|
|
|
# Entry present but without a whisper field still falls through.
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.SUPPORTED_LANGUAGES",
|
|
{"legacy": {"name": "Legacy", "vosk": None}},
|
|
):
|
|
self.assertEqual(resolve_whisper_language("legacy"), "legacy")
|
|
|
|
def test_vosk_init_italian_medium_model_resolves_correctly(self):
|
|
"""Selecting Italian + the 'medium' VOSK model must not crash (issue #550)."""
|
|
manager = SpeechRecognitionManager(engine="vosk", language="it", model_size="medium")
|
|
|
|
self.assertEqual(
|
|
manager.vosk_model_map["medium"],
|
|
VOSK_MODEL_INFO["medium"]["languages"]["it"],
|
|
)
|
|
self.assertIn("vosk-model-it-0.22", manager.vosk_model_path)
|
|
|
|
def test_get_vosk_model_path_raises_clear_error_for_unmapped_language(self):
|
|
"""An unmapped (size, language) combination should raise a clear,
|
|
actionable error instead of crashing deep inside os.path.join with a
|
|
cryptic TypeError (the actual failure mode reported in issue #550,
|
|
before the VOSK_MODEL_INFO data gap was fixed)."""
|
|
manager = SpeechRecognitionManager(engine="vosk", language="en-us", model_size="small")
|
|
|
|
# Simulate a language that has no mapping for the requested size,
|
|
# mirroring what happened for "it"/"medium" prior to the data fix.
|
|
manager.model_size = "medium"
|
|
manager.language = "it"
|
|
manager.vosk_model_map = {
|
|
"small": "vosk-model-small-it-0.22",
|
|
"medium": None,
|
|
"large": None,
|
|
}
|
|
|
|
with self.assertRaises(ValueError) as ctx:
|
|
manager._get_vosk_model_path()
|
|
|
|
self.assertIn("medium", str(ctx.exception))
|
|
self.assertIn("it", str(ctx.exception))
|
|
|
|
def test_download_vosk_model(self):
|
|
"""Test downloading VOSK model."""
|
|
# Make os.path.exists return False to trigger download
|
|
with patch("os.path.exists", return_value=False):
|
|
# Instantiate manager with defer_download=False to trigger download
|
|
_ = SpeechRecognitionManager(engine="vosk", model_size="small", defer_download=False)
|
|
|
|
# Verify download was attempted
|
|
self.mock_download.assert_called_once()
|
|
|
|
def test_start_recognition(self):
|
|
"""Test starting speech recognition."""
|
|
# Create a manager
|
|
manager = SpeechRecognitionManager(engine="vosk")
|
|
|
|
# Reset mock for clean test
|
|
self.mock_thread_class.reset_mock()
|
|
self.mock_play_start.reset_mock()
|
|
|
|
# Start recognition
|
|
manager.start_recognition()
|
|
|
|
# Verify state changed
|
|
self.assertEqual(manager.state, RecognitionState.LISTENING)
|
|
|
|
# Verify start sound was played
|
|
self.mock_play_start.assert_called_once()
|
|
|
|
# Verify recording flag was set
|
|
self.assertTrue(manager.should_record)
|
|
|
|
# Verify threads were started
|
|
self.assertEqual(self.mock_thread_class.call_count, 2)
|
|
self.mock_thread.start.assert_called()
|
|
|
|
# Try starting when already listening
|
|
self.mock_thread_class.reset_mock()
|
|
manager.start_recognition()
|
|
|
|
# Should not start again
|
|
self.mock_thread_class.assert_not_called()
|
|
|
|
def test_stop_recognition(self):
|
|
"""Test stopping speech recognition."""
|
|
# Create a manager
|
|
manager = SpeechRecognitionManager(engine="vosk")
|
|
|
|
# Setup manager state
|
|
manager.state = RecognitionState.LISTENING
|
|
manager.should_record = True
|
|
manager.audio_thread = self.mock_thread
|
|
manager.recognition_thread = self.mock_thread
|
|
self.mock_thread.is_alive.return_value = True
|
|
self.mock_thread.join.side_effect = lambda *_args, **_kwargs: setattr(
|
|
self.mock_thread.is_alive, "return_value", False
|
|
)
|
|
|
|
# Reset mocks
|
|
self.mock_play_stop.reset_mock()
|
|
|
|
# Stop recognition
|
|
manager.stop_recognition()
|
|
|
|
# Verify state changed
|
|
self.assertEqual(manager.state, RecognitionState.IDLE)
|
|
|
|
# Verify stop sound was played
|
|
self.mock_play_stop.assert_called_once()
|
|
|
|
# Verify recording flag was cleared
|
|
self.assertFalse(manager.should_record)
|
|
|
|
# Verify threads were joined
|
|
self.mock_thread.join.assert_called()
|
|
|
|
def test_record_audio(self):
|
|
"""Test recording audio with simulated voice activity."""
|
|
# This test would be complex due to audio processing, just test basic setup
|
|
manager = SpeechRecognitionManager(engine="vosk")
|
|
manager.should_record = True
|
|
|
|
# Skip detailed testing of the audio recording function
|
|
self.assertTrue(hasattr(manager, "_record_audio"))
|
|
|
|
def test_process_final_buffer_vosk(self):
|
|
"""Test processing the final audio buffer with VOSK."""
|
|
# Setup manager
|
|
manager = SpeechRecognitionManager(engine="vosk")
|
|
|
|
# Replace recognizer with a mock that returns proper JSON
|
|
mock_recognizer = MagicMock()
|
|
mock_recognizer.FinalResult.return_value = MOCK_VOSK_RESULT
|
|
mock_recognizer.AcceptWaveform.return_value = True
|
|
manager.recognizer = mock_recognizer
|
|
|
|
# Setup command processor mock
|
|
self.mock_cmd.process_text.return_value = ("processed text", ["action1"])
|
|
|
|
# Register mock callbacks
|
|
text_callback = MagicMock()
|
|
action_callback = MagicMock()
|
|
manager.register_text_callback(text_callback)
|
|
manager.register_action_callback(action_callback)
|
|
|
|
# Prepare audio buffer
|
|
manager.audio_buffer = [b"audio_data1", b"audio_data2"]
|
|
|
|
# Process the buffer
|
|
manager._process_final_buffer()
|
|
|
|
# Verify command processor was called
|
|
self.mock_cmd.process_text.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_process_final_buffer_whisper(self):
|
|
"""Test processing the final audio buffer with Whisper."""
|
|
# Skip the problematic file operations by creating a mock implementation
|
|
# of the _process_final_buffer method
|
|
with patch.object(SpeechRecognitionManager, "_process_final_buffer") as mock_process:
|
|
# Setup manager with whisper engine (this should work now with mocked torch)
|
|
manager = SpeechRecognitionManager(engine="whisper")
|
|
|
|
# Setup side effect function that will be called when _process_final_buffer is called
|
|
def process_side_effect():
|
|
# Simulate processing the result through command processor
|
|
processed_text, actions = self.mock_cmd.process_text("whisper transcription")
|
|
# Call callbacks as the real method would
|
|
for callback in manager.text_callbacks:
|
|
callback(processed_text)
|
|
for callback in manager.action_callbacks:
|
|
for action in actions:
|
|
callback(action)
|
|
|
|
# Set the side effect
|
|
mock_process.side_effect = process_side_effect
|
|
|
|
# Set up a mock model just for completeness
|
|
whisper_model = MagicMock()
|
|
whisper_model.transcribe.return_value = {"text": "whisper transcription"}
|
|
manager.model = whisper_model
|
|
|
|
# Setup command processor mock
|
|
self.mock_cmd.process_text.return_value = ("processed whisper", ["action2"])
|
|
|
|
# Register mock callbacks
|
|
text_callback = MagicMock()
|
|
action_callback = MagicMock()
|
|
manager.register_text_callback(text_callback)
|
|
manager.register_action_callback(action_callback)
|
|
|
|
# Prepare audio buffer (not actually used because we're mocking the method)
|
|
manager.audio_buffer = [b"audio_data1", b"audio_data2"]
|
|
|
|
# Call the mocked process method
|
|
manager._process_final_buffer()
|
|
|
|
# Verify command processor was called with expected text
|
|
self.mock_cmd.process_text.assert_called_once_with("whisper transcription")
|
|
|
|
# Verify callbacks were called with expected values
|
|
text_callback.assert_called_once_with("processed whisper")
|
|
action_callback.assert_called_once_with("action2")
|
|
|
|
def test_empty_buffer(self):
|
|
"""Test processing an empty buffer."""
|
|
# Setup manager
|
|
manager = SpeechRecognitionManager(engine="vosk")
|
|
|
|
# Process empty buffer
|
|
manager.audio_buffer = []
|
|
manager._process_final_buffer()
|
|
|
|
# Verify command processor was not called
|
|
self.mock_cmd.process_text.assert_not_called()
|
|
|
|
def test_configure(self):
|
|
"""Test configuring recognition parameters."""
|
|
# Setup manager
|
|
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)
|
|
|
|
# Configure with out-of-range values
|
|
manager.reconfigure(vad_sensitivity=10, silence_timeout=10.0)
|
|
self.assertEqual(manager.vad_sensitivity, 5) # Clamped to max
|
|
self.assertEqual(manager.silence_timeout, 5.0) # Clamped to max
|
|
|
|
manager.reconfigure(vad_sensitivity=0, silence_timeout=0.2)
|
|
self.assertEqual(manager.vad_sensitivity, 1) # Clamped to min
|
|
self.assertEqual(manager.silence_timeout, 0.5) # Clamped to min
|