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.
This commit is contained in:
Jatin K Malik
2026-01-20 19:35:16 -08:00
committed by GitHub
parent 7a6d0d4e2b
commit eec0182644
4 changed files with 187 additions and 27 deletions
+2
View File
@@ -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
},
+88 -3
View File
@@ -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}")
+24 -24
View File
@@ -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
+73
View File
@@ -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")