chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user