diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..92b7625 --- /dev/null +++ b/.flake8 @@ -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 diff --git a/activate-vocalinux.sh b/activate-vocalinux.sh new file mode 100755 index 0000000..1d6def9 --- /dev/null +++ b/activate-vocalinux.sh @@ -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" diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index acf631c..2f40c1a 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -5,7 +5,6 @@ Main entry point for Vocalinux application. import argparse import logging -import os import sys # Configure logging @@ -16,10 +15,8 @@ logging.basicConfig( logger = logging.getLogger(__name__) # Import from the vocalinux package -from .speech_recognition import recognition_manager -from .text_injection import text_injector -from .ui import tray_indicator -from .ui.action_handler import ActionHandler +from .ui import tray_indicator # noqa: E402 +from .ui.action_handler import ActionHandler # noqa: E402 def parse_arguments(): @@ -47,27 +44,30 @@ def check_dependencies(): """Check for required dependencies and provide helpful error messages.""" 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: import gi gi.require_version("Gtk", "3.0") gi.require_version("AppIndicator3", "0.1") - from gi.repository import AppIndicator3, Gtk - except (ImportError, ValueError) as e: + except (ImportError, ValueError): 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: logger.error("Missing required dependencies:") for dep in missing_deps: @@ -99,17 +99,25 @@ def main(): sys.exit(1) # 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 config_manager = ConfigManager() 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 # Check if args are still at their defaults (user didn't explicitly set them) engine = saved_settings.get("engine", args.engine) 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)") + if audio_device_index is not None: + logger.info(f"Using audio device index={audio_device_index} (from saved config)") # Initialize main components logger.info("Initializing Vocalinux...") @@ -119,6 +127,9 @@ def main(): speech_engine = recognition_manager.SpeechRecognitionManager( engine=engine, model_size=model_size, + vad_sensitivity=vad_sensitivity, + silence_timeout=silence_timeout, + audio_device_index=audio_device_index, ) # Initialize text injection system diff --git a/src/vocalinux/speech_recognition/__init__.py b/src/vocalinux/speech_recognition/__init__.py index ffeabca..199463d 100644 --- a/src/vocalinux/speech_recognition/__init__.py +++ b/src/vocalinux/speech_recognition/__init__.py @@ -3,3 +3,13 @@ Speech recognition module for Vocalinux """ 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) diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index c14b8f1..fac706c 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -5,18 +5,17 @@ This module provides a unified interface to different speech recognition engines currently supporting VOSK and Whisper. """ -import contextlib import ctypes import json import logging import os -import sys import threading import time -from pathlib import Path from typing import Callable, List, Optional 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 @@ -48,8 +47,155 @@ def _setup_alsa_error_handler(): # Set up ALSA error handler at module load time _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__) @@ -96,6 +242,7 @@ class SpeechRecognitionManager: engine: The speech recognition engine to use ("vosk" or "whisper") 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) + audio_device_index: Optional audio input device index (None for default) """ self.engine = engine self.model_size = model_size @@ -119,6 +266,13 @@ class SpeechRecognitionManager: self.vad_sensitivity = kwargs.get("vad_sensitivity", 3) 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 self.should_record = False self.audio_buffer = [] @@ -479,19 +633,27 @@ class SpeechRecognitionManager: def _download_whisper_model(self, cache_dir: str): """Download a Whisper model with progress tracking.""" - import hashlib - import requests self._download_cancelled = False # Whisper model URLs (from openai-whisper package) model_urls = { - "tiny": "https://openaipublic.azureedge.net/main/whisper/models/65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/tiny.pt", - "base": "https://openaipublic.azureedge.net/main/whisper/models/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", + "tiny": "https://openaipublic.azureedge.net/main/whisper/models/" + "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9/" + "tiny.pt", + "base": "https://openaipublic.azureedge.net/main/whisper/models/" + "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) @@ -630,6 +792,46 @@ class SpeechRecognitionManager: """ 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): """ Update the recognition state and notify callbacks. @@ -655,12 +857,12 @@ class SpeechRecognitionManager: # Check if model is ready if not self.model_ready: 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() _show_notification( "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", ) return @@ -716,9 +918,10 @@ class SpeechRecognitionManager: def _record_audio(self): """Record audio from the microphone.""" - try: - import wave + # Lazy import to avoid circular dependency + from ..ui.audio_feedback import play_error_sound # noqa: F401 + try: import numpy as np import pyaudio except ImportError as e: @@ -739,29 +942,94 @@ class SpeechRecognitionManager: # Initialize PyAudio audio = pyaudio.PyAudio() - # Open microphone stream - stream = audio.open( - format=FORMAT, - channels=CHANNELS, - rate=RATE, - input=True, - frames_per_buffer=CHUNK, - ) + # Log available devices for debugging + logger.debug("Available audio input devices:") + for i in range(audio.get_device_count()): + try: + info = audio.get_device_info_by_index(i) + if info.get("maxInputChannels", 0) > 0: + 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") # Record audio while should_record is True 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: try: data = stream.read(CHUNK, exception_on_overflow=False) self.audio_buffer.append(data) # Simple Voice Activity Detection (VAD) - # TODO: Implement proper VAD using webrtcvad or similar audio_data = np.frombuffer(data, dtype=np.int16) 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) # Ensure vad_sensitivity is treated as integer for calculation try: @@ -776,15 +1044,22 @@ class SpeechRecognitionManager: if volume < threshold: # Silence silence_counter += CHUNK / RATE # Convert chunks to seconds if silence_counter > self.silence_timeout: # Use self.silence_timeout - logger.debug("Silence detected, processing buffer") - self._update_state(RecognitionState.PROCESSING) - # Process final buffer - self._process_final_buffer() - # Reset for next utterance - self.audio_buffer = [] + if len(self.audio_buffer) > 0: + logger.debug("Silence detected, processing buffer") + self._update_state(RecognitionState.PROCESSING) + # Process final buffer + self._process_final_buffer() + # Reset for next utterance + self.audio_buffer = [] silence_counter = 0 self._update_state(RecognitionState.LISTENING) 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 except Exception as e: logger.error(f"Error reading audio data: {e}") @@ -794,6 +1069,15 @@ class SpeechRecognitionManager: stream.stop_stream() stream.close() 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") except Exception as e: @@ -846,6 +1130,7 @@ class SpeechRecognitionManager: model_size: Optional[str] = None, vad_sensitivity: Optional[int] = None, silence_timeout: Optional[float] = None, + audio_device_index: Optional[int] = None, force_download: bool = True, **kwargs, # Allow for future expansion ): @@ -857,10 +1142,11 @@ class SpeechRecognitionManager: model_size: The new model size. vad_sensitivity: New VAD sensitivity (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). """ 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 @@ -878,6 +1164,13 @@ class SpeechRecognitionManager: if silence_timeout is not None: 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: logger.info("Engine or model changed, re-initializing...") # When reconfiguring from UI, allow downloads diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 507eb29..0e1bbbe 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -7,8 +7,7 @@ This module handles loading, saving, and accessing user preferences. import json import logging import os -from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict logger = logging.getLogger(__name__) @@ -21,8 +20,12 @@ DEFAULT_CONFIG = { "speech_recognition": { # Changed section name "engine": "whisper", # "vosk" or "whisper" "model_size": "tiny", # "tiny", "small", "medium", or "large" - "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) - Moved here - "silence_timeout": 2.0, # Seconds of silence before stopping recognition - Moved here + "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) + "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": { "toggle_recognition": "ctrl+ctrl", # Double-tap Ctrl @@ -101,7 +104,7 @@ class ConfigManager: Get a configuration value. Args: - section: The configuration section (e.g., "speech_recognition", "shortcuts") + section: The configuration section key: The configuration key within the section default: The default value to return if the key doesn't exist @@ -118,7 +121,7 @@ class ConfigManager: Set a configuration value. Args: - section: The configuration section (e.g., "speech_recognition", "shortcuts") + section: The configuration section key: The configuration key within the section value: The value to set diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index cd88d86..4b7e178 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -9,20 +9,22 @@ import logging import os import threading import time -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import gi gi.require_version("Gtk", "3.0") # 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 if TYPE_CHECKING: - from ..speech_recognition.recognition_manager import SpeechRecognitionManager - from .config_manager import ConfigManager + from ..speech_recognition.recognition_manager import ( # noqa: E402 + SpeechRecognitionManager, + ) + from .config_manager import ConfigManager # noqa: E402 logger = logging.getLogger(__name__) @@ -312,17 +314,92 @@ class SettingsDialog(Gtk.Dialog): self.set_border_width(10) # --- 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) + row = 0 + + # ==================== AUDIO INPUT SECTION (TOP) ==================== + audio_label = Gtk.Label(label="Audio Input", 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="Speech Engine", use_markup=True, halign=Gtk.Align.START + ) + self.grid.attach(engine_label, 0, row, 2, 1) + row += 1 + # 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.grid.attach(self.engine_combo, 1, row, 1, 1) + row += 1 # 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.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 = Gtk.Label( @@ -330,65 +407,11 @@ class SettingsDialog(Gtk.Dialog): use_markup=True, halign=Gtk.Align.END, ) - self.grid.attach(model_legend, 0, 2, 2, 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="Recognition Settings", 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) + self.grid.attach(model_legend, 0, row, 2, 1) + row += 1 # Whisper Info Box (initially hidden) 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( Gtk.Label( label="Whisper Model Info", @@ -418,30 +441,93 @@ class SettingsDialog(Gtk.Dialog): ) 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 self.model_combo.connect("changed", self._on_model_changed) - # Test Area - self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, 6, 2, 1) + # ==================== SEPARATOR ==================== + self.grid.attach(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, row, 2, 1) + row += 1 + + # ==================== RECOGNITION SETTINGS SECTION ==================== + recognition_label = Gtk.Label( + label="Recognition Settings", 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( label="Test Recognition", 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.set_min_content_height(100) + scrolled_window.set_min_content_height(80) self.test_textview = Gtk.TextView( editable=False, cursor_visible=False, wrap_mode=Gtk.WrapMode.WORD ) self.test_buffer = self.test_textview.get_buffer() 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.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 ---- # Load settings FIRST before creating UI connections @@ -468,7 +554,7 @@ class SettingsDialog(Gtk.Dialog): engine_text = self.current_engine.capitalize() logger.info(f"Setting active engine to: {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 if self.current_engine == "vosk": self.engine_combo.set_active(0) @@ -790,7 +876,6 @@ class SettingsDialog(Gtk.Dialog): f"★ Recommended for your system: {reason}" ) else: - rec_info = WHISPER_MODEL_INFO[recommended] self.whisper_recommendation_label.set_markup( f"Tip: {recommended.capitalize()} is recommended for your system ({reason})" ) @@ -833,7 +918,6 @@ class SettingsDialog(Gtk.Dialog): f"★ Recommended for your system: {reason}" ) else: - rec_info = VOSK_MODEL_INFO[recommended] self.vosk_recommendation_label.set_markup( f"Tip: {recommended.capitalize()} is recommended for your system ({reason})" ) @@ -1112,3 +1196,123 @@ For now, the engine has been reverted to VOSK.""" error_dialog.run() error_dialog.destroy() 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("Device list refreshed") + + 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"Selected: {device_name}") + + 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("Recording... speak into your microphone") + 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"✓ Audio detected! " + f"Peak level: {level_percent:.0f}%" + ) + else: + self.audio_test_status.set_markup( + f"⚠ Very low audio level " + f"(peak: {level_percent:.1f}%)\n" + "Check if microphone is muted or try a different device" + ) + else: + error_msg = result.get("error", "Unknown error") + self.audio_test_status.set_markup( + f"✗ Test failed: {error_msg}" + ) + + return False # Don't repeat diff --git a/tests/conftest.py b/tests/conftest.py index 1c0619f..3717f4d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,18 @@ This file makes sure that the 'src' module can be imported in tests. import os import sys +from unittest.mock import MagicMock import pytest # 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__), ".."))) +# 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 pytest_plugins = [] diff --git a/tests/test_main.py b/tests/test_main.py index 5f13d06..f1cfd55 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -95,7 +95,13 @@ class TestMainModule(unittest.TestCase): main() # 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_action_handler.assert_called_once_with(mock_text_instance) mock_tray.assert_called_once_with( diff --git a/tests/test_recognition_manager.py b/tests/test_recognition_manager.py index 22b3544..af158be 100644 --- a/tests/test_recognition_manager.py +++ b/tests/test_recognition_manager.py @@ -20,34 +20,15 @@ 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, -) - -# 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() +from vocalinux.speech_recognition.recognition_manager import MODELS_DIR, SpeechRecognitionManager -# 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): """Test cases for the speech recognition functionality.""" diff --git a/tests/test_speech_recognition.py b/tests/test_speech_recognition.py index bf98669..805bd3c 100644 --- a/tests/test_speech_recognition.py +++ b/tests/test_speech_recognition.py @@ -66,6 +66,9 @@ 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 + # Update import paths to use the new package structure from vocalinux.common_types import RecognitionState from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager @@ -100,21 +103,10 @@ class TestSpeechRecognition(unittest.TestCase): self.mock_thread = MagicMock() self.mock_thread_class.return_value = self.mock_thread - # Mock audio feedback functions - self.patcher_play_start = patch( - "vocalinux.speech_recognition.recognition_manager.play_start_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() + # 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") @@ -130,12 +122,12 @@ class TestSpeechRecognition(unittest.TestCase): self.patcher_exists.stop() self.patcher_cmd.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_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