From eec01826445398cd98726b9e7b70b8faf7965695 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Tue, 20 Jan 2026 19:35:16 -0800 Subject: [PATCH] fix: persist model selection per-engine when switching between VOSK and Whisper (#64) - Add vosk_model_size and whisper_model_size to config to track model selection independently for each engine - Update config manager with get_model_size_for_engine() and set_model_size_for_engine() methods for per-engine model tracking - Add automatic migration for existing configs without per-engine model sizes - Update settings dialog to use engine-specific model sizes when populating model options - Update install.sh to include new per-engine model fields in default config - Fix: Previously, switching from VOSK to Whisper would incorrectly try to download the recommended model instead of the previously used model Fixes the issue where switching back to Whisper after using VOSK would start downloading the medium model instead of remembering the tiny model that was configured during installation. --- install.sh | 2 + src/vocalinux/ui/config_manager.py | 91 ++++++++++++++++++++++++++++- src/vocalinux/ui/settings_dialog.py | 48 +++++++-------- tests/test_config_manager.py | 73 +++++++++++++++++++++++ 4 files changed, 187 insertions(+), 27 deletions(-) diff --git a/install.sh b/install.sh index fc26e29..d4df962 100755 --- a/install.sh +++ b/install.sh @@ -670,6 +670,8 @@ install_python_package() { "speech_recognition": { "engine": "vosk", "model_size": "small", + "vosk_model_size": "small", + "whisper_model_size": "tiny", "vad_sensitivity": 3, "silence_timeout": 2.0 }, diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 0bf0134..e6a9fe8 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -19,7 +19,9 @@ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json") DEFAULT_CONFIG = { "speech_recognition": { # Changed section name "engine": "whisper", # "vosk" or "whisper" - whisper is default for better accuracy - "model_size": "tiny", # "tiny", "small", "medium", or "large" + "model_size": "tiny", # Current model size (for backward compatibility) + "vosk_model_size": "small", # Default model for VOSK engine + "whisper_model_size": "tiny", # Default model for Whisper engine "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) "silence_timeout": 2.0, # Seconds of silence before stopping }, @@ -51,7 +53,9 @@ class ConfigManager: def __init__(self): """Initialize the configuration manager.""" - self.config = DEFAULT_CONFIG.copy() + import copy + + self.config = copy.deepcopy(DEFAULT_CONFIG) self._ensure_config_dir() self.load_config() @@ -73,13 +77,53 @@ class ConfigManager: with open(CONFIG_FILE, "r") as f: user_config = json.load(f) + # Check if migration is needed BEFORE merging with defaults + needs_migration = self._check_needs_migration(user_config) + # Update the default config with user settings self._update_dict_recursive(self.config, user_config) logger.info(f"Loaded configuration from {CONFIG_FILE}") + # Migrate old config format if needed + if needs_migration: + self._migrate_config(user_config) + except Exception as e: logger.error(f"Failed to load config: {e}") + def _check_needs_migration(self, user_config: Dict) -> bool: + """Check if the user config needs migration to add per-engine model sizes.""" + sr_config = user_config.get("speech_recognition", {}) + # Need migration if we have model_size but not the per-engine keys + return "model_size" in sr_config and ( + "vosk_model_size" not in sr_config or "whisper_model_size" not in sr_config + ) + + def _migrate_config(self, user_config: Dict): + """Migrate old config formats to the current format.""" + sr_config = self.config.get("speech_recognition", {}) + user_sr_config = user_config.get("speech_recognition", {}) + + # Get the current engine and model from the user's original config + current_engine = user_sr_config.get("engine", "vosk") + current_model = user_sr_config.get("model_size", "small") + + # Set the per-engine model sizes based on the user's original config + if "vosk_model_size" not in user_sr_config: + # If current engine is vosk, use the current model; otherwise use default + sr_config["vosk_model_size"] = current_model if current_engine == "vosk" else "small" + logger.info(f"Migrated vosk_model_size to: {sr_config['vosk_model_size']}") + + if "whisper_model_size" not in user_sr_config: + # If current engine is whisper, use the current model; otherwise use default + sr_config["whisper_model_size"] = ( + current_model if current_engine == "whisper" else "tiny" + ) + logger.info(f"Migrated whisper_model_size to: {sr_config['whisper_model_size']}") + + self.save_config() + logger.info("Config migrated to new per-engine model format") + def save_config(self): """Save the current configuration to the config file.""" try: @@ -143,12 +187,53 @@ class ConfigManager: """Get the entire configuration dictionary.""" return self.config + def get_model_size_for_engine(self, engine: str) -> str: + """Get the saved model size for a specific engine. + + Args: + engine: The engine name ("vosk" or "whisper") + + Returns: + The model size for the engine, or the default if not found + """ + sr_config = self.config.get("speech_recognition", {}) + + # Try engine-specific model size first + engine_key = f"{engine.lower()}_model_size" + if engine_key in sr_config: + return sr_config[engine_key] + + # Fall back to generic model_size for backward compatibility + return sr_config.get("model_size", "small" if engine == "vosk" else "tiny") + + def set_model_size_for_engine(self, engine: str, model_size: str): + """Set the model size for a specific engine. + + Args: + engine: The engine name ("vosk" or "whisper") + model_size: The model size to save + """ + if "speech_recognition" not in self.config: + self.config["speech_recognition"] = {} + + engine_key = f"{engine.lower()}_model_size" + self.config["speech_recognition"][engine_key] = model_size + # Also update the generic model_size for backward compatibility + self.config["speech_recognition"]["model_size"] = model_size + logger.info(f"Set {engine} model size to: {model_size}") + def update_speech_recognition_settings(self, settings: Dict[str, Any]): """Update multiple speech recognition settings at once.""" if "speech_recognition" not in self.config: self.config["speech_recognition"] = {} - # Only update keys present in the provided settings dict + # Handle engine-specific model size updates + if "engine" in settings and "model_size" in settings: + engine = settings["engine"] + model_size = settings["model_size"] + self.set_model_size_for_engine(engine, model_size) + + # Update all other keys present in the provided settings dict for key, value in settings.items(): self.config["speech_recognition"][key] = value logger.info(f"Updated speech recognition settings: {settings}") diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 4b7e178..fffc6c3 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -21,9 +21,7 @@ from ..common_types import RecognitionState # noqa: E402 # Avoid circular imports for type checking if TYPE_CHECKING: - from ..speech_recognition.recognition_manager import ( # noqa: E402 - SpeechRecognitionManager, - ) + from ..speech_recognition.recognition_manager import SpeechRecognitionManager # noqa: E402 from .config_manager import ConfigManager # noqa: E402 logger = logging.getLogger(__name__) @@ -586,7 +584,8 @@ class SettingsDialog(Gtk.Dialog): # Use .get with defaults for robustness sr_settings = settings.get("speech_recognition", {}) engine = sr_settings.get("engine", "vosk") - model_size = sr_settings.get("model_size", "small") + # Get model size for the specific engine + model_size = self.config_manager.get_model_size_for_engine(engine) vad_sensitivity = sr_settings.get("vad_sensitivity", 3) silence_timeout = sr_settings.get("silence_timeout", 2.0) @@ -616,12 +615,9 @@ class SettingsDialog(Gtk.Dialog): engine = engine_text.lower() logger.info(f"Populating model options for engine: {engine}") - # Get recommended model based on engine - recommended_model = None - if engine == "whisper": - recommended_model, _ = _get_recommended_whisper_model() - elif engine == "vosk": - recommended_model, _ = _get_recommended_vosk_model() + # Get the saved model size for THIS specific engine (not the generic one) + saved_model_for_engine = self.config_manager.get_model_size_for_engine(engine) + logger.info(f"Saved model for {engine}: {saved_model_for_engine}") # Track which models are downloaded and find smallest downloaded downloaded_models = [] @@ -635,10 +631,7 @@ class SettingsDialog(Gtk.Dialog): info = WHISPER_MODEL_INFO[size] is_downloaded = _is_whisper_model_downloaded(size) status = "✓" if is_downloaded else "↓" - rec = " ★" if size == recommended_model else "" - display_text = ( - f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}" - ) + display_text = f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}" if is_downloaded: downloaded_models.append(size) if smallest_model is None: @@ -647,10 +640,7 @@ class SettingsDialog(Gtk.Dialog): info = VOSK_MODEL_INFO[size] is_downloaded = _is_vosk_model_downloaded(size) status = "✓" if is_downloaded else "↓" - rec = " ★" if size == recommended_model else "" - display_text = ( - f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}{rec}" - ) + display_text = f"{size.capitalize()} ({_format_size(info['size_mb'])}) {status}" if is_downloaded: downloaded_models.append(size) if smallest_model is None: @@ -661,19 +651,29 @@ class SettingsDialog(Gtk.Dialog): self.model_combo.append(size.capitalize(), display_text) # Determine which model to select: - # 1. If saved model is downloaded, use it - # 2. Else if any model is downloaded, use the first (smallest) downloaded - # 3. Else use the smallest model (but don't auto-download) - saved_model = self.current_model_size.lower() - if saved_model in downloaded_models: + # 1. If saved model for this engine is downloaded, use it + # 2. Else if saved model for this engine exists (even if not downloaded), use it + # (user will be prompted to download when applying) + # 3. Else if any model is downloaded, use the smallest downloaded + # 4. Else use the smallest model for this engine + saved_model = saved_model_for_engine.lower() + + # Check if the saved model is valid for this engine + valid_models = [m.lower() for m in ENGINE_MODELS.get(engine, [])] + + if saved_model in valid_models: + # Use the saved model for this engine (whether downloaded or not) model_to_set = saved_model.capitalize() elif downloaded_models: + # Saved model isn't valid for this engine, use smallest downloaded model_to_set = downloaded_models[0].capitalize() else: + # No downloaded models, use the smallest model for this engine model_to_set = smallest_model.capitalize() if smallest_model else "Small" logger.info( - f"Setting active model to: {model_to_set} (saved={saved_model}, downloaded={downloaded_models})" + f"Setting active model to: {model_to_set} (saved_for_engine={saved_model}, " + f"valid={saved_model in valid_models}, downloaded={downloaded_models})" ) # Try to set by ID diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 9cead6c..77865e3 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -200,3 +200,76 @@ class TestConfigManager(unittest.TestCase): self.assertEqual(target["a"]["e"], 20) # Added self.assertEqual(target["d"], 3) # Unchanged self.assertEqual(target["f"], 30) # Added + + def test_get_model_size_for_engine(self): + """Test getting model size for a specific engine.""" + config_manager = ConfigManager() + + # Test default values + self.assertEqual(config_manager.get_model_size_for_engine("vosk"), "small") + self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "tiny") + + # Set specific model sizes + config_manager.set_model_size_for_engine("vosk", "medium") + config_manager.set_model_size_for_engine("whisper", "small") + + # Verify they are returned correctly + self.assertEqual(config_manager.get_model_size_for_engine("vosk"), "medium") + self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "small") + + def test_set_model_size_for_engine(self): + """Test setting model size for a specific engine.""" + config_manager = ConfigManager() + + # Set model size for vosk + config_manager.set_model_size_for_engine("vosk", "large") + self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "large") + self.assertEqual(config_manager.config["speech_recognition"]["model_size"], "large") + + # Set model size for whisper + config_manager.set_model_size_for_engine("whisper", "medium") + self.assertEqual( + config_manager.config["speech_recognition"]["whisper_model_size"], "medium" + ) + self.assertEqual(config_manager.config["speech_recognition"]["model_size"], "medium") + + def test_migrate_old_config(self): + """Test migration of old config format without per-engine model sizes.""" + # Create an old-style config file without per-engine model sizes + old_config = { + "speech_recognition": { + "engine": "vosk", + "model_size": "medium", + "vad_sensitivity": 3, + "silence_timeout": 2.0, + } + } + + with open(self.temp_config_file, "w") as f: + json.dump(old_config, f) + + # Load the config (should trigger migration) + config_manager = ConfigManager() + + # Verify migration added per-engine model sizes + self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "medium") + self.assertEqual(config_manager.config["speech_recognition"]["whisper_model_size"], "tiny") + + def test_update_speech_recognition_settings_per_engine(self): + """Test that update_speech_recognition_settings saves per-engine model sizes.""" + config_manager = ConfigManager() + + # Update settings for vosk + config_manager.update_speech_recognition_settings({"engine": "vosk", "model_size": "large"}) + + self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "large") + + # Update settings for whisper + config_manager.update_speech_recognition_settings( + {"engine": "whisper", "model_size": "small"} + ) + + self.assertEqual(config_manager.config["speech_recognition"]["whisper_model_size"], "small") + + # Verify the vosk setting wasn't changed + self.assertEqual(config_manager.config["speech_recognition"]["vosk_model_size"], "large")