From 9646011e77e71ef985b8fba2f075d20939b80ae2 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Tue, 29 Sep 2026 01:06:08 -0700 Subject: [PATCH] fix(settings): speech model follow-ups from QA (#836) * fix(settings): speech model follow-ups from #834 Keep the Unused downloads expander in sync with search, recommend Faster Whisper .en weights for English, parse nvidia-smi MiB VRAM, stop simple mode from clobbering Faster Whisper/Parakeet, and ceil RAM so ~7.4 GiB counts as 8. * fix(settings): keep FW .en picker ids and test unused search Represent Faster Whisper English catalog variants in the Settings size combo so tray-saved small.en is not rejected and overwritten. Add behavioral unused-downloads search coverage for match/nonmatch/clear. --- src/vocalinux/ui/settings_dialog.py | 85 ++++++- .../utils/faster_whisper_model_info.py | 3 +- src/vocalinux/utils/whispercpp_model_info.py | 42 ++-- tests/test_faster_whisper_engine.py | 12 + tests/test_model_missing_offer.py | 32 +++ tests/test_settings_dialog.py | 70 ++++++ tests/test_settings_model_state.py | 157 ++++++++++++ tests/test_simple_model_settings.py | 33 +++ tests/test_unused_downloads_height.py | 235 ++++++++++++++++++ tests/test_whispercpp_model_info_ext.py | 100 ++++++++ 10 files changed, 744 insertions(+), 25 deletions(-) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index fb8e145..5127b65 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -18,6 +18,7 @@ UX Design Notes: """ import logging +import math import os import re import threading @@ -175,11 +176,15 @@ ENGINE_MODELS = { ], # Parakeet TDT 0.6B int8 bundles "faster_whisper": [ "tiny", + "tiny.en", "base", + "base.en", "small", + "small.en", "medium", + "medium.en", "large-v3", - ], # faster-whisper models mirror OpenAI Whisper sizes + ], # catalog sizes plus English-only .en variants (no large-v3.en) "remote_api": [], # Remote API does not need local models } @@ -330,6 +335,20 @@ def _recommended_whispercpp_variant_for_language( return recommended_model, reason +def _recommended_faster_whisper_variant_for_language( + recommended_model: str, + reason: str, + language_id: str, +) -> tuple[str, str]: + """Adjust a Faster Whisper hardware recommendation to the selected language.""" + english_variant = ( + recommended_model if recommended_model.endswith(".en") else f"{recommended_model}.en" + ) + if _language_is_english(language_id) and english_variant in FASTER_WHISPER_MODEL_INFO: + return english_variant, reason + return recommended_model, reason + + 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.""" return default_variant_for_size(model_size, _language_is_english(language_id)) @@ -1461,7 +1480,7 @@ def _get_recommended_whisper_model() -> tuple: try: import psutil - ram_gb = psutil.virtual_memory().total // (1024**3) + ram_gb = math.ceil(psutil.virtual_memory().total / (1024**3)) # Check for CUDA - suppress warnings during detection has_cuda = False @@ -1521,7 +1540,7 @@ def _get_recommended_vosk_model() -> tuple: try: import psutil - ram_gb = psutil.virtual_memory().total // (1024**3) + ram_gb = math.ceil(psutil.virtual_memory().total / (1024**3)) # VOSK models are CPU-based, so we recommend based on RAM and disk space if ram_gb >= 4: @@ -1567,7 +1586,10 @@ def recommended_model_for_engine( reason = parakeet.RECOMMENDED_REASON size_mb = parakeet.PARAKEET_MODEL_INFO.get(model_id, {}).get("size_mb", 0) elif engine == "faster_whisper": - model_id, reason = get_recommended_faster_whisper_model() + recommended_model, reason = get_recommended_faster_whisper_model() + model_id, reason = _recommended_faster_whisper_variant_for_language( + recommended_model, reason, language + ) size_mb = FASTER_WHISPER_MODEL_INFO.get(model_id, {}).get("size_mb", 0) else: # Remote API transcribes server-side; there is nothing to download. @@ -2624,7 +2646,7 @@ class SettingsDialog(Gtk.Dialog): page_names = {page.name for page in self._pages} self._search_previous_page = visible_page if visible_page in page_names else "dictation" - baseline = {"rows": {}, "groups": {}, "extras": {}} + baseline = {"rows": {}, "groups": {}, "extras": {}, "islands": {}} for page in self._pages: for group in page.groups: visible = group.get_visible() @@ -2640,6 +2662,10 @@ class SettingsDialog(Gtk.Dialog): baseline["rows"][row] = row.get_visible() for extra in page.extras: baseline["extras"][extra] = extra.get_visible() + # Nested unused_models_group is the searchable unit; the expander card + # that wraps it is not a PreferencesGroup, so record it separately. + if self.unused_island is not None: + baseline["islands"][self.unused_island] = self.unused_island.get_visible() self._search_baseline = baseline def _restore_search_baseline(self): @@ -2725,6 +2751,12 @@ class SettingsDialog(Gtk.Dialog): page.match_count_label.hide() page.sidebar_row.set_sensitive(False) + # collect_children finds unused_models_group inside unused_island, so the + # expander card is not an extra. Hide the header when the nested group + # has no search hits; restore puts the island back from the snapshot. + if self.unused_island is not None and hasattr(self, "unused_models_group"): + self.unused_island.set_visible(self.unused_models_group.get_visible()) + if first_match_page is not None: self.sidebar_listbox.select_row(first_match_page.sidebar_row) self.settings_stack.set_visible_child_name(first_match_page.name) @@ -3575,6 +3607,10 @@ class SettingsDialog(Gtk.Dialog): self.unused_models_group = PreferencesGroup( keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"), ) + # Title lives on the expander card. Copy it onto the nested group so + # search matches "Unused downloads" without drawing a second header. + self.unused_models_group.title = "Unused downloads" + self.unused_models_group.description = "Downloaded, but not the one in use" # The island is the card; this group only holds rows for search/delete. self.unused_models_group.get_style_context().remove_class("preferences-group") @@ -5703,6 +5739,11 @@ class SettingsDialog(Gtk.Dialog): recommended_model = parakeet.RECOMMENDED_MODEL elif engine == "faster_whisper": recommended_model, _ = get_recommended_faster_whisper_model() + recommended_model, _ = _recommended_faster_whisper_variant_for_language( + recommended_model, + "", + self.language_combo.get_active_id() or self.language, + ) else: recommended_model, _ = _get_recommended_vosk_model() @@ -5737,18 +5778,26 @@ class SettingsDialog(Gtk.Dialog): if smallest_model is None: smallest_model = size - self.model_combo.append(size.capitalize(), display_text) + # whisper.cpp already appends the catalog id; Faster Whisper + # ids include dots (`small.en`) which capitalize() mangles. + combo_id = size if engine == "faster_whisper" else size.capitalize() + self.model_combo.append(combo_id, display_text) # Determine which model to select saved_model = saved_model_for_engine.lower() valid_models = [m.lower() for m in ENGINE_MODELS.get(engine, [])] if saved_model in valid_models: - model_to_set = saved_model.capitalize() + selected = saved_model elif downloaded_models: - model_to_set = downloaded_models[0].capitalize() + selected = downloaded_models[0] else: - model_to_set = smallest_model.capitalize() if smallest_model else "Small" + selected = smallest_model + + if engine == "faster_whisper": + model_to_set = selected or "tiny" + else: + model_to_set = selected.capitalize() if selected else "Small" logger.info(f"Setting active model to: {model_to_set}") @@ -6459,14 +6508,25 @@ class SettingsDialog(Gtk.Dialog): Simple mode deliberately steers the existing widgets instead of writing the configuration itself, so applying, downloading and the info card keep going through exactly one code path. + + Faster Whisper and Parakeet stay selected: language still follows the + simple questions, but the engine is not forced to whisper.cpp. Other + engines are steered onto whisper.cpp and get the full size/variant derive. """ language = self._simple_decoding_language() priority = self.simple_priority_combo.get_active_id() or BALANCED - self.engine_combo.set_active_id("whisper_cpp") + engine = self._get_selected_engine() + if engine not in ("faster_whisper", "parakeet"): + self.engine_combo.set_active_id("whisper_cpp") + engine = "whisper_cpp" + self._set_combo_active_id_or_first(self.language_combo, language) self.language = language + if engine != "whisper_cpp": + return + recommended, _ = self._get_recommended_whispercpp_model_for_language() size = size_for_priority(get_whispercpp_model_size(recommended), priority) variant = _default_whispercpp_variant_for_size(size, language) or size @@ -6706,6 +6766,11 @@ class SettingsDialog(Gtk.Dialog): info = FASTER_WHISPER_MODEL_INFO[model_name] is_downloaded = is_faster_whisper_model_downloaded(model_name) recommended, reason = get_recommended_faster_whisper_model() + recommended, reason = _recommended_faster_whisper_variant_for_language( + recommended, + reason, + self.language_combo.get_active_id() or self.language, + ) extra_info = f"Parameters: {info['params']}" else: self.model_info_card.hide() diff --git a/src/vocalinux/utils/faster_whisper_model_info.py b/src/vocalinux/utils/faster_whisper_model_info.py index 4ebbeba..188f97a 100644 --- a/src/vocalinux/utils/faster_whisper_model_info.py +++ b/src/vocalinux/utils/faster_whisper_model_info.py @@ -7,6 +7,7 @@ loads it, the same way Parakeet and whisper.cpp models are. """ import logging +import math import os import shutil from functools import lru_cache @@ -238,7 +239,7 @@ def get_recommended_model() -> tuple[str, str]: try: import psutil - ram_gb = int(psutil.virtual_memory().total) // (1024**3) + ram_gb = math.ceil(int(psutil.virtual_memory().total) / (1024**3)) except Exception: ram_gb = 4 diff --git a/src/vocalinux/utils/whispercpp_model_info.py b/src/vocalinux/utils/whispercpp_model_info.py index 433f042..c238ca9 100644 --- a/src/vocalinux/utils/whispercpp_model_info.py +++ b/src/vocalinux/utils/whispercpp_model_info.py @@ -6,6 +6,7 @@ for whisper.cpp, supporting Vulkan, CUDA, and CPU backends. """ import logging +import math import os import re import subprocess @@ -426,6 +427,24 @@ def detect_cpu_info() -> str: return "CPU" +def _parse_cuda_vram_gib(backend_info: str) -> Optional[float]: + """Parse VRAM in GiB from nvidia-smi-style backend info. + + Accepts MiB/MB/GiB/GB (case-insensitive). MiB and MB are converted by + dividing by 1024. Returns None when no size can be parsed. + """ + if not isinstance(backend_info, str) or not backend_info: + return None + match = re.search(r"(\d+(?:\.\d+)?)\s*(gi?b|mi?b)", backend_info, flags=re.IGNORECASE) + if not match: + return None + amount = float(match.group(1)) + unit = match.group(2).lower() + if unit in {"mb", "mib"}: + amount /= 1024.0 + return amount + + def get_recommended_model() -> tuple[str, str]: """ Get the recommended whisper.cpp model based on system configuration. @@ -436,7 +455,7 @@ def get_recommended_model() -> tuple[str, str]: try: import psutil - ram_gb = psutil.virtual_memory().total // (1024**3) + ram_gb = math.ceil(psutil.virtual_memory().total / (1024**3)) # Detect available compute backends backend, backend_info = detect_compute_backend() @@ -448,19 +467,14 @@ def get_recommended_model() -> tuple[str, str]: else: return "base", f"Vulkan GPU with {ram_gb}GB RAM" elif backend == ComputeBackend.CUDA: - # CUDA has more VRAM typically - if "GB" in backend_info: - try: - vram_gb = int(backend_info.split("GB")[0].split("(")[-1].strip()) - if vram_gb >= 8: - return "medium", f"CUDA GPU with {vram_gb}GB VRAM" - elif vram_gb >= 4: - return "small", f"CUDA GPU with {vram_gb}GB VRAM" - else: - return "base", f"CUDA GPU with limited VRAM" - except (ValueError, IndexError): - pass - return "small", f"CUDA GPU detected" + vram_gb = _parse_cuda_vram_gib(backend_info or "") + if vram_gb is not None: + if vram_gb >= 8: + return "medium", f"CUDA GPU with {vram_gb:g}GB VRAM" + if vram_gb >= 4: + return "small", f"CUDA GPU with {vram_gb:g}GB VRAM" + return "base", "CUDA GPU with limited VRAM" + return "small", "CUDA GPU detected" else: # CPU-only recommendations based on RAM if ram_gb >= 16: diff --git a/tests/test_faster_whisper_engine.py b/tests/test_faster_whisper_engine.py index 7bf0989..4f2d8c0 100644 --- a/tests/test_faster_whisper_engine.py +++ b/tests/test_faster_whisper_engine.py @@ -200,6 +200,18 @@ class TestFasterWhisperModelInfo: model, _reason = get_recommended_model() assert model == "base" + def test_get_recommended_model_cuda_ceils_fractional_ram(self): + """~7.4 GiB must count as 8 GiB so CUDA recommends small, not base.""" + with patch( + "vocalinux.utils.faster_whisper_model_info._has_torch_cuda", + return_value=True, + ): + with patch("psutil.virtual_memory") as mock_mem: + mock_mem.return_value.total = int(7.4 * 1024**3) + model, reason = get_recommended_model() + assert model == "small" + assert "8GB" in reason + def test_has_torch_cuda_available(self): """Test that torch detection reports CUDA availability.""" from vocalinux.utils.faster_whisper_model_info import _has_torch_cuda diff --git a/tests/test_model_missing_offer.py b/tests/test_model_missing_offer.py index 67718a3..6dfa258 100644 --- a/tests/test_model_missing_offer.py +++ b/tests/test_model_missing_offer.py @@ -105,6 +105,38 @@ class TestRecommendedModelForEngine: def test_remote_api_has_nothing_to_download(self, settings_dialog): assert settings_dialog.recommended_model_for_engine("remote_api", "auto") is None + def test_faster_whisper_english_gets_the_english_only_variant(self, settings_dialog): + with patch.object( + settings_dialog, + "get_recommended_faster_whisper_model", + return_value=("small", "CUDA GPU"), + ): + result = settings_dialog.recommended_model_for_engine("faster_whisper", "en-us") + + assert result.model_id == "small.en" + assert "CUDA GPU" in result.reason + assert result.model_id in settings_dialog.ENGINE_MODELS["faster_whisper"] + + def test_faster_whisper_non_english_keeps_the_multilingual_model(self, settings_dialog): + with patch.object( + settings_dialog, + "get_recommended_faster_whisper_model", + return_value=("tiny", "4GB RAM"), + ): + result = settings_dialog.recommended_model_for_engine("faster_whisper", "de") + + assert result.model_id == "tiny" + + def test_faster_whisper_skips_english_variant_when_catalog_has_none(self, settings_dialog): + with patch.object( + settings_dialog, + "get_recommended_faster_whisper_model", + return_value=("large-v3", "lots of RAM"), + ): + result = settings_dialog.recommended_model_for_engine("faster_whisper", "en-us") + + assert result.model_id == "large-v3" + class TestOffer: """What the notification carries.""" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 84601be..41b28b7 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -604,6 +604,19 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): self.assertTrue(callable(_get_recommended_whisper_model)) + def test_get_recommended_whisper_model_ceils_fractional_ram(self): + """~7.4 GiB must count as 8 GiB so the CPU path recommends small.""" + from vocalinux.ui.settings_dialog import _get_recommended_whisper_model + + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value = MagicMock(total=int(7.4 * (1024**3))) + torch_mock = MagicMock() + torch_mock.cuda.is_available.return_value = False + with patch.dict("sys.modules", {"psutil": mock_psutil, "torch": torch_mock}): + model, reason = _get_recommended_whisper_model() + self.assertEqual(model, "small") + self.assertIn("8GB", reason) + def test_get_recommended_vosk_model_function_exists(self): """Test that _get_recommended_vosk_model function exists.""" from vocalinux.ui.settings_dialog import _get_recommended_vosk_model @@ -622,6 +635,20 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): self.assertIn("large-v3-turbo-q5_0", WHISPERCPP_MODEL_INFO) self.assertNotIn("small.en-tdrz", WHISPERCPP_MODEL_INFO) + def test_faster_whisper_picker_lists_english_only_catalog_ids(self): + """Settings must list the same .en ids the tray can persist.""" + from vocalinux.ui.settings_dialog import ENGINE_MODELS, FASTER_WHISPER_MODEL_INFO + + fw = ENGINE_MODELS["faster_whisper"] + for model_id in ("tiny.en", "base.en", "small.en", "medium.en"): + self.assertIn(model_id, fw) + self.assertIn(model_id, FASTER_WHISPER_MODEL_INFO) + self.assertIn("large-v3", fw) + self.assertNotIn("large-v3.en", fw) + self.assertNotIn("large-v3.en", FASTER_WHISPER_MODEL_INFO) + for model_id in fw: + self.assertIn(model_id, FASTER_WHISPER_MODEL_INFO) + def test_model_display_name_large_v3_turbo(self): """Test display labels for whisper.cpp model variants.""" from vocalinux.ui.settings_dialog import _model_display_name @@ -720,6 +747,37 @@ class TestSettingsDialogHelperFunctions(unittest.TestCase): ) self.assertEqual(_default_whispercpp_variant_for_size("medium", "auto"), "medium") + def test_faster_whisper_recommendation_uses_language_for_english_variant(self): + """English language nudges Faster Whisper recommendations to .en variants.""" + from vocalinux.ui.settings_dialog import ( + _recommended_faster_whisper_variant_for_language, + ) + + self.assertEqual( + _recommended_faster_whisper_variant_for_language( + "small", + "mock hardware reason", + "en-us", + ), + ("small.en", "mock hardware reason"), + ) + self.assertEqual( + _recommended_faster_whisper_variant_for_language( + "tiny", + "mock hardware reason", + "de", + ), + ("tiny", "mock hardware reason"), + ) + self.assertEqual( + _recommended_faster_whisper_variant_for_language( + "large-v3", + "mock hardware reason", + "en-us", + ), + ("large-v3", "mock hardware reason"), + ) + def test_whispercpp_variant_for_language_retargets_ambiguous_size_ids(self): """Bare size ids must follow language, not stick on multilingual.""" from vocalinux.ui.settings_dialog import _whispercpp_variant_for_language @@ -941,6 +999,18 @@ class TestSettingsSearch(unittest.TestCase): self.assertIn("Gtk.Container", source) self.assertIn("widget.get_children()", source) + def test_unused_island_follows_nested_group_during_search(self): + """The Unused downloads expander header must not leak next to unrelated hits.""" + source = self._settings_source() + search_body = source.split("def _on_search_changed(self, entry)")[1].split("def ")[0] + self.assertIn("self.unused_island.set_visible", search_body) + self.assertIn("self.unused_models_group.get_visible()", search_body) + snapshot_body = source.split("def _snapshot_search_baseline")[1].split("def ")[0] + self.assertIn("self.unused_island", snapshot_body) + restore_body = source.split("def _restore_search_baseline")[1].split("def ")[0] + self.assertIn("widget.set_visible(visible)", restore_body) + self.assertIn('self.unused_models_group.title = "Unused downloads"', source) + def test_search_respects_closed_revealers(self): """A locked Advanced section must not leak controls into search.""" source = self._settings_source() diff --git a/tests/test_settings_model_state.py b/tests/test_settings_model_state.py index 5eb99cd..7e2a890 100644 --- a/tests/test_settings_model_state.py +++ b/tests/test_settings_model_state.py @@ -788,6 +788,54 @@ def _dialog_for_engine_ui(engine_text: str): return dialog +class _FakeCombo: + """ComboBoxText stand-in that records ids the way `_populate_model_options` writes them.""" + + def __init__(self) -> None: + self._items: list[tuple[str, str]] = [] + self._active_id: str | None = None + + def remove_all(self) -> None: + self._items.clear() + self._active_id = None + + def append(self, item_id: str, text: str) -> None: + self._items.append((item_id, text)) + + def set_active_id(self, item_id: str) -> bool: + if any(stored_id == item_id for stored_id, _text in self._items): + self._active_id = item_id + return True + return False + + def set_active(self, index: int) -> None: + self._active_id = self._items[index][0] + + def get_active_id(self) -> str | None: + return self._active_id + + def get_active_text(self) -> str | None: + for stored_id, text in self._items: + if stored_id == self._active_id: + return text + return None + + def get_model(self) -> list[tuple[str, str]]: + # Gtk.ComboBoxText stores (display text, id); the populate fallback + # compares row[0] against the id it is trying to restore. + return [(text, stored_id) for stored_id, text in self._items] + + @property + def ids(self) -> list[str]: + return [item_id for item_id, _text in self._items] + + def text_for(self, item_id: str) -> str: + for stored_id, text in self._items: + if stored_id == item_id: + return text + raise KeyError(item_id) + + def _dialog_for_selected_settings(engine_text: str, language_id: str, model_id: str = "small"): """Stub combos and spins so `get_selected_settings` can run unbound.""" dialog = _dialog_stub() @@ -811,6 +859,115 @@ def _dialog_for_selected_settings(engine_text: str, language_id: str, model_id: return dialog +def _faster_whisper_picker_dialog( + dialog_class: type[Any], *, saved_model: str, language: str = "en-us" +) -> Mock: + """Stub enough widgets to run real `_populate_model_options` for Faster Whisper.""" + dialog = _dialog_for_selected_settings("Faster Whisper", language, model_id=saved_model) + dialog.language = language + dialog.config_manager.get_model_size_for_engine.return_value = saved_model + dialog.model_combo = _FakeCombo() + dialog.model_variant_combo = _FakeCombo() + _bind_real(dialog, dialog_class, "_populate_model_options", "get_selected_settings") + return dialog + + +def _populate_faster_whisper_picker( + settings_dialog: Any, + dialog_class: type[Any], + *, + saved_model: str, + language: str = "en-us", + recommended: str = "small", +) -> Mock: + dialog = _faster_whisper_picker_dialog(dialog_class, saved_model=saved_model, language=language) + with ( + patch.object( + settings_dialog, + "get_recommended_faster_whisper_model", + return_value=(recommended, "CUDA GPU"), + ), + patch.object(settings_dialog, "is_faster_whisper_model_downloaded", return_value=False), + ): + dialog._populate_model_options() + return dialog + + +def test_faster_whisper_picker_keeps_saved_english_model(settings_dialog, dialog_class): + """A tray-persisted ``small.en`` must stay selected instead of falling back.""" + dialog = _populate_faster_whisper_picker( + settings_dialog, dialog_class, saved_model="small.en", language="en-us" + ) + + assert "small.en" in dialog.model_combo.ids + assert "Small.en" not in dialog.model_combo.ids + assert dialog.model_combo.get_active_id() == "small.en" + assert dialog.get_selected_settings()["model_size"] == "small.en" + + +def test_faster_whisper_english_recommendation_stars_en_variant(settings_dialog, dialog_class): + """English language must star ``small.en``, not the bare multilingual row.""" + dialog = _populate_faster_whisper_picker( + settings_dialog, + dialog_class, + saved_model="tiny", + language="en-us", + recommended="small", + ) + + assert " ★" in dialog.model_combo.text_for("small.en") + assert " ★" not in dialog.model_combo.text_for("small") + + +def test_faster_whisper_non_english_stars_multilingual_and_keeps_saved_en( + settings_dialog, dialog_class +): + """German must star ``small``, not ``small.en``, and must not rewrite a saved .en id.""" + dialog = _populate_faster_whisper_picker( + settings_dialog, + dialog_class, + saved_model="small.en", + language="de", + recommended="small", + ) + + assert dialog.model_combo.get_active_id() == "small.en" + assert " ★" in dialog.model_combo.text_for("small") + assert " ★" not in dialog.model_combo.text_for("small.en") + assert dialog.get_selected_settings()["model_size"] == "small.en" + + +def test_faster_whisper_selected_settings_keep_dotted_id(dialog_class): + """Raw combo ids such as ``small.en`` must persist through instant-apply.""" + dialog = _dialog_for_selected_settings("Faster Whisper", "en-us", model_id="small.en") + + settings = dialog_class.get_selected_settings(dialog) + + assert settings["engine"] == "faster_whisper" + assert settings["model_size"] == "small.en" + + +def test_whisper_picker_still_uses_capitalized_combo_ids(settings_dialog, dialog_class): + """Raw Faster Whisper ids must not leak into engines whose combo ids are capitalized.""" + dialog = _dialog_for_selected_settings("Whisper", "en-us", model_id="small") + dialog.language = "en-us" + dialog.config_manager.get_model_size_for_engine.return_value = "small" + dialog.model_combo = _FakeCombo() + dialog.model_variant_combo = _FakeCombo() + _bind_real(dialog, dialog_class, "_populate_model_options") + with ( + patch.object( + settings_dialog, "_get_recommended_whisper_model", return_value=("small", "reason") + ), + patch.object(settings_dialog, "_is_whisper_model_downloaded", return_value=False), + ): + dialog._populate_model_options() + + assert "Small" in dialog.model_combo.ids + assert "small" not in dialog.model_combo.ids + assert dialog.model_combo.get_active_id() == "Small" + + def test_parakeet_hides_the_language_picker(dialog_class): """Parakeet language coverage is the model, so the picker must not stay shown.""" dialog = _dialog_for_engine_ui("Parakeet") diff --git a/tests/test_simple_model_settings.py b/tests/test_simple_model_settings.py index 151156f..5081ee2 100644 --- a/tests/test_simple_model_settings.py +++ b/tests/test_simple_model_settings.py @@ -60,6 +60,7 @@ def _dialog_stub(language="pl", multi=False, priority=BALANCED, recommended="sma dialog.simple_second_language_combo.get_active_id.return_value = second dialog.simple_priority_combo.get_active_id.return_value = priority dialog._get_recommended_whispercpp_model_for_language.return_value = (recommended, "reason") + dialog._get_selected_engine.return_value = "whisper_cpp" # The language resolution is the code under test's own helper, not a mock. dialog._simple_decoding_language.side_effect = ( lambda: _sd().SettingsDialog._simple_decoding_language(dialog) @@ -688,6 +689,38 @@ def test_a_plain_weight_is_preferred_over_a_quantized_one(settings_dialog, dialo assert dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") == "base" +@pytest.mark.parametrize("engine", ["faster_whisper", "parakeet"]) +def test_simple_choice_leaves_faster_whisper_and_parakeet_alone( + settings_dialog, dialog_class, engine +): + """Editing a simple question must not yank Advanced off Faster Whisper or Parakeet.""" + dialog = _dialog_stub(language="en-us", multi=False, priority=BALANCED, recommended="small") + dialog._get_selected_engine.return_value = engine + + dialog_class._apply_simple_choice(dialog) + + dialog.engine_combo.set_active_id.assert_not_called() + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "en-us") + assert dialog.language == "en-us" + dialog.model_combo.set_active_id.assert_not_called() + dialog.model_variant_combo.set_active_id.assert_not_called() + + +@pytest.mark.parametrize("engine", ["vosk", "remote_api", "whisper", "unknown"]) +def test_simple_choice_steers_incompatible_engines_to_whisper_cpp( + settings_dialog, dialog_class, engine +): + dialog = _dialog_stub(language="pl", multi=False, priority=BALANCED, recommended="small") + dialog._get_selected_engine.return_value = engine + + dialog_class._apply_simple_choice(dialog) + + dialog.engine_combo.set_active_id.assert_called_once_with("whisper_cpp") + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "pl") + dialog.model_combo.set_active_id.assert_called_once_with("small") + dialog.model_variant_combo.set_active_id.assert_called_once_with("small") + + def test_simple_mode_consults_the_disk_before_settling_on_a_variant(settings_dialog, dialog_class): dialog = _dialog_stub(language="en-us", multi=False, priority=BALANCED, recommended="small") dialog._on_disk_stand_in.side_effect = lambda variant, size, language: "small" diff --git a/tests/test_unused_downloads_height.py b/tests/test_unused_downloads_height.py index a0c34ba..04dd52d 100644 --- a/tests/test_unused_downloads_height.py +++ b/tests/test_unused_downloads_height.py @@ -6,9 +6,18 @@ helper that is unit-tested directly, and the wiring around it is asserted against the source of the two methods. """ +from __future__ import annotations + +import importlib import inspect import re +import sys +from typing import Any +from unittest.mock import MagicMock, Mock, patch +import pytest + +import vocalinux.ui from vocalinux.ui import settings_dialog from vocalinux.ui.settings_dialog import ( _UNUSED_DOWNLOADS_MAX_HEIGHT, @@ -89,3 +98,229 @@ def test_unused_downloads_are_a_sibling_expander_not_nested_in_advanced(): assert "self.advanced_box.pack_start(self.unused_models_group" not in src assert "self.unused_expander" in src assert "_make_expander_card" in src + + +def test_unused_island_visibility_tracks_nested_group_during_search(): + """Search hides leftover-model rows; the expander header must follow them.""" + src = inspect.getsource(settings_dialog) + search_body = src.split("def _on_search_changed(self, entry)")[1].split("def ")[0] + snapshot_body = src.split("def _snapshot_search_baseline")[1].split("def ")[0] + assert "self.unused_island.set_visible" in search_body + assert "self.unused_models_group.get_visible()" in search_body + assert "unused_island" in snapshot_body + + +@pytest.fixture(scope="module") +def settings_dialog_module(): + """Reimport with real Gtk bases so SettingsDialog search methods stay callable.""" + 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 + + +class _Visible: + """Widget stand-in with the visibility API search actually calls.""" + + def __init__(self, visible: bool = True) -> None: + self._visible = visible + self._parent = None + + def get_visible(self) -> bool: + return self._visible + + def set_visible(self, visible: bool) -> None: + self._visible = bool(visible) + + def hide(self) -> None: + self._visible = False + + def show(self) -> None: + self._visible = True + + def get_parent(self) -> Any: + return self._parent + + +class _Label(_Visible): + def __init__(self) -> None: + super().__init__(visible=False) + self.text = "" + + def set_text(self, text: str) -> None: + self.text = text + + +class _SidebarRow(_Visible): + def __init__(self) -> None: + super().__init__(visible=True) + self.sensitive = True + + def set_sensitive(self, sensitive: bool) -> None: + self.sensitive = bool(sensitive) + + +class _Group(_Visible): + def __init__( + self, + title: str = "", + description: str = "", + keywords: tuple[str, ...] = (), + rows: list[Any] | None = None, + visible: bool = True, + ) -> None: + super().__init__(visible) + self.title = title + self.description = description + self.keywords = keywords + self.rows = list(rows or []) + + +class _Page: + def __init__(self, name: str, groups: list[_Group]) -> None: + self.name = name + self.groups = groups + self.extras: list[Any] = [] + self.box = _Visible() + self.sidebar_row = _SidebarRow() + self.match_count_label = _Label() + self.update_badge_label = None + + +class _Stack: + def __init__(self, name: str) -> None: + self._name = name + + def get_visible_child_name(self) -> str: + return self._name + + def set_visible_child_name(self, name: str) -> None: + self._name = name + + +class _Listbox: + def __init__(self) -> None: + self.selected = None + + def select_row(self, row: Any) -> None: + self.selected = row + + def unselect_all(self) -> None: + self.selected = None + + +class _Entry: + def __init__(self, text: str) -> None: + self._text = text + + def get_text(self) -> str: + return self._text + + +def _search_row(settings_dialog_module: Any, title: str, **kwargs: Any) -> Any: + """PreferenceRow subclass that skips GTK construction. + + ``matches_query`` is left on PreferenceRow so a stub cannot make row-only + searches look like they work. + """ + + class _Row(settings_dialog_module.PreferenceRow, _Visible): + def __init__( + self, + title: str, + subtitle: str = "", + keywords: tuple[str, ...] = (), + visible: bool = True, + ) -> None: + _Visible.__init__(self, visible) + self.title = title + self.subtitle = subtitle + self.keywords = keywords + + return _Row(title, **kwargs) + + +def _wire_unused_search_dialog(settings_dialog_module: Any) -> Mock: + """Minimal dialog for `_on_search_changed` unused-island visibility.""" + unused_row = _search_row( + settings_dialog_module, + "Faster Whisper small", + subtitle="244 MB", + keywords=("unused", "download"), + ) + unused_group = _Group( + title="Unused downloads", + description="Downloaded, but not the one in use", + keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"), + rows=[unused_row], + ) + other_group = _Group( + title="Engine", + rows=[_search_row(settings_dialog_module, "Speech engine")], + ) + page = _Page("speech", groups=[other_group, unused_group]) + unused_island = _Visible(visible=True) + + dialog = Mock() + dialog._pages = [page] + dialog.unused_island = unused_island + dialog.unused_models_group = unused_group + dialog.settings_stack = _Stack("speech") + dialog.sidebar_listbox = _Listbox() + dialog._search_baseline = None + dialog._search_previous_page = None + dialog._pending_update = None + dialog.search_empty_label = _Label() + dialog._update_engine_specific_ui = lambda: None + dialog._set_about_update_badge = lambda *_args, **_kwargs: None + + dialog_class = settings_dialog_module.SettingsDialog + for name in ( + "_snapshot_search_baseline", + "_restore_search_baseline", + "_on_search_changed", + ): + setattr(dialog, name, getattr(dialog_class, name).__get__(dialog)) + return dialog + + +def test_unused_island_follows_search_hits_and_restores_on_clear(settings_dialog_module): + """Matching, non-matching, and cleared queries must drive expander visibility.""" + dialog = _wire_unused_search_dialog(settings_dialog_module) + island = dialog.unused_island + group = dialog.unused_models_group + unused_row = group.rows[0] + engine_row = dialog._pages[0].groups[0].rows[0] + + # Subtitle-only needle: group title/keywords do not contain "244", so the + # island can show only if PreferenceRow.matches_query actually runs. + dialog._on_search_changed(_Entry("244")) + assert unused_row.get_visible() is True + assert engine_row.get_visible() is False + assert island.get_visible() is True + assert group.get_visible() is True + + dialog._on_search_changed(_Entry("unused")) + assert island.get_visible() is True + assert group.get_visible() is True + + dialog._on_search_changed(_Entry("engine")) + assert unused_row.get_visible() is False + assert island.get_visible() is False + assert group.get_visible() is False + + dialog._on_search_changed(_Entry("")) + assert island.get_visible() is True + assert group.get_visible() is True diff --git a/tests/test_whispercpp_model_info_ext.py b/tests/test_whispercpp_model_info_ext.py index bcc1c3a..d5c0745 100644 --- a/tests/test_whispercpp_model_info_ext.py +++ b/tests/test_whispercpp_model_info_ext.py @@ -948,6 +948,106 @@ class TestGetRecommendedModel(unittest.TestCase): self.assertIsInstance(reason, str) self.assertGreater(len(reason), 0) + def test_parse_cuda_vram_mib_and_gb(self): + """nvidia-smi reports MiB; older mocks used GB. Both must parse.""" + from vocalinux.utils.whispercpp_model_info import _parse_cuda_vram_gib + + self.assertEqual(_parse_cuda_vram_gib("NVIDIA GeForce RTX 3060 (6144 MiB)"), 6.0) + self.assertEqual(_parse_cuda_vram_gib("NVIDIA GeForce RTX 3070 (8192 MiB)"), 8.0) + self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 4090 (24GB)"), 24.0) + self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 3080 (10 GB)"), 10.0) + self.assertEqual(_parse_cuda_vram_gib("NVIDIA RTX 3060 (8 GiB)"), 8.0) + self.assertEqual(_parse_cuda_vram_gib("NVIDIA GTX 1650 (4096 MB)"), 4.0) + self.assertIsNone(_parse_cuda_vram_gib("NVIDIA GPU")) + self.assertIsNone(_parse_cuda_vram_gib("")) + + def test_get_recommended_model_with_cuda_mib_medium(self): + """8 GiB of CUDA VRAM reported as MiB must recommend medium.""" + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) + + with patch.dict("sys.modules", {"psutil": mock_psutil}): + with patch( + "vocalinux.utils.whispercpp_model_info.detect_compute_backend" + ) as mock_backend: + mock_backend.return_value = ("cuda", "NVIDIA GeForce RTX 3070 (8192 MiB)") + + from vocalinux.utils.whispercpp_model_info import get_recommended_model + + model, reason = get_recommended_model() + + self.assertEqual(model, "medium") + self.assertIn("VRAM", reason) + + def test_get_recommended_model_with_cuda_mib_small(self): + """6 GiB of CUDA VRAM reported as MiB must recommend small.""" + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) + + with patch.dict("sys.modules", {"psutil": mock_psutil}): + with patch( + "vocalinux.utils.whispercpp_model_info.detect_compute_backend" + ) as mock_backend: + mock_backend.return_value = ("cuda", "NVIDIA GeForce RTX 3060 (6144 MiB)") + + from vocalinux.utils.whispercpp_model_info import get_recommended_model + + model, reason = get_recommended_model() + + self.assertEqual(model, "small") + + def test_get_recommended_model_with_cuda_mib_base(self): + """Low CUDA VRAM reported as MiB must recommend base.""" + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value = MagicMock(total=8 * (1024**3)) + + with patch.dict("sys.modules", {"psutil": mock_psutil}): + with patch( + "vocalinux.utils.whispercpp_model_info.detect_compute_backend" + ) as mock_backend: + mock_backend.return_value = ("cuda", "NVIDIA GeForce GTX 1050 (2048 MiB)") + + from vocalinux.utils.whispercpp_model_info import get_recommended_model + + model, reason = get_recommended_model() + + self.assertEqual(model, "base") + + def test_get_recommended_model_with_cuda_unparseable_vram_falls_back(self): + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value = MagicMock(total=16 * (1024**3)) + + with patch.dict("sys.modules", {"psutil": mock_psutil}): + with patch( + "vocalinux.utils.whispercpp_model_info.detect_compute_backend" + ) as mock_backend: + mock_backend.return_value = ("cuda", "NVIDIA GPU") + + from vocalinux.utils.whispercpp_model_info import get_recommended_model + + model, reason = get_recommended_model() + + self.assertEqual(model, "small") + self.assertIn("CUDA GPU detected", reason) + + def test_get_recommended_model_vulkan_ceils_fractional_ram(self): + """~7.4 GiB must count as 8 GiB so Vulkan recommends small, not base.""" + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value = MagicMock(total=int(7.4 * (1024**3))) + + with patch.dict("sys.modules", {"psutil": mock_psutil}): + with patch( + "vocalinux.utils.whispercpp_model_info.detect_compute_backend" + ) as mock_backend: + mock_backend.return_value = ("vulkan", "Intel Arc A770") + + from vocalinux.utils.whispercpp_model_info import get_recommended_model + + model, reason = get_recommended_model() + + self.assertEqual(model, "small") + self.assertIn("8GB", reason) + class TestGetModelPath(unittest.TestCase): """Test cases for get_model_path function."""