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:
+29
-18
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user