From 43bf363f6b91afaf1a6c6494c588d25e185d0174 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Mon, 7 Sep 2026 23:11:49 +0200 Subject: [PATCH] fix(models): let the selected language decide the whisper.cpp variant (#795) * fix(models): let the selected language decide the whisper.cpp variant whisper_cpp_model_size holds the id that loads the model, and for every size below "large" the multilingual variant id is the bare size name. A stored "medium" therefore could not say whether the user picked the multilingual variant or never picked anything, so the language-aware default never ran and selecting English left the picker on "Standard multilingual" while the panel recommended English-only right underneath. Record the specialization the user picks in its own key. Without that pin the variant is derived from the language; a value that is not a bare size name was written by an older build as a deliberate choice and is adopted as the pin, so existing configurations keep the variant they had. Closes #776 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(models): keep language-derived whisper.cpp variants unpinned Auto-apply was writing the derived specialization into both the pin and whisper_cpp_model_size, so a later language change fought a stale pin and a leftover .en id. Only deliberate specializations are pinned now; unpinned saves store the bare size and resolve against the current language. * fix(models): keep multilingual whisper.cpp pins across languages Language auto-apply cleared any pin that matched the new language default, so an explicit multilingual choice made under English was lost when switching away and back. Preserve bare-size pins that still match the selection; keep language-derived and stale .en pins unpinned. Wrap the isort import for CI. --------- Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Jatin K Malik --- src/vocalinux/ui/config_manager.py | 105 ++++++- src/vocalinux/ui/settings_dialog.py | 61 ++-- src/vocalinux/utils/whispercpp_model_info.py | 17 ++ tests/test_whispercpp_variant_resolution.py | 289 +++++++++++++++++++ 4 files changed, 444 insertions(+), 28 deletions(-) create mode 100644 tests/test_whispercpp_variant_resolution.py diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index cea67d2..2d03a8c 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -12,6 +12,10 @@ import threading from typing import Any, Optional from ..utils.paths import config_dir +from ..utils.vosk_model_info import SUPPORTED_LANGUAGES +from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES +from ..utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, default_variant_for_size +from ..utils.whispercpp_model_info import get_model_size as get_whispercpp_model_size logger = logging.getLogger(__name__) @@ -69,6 +73,10 @@ DEFAULT_CONFIG = { "vosk_model_size": "small", # Default model for VOSK engine "whisper_model_size": "tiny", # Default model for Whisper engine "whisper_cpp_model_size": "tiny", # Default model for whisper.cpp engine + # Variant the user picked explicitly in Settings. Empty means "not pinned": + # the variant is then derived from the selected language, because a bare size + # name ("medium") is indistinguishable from the multilingual variant id. + "whisper_cpp_model_variant": "", "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) "silence_timeout": 2.0, # Seconds of silence before stopping "stop_sound_guard_ms": 200, # Small tail trim to avoid the stop sound without clipping speech @@ -151,6 +159,38 @@ DEFAULT_CONFIG = { } +def resolve_whispercpp_variant(saved_model: str, pinned_variant: str, language_id: str) -> str: + """Resolve the loadable whisper.cpp id for a saved size, pin, and language. + + A pin outranks everything. When unpinned, a plain ``{size}.en`` English-only + id is the language-derived default, not a legacy specialization, so a later + language change can re-derive. True legacy specializations (quantized, turbo, + versioned large, ``{size}.en-q*``) are still honoured. + """ + pinned = pinned_variant.lower() if isinstance(pinned_variant, str) else "" + if pinned in WHISPERCPP_MODEL_INFO: + return pinned + + saved = saved_model.lower() if isinstance(saved_model, str) else "" + size = saved if saved in WHISPERCPP_MODEL_SIZES else get_whispercpp_model_size(saved or "tiny") + if size not in WHISPERCPP_MODEL_SIZES: + size = get_whispercpp_model_size("tiny") + + # Honour true leftover specializations, but not a plain English-only id. + if ( + saved in WHISPERCPP_MODEL_INFO + and saved not in WHISPERCPP_MODEL_SIZES + and saved != f"{size}.en" + ): + return saved + + language_is_english = SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en" + derived = default_variant_for_size(size, language_is_english) + if derived in WHISPERCPP_MODEL_INFO: + return derived + return saved if saved in WHISPERCPP_MODEL_INFO else "tiny" + + class ConfigManager: """ Manager for user configuration settings. @@ -432,23 +472,33 @@ class ConfigManager: return self.config def get_model_size_for_engine(self, engine: str) -> str: - """Get the saved model size for a specific engine. + """Return the model id the engine should load for ``engine``. - Args: - engine: The engine name ("vosk", "whisper", or "whisper_cpp") - - Returns: - The model size for the engine, or the default if not found + For whisper.cpp an unpinned size is resolved against the saved + language so a stored bare size still loads ``{size}.en`` under English, + and a leftover plain ``{size}.en`` can re-derive after a language change. """ 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] + saved = sr_config[engine_key] + else: + # Fall back to generic model_size for backward compatibility + saved = sr_config.get("model_size", "small" if engine == "vosk" else "tiny") - # Fall back to generic model_size for backward compatibility - return sr_config.get("model_size", "small" if engine == "vosk" else "tiny") + if engine.lower() != "whisper_cpp": + return saved + + # Unpinned configs store the bare size; the engine still needs the + # language-derived loadable id (and leftover ``{size}.en`` must not + # block a later language change). + return resolve_whispercpp_variant( + saved, + self.get_model_variant_for_engine(engine), + sr_config.get("language", "auto"), + ) def set_model_size_for_engine(self, engine: str, model_size: str): """Set the model size for a specific engine. @@ -466,6 +516,24 @@ class ConfigManager: self.config["speech_recognition"]["model_size"] = model_size logger.info(f"Set {engine} model size to: {model_size}") + def get_model_variant_for_engine(self, engine: str) -> str: + """Return the variant the user pinned for an engine, or "" when unpinned. + + Only whisper.cpp has variants. A pinned value means the user chose that + specialization in Settings; an empty value means the variant should be + derived from the selected language. + """ + sr_config = self.config.get("speech_recognition", {}) + return sr_config.get(f"{engine.lower()}_model_variant", "") or "" + + def set_model_variant_for_engine(self, engine: str, model_variant: str) -> None: + """Pin the variant the user chose for an engine ("" clears the pin).""" + if "speech_recognition" not in self.config: + self.config["speech_recognition"] = {} + + self.config["speech_recognition"][f"{engine.lower()}_model_variant"] = model_variant + logger.info(f"Set {engine} model variant to: {model_variant!r}") + def is_voice_commands_enabled(self) -> bool: """Check if voice commands should be enabled. @@ -484,7 +552,7 @@ class ConfigManager: return enabled - def update_speech_recognition_settings(self, settings: dict[str, Any]): + def update_speech_recognition_settings(self, settings: dict[str, Any]) -> None: """Update multiple speech recognition settings at once.""" if "speech_recognition" not in self.config: self.config["speech_recognition"] = {} @@ -493,10 +561,25 @@ class ConfigManager: if "engine" in settings and "model_size" in settings: engine = settings["engine"] model_size = settings["model_size"] + # Unpinned whisper.cpp: persist the bare size so a later language + # change can re-derive. Callers still pass the full loadable id as + # settings["model_size"] for reconfigure/download. + if engine == "whisper_cpp" and not settings.get("model_variant"): + model_size = get_whispercpp_model_size(model_size) self.set_model_size_for_engine(engine, model_size) - # Update all other keys present in the provided settings dict + # Empty means unpinned (derive from language). A non-empty value is a + # deliberate specialization, including multilingual while English. + if "engine" in settings and "model_variant" in settings: + self.set_model_variant_for_engine(settings["engine"], settings["model_variant"]) + + # Update remaining keys. model_size / model_variant were already applied + # through the engine-specific setters above (including bare-size persistence + # for an unpinned whisper.cpp selection); writing them again would put the + # full derived id back into the generic model_size key. for key, value in settings.items(): + if key in ("model_size", "model_variant"): + continue 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 1b1df2f..497c65f 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -58,6 +58,7 @@ from ..utils.whisper_model_info import ( # noqa: E402 from ..utils.whispercpp_model_info import MODEL_SIZES as WHISPERCPP_MODEL_SIZES from ..utils.whispercpp_model_info import ( WHISPERCPP_MODEL_INFO, + default_variant_for_size, ) from ..utils.whispercpp_model_info import delete_model as delete_whispercpp_model from ..utils.whispercpp_model_info import ( @@ -80,6 +81,7 @@ from .config_manager import ( # noqa: E402 DEFAULT_SOUND_EFFECT_TONE, PASTE_SHORTCUTS, SOUND_EFFECT_TONES, + resolve_whispercpp_variant, ) from .keyboard_backends import ( # noqa: E402 DEFAULT_SHORTCUT, @@ -259,19 +261,7 @@ def _recommended_whispercpp_variant_for_language( def _default_whispercpp_variant_for_size(model_size: str, language_id: str) -> Optional[str]: """Return the default specialization for a user-selected size and language.""" - variants = get_whispercpp_model_variants(model_size) - if not variants: - return None - - english_variant = f"{model_size}.en" - if _language_is_english(language_id) and english_variant in variants: - return english_variant - - standard_variant = "large" if model_size == "large" else model_size - if standard_variant in variants: - return standard_variant - - return variants[0] + return default_variant_for_size(model_size, _language_is_english(language_id)) def _is_language_paired_standard_variant(model_name: str) -> bool: @@ -4874,12 +4864,29 @@ class SettingsDialog(Gtk.Dialog): self._populating_models = False self._refresh_unused_downloads() + def _resolve_saved_whispercpp_variant(self, saved_model_for_engine: str) -> str: + """Resolve which variant the saved configuration actually asks for. + + ``whisper_cpp_model_size`` holds the id that loads the model, but for every + size below "large" the multilingual variant id *is* the bare size name, so + a stored "medium" cannot say whether the user chose the multilingual variant + or never chose anything. The explicit pin written by Settings answers that. + Without a pin the variant follows the selected language, which is what makes + picking English actually select an English-only model. + + A leftover plain ``{size}.en`` English-only id is not treated as a permanent + specialization: it is re-derived from the language currently in the combo. + """ + pinned = self.config_manager.get_model_variant_for_engine("whisper_cpp") + language_id = self.language_combo.get_active_id() or self.language + return resolve_whispercpp_variant(saved_model_for_engine, pinned, language_id) + def _populate_whispercpp_model_options(self, saved_model_for_engine: str): """Populate whisper.cpp size and specialization selectors.""" recommended_model, _ = self._get_recommended_whispercpp_model_for_language() recommended_size = get_whispercpp_model_size(recommended_model) - saved_model = saved_model_for_engine.lower() + saved_model = self._resolve_saved_whispercpp_variant(saved_model_for_engine) if saved_model not in WHISPERCPP_MODEL_INFO: saved_model = ( recommended_model if recommended_model in WHISPERCPP_MODEL_INFO else "tiny" @@ -5637,7 +5644,7 @@ class SettingsDialog(Gtk.Dialog): except Exception as e: # pragma: no cover - a resync must never mask the real error logger.debug(f"Could not check the engine picker against the config: {e}") - def _save_selected_settings(self, settings: dict): + def _save_selected_settings(self, settings: dict[str, Any]) -> None: """Persist selected settings to their appropriate config sections.""" sr_settings = {k: v for k, v in settings.items() if not k.startswith("whispercpp_")} advanced_settings = {k: v for k, v in settings.items() if k.startswith("whispercpp_")} @@ -5647,15 +5654,34 @@ class SettingsDialog(Gtk.Dialog): self.config_manager.set("advanced", key, value) self.config_manager.save_settings() - def get_selected_settings(self) -> dict: + def get_selected_settings(self) -> dict[str, Any]: """Return the currently selected settings from the UI.""" engine_text = self.engine_combo.get_active_text() model_id = self.model_combo.get_active_id() language_id = self.language_combo.get_active_id() engine = _engine_from_display(engine_text) if engine_text else "vosk" + model_variant = "" if engine == "whisper_cpp": - model_size = self._get_selected_whispercpp_model() + selected = self._get_selected_whispercpp_model() + size = get_whispercpp_model_size(selected) + derived = self._get_default_whispercpp_variant_for_size(size) + # Full id for reconfigure/download. Pin deliberate specializations + # (including multilingual while English). A language-derived default + # stays unpinned so a later language change can re-derive — except + # when an existing bare-size pin already matches the selection: that + # is a deliberate multilingual pin that became the new language's + # default, and must survive so returning to English still honours it. + model_size = selected + existing_pin = ( + self.config_manager.get_model_variant_for_engine("whisper_cpp") or "" + ).lower() + if selected != derived: + model_variant = selected + elif existing_pin == selected and selected in WHISPERCPP_MODEL_SIZES: + model_variant = selected + else: + model_variant = "" else: model_size = model_id.lower() if model_id else "small" language = language_id if language_id else self._default_language_for_engine(engine) @@ -5666,6 +5692,7 @@ class SettingsDialog(Gtk.Dialog): settings = { "engine": engine, "model_size": model_size, + "model_variant": model_variant, "language": language, "vad_sensitivity": vad, "silence_timeout": silence, diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index 223dc4d..433f042 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -127,6 +127,23 @@ def get_model_variants(model_size: str) -> list[str]: return list(MODEL_VARIANTS_BY_SIZE.get(model_size.lower(), [])) +def default_variant_for_size(model_size: str, language_is_english: bool) -> Optional[str]: + """Return the default specialization for a size bucket and language.""" + variants = get_model_variants(model_size) + if not variants: + return None + + english_variant = f"{model_size}.en" + if language_is_english and english_variant in variants: + return english_variant + + standard_variant = "large" if model_size == "large" else model_size + if standard_variant in variants: + return standard_variant + + return variants[0] + + def is_english_only_model(model_name: str) -> bool: """Return whether a whisper.cpp model variant is English-only.""" return ".en" in model_name.lower() diff --git a/tests/test_whispercpp_variant_resolution.py b/tests/test_whispercpp_variant_resolution.py new file mode 100644 index 0000000..222a217 --- /dev/null +++ b/tests/test_whispercpp_variant_resolution.py @@ -0,0 +1,289 @@ +"""Picking a language must decide the whisper.cpp variant (#776). + +``whisper_cpp_model_size`` stores the id that loads the model, and for every size +below "large" the multilingual variant id is the bare size name. A stored "medium" +therefore cannot say whether the user chose the multilingual variant or never chose +anything, which is why selecting English used to leave the picker on multilingual. +""" + +import importlib +import sys +from typing import Any +from unittest.mock import MagicMock, Mock, patch + +import pytest + +import vocalinux.ui +from vocalinux.ui.config_manager import ConfigManager, resolve_whispercpp_variant + + +@pytest.fixture(scope="module") +def settings_dialog(): + """Import settings_dialog with real base classes for its GTK subclasses. + + Same reasoning as tests/test_settings_model_state.py: conftest swaps gi for a + MagicMock, so the three bases the module subclasses have to be real classes for + its methods to stay reachable. Both sys.modules and the package attribute are + restored, otherwise later tests patching the module by name reach a second + module object (#686). + """ + repository = sys.modules["gi.repository"] + bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")} + saved_module = sys.modules.pop("vocalinux.ui.settings_dialog", None) + saved_attribute = getattr(vocalinux.ui, "settings_dialog", None) + try: + with patch.object(repository, "Gtk", MagicMock(**bases)): + module = importlib.import_module("vocalinux.ui.settings_dialog") + yield module + finally: + sys.modules.pop("vocalinux.ui.settings_dialog", None) + if saved_module is not None: + sys.modules["vocalinux.ui.settings_dialog"] = saved_module + if saved_attribute is not None: + vocalinux.ui.settings_dialog = saved_attribute + elif hasattr(vocalinux.ui, "settings_dialog"): + del vocalinux.ui.settings_dialog + + +@pytest.fixture +def dialog_class(settings_dialog): + return settings_dialog.SettingsDialog + + +def _dialog_stub(settings_dialog: Any, language: str, pinned_variant: str = "") -> Mock: + """A stand-in ``self`` whose language drives the real derivation helper.""" + dialog = Mock() + dialog.language = language + dialog.language_combo.get_active_id.return_value = language + dialog.config_manager.get_model_variant_for_engine.return_value = pinned_variant + # The derivation itself is the code under test's collaborator, not a mock: + # it is the real module-level helper, driven by the language above. + dialog._get_default_whispercpp_variant_for_size.side_effect = ( + lambda size: settings_dialog._default_whispercpp_variant_for_size(size, language) + ) + return dialog + + +def _selection_dialog( + settings_dialog: Any, + language: str, + selected_variant: str, + pinned_variant: str = "", +) -> Mock: + """A stand-in ``self`` for ``get_selected_settings`` whisper.cpp pin tests.""" + dialog = Mock() + dialog.engine_combo.get_active_text.return_value = "whisper.cpp" + dialog.model_combo.get_active_id.return_value = "medium" + dialog.language_combo.get_active_id.return_value = language + dialog.language = language + dialog.config_manager.get_model_variant_for_engine.return_value = pinned_variant + dialog._get_selected_whispercpp_model.return_value = selected_variant + dialog._get_default_whispercpp_variant_for_size.side_effect = ( + lambda size: settings_dialog._default_whispercpp_variant_for_size(size, language) + ) + dialog.vad_spin.get_value.return_value = 3 + dialog.silence_spin.get_value.return_value = 2.0 + dialog.advanced_no_timestamps_switch.get_active.return_value = True + dialog.advanced_no_context_switch.get_active.return_value = True + dialog.advanced_initial_prompt_buffer.get_text.return_value = "" + dialog.advanced_temperature_spin.get_value.return_value = 0.0 + dialog.advanced_temperature_inc_spin.get_value.return_value = -1.0 + dialog.advanced_entropy_thold_spin.get_value.return_value = 2.4 + dialog.advanced_logprob_thold_spin.get_value.return_value = -1.0 + dialog.advanced_no_speech_thold_spin.get_value.return_value = 0.6 + dialog.gpu_device_combo.get_active_id.return_value = None + return dialog + + +@pytest.mark.parametrize("size", ["tiny", "base", "small", "medium"]) +def test_bare_size_with_english_selected_resolves_to_the_english_variant( + settings_dialog, dialog_class, size +): + """The reported bug: a bare size plus English must not stay multilingual.""" + dialog = _dialog_stub(settings_dialog, "en-us") + + resolved = dialog_class._resolve_saved_whispercpp_variant(dialog, size) + + assert resolved == f"{size}.en" + + +def test_bare_size_with_a_non_english_language_stays_multilingual(settings_dialog, dialog_class): + """No English-only weights exist for other languages, so multilingual is right.""" + dialog = _dialog_stub(settings_dialog, "pl") + + assert dialog_class._resolve_saved_whispercpp_variant(dialog, "medium") == "medium" + + +def test_an_explicit_multilingual_pin_survives_selecting_english(settings_dialog, dialog_class): + """A deliberate choice must outrank the language-based default.""" + dialog = _dialog_stub(settings_dialog, "en-us", pinned_variant="medium") + + assert dialog_class._resolve_saved_whispercpp_variant(dialog, "medium") == "medium" + + +def test_a_legacy_specialization_is_kept_without_a_pin(settings_dialog, dialog_class): + """Older builds stored only one value; a non-size id was a deliberate pick.""" + dialog = _dialog_stub(settings_dialog, "en-us") + + assert dialog_class._resolve_saved_whispercpp_variant(dialog, "medium-q5_0") == "medium-q5_0" + + +def test_large_has_no_english_variant_so_english_still_gets_multilingual( + settings_dialog, dialog_class +): + """large ships multilingual weights only; the derivation must not invent large.en.""" + dialog = _dialog_stub(settings_dialog, "en-us") + + resolved = dialog_class._resolve_saved_whispercpp_variant(dialog, "large") + + assert resolved == "large" + assert resolved in settings_dialog.WHISPERCPP_MODEL_INFO + + +def test_an_unknown_saved_value_falls_back_to_a_real_variant(settings_dialog, dialog_class): + """Garbage in the config must not propagate into a download attempt.""" + dialog = _dialog_stub(settings_dialog, "en-us") + + resolved = dialog_class._resolve_saved_whispercpp_variant(dialog, "not-a-model") + + assert resolved in settings_dialog.WHISPERCPP_MODEL_INFO + + +def test_language_derived_selection_does_not_pin(settings_dialog: Any, dialog_class: Any) -> None: + """en-us + the derived English-only variant must stay unpinned on auto-save.""" + dialog = _selection_dialog(settings_dialog, "en-us", "medium.en") + + settings = dialog_class.get_selected_settings(dialog) + + assert settings["model_size"] == "medium.en" + assert settings["model_variant"] == "" + + +def test_deliberate_multilingual_while_english_pins_bare_size( + settings_dialog: Any, dialog_class: Any +) -> None: + """Choosing multilingual while English is a specialization and must pin.""" + dialog = _selection_dialog(settings_dialog, "en-us", "medium") + + settings = dialog_class.get_selected_settings(dialog) + + assert settings["model_size"] == "medium" + assert settings["model_variant"] == "medium" + + +def test_explicit_multilingual_pin_survives_non_english_auto_save( + settings_dialog: Any, dialog_class: Any +) -> None: + """A multilingual pin must not clear when it matches the new language default.""" + dialog = _selection_dialog(settings_dialog, "pl", "medium", pinned_variant="medium") + + settings = dialog_class.get_selected_settings(dialog) + + assert settings["model_size"] == "medium" + assert settings["model_variant"] == "medium" + + +def test_stale_english_only_pin_matching_derived_still_clears( + settings_dialog: Any, dialog_class: Any +) -> None: + """A leftover ``{size}.en`` pin that equals the English default must clear.""" + dialog = _selection_dialog(settings_dialog, "en-us", "medium.en", pinned_variant="medium.en") + + settings = dialog_class.get_selected_settings(dialog) + + assert settings["model_size"] == "medium.en" + assert settings["model_variant"] == "" + + +def test_unpinned_non_english_derived_selection_stays_unpinned( + settings_dialog: Any, dialog_class: Any +) -> None: + """Polish + multilingual with an empty pin must stay unpinned on auto-save.""" + dialog = _selection_dialog(settings_dialog, "pl", "medium") + + settings = dialog_class.get_selected_settings(dialog) + + assert settings["model_size"] == "medium" + assert settings["model_variant"] == "" + + +def test_unpinned_plain_en_id_rederives_for_a_new_language( + settings_dialog: Any, dialog_class: Any +) -> None: + """A leftover ``medium.en`` with an empty pin must not block Polish.""" + dialog = _dialog_stub(settings_dialog, "pl") + + assert dialog_class._resolve_saved_whispercpp_variant(dialog, "medium.en") == "medium" + + +def test_unpinned_plain_en_id_still_follows_english( + settings_dialog: Any, dialog_class: Any +) -> None: + """The same leftover ``medium.en`` still derives English-only under English.""" + dialog = _dialog_stub(settings_dialog, "en-us") + + assert dialog_class._resolve_saved_whispercpp_variant(dialog, "medium.en") == "medium.en" + + +def test_unpinned_quantized_en_id_is_kept_as_legacy( + settings_dialog: Any, dialog_class: Any +) -> None: + """``{size}.en-q*`` is a real leftover specialization, not a language default.""" + dialog = _dialog_stub(settings_dialog, "pl") + + assert ( + dialog_class._resolve_saved_whispercpp_variant(dialog, "medium.en-q5_0") == "medium.en-q5_0" + ) + + +def test_unpinned_save_of_derived_en_persists_bare_size_and_rederives() -> None: + """Auto-apply of a derived .en must not pin, and language can re-derive.""" + manager = ConfigManager() + manager.update_speech_recognition_settings( + { + "engine": "whisper_cpp", + "model_size": "medium.en", + "model_variant": "", + "language": "en-us", + } + ) + sr_config = manager.config["speech_recognition"] + + assert sr_config["whisper_cpp_model_variant"] == "" + assert sr_config["whisper_cpp_model_size"] == "medium" + assert sr_config["model_size"] == "medium" + # Bare size in the config, but English still loads the derived .en id. + assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en" + assert resolve_whispercpp_variant("medium.en", "", "pl") == "medium" + assert resolve_whispercpp_variant(sr_config["whisper_cpp_model_size"], "", "pl") == "medium" + + manager.config["speech_recognition"]["language"] = "pl" + assert manager.get_model_size_for_engine("whisper_cpp") == "medium" + + +def test_leftover_en_in_model_size_does_not_block_language_switch() -> None: + """Configs that already stored .en with an empty pin must re-derive.""" + manager = ConfigManager() + manager.config["speech_recognition"]["whisper_cpp_model_size"] = "medium.en" + manager.config["speech_recognition"]["whisper_cpp_model_variant"] = "" + manager.config["speech_recognition"]["language"] = "pl" + + assert manager.get_model_size_for_engine("whisper_cpp") == "medium" + + +def test_pinned_save_persists_the_full_variant() -> None: + """A deliberate pin still stores the loadable id so the engine loads it.""" + manager = ConfigManager() + manager.update_speech_recognition_settings( + { + "engine": "whisper_cpp", + "model_size": "medium", + "model_variant": "medium", + "language": "en-us", + } + ) + sr_config = manager.config["speech_recognition"] + + assert sr_config["whisper_cpp_model_variant"] == "medium" + assert sr_config["whisper_cpp_model_size"] == "medium" + assert manager.get_model_size_for_engine("whisper_cpp") == "medium"