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.
This commit is contained in:
Jatin K Malik
2026-09-29 01:06:08 -07:00
committed by GitHub
parent 06f049ac76
commit 9646011e77
10 changed files with 744 additions and 25 deletions
+75 -10
View File
@@ -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()
@@ -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
+28 -14
View File
@@ -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:
+12
View File
@@ -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
+32
View File
@@ -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."""
+70
View File
@@ -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()
+157
View File
@@ -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")
+33
View File
@@ -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"
+235
View File
@@ -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
+100
View File
@@ -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."""