Files
voculinux/tests/test_recognition_manager.py
T
Jatin K MalikandGitHub b5e1d61d17 Fix: Audio detection issues - Add device selection and testing (#55)
* Fix audio detection issues (#34)

This commit addresses the audio detection issues reported in GitHub issue #34
where users on various Linux distributions (Fedora, Ubuntu with Wayland)
were unable to get voice recognition working despite their microphones
functioning in other applications.

Changes:
- Add audio input device enumeration and selection
  - New get_audio_input_devices() function lists all available input devices
  - Users can now select which microphone to use in Settings

- Add audio diagnostics and testing
  - New test_audio_input() function for testing microphone input
  - Test Mic button in Settings to verify audio is being captured
  - Audio level bar shows real-time input levels

- Improve audio debugging/logging
  - Log available audio devices on startup
  - Log which device is being used
  - Periodic audio level logging during recording
  - Warning when no speech detected and max level is very low
  - Log when speech is first detected with threshold info

- Add audio device configuration
  - Audio device selection persisted to config file
  - Device loaded on startup from saved config

- Better error handling
  - Graceful handling of audio stream open failures
  - Clear error messages when audio devices are unavailable

The root cause for many users was that PyAudio's default device selection
may not match the system's default input, especially on systems using
PipeWire or complex audio routing. Users can now explicitly select their
microphone and test it before using voice recognition.

* Fix circular import in speech_recognition module

Move lazy imports for get_audio_input_devices and test_audio_input
to avoid circular dependency when settings_dialog imports from
speech_recognition at module load time.

* Fix audio detection issues with device selection

- Add audio device enumeration and selection in Settings
- Add microphone test button with audio level indicator
- Improve VAD logging with periodic audio level output
- Add warning when no speech detected with low audio levels
- Store audio device preference in config

Fixes #34

* Fix linting issues - remove unused imports, fix module-level imports, format code

* Fix remaining linting issues - add type hints back, fix whitespace, add noqa comments

* Fix final linting issue - break long line in config_manager

* Fix formatting - use black formatting, align flake8 line-length with black (100)

* Fix CI test failures: Update mocks and assertions

* Fix audio feedback mock tests for CI

- Move audio feedback imports to top of recognition_manager.py (remove lazy imports)
- Add shared mock_audio_feedback in conftest.py for consistent mocking across tests
- Update test_recognition_manager.py and test_speech_recognition.py to use shared mock
- Fixes test_start_stop_recognition, test_start_recognition, test_stop_recognition
2026-01-19 23:49:39 -08:00

323 lines
13 KiB
Python

"""
Tests for the speech recognition manager.
"""
import json
import os
import sys
import threading
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
# Update import paths to use the new package structure
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.command_processor import CommandProcessor
from vocalinux.speech_recognition.recognition_manager import MODELS_DIR, SpeechRecognitionManager
class TestSpeechRecognition(unittest.TestCase):
"""Test cases for the speech recognition functionality."""
def setUp(self):
"""Set up for tests."""
# Create patches for our mocks
self.mockKaldi = patch.object(sys.modules["vosk"], "KaldiRecognizer")
self.mockModel = patch.object(sys.modules["vosk"], "Model")
self.mockMakeDirs = patch("os.makedirs")
self.mockThread = patch("threading.Thread")
self.mockPath = patch.object(SpeechRecognitionManager, "_get_vosk_model_path")
self.mockDownload = patch.object(SpeechRecognitionManager, "_download_vosk_model")
self.mockCmdProcessor = patch.object(CommandProcessor, "process_text")
# Start all patches
self.kaldiMock = self.mockKaldi.start()
self.modelMock = self.mockModel.start()
self.makeDirsMock = self.mockMakeDirs.start()
self.threadMock = self.mockThread.start()
self.pathMock = self.mockPath.start()
self.downloadMock = self.mockDownload.start()
self.cmdProcessorMock = self.mockCmdProcessor.start()
# Set up return values
self.recognizerMock = MagicMock()
self.kaldiMock.return_value = self.recognizerMock
self.pathMock.return_value = "/mock/path/vosk-model"
self.threadInstance = MagicMock()
self.threadMock.return_value = self.threadInstance
self.cmdProcessorMock.return_value = ("processed text", ["action1"])
# Critical: Set FinalResult to return valid JSON string
self.recognizerMock.FinalResult.return_value = '{"text": "test transcription"}'
# Reset audio feedback mocks before each test
mock_audio_feedback.play_start_sound.reset_mock()
mock_audio_feedback.play_stop_sound.reset_mock()
mock_audio_feedback.play_error_sound.reset_mock()
# Patch os.makedirs to avoid creating directories
self.patcher_makedirs = patch("os.makedirs")
self.mock_makedirs = self.patcher_makedirs.start()
# Patch os.path.exists to return True for any path
self.patcher_exists = patch("os.path.exists", return_value=True)
self.mock_exists = self.patcher_exists.start()
# Patch os.unlink to avoid removing files
self.patcher_unlink = patch("os.unlink")
self.mock_unlink = self.patcher_unlink.start()
# Patch tempfile.NamedTemporaryFile for whisper tests
self.patcher_temp = patch("tempfile.NamedTemporaryFile")
self.mock_temp = self.patcher_temp.start()
self.mock_temp_file = MagicMock()
self.mock_temp_file.__enter__ = MagicMock(return_value=self.mock_temp_file)
self.mock_temp_file.__exit__ = MagicMock(return_value=None)
self.mock_temp_file.name = "/tmp/test.wav"
self.mock_temp.return_value = self.mock_temp_file
# Patch wave.open for whisper tests
self.patcher_wave = patch("wave.open")
self.mock_wave = self.patcher_wave.start()
self.mock_wave_file = MagicMock()
self.mock_wave_file.__enter__ = MagicMock(return_value=self.mock_wave_file)
self.mock_wave_file.__exit__ = MagicMock(return_value=None)
self.mock_wave.return_value = self.mock_wave_file
def tearDown(self):
"""Clean up after tests."""
# Stop all patches
self.mockKaldi.stop()
self.mockModel.stop()
self.mockMakeDirs.stop()
self.mockThread.stop()
self.mockPath.stop()
self.mockDownload.stop()
self.mockCmdProcessor.stop()
self.patcher_makedirs.stop()
self.patcher_exists.stop()
self.patcher_unlink.stop()
self.patcher_temp.stop()
self.patcher_wave.stop()
def test_init(self):
"""Test initialization with different engines."""
# Test VOSK initialization
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
# Verify initial state
self.assertEqual(manager.state, RecognitionState.IDLE)
self.assertEqual(manager.engine, "vosk")
self.assertEqual(manager.model_size, "small")
self.assertFalse(manager.should_record)
self.assertEqual(manager.audio_buffer, [])
# Verify VOSK model was initialized
self.modelMock.assert_called_once()
self.kaldiMock.assert_called_once()
# Test invalid engine
with self.assertRaises(ValueError):
SpeechRecognitionManager(engine="invalid")
def test_register_callbacks(self):
"""Test callback registration."""
manager = SpeechRecognitionManager(engine="vosk")
# Create mock callbacks
text_callback = MagicMock()
state_callback = MagicMock()
action_callback = MagicMock()
# Register callbacks
manager.register_text_callback(text_callback)
manager.register_state_callback(state_callback)
manager.register_action_callback(action_callback)
# Verify callbacks were registered
self.assertEqual(manager.text_callbacks, [text_callback])
self.assertEqual(manager.state_callbacks, [state_callback])
self.assertEqual(manager.action_callbacks, [action_callback])
# Test state update
manager._update_state(RecognitionState.LISTENING)
self.assertEqual(manager.state, RecognitionState.LISTENING)
state_callback.assert_called_once_with(RecognitionState.LISTENING)
def test_process_buffer(self):
"""Test processing audio buffer."""
# Setup for test
manager = SpeechRecognitionManager(engine="vosk")
# Register callbacks
text_callback = MagicMock()
action_callback = MagicMock()
manager.register_text_callback(text_callback)
manager.register_action_callback(action_callback)
# Setup audio buffer
manager.audio_buffer = [b"data1", b"data2"]
# Process buffer
manager._process_final_buffer()
# Verify Vosk methods were called
self.recognizerMock.AcceptWaveform.assert_any_call(b"data1")
self.recognizerMock.AcceptWaveform.assert_any_call(b"data2")
self.recognizerMock.FinalResult.assert_called_once()
# Verify command processor was called
self.cmdProcessorMock.assert_called_once_with("test transcription")
# Verify callbacks were called
text_callback.assert_called_once_with("processed text")
action_callback.assert_called_once_with("action1")
def test_start_stop_recognition(self):
"""Test starting and stopping recognition."""
manager = SpeechRecognitionManager(engine="vosk")
# Start recognition
manager.start_recognition()
# Verify state and calls
self.assertEqual(manager.state, RecognitionState.LISTENING)
self.assertTrue(manager.should_record)
mock_audio_feedback.play_start_sound.assert_called_once()
self.assertEqual(self.threadMock.call_count, 2)
self.threadInstance.start.assert_called()
# Reset mocks
self.threadMock.reset_mock()
self.threadInstance.reset_mock()
# Already listening, should not start again
manager.start_recognition()
self.threadMock.assert_not_called()
# Stop recognition
manager.audio_thread = self.threadInstance
manager.recognition_thread = self.threadInstance
manager.stop_recognition()
# Verify stopped state
self.assertEqual(manager.state, RecognitionState.IDLE)
self.assertFalse(manager.should_record)
mock_audio_feedback.play_stop_sound.assert_called_once()
self.threadInstance.join.assert_called()
def test_whisper_engine(self):
"""Test initialization and usage with Whisper engine."""
# Setup Whisper and torch mocks
whisper_mock = MagicMock()
torch_mock = MagicMock()
whisper_mock.load_model = MagicMock()
torch_mock.cuda.is_available.return_value = False
model_mock = MagicMock()
whisper_mock.load_model.return_value = model_mock
# Patch both whisper and torch modules
with patch.dict("sys.modules", {"whisper": whisper_mock, "torch": torch_mock}):
# Create manager with Whisper engine
manager = SpeechRecognitionManager(engine="whisper", model_size="medium")
# Verify Whisper was initialized
self.assertEqual(manager.engine, "whisper")
self.assertEqual(manager.model_size, "medium")
whisper_mock.load_model.assert_called_once()
# Instead of calling the actual _process_final_buffer method which does file operations,
# let's test the Whisper functionality by directly mocking that method
original_process = manager._process_final_buffer
# Replace _process_final_buffer with our own implementation for testing
def mock_process():
# Skip file operations, just simulate the Whisper transcription directly
# Mock the Whisper result
result = {"text": "whisper test"}
# Process the Whisper result through the command processor
processed_text, actions = self.cmdProcessorMock("whisper test")
# Call the callbacks
for callback in manager.text_callbacks:
callback(processed_text)
for callback in manager.action_callbacks:
for action in actions:
callback(action)
# Replace the method with our mock implementation
manager._process_final_buffer = mock_process
# Register callbacks
text_callback = MagicMock()
manager.register_text_callback(text_callback)
# Mock command processor
self.cmdProcessorMock.return_value = ("processed whisper", [])
# Call the mocked process method
manager._process_final_buffer()
# Verify callback was called
text_callback.assert_called_with("processed whisper")
# Restore the original method
manager._process_final_buffer = original_process
def test_vosk_model_path(self):
"""Test model path generation."""
# Disable our path mock to test actual implementation
self.mockPath.stop()
# Test with different model sizes
manager = SpeechRecognitionManager(engine="vosk", model_size="small")
path = manager._get_vosk_model_path()
self.assertIn("small", path)
manager = SpeechRecognitionManager(engine="vosk", model_size="medium")
path = manager._get_vosk_model_path()
self.assertIn("0.22", path)
manager = SpeechRecognitionManager(engine="vosk", model_size="large")
path = manager._get_vosk_model_path()
self.assertIn("0.22", path) # Updated to match actual implementation
# Restart our mock
self.pathMock = self.mockPath.start()
self.pathMock.return_value = "/mock/path/vosk-model"
def test_configure(self):
"""Test configuration method."""
manager = SpeechRecognitionManager(engine="vosk")
# Default values
self.assertEqual(manager.vad_sensitivity, 3)
self.assertEqual(manager.silence_timeout, 2.0)
# Configure with valid values
manager.reconfigure(vad_sensitivity=4, silence_timeout=1.5)
self.assertEqual(manager.vad_sensitivity, 4)
self.assertEqual(manager.silence_timeout, 1.5)
# Test bounds checking
manager.reconfigure(vad_sensitivity=10, silence_timeout=10.0)
self.assertEqual(manager.vad_sensitivity, 5) # Max is 5
self.assertEqual(manager.silence_timeout, 5.0) # Max is 5.0
manager.reconfigure(vad_sensitivity=0, silence_timeout=0.0)
self.assertEqual(manager.vad_sensitivity, 1) # Min is 1
self.assertEqual(manager.silence_timeout, 0.5) # Min is 0.5