Files
voculinux/tests/test_whispercpp_variant_resolution.py
T
43bf363f6b 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>
2026-09-07 14:11:49 -07:00

290 lines
12 KiB
Python

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