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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
Kacper Paczos
2026-09-07 14:11:49 -07:00
committed by GitHub
co-authored by Kacper Paczos Claude Opus 5 Jatin K Malik
parent f02a6a7c66
commit 43bf363f6b
4 changed files with 444 additions and 28 deletions
+94 -11
View File
@@ -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}")
+44 -17
View File
@@ -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,
@@ -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()
+289
View File
@@ -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"