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")