feat(commands): make voice commands optional with auto-enable for VOSK (#274)
* feat(commands): make voice commands optional with auto-enable for VOSK This implements a new voice commands feature that: - Adds 'voice_commands_enabled' config option (None=auto, True, False) - Auto-enables voice commands for VOSK engine (which needs them for punctuation) - Auto-disables for Whisper engines (which handle punctuation natively) - Adds a toggle in Settings > Recognition tab to override the default The toggle shows the current auto-detected state and allows users to enable/disable voice commands regardless of engine choice. This addresses issue #147 discussion about voice commands utility with modern Whisper models that handle punctuation well. Closes #270 (by providing an alternative solution - disabling commands) * fix(tests): add voice_commands_enabled parameter to test expectations Update test assertions to include the new voice_commands_enabled parameter that was added to SpeechRecognitionManager. * style(tests): fix black formatting in test_speech_recognition.py * fix(commands): keep voice command mode in sync on reconfigure
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user