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 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
@@ -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)
@@ -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
+9 -6
View File
@@ -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
+279 -75
View File
@@ -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="<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
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="<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)
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="<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.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="<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(
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.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"<span foreground='green'>★ Recommended for your system: {reason}</span>"
)
else:
rec_info = WHISPER_MODEL_INFO[recommended]
self.whisper_recommendation_label.set_markup(
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>"
)
else:
rec_info = VOSK_MODEL_INFO[recommended]
self.vosk_recommendation_label.set_markup(
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.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("<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 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 = []
+7 -1
View File
@@ -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(
+4 -23
View File
@@ -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."""
+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)
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