diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 0c29f38..e7f984d 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -262,6 +262,7 @@ def main(): vad_sensitivity = saved_settings.get("vad_sensitivity", 3) silence_timeout = saved_settings.get("silence_timeout", 2.0) + voice_commands_enabled = saved_settings.get("voice_commands_enabled") # None = auto audio_device_index = audio_settings.get("device_index", None) logger.info(f"Final settings: engine={engine}, language={language}, model={model_size}") @@ -279,6 +280,7 @@ def main(): language=language, vad_sensitivity=vad_sensitivity, silence_timeout=silence_timeout, + voice_commands_enabled=voice_commands_enabled, audio_device_index=audio_device_index, ) diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 88482c8..ec4a8ad 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -18,11 +18,7 @@ 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 ..utils.vosk_model_info import VOSK_MODEL_INFO -from ..utils.whispercpp_model_info import ( - WHISPERCPP_MODEL_INFO, - get_model_path, - is_model_downloaded, -) +from ..utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, get_model_path, is_model_downloaded from .command_processor import CommandProcessor @@ -461,6 +457,11 @@ class SpeechRecognitionManager: self.model = None self.recognizer = None # Added for VOSK self.command_processor = CommandProcessor() + + # Voice commands: None=auto (VOSK=yes, Whisper=no), True=always on, False=always off + self._voice_commands_preference = kwargs.get("voice_commands_enabled") + self._voice_commands_enabled = self._resolve_voice_commands_enabled() + self.text_callbacks: List[Callable[[str], None]] = [] self.state_callbacks: List[Callable[[RecognitionState], None]] = [] self.action_callbacks: List[Callable[[str], None]] = [] @@ -516,6 +517,12 @@ class SpeechRecognitionManager: else: raise ValueError(f"Unsupported speech recognition engine: {engine}") + def _resolve_voice_commands_enabled(self) -> bool: + """Resolve effective voice commands state from preference and engine.""" + if self._voice_commands_preference is None: + return self.engine == "vosk" + return bool(self._voice_commands_preference) + def _init_vosk(self): """Initialize the VOSK speech recognition engine.""" # VOSK doesn't support auto-detect, so fall back to en-us for "auto" @@ -1779,9 +1786,15 @@ class SpeechRecognitionManager: logger.error(f"Unknown engine: {self.engine}") return - # Process commands + # Process text - either with voice commands or pass through directly if text: - processed_text, actions = self.command_processor.process_text(text) + if self._voice_commands_enabled: + # Process with voice commands (original behavior) + processed_text, actions = self.command_processor.process_text(text) + else: + # Voice commands disabled - pass text through directly (Whisper handles punctuation) + processed_text = text.strip() + actions = [] # Call text callbacks with processed text if processed_text: @@ -1892,6 +1905,11 @@ class SpeechRecognitionManager: else: self.audio_device_index = audio_device_index + if "voice_commands_enabled" in kwargs: + self._voice_commands_preference = kwargs.get("voice_commands_enabled") + + self._voice_commands_enabled = self._resolve_voice_commands_enabled() + if restart_needed: logger.info("Engine or model changed, re-initializing...") # When reconfiguring from UI, allow downloads diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 408f42c..47ef559 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -26,6 +26,7 @@ DEFAULT_CONFIG = { "whisper_cpp_model_size": "tiny", # Default model for whisper.cpp engine "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) "silence_timeout": 2.0, # Seconds of silence before stopping + "voice_commands_enabled": None, # None = auto (enabled for VOSK, disabled for Whisper) }, "audio": { "device_index": None, # Audio input device index (None for system default) @@ -230,6 +231,24 @@ class ConfigManager: self.config["speech_recognition"]["model_size"] = model_size logger.info(f"Set {engine} model size to: {model_size}") + def is_voice_commands_enabled(self) -> bool: + """Check if voice commands should be enabled. + + Returns: + True if voice commands should be enabled, False otherwise. + If voice_commands_enabled is None (auto), returns True for VOSK, + False for Whisper engines. + """ + sr_config = self.config.get("speech_recognition", {}) + enabled = sr_config.get("voice_commands_enabled") + + if enabled is None: + # Auto mode: enabled for VOSK, disabled for Whisper engines + engine = sr_config.get("engine", "whisper_cpp") + return engine == "vosk" + + return enabled + def update_speech_recognition_settings(self, settings: Dict[str, Any]): """Update multiple speech recognition settings at once.""" if "speech_recognition" not in self.config: diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index d227a08..4b7d232 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -1081,11 +1081,25 @@ class SettingsDialog(Gtk.Dialog): ) group.add_row(silence_row) + # Voice Commands Toggle + self.voice_commands_switch = Gtk.Switch() + self.voice_commands_switch.set_tooltip_text( + "Enable voice commands like 'new line', 'period', 'undo', etc.\n" + "Useful for VOSK engine. Whisper engines handle punctuation automatically." + ) + voice_commands_row = PreferenceRow( + title="Voice Commands", + subtitle="Enable voice commands for punctuation and editing", + widget=self.voice_commands_switch, + ) + group.add_row(voice_commands_row) + self.recognition_settings_tab.pack_start(group, False, False, 0) # Connect signals self.vad_spin.connect("value-changed", self._on_vad_changed) self.silence_spin.connect("value-changed", self._on_silence_changed) + self.voice_commands_switch.connect("state-set", self._on_voice_commands_toggled) def _build_shortcuts_section(self): """Build the Keyboard Shortcuts section.""" @@ -1341,6 +1355,10 @@ class SettingsDialog(Gtk.Dialog): self.vad_spin.set_value(self.current_vad) self.silence_spin.set_value(self.current_silence) + # Set voice commands switch based on config + voice_commands_enabled = self.config_manager.is_voice_commands_enabled() + self.voice_commands_switch.set_active(voice_commands_enabled) + def _get_current_settings(self): """Get current settings from config manager.""" self.config_manager.load_config() @@ -1468,6 +1486,18 @@ class SettingsDialog(Gtk.Dialog): self.language_combo.set_active_id(self.language) self._update_engine_specific_ui() self._update_model_info() + self._update_voice_commands_for_engine() + + def _update_voice_commands_for_engine(self): + """Update voice commands switch based on current engine.""" + sr_config = self.config_manager.get_settings().get("speech_recognition", {}) + voice_commands_enabled = sr_config.get("voice_commands_enabled") + + if voice_commands_enabled is None: + engine_text = self.engine_combo.get_active_text() + engine = engine_text.lower() if engine_text else sr_config.get("engine", "whisper_cpp") + auto_enabled = engine == "vosk" + self.voice_commands_switch.set_active(auto_enabled) def _on_model_changed(self, widget): """Handle changes in the selected model.""" @@ -1485,6 +1515,23 @@ class SettingsDialog(Gtk.Dialog): """Handle changes in silence timeout.""" self._auto_apply_settings() + def _on_voice_commands_toggled(self, widget, state): + """Handle toggle of the voice commands switch.""" + if self._initializing or self._applying_settings: + return False + + enabled = bool(state) + logger.info(f"Voice commands toggled: {enabled}") + + self.config_manager.set("speech_recognition", "voice_commands_enabled", enabled) + self.config_manager.save_settings() + try: + self.speech_engine.reconfigure(voice_commands_enabled=enabled, force_download=False) + except Exception as e: + logger.warning(f"Failed to apply voice commands toggle immediately: {e}") + logger.info(f"Voice commands {'enabled' if enabled else 'disabled'}") + return False + def _populate_language_options(self): """Populate language dropdown with supported languages.""" self.language_combo.remove_all() diff --git a/tests/test_main.py b/tests/test_main.py index 583c4ce..10920f8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -220,6 +220,7 @@ class TestMainModule(unittest.TestCase): vad_sensitivity=3, silence_timeout=2.0, audio_device_index=None, + voice_commands_enabled=None, ) mock_text.assert_called_once_with(wayland_mode=True) mock_action_handler.assert_called_once_with(mock_text_instance) diff --git a/tests/test_speech_recognition.py b/tests/test_speech_recognition.py index 7fbeba6..bf2a858 100644 --- a/tests/test_speech_recognition.py +++ b/tests/test_speech_recognition.py @@ -151,6 +151,31 @@ class TestSpeechRecognition(unittest.TestCase): with self.assertRaises(ValueError): SpeechRecognitionManager(engine="invalid") + def test_voice_commands_auto_mode_follows_engine(self): + vosk_manager = SpeechRecognitionManager( + engine="vosk", model_size="small", voice_commands_enabled=None + ) + self.assertTrue(getattr(vosk_manager, "_voice_commands_enabled")) + + whisper_manager = SpeechRecognitionManager( + engine="whisper", model_size="small", voice_commands_enabled=None + ) + self.assertFalse(getattr(whisper_manager, "_voice_commands_enabled")) + + def test_voice_commands_auto_mode_updates_on_engine_reconfigure(self): + manager = SpeechRecognitionManager(engine="whisper", voice_commands_enabled=None) + self.assertFalse(getattr(manager, "_voice_commands_enabled")) + + manager.reconfigure(engine="vosk", force_download=False) + self.assertTrue(getattr(manager, "_voice_commands_enabled")) + + def test_voice_commands_toggle_updates_on_reconfigure(self): + manager = SpeechRecognitionManager(engine="vosk", voice_commands_enabled=True) + self.assertTrue(getattr(manager, "_voice_commands_enabled")) + + manager.reconfigure(voice_commands_enabled=False, force_download=False) + self.assertFalse(getattr(manager, "_voice_commands_enabled")) + def test_callbacks(self): """Test registering and using callbacks.""" manager = SpeechRecognitionManager(engine="vosk") @@ -205,7 +230,9 @@ class TestSpeechRecognition(unittest.TestCase): with patch.object(SpeechRecognitionManager, "_get_vosk_model_path") as mock_get_path: # Test small model size mock_get_path.return_value = "/path/to/vosk-model-small-en-us-0.15" - _ = SpeechRecognitionManager(engine="vosk", model_size="small") + _ = SpeechRecognitionManager( + engine="vosk", model_size="small", voice_commands_enabled=True + ) # Verify the small model path is constructed correctly mock_get_path.assert_called_with()