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