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
This commit is contained in:
Jatin K Malik
2026-01-19 23:49:39 -08:00
committed by GitHub
parent e5263b2078
commit b5e1d61d17
11 changed files with 702 additions and 173 deletions
+18
View File
@@ -0,0 +1,18 @@
[flake8]
max-line-length = 100
extend-ignore = E203, W503
exclude =
.git,
__pycache__,
.venv,
venv,
test_venv,
build,
dist,
node_modules,
*.egg-info
# Per-file ignores
per-file-ignores =
src/vocalinux/ui/settings_dialog.py:E501
src/vocalinux/speech_recognition/recognition_manager.py:E501
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# This script activates the Vocalinux virtual environment
source "venv/bin/activate"
echo "Vocalinux virtual environment activated."
echo "To start the application, run: vocalinux"
+29 -18
View File
@@ -5,7 +5,6 @@ Main entry point for Vocalinux application.
import argparse import argparse
import logging import logging
import os
import sys import sys
# Configure logging # Configure logging
@@ -16,10 +15,8 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Import from the vocalinux package # Import from the vocalinux package
from .speech_recognition import recognition_manager from .ui import tray_indicator # noqa: E402
from .text_injection import text_injector from .ui.action_handler import ActionHandler # noqa: E402
from .ui import tray_indicator
from .ui.action_handler import ActionHandler
def parse_arguments(): def parse_arguments():
@@ -47,27 +44,30 @@ def check_dependencies():
"""Check for required dependencies and provide helpful error messages.""" """Check for required dependencies and provide helpful error messages."""
missing_deps = [] missing_deps = []
# pynput is used for keyboard detection but we check at module startup
# requests is used by various components
# These are intentional checks to provide user-friendly error messages
try:
import pynput # noqa: F401
except ImportError:
missing_deps.append("pynput (install with: pip install pynput)")
try:
import requests # noqa: F401
except ImportError:
missing_deps.append("requests (install with: pip install requests)")
try: try:
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
gi.require_version("AppIndicator3", "0.1") gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3, Gtk except (ImportError, ValueError):
except (ImportError, ValueError) as e:
missing_deps.append( missing_deps.append(
"GTK3 and AppIndicator3 (install with: sudo apt install python3-gi gir1.2-appindicator3-0.1)" "GTK3 and AppIndicator3 (install with: sudo apt install "
"python3-gi gir1.2-appindicator3-0.1)"
) )
try:
import pynput
except ImportError:
missing_deps.append("pynput (install with: pip install pynput)")
try:
import requests
except ImportError:
missing_deps.append("requests (install with: pip install requests)")
if missing_deps: if missing_deps:
logger.error("Missing required dependencies:") logger.error("Missing required dependencies:")
for dep in missing_deps: for dep in missing_deps:
@@ -99,17 +99,25 @@ def main():
sys.exit(1) sys.exit(1)
# Load saved configuration to get engine/model settings # Load saved configuration to get engine/model settings
from .speech_recognition import recognition_manager
from .text_injection import text_injector
from .ui.config_manager import ConfigManager from .ui.config_manager import ConfigManager
config_manager = ConfigManager() config_manager = ConfigManager()
saved_settings = config_manager.get_settings().get("speech_recognition", {}) saved_settings = config_manager.get_settings().get("speech_recognition", {})
audio_settings = config_manager.get_settings().get("audio", {})
# Use saved settings if no command-line override was provided # Use saved settings if no command-line override was provided
# Check if args are still at their defaults (user didn't explicitly set them) # Check if args are still at their defaults (user didn't explicitly set them)
engine = saved_settings.get("engine", args.engine) engine = saved_settings.get("engine", args.engine)
model_size = saved_settings.get("model_size", args.model) model_size = saved_settings.get("model_size", args.model)
vad_sensitivity = saved_settings.get("vad_sensitivity", 3)
silence_timeout = saved_settings.get("silence_timeout", 2.0)
audio_device_index = audio_settings.get("device_index", None)
logger.info(f"Using engine={engine}, model={model_size} (from saved config)") logger.info(f"Using engine={engine}, model={model_size} (from saved config)")
if audio_device_index is not None:
logger.info(f"Using audio device index={audio_device_index} (from saved config)")
# Initialize main components # Initialize main components
logger.info("Initializing Vocalinux...") logger.info("Initializing Vocalinux...")
@@ -119,6 +127,9 @@ def main():
speech_engine = recognition_manager.SpeechRecognitionManager( speech_engine = recognition_manager.SpeechRecognitionManager(
engine=engine, engine=engine,
model_size=model_size, model_size=model_size,
vad_sensitivity=vad_sensitivity,
silence_timeout=silence_timeout,
audio_device_index=audio_device_index,
) )
# Initialize text injection system # Initialize text injection system
@@ -3,3 +3,13 @@ Speech recognition module for Vocalinux
""" """
from . import command_processor, recognition_manager from . import command_processor, recognition_manager
def get_audio_input_devices():
"""Get available audio input devices."""
return recognition_manager.get_audio_input_devices()
def test_audio_input(device_index=None, duration=1.0):
"""Test audio input from a device."""
return recognition_manager.test_audio_input(device_index, duration)
@@ -5,18 +5,17 @@ This module provides a unified interface to different speech recognition engines
currently supporting VOSK and Whisper. currently supporting VOSK and Whisper.
""" """
import contextlib
import ctypes import ctypes
import json import json
import logging import logging
import os import os
import sys
import threading import threading
import time import time
from pathlib import Path
from typing import Callable, List, Optional from typing import Callable, List, Optional
from ..common_types import RecognitionState from ..common_types import RecognitionState
from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound
from .command_processor import CommandProcessor
# ALSA error handler to suppress warnings during PyAudio initialization # ALSA error handler to suppress warnings during PyAudio initialization
@@ -48,8 +47,155 @@ def _setup_alsa_error_handler():
# Set up ALSA error handler at module load time # Set up ALSA error handler at module load time
_alsa_handler = _setup_alsa_error_handler() _alsa_handler = _setup_alsa_error_handler()
from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound
from .command_processor import CommandProcessor
def get_audio_input_devices() -> list:
"""
Get a list of available audio input devices.
Returns:
List of tuples: (device_index, device_name, is_default)
"""
devices = []
try:
import pyaudio
audio = pyaudio.PyAudio()
default_input_device = None
try:
default_info = audio.get_default_input_device_info()
default_input_device = default_info.get("index")
except (IOError, OSError):
pass # No default input device
for i in range(audio.get_device_count()):
try:
info = audio.get_device_info_by_index(i)
# Only include devices that have input channels
if info.get("maxInputChannels", 0) > 0:
name = info.get("name", f"Device {i}")
is_default = i == default_input_device
devices.append((i, name, is_default))
except (IOError, OSError):
continue
audio.terminate()
except ImportError:
logger.error("PyAudio not installed, cannot enumerate audio devices")
except Exception as e:
logger.error(f"Error enumerating audio devices: {e}")
return devices
def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict:
"""
Test audio input from a device and return diagnostic information.
Args:
device_index: The device index to test (None for default)
duration: How long to record in seconds
Returns:
Dictionary with test results including:
- success: bool
- device_name: str
- sample_count: int
- max_amplitude: float
- mean_amplitude: float
- has_signal: bool (amplitude above noise floor)
- error: str (if failed)
"""
result = {
"success": False,
"device_name": "Unknown",
"device_index": device_index,
"sample_count": 0,
"max_amplitude": 0.0,
"mean_amplitude": 0.0,
"has_signal": False,
"error": None,
}
try:
import numpy as np
import pyaudio
CHUNK = 1024
FORMAT = pyaudio.paInt16
CHANNELS = 1
RATE = 16000
audio = pyaudio.PyAudio()
# Get device info
try:
if device_index is not None:
info = audio.get_device_info_by_index(device_index)
else:
info = audio.get_default_input_device_info()
device_index = info.get("index")
result["device_name"] = info.get("name", "Unknown")
result["device_index"] = device_index
except (IOError, OSError) as e:
result["error"] = f"Cannot get device info: {e}"
audio.terminate()
return result
# Open stream
try:
stream_kwargs = {
"format": FORMAT,
"channels": CHANNELS,
"rate": RATE,
"input": True,
"frames_per_buffer": CHUNK,
}
if device_index is not None:
stream_kwargs["input_device_index"] = device_index
stream = audio.open(**stream_kwargs)
except (IOError, OSError) as e:
result["error"] = f"Cannot open audio stream: {e}"
audio.terminate()
return result
# Record and analyze
all_amplitudes = []
frames_to_read = int(RATE * duration / CHUNK)
for _ in range(frames_to_read):
try:
data = stream.read(CHUNK, exception_on_overflow=False)
audio_data = np.frombuffer(data, dtype=np.int16)
amplitudes = np.abs(audio_data)
all_amplitudes.extend(amplitudes)
except Exception as e:
result["error"] = f"Error reading audio: {e}"
break
stream.stop_stream()
stream.close()
audio.terminate()
if all_amplitudes:
all_amplitudes = np.array(all_amplitudes)
result["success"] = True
result["sample_count"] = len(all_amplitudes)
result["max_amplitude"] = float(np.max(all_amplitudes))
result["mean_amplitude"] = float(np.mean(all_amplitudes))
# Signal present if max amplitude is above typical digital noise floor
# 16-bit audio has max value of 32768, noise floor is typically < 100
result["has_signal"] = result["max_amplitude"] > 200
except ImportError as e:
result["error"] = f"Missing dependency: {e}"
except Exception as e:
result["error"] = f"Unexpected error: {e}"
return result
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -96,6 +242,7 @@ class SpeechRecognitionManager:
engine: The speech recognition engine to use ("vosk" or "whisper") engine: The speech recognition engine to use ("vosk" or "whisper")
model_size: The size of the model to use ("small", "medium", "large") model_size: The size of the model to use ("small", "medium", "large")
defer_download: If True, don't download missing models at startup (default: True) defer_download: If True, don't download missing models at startup (default: True)
audio_device_index: Optional audio input device index (None for default)
""" """
self.engine = engine self.engine = engine
self.model_size = model_size self.model_size = model_size
@@ -119,6 +266,13 @@ class SpeechRecognitionManager:
self.vad_sensitivity = kwargs.get("vad_sensitivity", 3) self.vad_sensitivity = kwargs.get("vad_sensitivity", 3)
self.silence_timeout = kwargs.get("silence_timeout", 2.0) self.silence_timeout = kwargs.get("silence_timeout", 2.0)
# Audio device selection (None means use system default)
self.audio_device_index = kwargs.get("audio_device_index", None)
# Audio diagnostics tracking
self._last_audio_level = 0.0
self._audio_level_callbacks: List[Callable[[float], None]] = []
# Recording control flags # Recording control flags
self.should_record = False self.should_record = False
self.audio_buffer = [] self.audio_buffer = []
@@ -479,19 +633,27 @@ class SpeechRecognitionManager:
def _download_whisper_model(self, cache_dir: str): def _download_whisper_model(self, cache_dir: str):
"""Download a Whisper model with progress tracking.""" """Download a Whisper model with progress tracking."""
import hashlib
import requests import requests
self._download_cancelled = False self._download_cancelled = False
# Whisper model URLs (from openai-whisper package) # Whisper model URLs (from openai-whisper package)
model_urls = { model_urls = {
"tiny": "https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt", "tiny": "https://openaipublic.azureedge.net/main/whisper/models/"
"base": "https://openaipublic.azureedge.net/main/whisper/models/ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/base.pt", "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/"
"small": "https://openaipublic.azureedge.net/main/whisper/models/9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/small.pt", "tiny.pt",
"medium": "https://openaipublic.azureedge.net/main/whisper/models/345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/medium.pt", "base": "https://openaipublic.azureedge.net/main/whisper/models/"
"large": "https://openaipublic.azureedge.net/main/whisper/models/e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/large-v3.pt", "ed3a0b6b1c0edf879ad9b11b1af5a0e6ab5db9205f891f668f8b0e6c6326e34e/"
"base.pt",
"small": "https://openaipublic.azureedge.net/main/whisper/models/"
"9ecf779972d90ba49c06d968637d720dd632c55bbf19d441fb42bf17a411e794/"
"small.pt",
"medium": "https://openaipublic.azureedge.net/main/whisper/models/"
"345ae4da62f9b3d59415adc60127b97c714f32e89e936602e85993674d08dcb1/"
"medium.pt",
"large": "https://openaipublic.azureedge.net/main/whisper/models/"
"e5b1a55b89c1367dacf97e3e19bfd829a01529dbfdeefa8caeb59b3f1b81dadb/"
"large-v3.pt",
} }
url = model_urls.get(self.model_size) url = model_urls.get(self.model_size)
@@ -630,6 +792,46 @@ class SpeechRecognitionManager:
""" """
self.action_callbacks.append(callback) self.action_callbacks.append(callback)
def register_audio_level_callback(self, callback: Callable[[float], None]):
"""
Register a callback function that will be called with audio level updates.
Args:
callback: A function that takes a float argument (0-100 representing audio level %)
"""
self._audio_level_callbacks.append(callback)
def unregister_audio_level_callback(self, callback: Callable[[float], None]):
"""
Unregister an audio level callback function.
Args:
callback: The callback function to remove.
"""
try:
self._audio_level_callbacks.remove(callback)
except ValueError:
pass
def set_audio_device(self, device_index: Optional[int]):
"""
Set the audio input device to use.
Args:
device_index: The device index to use, or None for system default
"""
if device_index != self.audio_device_index:
logger.info(f"Audio device changed from {self.audio_device_index} to {device_index}")
self.audio_device_index = device_index
def get_audio_device(self) -> Optional[int]:
"""Get the currently configured audio device index."""
return self.audio_device_index
def get_last_audio_level(self) -> float:
"""Get the last recorded audio level (0-100)."""
return self._last_audio_level
def _update_state(self, new_state: RecognitionState): def _update_state(self, new_state: RecognitionState):
""" """
Update the recognition state and notify callbacks. Update the recognition state and notify callbacks.
@@ -655,12 +857,12 @@ class SpeechRecognitionManager:
# Check if model is ready # Check if model is ready
if not self.model_ready: if not self.model_ready:
logger.warning( logger.warning(
"Cannot start recognition: model not downloaded. Please download via Settings." "Cannot start recognition: model not downloaded. " "Please download via Settings."
) )
play_error_sound() play_error_sound()
_show_notification( _show_notification(
"No Speech Model", "No Speech Model",
"Please open Settings and download a speech recognition model to use dictation.", "Please open Settings and download a speech recognition model " "to use dictation.",
"dialog-warning", "dialog-warning",
) )
return return
@@ -716,9 +918,10 @@ class SpeechRecognitionManager:
def _record_audio(self): def _record_audio(self):
"""Record audio from the microphone.""" """Record audio from the microphone."""
try: # Lazy import to avoid circular dependency
import wave from ..ui.audio_feedback import play_error_sound # noqa: F401
try:
import numpy as np import numpy as np
import pyaudio import pyaudio
except ImportError as e: except ImportError as e:
@@ -739,29 +942,94 @@ class SpeechRecognitionManager:
# Initialize PyAudio # Initialize PyAudio
audio = pyaudio.PyAudio() audio = pyaudio.PyAudio()
# Open microphone stream # Log available devices for debugging
stream = audio.open( logger.debug("Available audio input devices:")
format=FORMAT, for i in range(audio.get_device_count()):
channels=CHANNELS, try:
rate=RATE, info = audio.get_device_info_by_index(i)
input=True, if info.get("maxInputChannels", 0) > 0:
frames_per_buffer=CHUNK, logger.debug(
) f" [{i}] {info.get('name')} (inputs: {info.get('maxInputChannels')})"
)
except (IOError, OSError):
continue
# Open microphone stream with optional device selection
stream_kwargs = {
"format": FORMAT,
"channels": CHANNELS,
"rate": RATE,
"input": True,
"frames_per_buffer": CHUNK,
}
# Use specified device if set, otherwise use system default
if self.audio_device_index is not None:
stream_kwargs["input_device_index"] = self.audio_device_index
try:
device_info = audio.get_device_info_by_index(self.audio_device_index)
logger.info(
f"Using audio device [{self.audio_device_index}]: {device_info.get('name')}"
)
except (IOError, OSError):
logger.warning(f"Could not get info for device index {self.audio_device_index}")
else:
try:
default_device = audio.get_default_input_device_info()
logger.info(
f"Using default audio device [{default_device.get('index')}]: {default_device.get('name')}"
)
except (IOError, OSError):
logger.warning("Could not get default input device info")
try:
stream = audio.open(**stream_kwargs)
except (IOError, OSError) as e:
logger.error(f"Failed to open audio stream: {e}")
logger.error("This may indicate a problem with the audio device or permissions.")
play_error_sound()
audio.terminate()
self._update_state(RecognitionState.ERROR)
return
logger.info("Audio recording started") logger.info("Audio recording started")
# Record audio while should_record is True # Record audio while should_record is True
silence_counter = 0 silence_counter = 0
speech_detected_in_session = False
log_level_interval = 0 # Counter for periodic level logging
max_level_seen = 0.0
while self.should_record: while self.should_record:
try: try:
data = stream.read(CHUNK, exception_on_overflow=False) data = stream.read(CHUNK, exception_on_overflow=False)
self.audio_buffer.append(data) self.audio_buffer.append(data)
# Simple Voice Activity Detection (VAD) # Simple Voice Activity Detection (VAD)
# TODO: Implement proper VAD using webrtcvad or similar
audio_data = np.frombuffer(data, dtype=np.int16) audio_data = np.frombuffer(data, dtype=np.int16)
volume = np.abs(audio_data).mean() volume = np.abs(audio_data).mean()
# Track max level and notify callbacks
# Normalize to 0-100 scale (16-bit audio max is ~32768)
normalized_level = min(100.0, (volume / 327.68))
self._last_audio_level = normalized_level
max_level_seen = max(max_level_seen, normalized_level)
# Notify audio level callbacks
for callback in self._audio_level_callbacks:
try:
callback(normalized_level)
except Exception as e:
logger.debug(f"Audio level callback error: {e}")
# Log audio levels periodically for debugging
log_level_interval += 1
if log_level_interval >= 50: # Every ~3 seconds at 16kHz/1024 chunks
logger.debug(
f"Audio level: current={normalized_level:.1f}%, max_seen={max_level_seen:.1f}%"
)
log_level_interval = 0
# Threshold based on sensitivity (1-5) # Threshold based on sensitivity (1-5)
# Ensure vad_sensitivity is treated as integer for calculation # Ensure vad_sensitivity is treated as integer for calculation
try: try:
@@ -776,15 +1044,22 @@ class SpeechRecognitionManager:
if volume < threshold: # Silence if volume < threshold: # Silence
silence_counter += CHUNK / RATE # Convert chunks to seconds silence_counter += CHUNK / RATE # Convert chunks to seconds
if silence_counter > self.silence_timeout: # Use self.silence_timeout if silence_counter > self.silence_timeout: # Use self.silence_timeout
logger.debug("Silence detected, processing buffer") if len(self.audio_buffer) > 0:
self._update_state(RecognitionState.PROCESSING) logger.debug("Silence detected, processing buffer")
# Process final buffer self._update_state(RecognitionState.PROCESSING)
self._process_final_buffer() # Process final buffer
# Reset for next utterance self._process_final_buffer()
self.audio_buffer = [] # Reset for next utterance
self.audio_buffer = []
silence_counter = 0 silence_counter = 0
self._update_state(RecognitionState.LISTENING) self._update_state(RecognitionState.LISTENING)
else: # Speech else: # Speech
if not speech_detected_in_session:
logger.debug(
f"Speech detected (level={normalized_level:.1f}%, "
f"threshold={500 / max(1, min(5, int(self.vad_sensitivity))):.0f})"
)
speech_detected_in_session = True
silence_counter = 0 silence_counter = 0
except Exception as e: except Exception as e:
logger.error(f"Error reading audio data: {e}") logger.error(f"Error reading audio data: {e}")
@@ -794,6 +1069,15 @@ class SpeechRecognitionManager:
stream.stop_stream() stream.stop_stream()
stream.close() stream.close()
audio.terminate() audio.terminate()
# Log summary
if not speech_detected_in_session and max_level_seen < 5:
logger.warning(
f"No speech detected during session. Max audio level was "
f"only {max_level_seen:.1f}%. This may indicate the wrong "
"audio device is selected or the microphone is muted."
)
logger.info("Audio recording stopped") logger.info("Audio recording stopped")
except Exception as e: except Exception as e:
@@ -846,6 +1130,7 @@ class SpeechRecognitionManager:
model_size: Optional[str] = None, model_size: Optional[str] = None,
vad_sensitivity: Optional[int] = None, vad_sensitivity: Optional[int] = None,
silence_timeout: Optional[float] = None, silence_timeout: Optional[float] = None,
audio_device_index: Optional[int] = None,
force_download: bool = True, force_download: bool = True,
**kwargs, # Allow for future expansion **kwargs, # Allow for future expansion
): ):
@@ -857,10 +1142,11 @@ class SpeechRecognitionManager:
model_size: The new model size. model_size: The new model size.
vad_sensitivity: New VAD sensitivity (for VOSK). vad_sensitivity: New VAD sensitivity (for VOSK).
silence_timeout: New silence timeout (for VOSK). silence_timeout: New silence timeout (for VOSK).
audio_device_index: Audio input device index (None for default, -1 to clear).
force_download: If True, download missing models (default: True for UI-triggered reconfigures). force_download: If True, download missing models (default: True for UI-triggered reconfigures).
""" """
logger.info( logger.info(
f"Reconfiguring speech engine. New settings: engine={engine}, model_size={model_size}, vad={vad_sensitivity}, silence={silence_timeout}" f"Reconfiguring speech engine. New settings: engine={engine}, model_size={model_size}, vad={vad_sensitivity}, silence={silence_timeout}, audio_device={audio_device_index}"
) )
restart_needed = False restart_needed = False
@@ -878,6 +1164,13 @@ class SpeechRecognitionManager:
if silence_timeout is not None: if silence_timeout is not None:
self.silence_timeout = max(0.5, min(5.0, float(silence_timeout))) self.silence_timeout = max(0.5, min(5.0, float(silence_timeout)))
# Handle audio device index (-1 means use default/clear selection)
if audio_device_index is not None:
if audio_device_index == -1:
self.audio_device_index = None
else:
self.audio_device_index = audio_device_index
if restart_needed: if restart_needed:
logger.info("Engine or model changed, re-initializing...") logger.info("Engine or model changed, re-initializing...")
# When reconfiguring from UI, allow downloads # When reconfiguring from UI, allow downloads
+9 -6
View File
@@ -7,8 +7,7 @@ This module handles loading, saving, and accessing user preferences.
import json import json
import logging import logging
import os import os
from pathlib import Path from typing import Any, Dict
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,8 +20,12 @@ DEFAULT_CONFIG = {
"speech_recognition": { # Changed section name "speech_recognition": { # Changed section name
"engine": "whisper", # "vosk" or "whisper" "engine": "whisper", # "vosk" or "whisper"
"model_size": "tiny", # "tiny", "small", "medium", or "large" "model_size": "tiny", # "tiny", "small", "medium", or "large"
"vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) - Moved here "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5)
"silence_timeout": 2.0, # Seconds of silence before stopping recognition - Moved here "silence_timeout": 2.0, # Seconds of silence before stopping
},
"audio": {
"device_index": None, # Audio input device index (None for system default)
"device_name": None, # Saved device name for display/reference
}, },
"shortcuts": { "shortcuts": {
"toggle_recognition": "ctrl+ctrl", # Double-tap Ctrl "toggle_recognition": "ctrl+ctrl", # Double-tap Ctrl
@@ -101,7 +104,7 @@ class ConfigManager:
Get a configuration value. Get a configuration value.
Args: Args:
section: The configuration section (e.g., "speech_recognition", "shortcuts") section: The configuration section
key: The configuration key within the section key: The configuration key within the section
default: The default value to return if the key doesn't exist default: The default value to return if the key doesn't exist
@@ -118,7 +121,7 @@ class ConfigManager:
Set a configuration value. Set a configuration value.
Args: Args:
section: The configuration section (e.g., "speech_recognition", "shortcuts") section: The configuration section
key: The configuration key within the section key: The configuration key within the section
value: The value to set value: The value to set
+279 -75
View File
@@ -9,20 +9,22 @@ import logging
import os import os
import threading import threading
import time import time
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
# Need GLib for idle_add # Need GLib for idle_add
from gi.repository import GLib, GObject, Gtk, Pango from gi.repository import GLib, Gtk # noqa: E402
from ..common_types import RecognitionState from ..common_types import RecognitionState # noqa: E402
# Avoid circular imports for type checking # Avoid circular imports for type checking
if TYPE_CHECKING: if TYPE_CHECKING:
from ..speech_recognition.recognition_manager import SpeechRecognitionManager from ..speech_recognition.recognition_manager import ( # noqa: E402
from .config_manager import ConfigManager SpeechRecognitionManager,
)
from .config_manager import ConfigManager # noqa: E402
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -312,17 +314,92 @@ class SettingsDialog(Gtk.Dialog):
self.set_border_width(10) self.set_border_width(10)
# --- UI Elements --- # --- UI Elements ---
self.grid = Gtk.Grid(column_spacing=10, row_spacing=15) self.grid = Gtk.Grid(column_spacing=10, row_spacing=8)
self.get_content_area().add(self.grid) self.get_content_area().add(self.grid)
row = 0
# ==================== AUDIO INPUT SECTION (TOP) ====================
audio_label = Gtk.Label(label="<b>Audio Input</b>", use_markup=True, halign=Gtk.Align.START)
self.grid.attach(audio_label, 0, row, 2, 1)
row += 1
# Audio device selection
audio_device_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
audio_device_box.pack_start(
Gtk.Label(label="Input Device:", halign=Gtk.Align.START), False, False, 0
)
self.audio_device_combo = Gtk.ComboBoxText()
self.audio_device_combo.set_tooltip_text(
"Select the microphone to use for voice recognition.\n"
"If you're having issues with audio detection, try different devices."
)
self._populate_audio_devices()
self.audio_device_combo.connect("changed", self._on_audio_device_changed)
audio_device_box.pack_start(self.audio_device_combo, True, True, 0)
# Refresh button
refresh_btn = Gtk.Button()
refresh_btn.set_image(Gtk.Image.new_from_icon_name("view-refresh", Gtk.IconSize.BUTTON))
refresh_btn.set_tooltip_text("Refresh audio device list")
refresh_btn.connect("clicked", self._on_refresh_audio_devices)
audio_device_box.pack_start(refresh_btn, False, False, 0)
self.grid.attach(audio_device_box, 0, row, 2, 1)
row += 1
# Audio level indicator and test button
audio_test_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
level_label = Gtk.Label(label="Level:", halign=Gtk.Align.START)
audio_test_box.pack_start(level_label, False, False, 0)
self.audio_level_bar = Gtk.LevelBar()
self.audio_level_bar.set_min_value(0)
self.audio_level_bar.set_max_value(100)
self.audio_level_bar.set_value(0)
self.audio_level_bar.set_size_request(150, -1)
audio_test_box.pack_start(self.audio_level_bar, True, True, 0)
self.test_audio_btn = Gtk.Button(label="Test Mic")
self.test_audio_btn.set_tooltip_text(
"Test the selected microphone for 2 seconds.\n"
"Speak into your microphone to verify it's working."
)
self.test_audio_btn.connect("clicked", self._on_test_audio_clicked)
audio_test_box.pack_start(self.test_audio_btn, False, False, 0)
self.grid.attach(audio_test_box, 0, row, 2, 1)
row += 1
# Audio test status label
self.audio_test_status = Gtk.Label(label="", use_markup=True, halign=Gtk.Align.START)
self.grid.attach(self.audio_test_status, 0, row, 2, 1)
row += 1
# ==================== SEPARATOR ====================
self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, row, 2, 1)
row += 1
# ==================== SPEECH ENGINE SECTION ====================
engine_label = Gtk.Label(
label="<b>Speech Engine</b>", use_markup=True, halign=Gtk.Align.START
)
self.grid.attach(engine_label, 0, row, 2, 1)
row += 1
# Engine Selection # Engine Selection
self.grid.attach(Gtk.Label(label="Speech Engine:", halign=Gtk.Align.START), 0, 0, 1, 1) self.grid.attach(Gtk.Label(label="Engine:", halign=Gtk.Align.START), 0, row, 1, 1)
self.engine_combo = Gtk.ComboBoxText() self.engine_combo = Gtk.ComboBoxText()
self.grid.attach(self.engine_combo, 1, row, 1, 1)
row += 1
# Model Size Selection # Model Size Selection
self.grid.attach(Gtk.Label(label="Model Size:", halign=Gtk.Align.START), 0, 1, 1, 1) self.grid.attach(Gtk.Label(label="Model Size:", halign=Gtk.Align.START), 0, row, 1, 1)
self.model_combo = Gtk.ComboBoxText() self.model_combo = Gtk.ComboBoxText()
self.grid.attach(self.model_combo, 1, 1, 1, 1) self.grid.attach(self.model_combo, 1, row, 1, 1)
row += 1
# Model legend (applies to both engines) # Model legend (applies to both engines)
model_legend = Gtk.Label( model_legend = Gtk.Label(
@@ -330,65 +407,11 @@ class SettingsDialog(Gtk.Dialog):
use_markup=True, use_markup=True,
halign=Gtk.Align.END, halign=Gtk.Align.END,
) )
self.grid.attach(model_legend, 0, 2, 2, 1) self.grid.attach(model_legend, 0, row, 2, 1)
row += 1
# Recognition Settings Box (shared between engines)
self.recognition_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.recognition_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
self.recognition_settings_box.pack_start(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 5
)
self.recognition_settings_box.pack_start(
Gtk.Label(label="<b>Recognition Settings</b>", use_markup=True, halign=Gtk.Align.START),
False,
False,
5,
)
self.recognition_settings_box.pack_start(self.recognition_grid, False, False, 0)
self.grid.attach(self.recognition_settings_box, 0, 3, 2, 1)
# VAD Sensitivity (controls how sensitive the mic is to speech vs silence)
self.recognition_grid.attach(
Gtk.Label(label="VAD Sensitivity (1-5):", halign=Gtk.Align.START), 0, 0, 1, 1
)
self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1)
self.vad_spin.set_tooltip_text("Higher = more sensitive to quiet speech")
self.vad_spin.connect("value-changed", self._on_vad_changed)
self.recognition_grid.attach(self.vad_spin, 1, 0, 1, 1)
# Silence Timeout (how long to wait before processing)
self.recognition_grid.attach(
Gtk.Label(label="Silence Timeout (sec):", halign=Gtk.Align.START), 0, 1, 1, 1
)
self.silence_spin = Gtk.SpinButton.new_with_range(0.5, 5.0, 0.1)
self.silence_spin.set_digits(1)
self.silence_spin.set_tooltip_text("Wait time after silence before processing speech")
self.silence_spin.connect("value-changed", self._on_silence_changed)
self.recognition_grid.attach(self.silence_spin, 1, 1, 1, 1)
# VOSK Model info label (shown only for VOSK)
self.vosk_model_info_label = Gtk.Label(
label="",
use_markup=True,
halign=Gtk.Align.START,
wrap=True,
)
self.recognition_grid.attach(self.vosk_model_info_label, 0, 2, 2, 1)
# VOSK Recommendation label (shown only for VOSK)
self.vosk_recommendation_label = Gtk.Label(
label="",
use_markup=True,
halign=Gtk.Align.START,
wrap=True,
)
self.recognition_grid.attach(self.vosk_recommendation_label, 0, 3, 2, 1)
# Whisper Info Box (initially hidden) # Whisper Info Box (initially hidden)
self.whisper_info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) self.whisper_info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
self.whisper_info_box.pack_start(
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 5
)
self.whisper_info_box.pack_start( self.whisper_info_box.pack_start(
Gtk.Label( Gtk.Label(
label="<b>Whisper Model Info</b>", label="<b>Whisper Model Info</b>",
@@ -418,30 +441,93 @@ class SettingsDialog(Gtk.Dialog):
) )
self.whisper_info_box.pack_start(self.whisper_recommendation_label, False, False, 5) self.whisper_info_box.pack_start(self.whisper_recommendation_label, False, False, 5)
self.grid.attach(self.whisper_info_box, 0, 4, 2, 1) self.grid.attach(self.whisper_info_box, 0, row, 2, 1)
row += 1
# Add model change handler # Add model change handler
self.model_combo.connect("changed", self._on_model_changed) self.model_combo.connect("changed", self._on_model_changed)
# Test Area # ==================== SEPARATOR ====================
self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, 6, 2, 1) self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, row, 2, 1)
row += 1
# ==================== RECOGNITION SETTINGS SECTION ====================
recognition_label = Gtk.Label(
label="<b>Recognition Settings</b>", use_markup=True, halign=Gtk.Align.START
)
self.grid.attach(recognition_label, 0, row, 2, 1)
row += 1
# VAD Sensitivity (controls how sensitive the mic is to speech vs silence)
self.grid.attach(
Gtk.Label(label="VAD Sensitivity (1-5):", halign=Gtk.Align.START), 0, row, 1, 1
)
self.vad_spin = Gtk.SpinButton.new_with_range(1, 5, 1)
self.vad_spin.set_tooltip_text("Higher = more sensitive to quiet speech")
self.vad_spin.connect("value-changed", self._on_vad_changed)
self.grid.attach(self.vad_spin, 1, row, 1, 1)
row += 1
# Silence Timeout (how long to wait before processing)
self.grid.attach(
Gtk.Label(label="Silence Timeout (sec):", halign=Gtk.Align.START), 0, row, 1, 1
)
self.silence_spin = Gtk.SpinButton.new_with_range(0.5, 5.0, 0.1)
self.silence_spin.set_digits(1)
self.silence_spin.set_tooltip_text("Wait time after silence before processing speech")
self.silence_spin.connect("value-changed", self._on_silence_changed)
self.grid.attach(self.silence_spin, 1, row, 1, 1)
row += 1
# VOSK Model info label (shown only for VOSK)
self.vosk_model_info_label = Gtk.Label(
label="",
use_markup=True,
halign=Gtk.Align.START,
wrap=True,
)
self.grid.attach(self.vosk_model_info_label, 0, row, 2, 1)
row += 1
# VOSK Recommendation label (shown only for VOSK)
self.vosk_recommendation_label = Gtk.Label(
label="",
use_markup=True,
halign=Gtk.Align.START,
wrap=True,
)
self.grid.attach(self.vosk_recommendation_label, 0, row, 2, 1)
row += 1
# Legacy recognition settings box (for compatibility)
self.recognition_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self.recognition_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
# ==================== SEPARATOR ====================
self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, row, 2, 1)
row += 1
# ==================== TEST RECOGNITION SECTION ====================
test_label = Gtk.Label( test_label = Gtk.Label(
label="<b>Test Recognition</b>", use_markup=True, halign=Gtk.Align.START label="<b>Test Recognition</b>", use_markup=True, halign=Gtk.Align.START
) )
self.grid.attach(test_label, 0, 7, 2, 1) self.grid.attach(test_label, 0, row, 2, 1)
row += 1
scrolled_window = Gtk.ScrolledWindow(hexpand=True, vexpand=True) scrolled_window = Gtk.ScrolledWindow(hexpand=True, vexpand=True)
scrolled_window.set_min_content_height(100) scrolled_window.set_min_content_height(80)
self.test_textview = Gtk.TextView( self.test_textview = Gtk.TextView(
editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD
) )
self.test_buffer = self.test_textview.get_buffer() self.test_buffer = self.test_textview.get_buffer()
scrolled_window.add(self.test_textview) scrolled_window.add(self.test_textview)
self.grid.attach(scrolled_window, 0, 8, 2, 1) self.grid.attach(scrolled_window, 0, row, 2, 1)
row += 1
self.test_button = Gtk.Button(label="Start Test (3 seconds)") self.test_button = Gtk.Button(label="Start Test (3 seconds)")
self.test_button.connect("clicked", self._on_test_clicked) self.test_button.connect("clicked", self._on_test_clicked)
self.grid.attach(self.test_button, 0, 9, 2, 1) self.grid.attach(self.test_button, 0, row, 2, 1)
row += 1
# ---- CRITICAL CHANGE ---- # ---- CRITICAL CHANGE ----
# Load settings FIRST before creating UI connections # Load settings FIRST before creating UI connections
@@ -468,7 +554,7 @@ class SettingsDialog(Gtk.Dialog):
engine_text = self.current_engine.capitalize() engine_text = self.current_engine.capitalize()
logger.info(f"Setting active engine to: {engine_text}") logger.info(f"Setting active engine to: {engine_text}")
if not self.engine_combo.set_active_id(engine_text): if not self.engine_combo.set_active_id(engine_text):
logger.warning(f"Could not set engine by ID, trying by index") logger.warning("Could not set engine by ID, trying by index")
# Fallback to setting by index # Fallback to setting by index
if self.current_engine == "vosk": if self.current_engine == "vosk":
self.engine_combo.set_active(0) self.engine_combo.set_active(0)
@@ -790,7 +876,6 @@ class SettingsDialog(Gtk.Dialog):
f"<span foreground='green'>★ Recommended for your system: {reason}</span>" f"<span foreground='green'>★ Recommended for your system: {reason}</span>"
) )
else: else:
rec_info = WHISPER_MODEL_INFO[recommended]
self.whisper_recommendation_label.set_markup( self.whisper_recommendation_label.set_markup(
f"<small>Tip: <b>{recommended.capitalize()}</b> is recommended for your system ({reason})</small>" f"<small>Tip: <b>{recommended.capitalize()}</b> is recommended for your system ({reason})</small>"
) )
@@ -833,7 +918,6 @@ class SettingsDialog(Gtk.Dialog):
f"<span foreground='green'>★ Recommended for your system: {reason}</span>" f"<span foreground='green'>★ Recommended for your system: {reason}</span>"
) )
else: else:
rec_info = VOSK_MODEL_INFO[recommended]
self.vosk_recommendation_label.set_markup( self.vosk_recommendation_label.set_markup(
f"<small>Tip: <b>{recommended.capitalize()}</b> is recommended for your system ({reason})</small>" f"<small>Tip: <b>{recommended.capitalize()}</b> is recommended for your system ({reason})</small>"
) )
@@ -1112,3 +1196,123 @@ For now, the engine has been reverted to VOSK."""
error_dialog.run() error_dialog.run()
error_dialog.destroy() error_dialog.destroy()
return False return False
def _populate_audio_devices(self):
"""Populate the audio device dropdown with available input devices."""
# Lazy import to avoid circular dependency
from ..speech_recognition.recognition_manager import get_audio_input_devices
self.audio_device_combo.remove_all()
# Add "System Default" option first
self.audio_device_combo.append("-1", "System Default")
# Get available devices
devices = get_audio_input_devices()
for device_index, device_name, is_default in devices:
label = device_name
if is_default:
label += " (default)"
self.audio_device_combo.append(str(device_index), label)
# Get saved device from config
saved_device = self.config_manager.get("audio", "device_index", None)
if saved_device is None:
self.audio_device_combo.set_active_id("-1")
else:
if not self.audio_device_combo.set_active_id(str(saved_device)):
# Saved device no longer available, fall back to default
logger.warning(f"Saved audio device {saved_device} no longer available")
self.audio_device_combo.set_active_id("-1")
logger.info(f"Found {len(devices)} audio input devices")
def _on_refresh_audio_devices(self, widget):
"""Handle refresh button click for audio devices."""
self._populate_audio_devices()
self.audio_test_status.set_markup("<i>Device list refreshed</i>")
def _on_audio_device_changed(self, widget):
"""Handle changes in the selected audio device."""
if self._initializing:
return
device_id = self.audio_device_combo.get_active_id()
if device_id is None:
return
device_index = int(device_id)
device_name = self.audio_device_combo.get_active_text()
# Save to config
if device_index == -1:
self.config_manager.set("audio", "device_index", None)
self.config_manager.set("audio", "device_name", None)
else:
self.config_manager.set("audio", "device_index", device_index)
self.config_manager.set("audio", "device_name", device_name)
self.config_manager.save_settings()
# Update speech engine
if device_index == -1:
self.speech_engine.set_audio_device(None)
else:
self.speech_engine.set_audio_device(device_index)
logger.info(f"Audio device changed to: [{device_index}] {device_name}")
self.audio_test_status.set_markup(f"<i>Selected: {device_name}</i>")
def _on_test_audio_clicked(self, widget):
"""Handle test audio button click."""
self.test_audio_btn.set_sensitive(False)
self.test_audio_btn.set_label("Testing...")
self.audio_test_status.set_markup("<i>Recording... speak into your microphone</i>")
self.audio_level_bar.set_value(0)
# Get selected device
device_id = self.audio_device_combo.get_active_id()
device_index = None if device_id == "-1" else int(device_id)
def run_test():
# Lazy import to avoid circular dependency
from ..speech_recognition.recognition_manager import test_audio_input
result = test_audio_input(device_index=device_index, duration=2.0)
GLib.idle_add(self._handle_audio_test_result, result)
threading.Thread(target=run_test, daemon=True).start()
def _handle_audio_test_result(self, result: dict):
"""Handle the result of an audio test."""
self.test_audio_btn.set_sensitive(True)
self.test_audio_btn.set_label("Test Mic")
if result.get("success"):
max_level = result.get("max_amplitude", 0)
has_signal = result.get("has_signal", False)
# Update level bar with max level (normalized to 0-100)
level_percent = min(100, (max_level / 327.68))
self.audio_level_bar.set_value(level_percent)
if has_signal:
self.audio_test_status.set_markup(
f"<span color='green'>✓ Audio detected!</span> "
f"Peak level: {level_percent:.0f}%"
)
else:
self.audio_test_status.set_markup(
f"<span color='orange'>⚠ Very low audio level</span> "
f"(peak: {level_percent:.1f}%)\n"
"<small>Check if microphone is muted or try a different device</small>"
)
else:
error_msg = result.get("error", "Unknown error")
self.audio_test_status.set_markup(
f"<span color='red'>✗ Test failed:</span> {error_msg}"
)
return False # Don't repeat
+6
View File
@@ -5,12 +5,18 @@ This file makes sure that the 'src' module can be imported in tests.
import os import os
import sys import sys
from unittest.mock import MagicMock
import pytest import pytest
# Add the parent directory to sys.path so that 'src' can be imported # Add the parent directory to sys.path so that 'src' can be imported
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# Mock only the audio_feedback module BEFORE any imports that use it
# This ensures all test modules get the same mock instance
mock_audio_feedback = MagicMock()
sys.modules["vocalinux.ui.audio_feedback"] = mock_audio_feedback
# This will help pytest discover all test files correctly # This will help pytest discover all test files correctly
pytest_plugins = [] pytest_plugins = []
+7 -1
View File
@@ -95,7 +95,13 @@ class TestMainModule(unittest.TestCase):
main() main()
# Verify components were initialized correctly # Verify components were initialized correctly
mock_speech.assert_called_once_with(engine="vosk", model_size="medium") mock_speech.assert_called_once_with(
engine="vosk",
model_size="medium",
vad_sensitivity=3,
silence_timeout=2.0,
audio_device_index=None,
)
mock_text.assert_called_once_with(wayland_mode=True) mock_text.assert_called_once_with(wayland_mode=True)
mock_action_handler.assert_called_once_with(mock_text_instance) mock_action_handler.assert_called_once_with(mock_text_instance)
mock_tray.assert_called_once_with( mock_tray.assert_called_once_with(
+4 -23
View File
@@ -20,34 +20,15 @@ sys.modules["tqdm"] = MagicMock()
sys.modules["numpy"] = MagicMock() sys.modules["numpy"] = MagicMock()
sys.modules["zipfile"] = 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 # Update import paths to use the new package structure
from vocalinux.common_types import RecognitionState from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.command_processor import CommandProcessor from vocalinux.speech_recognition.command_processor import CommandProcessor
from vocalinux.speech_recognition.recognition_manager import ( from vocalinux.speech_recognition.recognition_manager import MODELS_DIR, SpeechRecognitionManager
MODELS_DIR,
SpeechRecognitionManager,
)
# Need to create a mock for audio_feedback module before importing the recognition_manager
mock_audio_feedback = MagicMock()
mock_audio_feedback.play_start_sound = MagicMock()
mock_audio_feedback.play_stop_sound = MagicMock()
mock_audio_feedback.play_error_sound = MagicMock()
# Use patch to properly mock the modules used by the recognition_manager
@patch(
"vocalinux.speech_recognition.recognition_manager.play_start_sound",
mock_audio_feedback.play_start_sound,
)
@patch(
"vocalinux.speech_recognition.recognition_manager.play_stop_sound",
mock_audio_feedback.play_stop_sound,
)
@patch(
"vocalinux.speech_recognition.recognition_manager.play_error_sound",
mock_audio_feedback.play_error_sound,
)
class TestSpeechRecognition(unittest.TestCase): class TestSpeechRecognition(unittest.TestCase):
"""Test cases for the speech recognition functionality.""" """Test cases for the speech recognition functionality."""
+10 -18
View File
@@ -66,6 +66,9 @@ mock_wave_file.__exit__ = MagicMock(return_value=None)
mock_wave.open = MagicMock(return_value=mock_wave_file) mock_wave.open = MagicMock(return_value=mock_wave_file)
sys.modules["wave"] = mock_wave sys.modules["wave"] = mock_wave
# Import the shared mock from conftest
from conftest import mock_audio_feedback
# Update import paths to use the new package structure # Update import paths to use the new package structure
from vocalinux.common_types import RecognitionState from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
@@ -100,21 +103,10 @@ class TestSpeechRecognition(unittest.TestCase):
self.mock_thread = MagicMock() self.mock_thread = MagicMock()
self.mock_thread_class.return_value = self.mock_thread self.mock_thread_class.return_value = self.mock_thread
# Mock audio feedback functions # Use the module-level audio feedback mock
self.patcher_play_start = patch( self.mock_play_start = mock_audio_feedback.play_start_sound
"vocalinux.speech_recognition.recognition_manager.play_start_sound" self.mock_play_stop = mock_audio_feedback.play_stop_sound
) self.mock_play_error = mock_audio_feedback.play_error_sound
self.mock_play_start = self.patcher_play_start.start()
self.patcher_play_stop = patch(
"vocalinux.speech_recognition.recognition_manager.play_stop_sound"
)
self.mock_play_stop = self.patcher_play_stop.start()
self.patcher_play_error = patch(
"vocalinux.speech_recognition.recognition_manager.play_error_sound"
)
self.mock_play_error = self.patcher_play_error.start()
# Patch the download method for vosk models # Patch the download method for vosk models
self.patcher_download = patch.object(SpeechRecognitionManager, "_download_vosk_model") self.patcher_download = patch.object(SpeechRecognitionManager, "_download_vosk_model")
@@ -130,12 +122,12 @@ class TestSpeechRecognition(unittest.TestCase):
self.patcher_exists.stop() self.patcher_exists.stop()
self.patcher_cmd.stop() self.patcher_cmd.stop()
self.patcher_thread.stop() self.patcher_thread.stop()
self.patcher_play_start.stop()
self.patcher_play_stop.stop()
self.patcher_play_error.stop()
self.patcher_download.stop() self.patcher_download.stop()
self.patcher_unlink.stop() self.patcher_unlink.stop()
# Reset the audio feedback mocks for the next test
mock_audio_feedback.reset_mock()
def test_init_state(self): def test_init_state(self):
"""Test the initial state of the speech recognition manager.""" """Test the initial state of the speech recognition manager."""
# Test VOSK initialization # Test VOSK initialization