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: