diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index a8feba1..5c12f8e 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -331,11 +331,37 @@ def main(): # Initialize action handler action_handler = ActionHandler(text_system) - # Create a wrapper function to track injected text for action handler - def text_callback_wrapper(text: str): - """Wrapper to track injected text and handle spacing between segments.""" - # Strip any leading/trailing whitespace from the incoming text as a - # safety net (whisper tokenizer sometimes prepends spaces to tokens) + # --- Callback wiring --------------------------------------------------- + # The speech engine emits three kinds of events, each handled by a + # dedicated callback registered below: + # + # text_callback(text: str) + # Called on the recognition thread when a transcription segment + # is finalised. The wrapper below strips whitespace, inserts + # inter-segment spaces, injects the text, and records it so + # "delete that" can undo it. + # + # action_callback(action: str) -> bool + # Called when a voice command (e.g. "undo", "select all") is + # recognised. Delegated directly to ActionHandler.handle_action. + # + # state_callback(state: RecognitionState) + # Called whenever the engine transitions state (IDLE → LISTENING, + # etc.). Used here to clear the "last injected" buffer when a + # new listening session starts. + # ------------------------------------------------------------------ + + def text_callback_wrapper(text: str) -> None: + """Bridge between speech engine text events and the text injector. + + Called on the recognition thread with each finalised transcription + segment. Strips leading/trailing whitespace (whisper tokenizer + sometimes prepends spaces), inserts a single space between + consecutive segments, then injects via TextInjector. + + Args: + text: Raw transcription segment from the speech engine. + """ text_to_inject = text.strip() if not text_to_inject: return @@ -351,14 +377,14 @@ def main(): if success: action_handler.set_last_injected_text(text) - # Connect speech recognition to text injection and action handling - speech_engine.register_text_callback(text_callback_wrapper) - speech_engine.register_action_callback(action_handler.handle_action) - - def on_state_change(state: RecognitionState): + def on_state_change(state: RecognitionState) -> None: + """Reset the last-injected buffer when a new listening session starts.""" if state == RecognitionState.LISTENING: action_handler.set_last_injected_text("") + # Connect speech recognition to text injection and action handling + speech_engine.register_text_callback(text_callback_wrapper) + speech_engine.register_action_callback(action_handler.handle_action) speech_engine.register_state_callback(on_state_change) # Initialize and start the system tray indicator diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 8428084..9a28151 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -96,7 +96,7 @@ def get_audio_input_devices() -> list: audio.terminate() except ImportError: logger.error("PyAudio not installed, cannot enumerate audio devices") - except Exception as e: + except OSError as e: logger.error(f"Error enumerating audio devices: {e}") return devices @@ -342,7 +342,7 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict: audio_data = np.frombuffer(data, dtype=np.int16) amplitudes = np.abs(audio_data) all_amplitudes.extend(amplitudes) - except Exception as e: + except (OSError, ValueError) as e: result["error"] = f"Error reading audio: {e}" break @@ -362,7 +362,7 @@ def test_audio_input(device_index: int = None, duration: float = 1.0) -> dict: except ImportError as e: result["error"] = f"Missing dependency: {e}" - except Exception as e: + except (OSError, ValueError, RuntimeError) as e: result["error"] = f"Unexpected error: {e}" return result @@ -433,7 +433,7 @@ def _show_notification(title: str, message: str, icon: str = "dialog-warning"): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - except Exception as e: + except (FileNotFoundError, OSError) as e: logger.debug(f"Could not show notification: {e}") @@ -715,7 +715,7 @@ class SpeechRecognitionManager: logger.error("Please install with: pip install openai-whisper torch") self.state = RecognitionState.ERROR raise - except Exception as e: + except (RuntimeError, OSError) as e: logger.error(f"Failed to initialize Whisper engine: {e}") self.state = RecognitionState.ERROR raise @@ -786,16 +786,14 @@ class SpeechRecognitionManager: return text - except Exception as e: + except (RuntimeError, OSError, ValueError) as e: logger.error(f"Error in Whisper transcription: {e}", exc_info=True) return "" def _init_whispercpp(self): """Initialize the whisper.cpp speech recognition engine.""" - import time - try: - from pywhispercpp.model import Model + from pywhispercpp.model import Model # noqa: F401 — used in _load_whispercpp_model # Validate model size for whisper.cpp valid_models = list(WHISPERCPP_MODEL_INFO.keys()) @@ -821,105 +819,7 @@ class SpeechRecognitionManager: logger.info(f"Downloading whisper.cpp '{self.model_size}' model...") self._download_whispercpp_model() - # Detect and log compute backend - from ..utils.whispercpp_model_info import ( - ComputeBackend, - detect_compute_backend, - get_backend_display_name, - ) - - backend, backend_info = detect_compute_backend() - logger.info(f"whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU") - logger.info( - f"whisper.cpp using {get_backend_display_name(backend)} backend: {backend_info}" - ) - - # Log hardware summary - import psutil - - total_ram_gb = psutil.virtual_memory().total // (1024**3) - logger.info(f"whisper.cpp hardware: {backend} | {backend_info} | RAM: {total_ram_gb}GB") - - # Validate model file exists and get size - if os.path.exists(model_path): - model_size_mb = os.path.getsize(model_path) / (1024 * 1024) - logger.info(f"whisper.cpp model file: {model_path} ({model_size_mb:.1f} MB)") - else: - logger.error(f"whisper.cpp model file not found: {model_path}") - raise FileNotFoundError(f"Model file not found: {model_path}") - - logger.info(f"Loading whisper.cpp '{self.model_size}' model...") - # Ensure previous model is released if re-initializing - self.model = None - - # Load model with pywhispercpp - # It auto-detects the best backend (Vulkan, CUDA, or CPU) - # Use all available CPU cores for best performance - import multiprocessing - - n_threads = multiprocessing.cpu_count() - cpu_count = multiprocessing.cpu_count() - - load_start_time = time.time() - - loaded_backend = backend - - # Attempt to load model with automatic backend selection - # If Vulkan GPU fails (e.g., incompatible Intel GPU), fallback to CPU - try: - self.model = Model( - model_path, - n_threads=n_threads, - suppress_blank=True, - no_speech_thold=0.6, - entropy_thold=2.4, - ) - except RuntimeError as model_error: - error_str = str(model_error).lower() - # Check for Vulkan 16-bit storage incompatibility - if ( - "16-bit storage" in error_str - or "unsupported device" in error_str - or "incompatible driver" in error_str - ): - logger.warning( - f"Vulkan GPU initialization failed: {model_error}. " - "Falling back to CPU backend." - ) - _show_notification( - "Vocalinux: GPU Fallback", - "Your GPU doesn't support whisper.cpp Vulkan.\n" - "Switched to CPU mode - still fast!", - "dialog-information", - ) - # Force CPU backend by disabling GPU backends - os.environ["GGML_VULKAN"] = "0" - os.environ["GGML_CUDA"] = "0" - # Retry with CPU-only backend - self.model = Model( - model_path, - n_threads=n_threads, - suppress_blank=True, - no_speech_thold=0.6, - entropy_thold=2.4, - ) - loaded_backend = ComputeBackend.CPU - backend_info = "CPU (fallback from incompatible Vulkan GPU)" - logger.info("Successfully loaded model with CPU backend") - else: - raise - - load_duration = time.time() - load_start_time - - logger.info( - f"whisper.cpp configured with n_threads={n_threads} (detected {cpu_count} CPUs)" - ) - logger.info( - f"whisper.cpp model loaded in {load_duration:.2f}s ({loaded_backend} backend)" - ) - - self._model_initialized = True - logger.info("whisper.cpp engine initialized successfully.") + self._load_whispercpp_model(model_path) except ImportError as e: logger.error(f"Failed to import pywhispercpp: {e}") @@ -927,11 +827,127 @@ class SpeechRecognitionManager: logger.error("Please install with: pip install pywhispercpp") self.state = RecognitionState.ERROR raise - except Exception as e: + except (FileNotFoundError, RuntimeError, OSError) as e: logger.error(f"Failed to initialize whisper.cpp engine: {e}", exc_info=True) self.state = RecognitionState.ERROR raise + def _load_whispercpp_model(self, model_path: str): + """Load the whisper.cpp model file and configure the compute backend. + + Detects the optimal backend (Vulkan → CUDA → CPU), loads the model, + and falls back to CPU if the GPU backend is incompatible. + + Args: + model_path: Filesystem path to the GGML model file. + """ + import multiprocessing + import time + + from pywhispercpp.model import Model + + from ..utils.whispercpp_model_info import ( + ComputeBackend, + detect_compute_backend, + get_backend_display_name, + ) + + # Detect and log compute backend + backend, backend_info = detect_compute_backend() + logger.info(f"whisper.cpp backend selection priority: Vulkan -> CUDA -> CPU") + logger.info( + f"whisper.cpp using {get_backend_display_name(backend)} backend: {backend_info}" + ) + + # Log hardware summary + import psutil + + total_ram_gb = psutil.virtual_memory().total // (1024**3) + logger.info(f"whisper.cpp hardware: {backend} | {backend_info} | RAM: {total_ram_gb}GB") + + # Validate model file exists and get size + if os.path.exists(model_path): + model_size_mb = os.path.getsize(model_path) / (1024 * 1024) + logger.info(f"whisper.cpp model file: {model_path} ({model_size_mb:.1f} MB)") + else: + raise FileNotFoundError(f"Model file not found: {model_path}") + + logger.info(f"Loading whisper.cpp '{self.model_size}' model...") + self.model = None # Release previous model if re-initializing + + n_threads = multiprocessing.cpu_count() + load_start_time = time.time() + loaded_backend = backend + + # Attempt to load model; fall back to CPU if GPU backend is incompatible + try: + self.model = Model( + model_path, + n_threads=n_threads, + suppress_blank=True, + no_speech_thold=0.6, + entropy_thold=2.4, + ) + except RuntimeError as model_error: + loaded_backend = self._handle_gpu_fallback( + model_error, model_path, n_threads, ComputeBackend.CPU + ) + + load_duration = time.time() - load_start_time + logger.info( + f"whisper.cpp configured with n_threads={n_threads} " + f"(detected {multiprocessing.cpu_count()} CPUs)" + ) + logger.info(f"whisper.cpp model loaded in {load_duration:.2f}s ({loaded_backend} backend)") + + self._model_initialized = True + logger.info("whisper.cpp engine initialized successfully.") + + def _handle_gpu_fallback(self, error, model_path: str, n_threads: int, cpu_backend): + """Handle GPU backend failure by falling back to CPU. + + Args: + error: The RuntimeError from model loading. + model_path: Path to the GGML model file. + n_threads: Number of CPU threads for the model. + cpu_backend: The CPU ComputeBackend enum value. + + Returns: + The backend that was actually used. + + Raises: + RuntimeError: If the error is not a known GPU incompatibility. + """ + from pywhispercpp.model import Model + + error_str = str(error).lower() + gpu_incompatible = ( + "16-bit storage" in error_str + or "unsupported device" in error_str + or "incompatible driver" in error_str + ) + if not gpu_incompatible: + raise error + + logger.warning(f"Vulkan GPU initialization failed: {error}. Falling back to CPU backend.") + _show_notification( + "Vocalinux: GPU Fallback", + "Your GPU doesn't support whisper.cpp Vulkan.\n" "Switched to CPU mode - still fast!", + "dialog-information", + ) + # Force CPU backend by disabling GPU backends + os.environ["GGML_VULKAN"] = "0" + os.environ["GGML_CUDA"] = "0" + self.model = Model( + model_path, + n_threads=n_threads, + suppress_blank=True, + no_speech_thold=0.6, + entropy_thold=2.4, + ) + logger.info("Successfully loaded model with CPU backend") + return cpu_backend + def _transcribe_with_whispercpp(self, audio_buffer: list[bytes]) -> str: """ Transcribe audio buffer using whisper.cpp. @@ -1111,7 +1127,7 @@ class SpeechRecognitionManager: if os.path.exists(temp_file): os.remove(temp_file) raise RuntimeError(f"Failed to download whisper.cpp model: {e}") from e - except Exception as e: + except (OSError, RuntimeError, ValueError) as e: logger.error(f"An error occurred during whisper.cpp model download: {e}") if os.path.exists(temp_file): os.remove(temp_file) @@ -1274,7 +1290,7 @@ class SpeechRecognitionManager: if os.path.exists(zip_path): os.remove(zip_path) raise RuntimeError("Downloaded VOSK model file is corrupted.") - except Exception as e: + except (OSError, RuntimeError, ValueError) as e: logger.error(f"An error occurred during VOSK model download/extraction: {e}") # Clean up potentially corrupted extraction if os.path.exists(zip_path): @@ -1390,7 +1406,7 @@ class SpeechRecognitionManager: if os.path.exists(temp_file): os.remove(temp_file) raise RuntimeError(f"Failed to download Whisper model: {e}") from e - except Exception as e: + except (OSError, RuntimeError, ValueError) as e: logger.error(f"An error occurred during Whisper model download: {e}") if os.path.exists(temp_file): os.remove(temp_file) diff --git a/src/vocalinux/ui/action_handler.py b/src/vocalinux/ui/action_handler.py index 901df62..d6fa77f 100644 --- a/src/vocalinux/ui/action_handler.py +++ b/src/vocalinux/ui/action_handler.py @@ -5,7 +5,7 @@ This module handles special voice commands like "delete that", "undo", etc. """ import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: from ..text_injection.text_injector import TextInjector @@ -21,6 +21,20 @@ class ActionHandler: and performs the appropriate actions like deleting text, undoing, etc. """ + # Keyboard shortcut mappings for simple forwarding actions. + # Actions not listed here have custom handlers defined as methods. + _SHORTCUT_ACTIONS: dict[str, str] = { + "undo": "ctrl+z", + "redo": "ctrl+y", + "select_all": "ctrl+a", + "select_line": "Home+shift+End", + "select_word": "ctrl+shift+Right", + "select_paragraph": "ctrl+shift+Down", + "cut": "ctrl+x", + "copy": "ctrl+c", + "paste": "ctrl+v", + } + def __init__(self, text_injector: "TextInjector"): """ Initialize the action handler. @@ -31,19 +45,12 @@ class ActionHandler: self.text_injector = text_injector self.last_injected_text = "" - # Map actions to handler methods - self.action_handlers = { + # Build action dispatch table: custom handlers + shortcut-based actions + self.action_handlers: dict[str, Callable[[], bool]] = { "delete_last": self._handle_delete_last, - "undo": self._handle_undo, - "redo": self._handle_redo, - "select_all": self._handle_select_all, - "select_line": self._handle_select_line, - "select_word": self._handle_select_word, - "select_paragraph": self._handle_select_paragraph, - "cut": self._handle_cut, - "copy": self._handle_copy, - "paste": self._handle_paste, } + for action, shortcut in self._SHORTCUT_ACTIONS.items(): + self.action_handlers[action] = self._make_shortcut_handler(shortcut) def handle_action(self, action: str) -> bool: """ @@ -77,6 +84,14 @@ class ActionHandler: """ self.last_injected_text = text + def _make_shortcut_handler(self, shortcut: str) -> Callable[[], bool]: + """Create a handler that sends a keyboard shortcut via the text injector.""" + + def handler() -> bool: + return self.text_injector._inject_keyboard_shortcut(shortcut) + + return handler + def _handle_delete_last(self) -> bool: """Handle 'delete that' command by sending backspace keys.""" if not self.last_injected_text: @@ -92,39 +107,3 @@ class ActionHandler: self.last_injected_text = "" return success - - def _handle_undo(self) -> bool: - """Handle 'undo' command by sending Ctrl+Z.""" - return self.text_injector._inject_keyboard_shortcut("ctrl+z") - - def _handle_redo(self) -> bool: - """Handle 'redo' command by sending Ctrl+Y.""" - return self.text_injector._inject_keyboard_shortcut("ctrl+y") - - def _handle_select_all(self) -> bool: - """Handle 'select all' command by sending Ctrl+A.""" - return self.text_injector._inject_keyboard_shortcut("ctrl+a") - - def _handle_select_line(self) -> bool: - """Handle 'select line' command by sending Home+Shift+End.""" - return self.text_injector._inject_keyboard_shortcut("Home+shift+End") - - def _handle_select_word(self) -> bool: - """Handle 'select word' command by sending Ctrl+Shift+Right.""" - return self.text_injector._inject_keyboard_shortcut("ctrl+shift+Right") - - def _handle_select_paragraph(self) -> bool: - """Handle 'select paragraph' command by sending Ctrl+Shift+Down.""" - return self.text_injector._inject_keyboard_shortcut("ctrl+shift+Down") - - def _handle_cut(self) -> bool: - """Handle 'cut' command by sending Ctrl+X.""" - return self.text_injector._inject_keyboard_shortcut("ctrl+x") - - def _handle_copy(self) -> bool: - """Handle 'copy' command by sending Ctrl+C.""" - return self.text_injector._inject_keyboard_shortcut("ctrl+c") - - def _handle_paste(self) -> bool: - """Handle 'paste' command by sending Ctrl+V.""" - return self.text_injector._inject_keyboard_shortcut("ctrl+v") diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 8d1e443..1a9ddca 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -4,10 +4,11 @@ Configuration manager for Vocalinux. This module handles loading, saving, and accessing user preferences. """ +import copy import json import logging import os -from typing import Any +from typing import Any, Optional logger = logging.getLogger(__name__) @@ -67,10 +68,12 @@ class ConfigManager: preferences for the application. """ + # Valid configuration sections — prevents accidental typos from silently + # creating new top-level config keys. + _VALID_SECTIONS = frozenset(DEFAULT_CONFIG.keys()) + def __init__(self): """Initialize the configuration manager.""" - import copy - self.config = copy.deepcopy(DEFAULT_CONFIG) self._ensure_config_dir() self.load_config() @@ -106,7 +109,7 @@ class ConfigManager: self._migrate_shortcuts_config() - except Exception as e: + except (json.JSONDecodeError, OSError) as e: logger.error(f"Failed to load config: {e}") def _check_needs_migration(self, user_config: dict) -> bool: @@ -162,12 +165,22 @@ class ConfigManager: logger.info(f"Saved configuration to {CONFIG_FILE}") return True - except Exception as e: + except (OSError, TypeError) as e: logger.error(f"Failed to save config: {e}") return False def save_settings(self): - """Save the current configuration to the config file.""" + """Save the current configuration to the config file. + + .. deprecated:: Use :meth:`save_config` instead. + """ + import warnings + + warnings.warn( + "save_settings() is deprecated, use save_config() instead", + DeprecationWarning, + stacklevel=2, + ) return self.save_config() def get(self, section: str, key: str, default: Any = None) -> Any: @@ -187,12 +200,54 @@ class ConfigManager: except KeyError: return default + # -- Typed accessors ------------------------------------------------------- + # These provide compile-time type safety for commonly accessed config values, + # avoiding the need for callers to cast the Any return from get(). + + def get_str(self, section: str, key: str, default: str = "") -> str: + """Get a configuration value as a string.""" + value = self.get(section, key, default) + return str(value) if value is not None else default + + def get_bool(self, section: str, key: str, default: bool = False) -> bool: + """Get a configuration value as a boolean.""" + value = self.get(section, key, default) + return bool(value) + + def get_int(self, section: str, key: str, default: int = 0) -> int: + """Get a configuration value as an integer.""" + value = self.get(section, key, default) + try: + return int(value) + except (TypeError, ValueError): + return default + + def get_float(self, section: str, key: str, default: float = 0.0) -> float: + """Get a configuration value as a float.""" + value = self.get(section, key, default) + try: + return float(value) + except (TypeError, ValueError): + return default + + def get_optional_int( + self, section: str, key: str, default: Optional[int] = None + ) -> Optional[int]: + """Get a configuration value as an optional integer (allows None).""" + value = self.get(section, key, default) + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return default + def set(self, section: str, key: str, value: Any) -> bool: """ Set a configuration value. Args: - section: The configuration section + section: The configuration section (must be a known section) key: The configuration key within the section value: The value to set @@ -201,12 +256,17 @@ class ConfigManager: """ try: if section not in self.config: + if section not in self._VALID_SECTIONS: + logger.warning( + f"Unknown config section '{section}' — valid sections: " + f"{sorted(self._VALID_SECTIONS)}" + ) self.config[section] = {} self.config[section][key] = value return True - except Exception as e: + except (KeyError, TypeError) as e: logger.error(f"Failed to set config value: {e}") return False diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index b8eb23c..00d1883 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -1190,7 +1190,7 @@ class SettingsDialog(Gtk.Dialog): self.shortcut_mode_combo.append(mode_id, display_name) # Load current mode from config - current_mode = self.config_manager.get("shortcuts", "mode", "toggle") + current_mode = self.config_manager.get_str("shortcuts", "mode", "toggle") if not self.shortcut_mode_combo.set_active_id(current_mode): self.shortcut_mode_combo.set_active_id("toggle") @@ -1217,7 +1217,9 @@ class SettingsDialog(Gtk.Dialog): self.shortcut_combo.append(shortcut_id, display_name) # Load current shortcut from config - current_shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl") + current_shortcut = self.config_manager.get_str( + "shortcuts", "toggle_recognition", "ctrl+ctrl" + ) if not self.shortcut_combo.set_active_id(current_shortcut): self.shortcut_combo.set_active_id("ctrl+ctrl") @@ -1321,7 +1323,7 @@ class SettingsDialog(Gtk.Dialog): # Ignore separator entries (used for grouped display) if shortcut_id.startswith("__separator_"): # Revert to the previously saved shortcut - current = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl") + current = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl") self.shortcut_combo.set_active_id(current) return @@ -2207,7 +2209,7 @@ For now, the engine has been reverted to VOSK.""" label += " (default)" self.audio_device_combo.append(str(device_index), label) - saved_device = self.config_manager.get("audio", "device_index", None) + saved_device = self.config_manager.get_optional_int("audio", "device_index", None) if saved_device is None: self.audio_device_combo.set_active_id("-1") diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index cd417ba..c67f521 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -29,22 +29,17 @@ from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk # Import local modules - Use protocols to avoid circular imports from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol -from ..suspend_handler import SuspendHandler # noqa: E402 - -# Import necessary components -from .config_manager import ConfigManager # noqa: E402 -from .keyboard_shortcuts import KeyboardShortcutManager # noqa: E402 -from .settings_dialog import SettingsDialog # noqa: E402 +from ..suspend_handler import SuspendHandler +from ..utils.resource_manager import ResourceManager +from .config_manager import ConfigManager +from .keyboard_shortcuts import KeyboardShortcutManager +from .settings_dialog import SettingsDialog logger = logging.getLogger(__name__) # Define constants APP_ID = "vocalinux" - -# Import the centralized resource manager -from ..utils.resource_manager import ResourceManager # noqa: E402 - # Initialize resource manager _resource_manager = ResourceManager() ICON_DIR = _resource_manager.icons_dir @@ -86,8 +81,8 @@ class TrayIndicator: self._syncing_autostart_menu = False # Get configured shortcut and mode from config - shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl") - mode = self.config_manager.get("shortcuts", "mode", "toggle") + shortcut = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl") + mode = self.config_manager.get_str("shortcuts", "mode", "toggle") # Initialize keyboard shortcut manager with configured shortcut and mode self.shortcut_manager = KeyboardShortcutManager(shortcut=shortcut, mode=mode) @@ -138,7 +133,7 @@ class TrayIndicator: self.shortcut_manager.register_release_callback(None) # Get configured mode from config - mode = self.config_manager.get("shortcuts", "mode", "toggle") + mode = self.config_manager.get_str("shortcuts", "mode", "toggle") logger.info(f"Setting up keyboard shortcuts with mode: {mode}") if mode == "toggle": @@ -294,7 +289,7 @@ class TrayIndicator: from . import autostart_manager autostart_enabled = autostart_manager.is_autostart_enabled() - config_enabled = self.config_manager.get("general", "autostart", False) + config_enabled = self.config_manager.get_bool("general", "autostart", False) if config_enabled != autostart_enabled: self.config_manager.set("general", "autostart", autostart_enabled) self.config_manager.save_settings() diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 90d5eba..722dad3 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -429,3 +429,85 @@ class TestConfigManager(unittest.TestCase): new_config_manager = ConfigManager() self.assertFalse(new_config_manager.is_sound_effects_enabled()) + + +class TestTypedAccessors(unittest.TestCase): + """Tests for typed config accessors.""" + + def setUp(self): + self.config_manager = ConfigManager.__new__(ConfigManager) + self.config_manager.config = { + "general": {"autostart": True, "language": "en"}, + "audio": {"sample_rate": 16000, "device_index": 2, "gain": 1.5}, + "shortcuts": {"toggle_recognition": "ctrl+ctrl", "mode": "toggle"}, + } + + def test_get_str_returns_string(self): + result = self.config_manager.get_str("shortcuts", "mode", "hold") + assert result == "toggle" + assert isinstance(result, str) + + def test_get_str_default(self): + result = self.config_manager.get_str("shortcuts", "nonexistent", "default") + assert result == "default" + + def test_get_str_converts_non_string(self): + result = self.config_manager.get_str("audio", "sample_rate", "0") + assert result == "16000" + assert isinstance(result, str) + + def test_get_str_none_returns_default(self): + self.config_manager.config["audio"]["device_index"] = None + result = self.config_manager.get_str("audio", "device_index", "fallback") + assert result == "fallback" + + def test_get_bool_returns_bool(self): + result = self.config_manager.get_bool("general", "autostart", False) + assert result is True + + def test_get_bool_default(self): + result = self.config_manager.get_bool("general", "nonexistent", True) + assert result is True + + def test_get_int_returns_int(self): + result = self.config_manager.get_int("audio", "sample_rate", 0) + assert result == 16000 + assert isinstance(result, int) + + def test_get_int_default_on_invalid(self): + self.config_manager.config["audio"]["sample_rate"] = "not_a_number" + result = self.config_manager.get_int("audio", "sample_rate", 44100) + assert result == 44100 + + def test_get_int_default_on_none(self): + result = self.config_manager.get_int("audio", "nonexistent", 99) + assert result == 99 + + def test_get_float_returns_float(self): + result = self.config_manager.get_float("audio", "gain", 0.0) + assert result == 1.5 + assert isinstance(result, float) + + def test_get_float_default_on_invalid(self): + self.config_manager.config["audio"]["gain"] = "bad" + result = self.config_manager.get_float("audio", "gain", 2.0) + assert result == 2.0 + + def test_get_optional_int_returns_int(self): + result = self.config_manager.get_optional_int("audio", "device_index", None) + assert result == 2 + assert isinstance(result, int) + + def test_get_optional_int_returns_none(self): + self.config_manager.config["audio"]["device_index"] = None + result = self.config_manager.get_optional_int("audio", "device_index", None) + assert result is None + + def test_get_optional_int_default_on_missing(self): + result = self.config_manager.get_optional_int("audio", "nonexistent", None) + assert result is None + + def test_get_optional_int_default_on_invalid(self): + self.config_manager.config["audio"]["device_index"] = "not_int" + result = self.config_manager.get_optional_int("audio", "device_index", 5) + assert result == 5 diff --git a/tests/test_recognition_manager.py b/tests/test_recognition_manager.py index 56ecc26..b392fb6 100644 --- a/tests/test_recognition_manager.py +++ b/tests/test_recognition_manager.py @@ -584,7 +584,7 @@ class TestModuleLevelFunctions(unittest.TestCase): """Test _show_notification handles errors gracefully.""" from vocalinux.speech_recognition.recognition_manager import _show_notification - with patch("subprocess.Popen", side_effect=Exception("Test error")): + with patch("subprocess.Popen", side_effect=FileNotFoundError("notify-send not found")): # Should not raise _show_notification("Test Title", "Test Message") diff --git a/tests/test_recognition_manager_advanced.py b/tests/test_recognition_manager_advanced.py index 99fb07c..d27502f 100644 --- a/tests/test_recognition_manager_advanced.py +++ b/tests/test_recognition_manager_advanced.py @@ -89,7 +89,7 @@ class TestGetAudioInputDevices(unittest.TestCase): def test_handles_error(self): mock_pyaudio = MagicMock() - mock_pyaudio.PyAudio.side_effect = Exception("no audio") + mock_pyaudio.PyAudio.side_effect = OSError("no audio") with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): devices = get_audio_input_devices() self.assertEqual(devices, []) diff --git a/tests/test_recognition_manager_core.py b/tests/test_recognition_manager_core.py index 8ba4bd1..1a13918 100644 --- a/tests/test_recognition_manager_core.py +++ b/tests/test_recognition_manager_core.py @@ -138,9 +138,9 @@ class TestGetAudioInputDevices(unittest.TestCase): self.assertEqual(devices, []) def test_get_audio_input_devices_exception(self): - """Test handling of general exceptions.""" + """Test handling of OS-level audio errors.""" mock_pyaudio = MagicMock() - mock_pyaudio.PyAudio.side_effect = RuntimeError("Unexpected error") + mock_pyaudio.PyAudio.side_effect = OSError("Audio device error") with patch.dict(sys.modules, {"pyaudio": mock_pyaudio}): devices = get_audio_input_devices() diff --git a/tests/test_recognition_manager_helpers.py b/tests/test_recognition_manager_helpers.py index bb25d44..a1cad32 100644 --- a/tests/test_recognition_manager_helpers.py +++ b/tests/test_recognition_manager_helpers.py @@ -99,7 +99,7 @@ class TestShowNotification(unittest.TestCase): mock_popen.assert_called_once() def test_show_notification_error(self): - with patch("subprocess.Popen", side_effect=Exception("no notify-send")): + with patch("subprocess.Popen", side_effect=FileNotFoundError("no notify-send")): # Should not raise _show_notification("Title", "Message") diff --git a/tests/test_recognition_manager_init.py b/tests/test_recognition_manager_init.py index 90a9ebd..58e5a7c 100644 --- a/tests/test_recognition_manager_init.py +++ b/tests/test_recognition_manager_init.py @@ -177,13 +177,14 @@ class TestGetAudioInputDevices: assert devices == [] def test_get_audio_input_devices_generic_exception(self): - """Test handling of generic exceptions during device enumeration.""" + """Test handling of OS-level audio errors during device enumeration.""" from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices - with patch.dict("sys.modules", {"pyaudio": MagicMock()}): - with patch("builtins.__import__", side_effect=RuntimeError("Unexpected error")): - devices = get_audio_input_devices() - assert devices == [] + mock_pyaudio = MagicMock() + mock_pyaudio.PyAudio.side_effect = OSError("Audio subsystem error") + with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}): + devices = get_audio_input_devices() + assert devices == [] class TestGetSupportedChannels: diff --git a/tests/test_settings_shortcuts.py b/tests/test_settings_shortcuts.py index 40045d3..0df4dba 100644 --- a/tests/test_settings_shortcuts.py +++ b/tests/test_settings_shortcuts.py @@ -59,7 +59,9 @@ class TestSettingsDialogShortcutsSection(unittest.TestCase): def test_shortcut_config_read(self): """Test that shortcut is read from config.""" - self.assertIn('self.config_manager.get("shortcuts", "toggle_recognition"', self.source_code) + self.assertIn( + 'self.config_manager.get_str("shortcuts", "toggle_recognition"', self.source_code + ) def test_shortcut_changed_handler_exists(self): """Test that shortcut changed handler exists."""