Files
voculinux/tests/test_whispercpp_variant_resolution.py
8ce85f1e9e fix(whispercpp): reuse an on-disk same-size weight at startup (#923)
* fix(whispercpp): reuse an on-disk same-size weight at startup

The Settings picker already lets a downloaded same-size model stand in
when the language-derived sibling is absent, but the startup path never
applied it: a headless config with language=en-us and model_size=medium
demanded ggml-medium.en.bin while ggml-medium.bin sat in the models
directory and loads fine.

Move the stand-in into whispercpp_model_info.on_disk_stand_in so both
paths share it, and give resolve_whispercpp_variant a prefer_on_disk
flag that only language-derived results use: pinned variants and stored
specializations still load exactly what they name, the .en preference
still wins when that file is present, and checksum verification is
untouched. get_model_size_for_engine opts in so startup and headless
launches match the Settings semantics.

Fixes #916

* fix(whispercpp): keep is_model_downloaded free of makedirs

get_model_path creates the model directory for callers that are about to
write a file, but is_model_downloaded is a read-only probe: on a fresh
profile its makedirs ran inside tests that stub os.makedirs for the
config directory, breaking them, and a bare existence check should not
mutate the filesystem anyway. Resolve the file path without creating
directories in the probe; get_model_path keeps creating for writers.

Refs #916

* test(whispercpp): annotate the stand-in fixture's return type

AGENTS.md requires type hints on every signature; _downloaded returns the
context manager from patch.object.

Refs #916

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 18:51:54 -07:00

392 lines
16 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
from vocalinux.utils import whispercpp_model_info
@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_follows_a_non_english_language(
settings_dialog: Any, dialog_class: Any
) -> None:
"""``{size}.en-q*`` cannot transcribe Polish; use the multilingual sibling."""
dialog = _dialog_stub(settings_dialog, "pl")
assert dialog_class._resolve_saved_whispercpp_variant(dialog, "medium.en-q5_0") == "medium-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_english_only_pin_does_not_block_a_non_english_language() -> None:
"""A leftover English-only pin must not trap Polish on those weights."""
manager = ConfigManager()
manager.config["speech_recognition"]["whisper_cpp_model_size"] = "medium"
manager.config["speech_recognition"]["whisper_cpp_model_variant"] = "medium.en"
manager.config["speech_recognition"]["language"] = "pl"
assert manager.get_model_size_for_engine("whisper_cpp") == "medium"
assert resolve_whispercpp_variant("medium.en", "medium.en", "pl") == "medium"
assert resolve_whispercpp_variant("medium.en", "medium.en", "auto") == "medium"
assert resolve_whispercpp_variant("medium.en-q5_0", "medium.en-q5_0", "fr") == "medium-q5_0"
assert resolve_whispercpp_variant("medium.en", "medium.en", "en-us") == "medium.en"
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"
# --- headless startup reuses what is on disk (#916) -----------------------
#
# The Settings picker's same-size stand-in never reached the startup path, so a
# headless config could demand ggml-medium.en.bin while ggml-medium.bin of the
# same size sat in the models directory. ``get_model_size_for_engine`` is the
# single resolution point startup reads.
def _manager(model_size: str, variant: str, language: str) -> ConfigManager:
manager = ConfigManager()
sr = manager.config["speech_recognition"]
sr["engine"] = "whisper_cpp"
sr["whisper_cpp_model_size"] = model_size
sr["whisper_cpp_model_variant"] = variant
sr["language"] = language
return manager
def _downloaded(*names: str) -> Any:
"""Patch only the disk probe; the real variant catalog stays in place."""
return patch.object(
whispercpp_model_info,
"is_model_downloaded",
side_effect=lambda name: name in names,
)
def test_startup_uses_downloaded_same_size_weight_for_a_missing_en_sibling() -> None:
"""The reported bug: ggml-medium.bin must stand in when medium.en is absent."""
manager = _manager("medium", "", "en-us")
with _downloaded("medium"):
assert manager.get_model_size_for_engine("whisper_cpp") == "medium"
def test_startup_keeps_the_en_preference_when_the_en_model_is_downloaded() -> None:
"""An on-disk .en still wins: the stand-in never overrides what is present."""
manager = _manager("medium", "", "en-us")
with _downloaded("medium", "medium.en"):
assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en"
def test_startup_demands_the_derived_model_when_nothing_of_that_size_is_on_disk() -> None:
"""No same-size weight on disk means the language-derived id and a download."""
manager = _manager("medium", "", "en-us")
with _downloaded("small"):
assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en"
def test_startup_never_stands_an_english_only_weight_in_for_another_language() -> None:
"""Polish cannot use .en weights, so a missing multilingual stays a download."""
manager = _manager("medium", "", "pl")
with _downloaded("medium.en"):
assert manager.get_model_size_for_engine("whisper_cpp") == "medium"
def test_a_pinned_variant_is_never_swapped_for_a_same_size_stand_in() -> None:
"""An explicit pin is a choice: it names the weights to fetch or load."""
manager = _manager("medium", "medium.en", "en-us")
with _downloaded("medium"):
assert manager.get_model_size_for_engine("whisper_cpp") == "medium.en"
def test_a_stored_specialization_is_never_swapped_for_a_same_size_stand_in() -> None:
"""A leftover quantized id in the size field is a deliberate pick too."""
manager = _manager("medium-q5_0", "", "en-us")
with _downloaded("medium"):
assert manager.get_model_size_for_engine("whisper_cpp") == "medium-q5_0"
def test_the_plain_weight_stands_in_ahead_of_a_quantized_one() -> None:
"""Same ranking as the picker: unquantized is closest to the derived variant."""
manager = _manager("medium", "", "en-us")
with _downloaded("medium-q5_0", "medium"):
assert manager.get_model_size_for_engine("whisper_cpp") == "medium"
def test_resolution_without_the_disk_preference_still_derives_the_sibling() -> None:
"""The picker path keeps deriving the language variant regardless of disk."""
with _downloaded("medium"):
assert resolve_whispercpp_variant("medium", "", "en-us") == "medium.en"