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:
Jatin K Malik
2026-02-28 13:06:05 -08:00
committed by GitHub
parent 18c099b5bb
commit 6fc1401ae0
6 changed files with 122 additions and 8 deletions
+2
View File
@@ -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
+19
View File
@@ -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:
+47
View File
@@ -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()