fix(settings): make model downloads explicit and cancel the whole intent (#1006)

* fix(settings): make model downloads explicit and cancel the whole intent

"Use it" on the recommendation card applied a different model than the one
shown, a cancelled or failed apply left the staged model behind so the next
Test Dictation downloaded it, and cancelling a download tore down and then
reloaded the previous model while the dialog waited.

- Recommendation is now passive text, compared by size tier, with a link to
  docs/MODEL_SELECTION.md. The "Use it" button is gone.
- Every Settings download asks first and shows size and source host.
  Declining, cancelling or failing drops the staged model spec and puts the
  pickers back on the saved config.
- Test Dictation refuses a model that is not on disk instead of applying it.
- reconfigure() fetches a missing model before releasing the loaded one, so
  a cancel restores state without reloading or rehashing the old model.
  Cancellation state is reset only when a download claim is taken or ended.
- Follow-layout mode shows the resolved language in the simple Main language
  control, read-only, and simple edits keep the "layout" setting.

Fixes #979
Fixes #980
Fixes #981
Fixes #982
Fixes #985
Fixes #988

* fix(settings): restore follow-layout mode when a model change is declined

* fix(settings): keep follow-layout and dictation consistent during model changes

* style(tests): format follow-layout regression tests

* fix(recognition): skip resume reinit while a model download is running

* fix(recognition): keep a lazy reload pending when resume is skipped during a download

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-21 01:04:10 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent f013b7ff88
commit 3e4230ffc2
14 changed files with 1143 additions and 319 deletions
@@ -68,6 +68,7 @@ from ..utils.pywhispercpp_loader import (
from ..utils.system_language import LANGUAGE_FOLLOWS_LAYOUT, language_for_active_layout
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO
from ..utils.whisper_model_info import (
WHISPER_MODEL_SIZES,
migrate_legacy_checkpoint_names,
whisper_model_file,
whisper_model_url,
@@ -480,6 +481,7 @@ class SpeechRecognitionManager:
# Set by the UI layer to offer the download when a model is missing
self._model_missing_handler: Optional[Callable[[str], bool]] = None
self._download_cancelled = False
self._prefetching_model = False
# Held by whichever UI is downloading a model. The tray and the settings
# dialog both download in the background, and there is only one progress
# callback and one engine configuration between them.
@@ -654,9 +656,54 @@ class SpeechRecognitionManager:
else:
raise ValueError(f"Unsupported speech recognition engine: {self.engine}")
def _download_selected_model_if_missing(self) -> None:
"""Fetch a missing target model without disturbing the loaded engine."""
if self.engine == "whisper_cpp":
valid_models = [
name for name in WHISPERCPP_MODEL_INFO if name not in NON_DICTATION_MODELS
]
if self.model_size in valid_models and not os.path.exists(
get_model_path(self.model_size)
):
self._download_whispercpp_model()
elif self.engine == "whisper":
if self.model_size in WHISPER_MODEL_SIZES:
cache_dir, model_file = self._whisper_model_cache_paths()
if not os.path.exists(model_file):
self._download_whisper_model(cache_dir)
elif self.engine == "faster_whisper":
if not faster_whisper.is_model_downloaded(self.model_size):
self._download_faster_whisper_model()
elif self.engine == "parakeet":
if not parakeet.is_model_downloaded(self.model_size):
self._download_parakeet_model()
elif self.engine == "vosk":
self._configure_vosk_model_map()
if not os.path.exists(self._get_vosk_model_path()):
self._download_vosk_model()
def _whisper_model_cache_paths(self) -> tuple[str, str]:
"""Prepare the same Whisper cache and checkpoint path used by init."""
whisper_cache_dir = os.path.join(MODELS_DIR, "whisper")
os.makedirs(whisper_cache_dir, exist_ok=True)
migrate_legacy_checkpoint_names(whisper_cache_dir)
model_file = os.path.join(whisper_cache_dir, whisper_model_file(self.model_size))
return whisper_cache_dir, model_file
def _configure_vosk_model_map(self) -> None:
"""Build the VOSK size-to-language model mapping used by init and download."""
vosk_language = "en-us" if self.language == "auto" else self.language
model_info = cast(dict[str, dict[str, Any]], VOSK_MODEL_INFO)
self.vosk_model_map = {
"small": model_info["small"]["languages"].get(vosk_language),
"medium": model_info["medium"]["languages"].get(vosk_language),
"large": model_info["large"]["languages"].get(vosk_language),
}
#: Class-level default so a manager built without ``__init__`` still answers
#: "not following the layout" instead of raising on the dictation path.
language_preference: str = "auto"
_prefetching_model: bool = False
#: Same for the one-shot override state (#805): __new__-built stubs in tests
#: must not raise on the dictation path either.
@@ -930,14 +977,7 @@ class SpeechRecognitionManager:
def _init_vosk(self):
"""Initialize the VOSK speech recognition engine."""
# VOSK doesn't support auto-detect, so fall back to en-us for "auto"
vosk_language = "en-us" if self.language == "auto" else self.language
self.vosk_model_map = {
"small": VOSK_MODEL_INFO["small"]["languages"].get(vosk_language),
"medium": VOSK_MODEL_INFO["medium"]["languages"].get(vosk_language),
"large": VOSK_MODEL_INFO["large"]["languages"].get(vosk_language),
}
self._configure_vosk_model_map()
try:
from vosk import KaldiRecognizer, Model
@@ -1053,23 +1093,16 @@ class SpeechRecognitionManager:
import torch
# Validate model size for Whisper
valid_whisper_models = ["tiny", "base", "small", "medium", "large"]
if self.model_size not in valid_whisper_models:
if self.model_size not in WHISPER_MODEL_SIZES:
logger.warning(
f"Model size '{self.model_size}' not valid for Whisper. "
f"Valid options: {valid_whisper_models}. Using 'base' instead."
f"Valid options: {WHISPER_MODEL_SIZES}. Using 'base' instead."
)
self.model_size = "base"
# Only this directory counts: load_model() gets it as download_root
# and looks nowhere else, so a copy in ~/.cache/whisper saves nothing.
whisper_cache_dir = os.path.join(MODELS_DIR, "whisper")
os.makedirs(whisper_cache_dir, exist_ok=True)
# Before asking whether the model is here: an earlier release saved it
# under the catalog name, which for "large" is not what load_model
# looks for. Without this it refetches 2.9GB and orphans the old file.
migrate_legacy_checkpoint_names(whisper_cache_dir)
model_file = os.path.join(whisper_cache_dir, whisper_model_file(self.model_size))
whisper_cache_dir, model_file = self._whisper_model_cache_paths()
model_exists = os.path.exists(model_file)
if not model_exists and self._defer_download:
@@ -1187,8 +1220,6 @@ class SpeechRecognitionManager:
"""Download the faster-whisper model files with progress tracking."""
import requests
self._download_cancelled = False
model_dir = faster_whisper.get_model_path(self.model_size)
os.makedirs(model_dir, exist_ok=True)
logger.info(f"Downloading faster-whisper '{self.model_size}' model to {model_dir}")
@@ -2034,8 +2065,6 @@ class SpeechRecognitionManager:
"""Download the Parakeet model files with progress tracking."""
import requests
self._download_cancelled = False
model_dir = parakeet.get_model_path(self.model_size)
os.makedirs(model_dir, exist_ok=True)
logger.info(f"Downloading Parakeet '{self.model_size}' model to {model_dir}")
@@ -2682,7 +2711,6 @@ class SpeechRecognitionManager:
"""
import requests
self._download_cancelled = False
url = whisper_model_url(self.model_size)
model_file = os.path.join(cache_dir, whisper_model_file(self.model_size))
temp_file = model_file + ".tmp"
@@ -2708,8 +2736,6 @@ class SpeechRecognitionManager:
"""Download a whisper.cpp model with progress tracking."""
import requests
self._download_cancelled = False
model_name = model_name or self.model_size
model_info = WHISPERCPP_MODEL_INFO.get(model_name)
if not model_info:
@@ -2849,7 +2875,10 @@ class SpeechRecognitionManager:
Returns:
True if the claim was taken, False if a download is already running
"""
return self._download_claim.acquire(blocking=False)
claimed = self._download_claim.acquire(blocking=False)
if claimed:
self._download_cancelled = False
return claimed
def end_download(self):
"""Release the claim taken by try_begin_download()."""
@@ -2858,6 +2887,8 @@ class SpeechRecognitionManager:
except RuntimeError:
# Never claimed; nothing to release
pass
else:
self._download_cancelled = False
@property
def download_in_progress(self) -> bool:
@@ -2875,8 +2906,6 @@ class SpeechRecognitionManager:
import requests
self._download_cancelled = False
model_name = self.vosk_model_map.get(self.model_size)
if not model_name:
raise ValueError(f"Unknown model size: {self.model_size}")
@@ -3232,6 +3261,16 @@ class SpeechRecognitionManager:
logger.warning(f"Cannot start recognition in current state: {self.state}")
return False
if getattr(self, "_prefetching_model", False) is True:
logger.warning("Cannot start recognition while a speech model is downloading")
play_error_sound()
_show_notification(
"Speech model downloading",
"Dictation will be available when the new model has finished downloading.",
"dialog-information",
)
return False
if self._auto_paused:
logger.warning(
"Cannot start recognition: auto-paused while a configured app/game is running"
@@ -4284,10 +4323,32 @@ class SpeechRecognitionManager:
# When reconfiguring from UI, allow downloads
old_defer = self._defer_download
self._defer_download = not force_download
if force_download:
try:
self._prefetching_model = True
try:
self._download_selected_model_if_missing()
finally:
self._prefetching_model = False
except Exception:
self._restore_reconfigure_state(previous)
if language_changed:
self.command_processor.set_language(self.language)
self._restore_language_after_oneshot()
self._defer_download = old_defer
raise
# Lock model access during reinitialization to prevent race condition
# with transcription threads that may be using the model/recognizer
with self._model_lock:
# with transcription threads that may be using the model/recognizer.
# A download worker can be waiting here while Cancel is pressed, so
# poll instead of blocking indefinitely on the active transcription.
while not self._model_lock.acquire(timeout=0.1):
if self._download_claim.locked() and self._download_cancelled:
self._restore_reconfigure_state(previous)
self.command_processor.set_language(self.language)
self._defer_download = old_defer
raise RuntimeError("Download cancelled")
try:
# Release old resources explicitly if necessary (Python's GC might handle it)
self.model = None
self.recognizer = None
@@ -4330,8 +4391,9 @@ class SpeechRecognitionManager:
)
self._update_state(RecognitionState.ERROR)
raise
finally:
self._defer_download = old_defer
finally:
self._defer_download = old_defer
self._model_lock.release()
else:
# If only VOSK params changed, just log it
logger.info("Applied VAD/silence timeout changes.")
@@ -4521,6 +4583,14 @@ class SpeechRecognitionManager:
Also used after auto-pause / idle keep-alive to reload the model.
"""
self._cancel_reload_recording()
if getattr(self, "_prefetching_model", False) is True:
# reconfigure() is fetching the next model and will load it itself;
# initializing here would start a second transfer of the same files.
# Leave a lazy reload pending in case that download is cancelled.
logger.info("Skipping engine reinit: a speech model download is in progress")
self._auto_paused = False
self._idle_unloaded = not self.model_ready
return
logger.info("Reinitializing speech engine after system resume")
if self.state != RecognitionState.IDLE:
+161 -116
View File
@@ -27,6 +27,7 @@ import time
from contextlib import contextmanager
from functools import partial
from typing import TYPE_CHECKING, Any, Callable, Iterator, Literal, NamedTuple, Optional, cast
from urllib.parse import urlparse
import gi
@@ -43,6 +44,7 @@ from ..common_types import ( # noqa: E402
from ..custom_dictionary import normalize_corrections # noqa: E402
from ..gateway_embed import GatewayStatus, get_gateway_embed_manager # noqa: E402
from ..speech_recognition.silero_vad import is_silero_available # noqa: E402
from ..utils import faster_whisper_model_info as faster_whisper
from ..utils.faster_whisper_model_info import delete_model as delete_faster_whisper_model
from ..utils.faster_whisper_model_info import ( # noqa: E402
get_recommended_model as get_recommended_faster_whisper_model,
@@ -92,6 +94,7 @@ from ..utils.vosk_model_info import ( # noqa: E402
from ..utils.whisper_model_info import ( # noqa: E402
migrate_legacy_checkpoint_names,
whisper_model_file,
whisper_model_url,
)
from ..utils.whispercpp_model_info import delete_model as delete_whispercpp_model
from ..utils.whispercpp_model_info import (
@@ -285,6 +288,9 @@ def _engine_from_display(display_name: str) -> str:
return display_name.lower()
MODEL_SELECTION_DOC_URL = "https://github.com/VocaHQ/vocalinux/blob/main/docs/MODEL_SELECTION.md"
def _model_display_name(model_name: str) -> str:
"""Get a user-friendly model display name."""
if model_name == "large":
@@ -1209,6 +1215,11 @@ class SearchablePicker(Gtk.Box):
self._active_id = None
self._label.set_text("")
def clear_active(self, text: str = "") -> None:
"""Clear the selected id and show an optional readout."""
self._active_id = None
self._label.set_text(text)
def get_model(self) -> list:
"""The ComboBoxText-shaped store: [display text, id] per row."""
return self.base_model
@@ -2183,6 +2194,29 @@ def _undownloaded_model_info(language: str, engine: str, model_name: str) -> Opt
return None
def _model_download_source_url(
engine: str, model_name: str, language: str, info: Optional[dict] = None
) -> str:
"""Return the URL used to download a selected model."""
catalog_url = (info or {}).get("url")
if catalog_url:
return str(catalog_url)
if engine == "whisper":
return whisper_model_url(model_name)
if engine == "whisper_cpp":
return str(WHISPERCPP_MODEL_INFO.get(model_name, {}).get("url", ""))
if engine == "vosk":
model_dir = vosk_model_dirname(model_name, language)
return f"https://alphacephei.com/vosk/models/{model_dir}.zip" if model_dir else ""
if engine == "parakeet":
files = parakeet.model_files(model_name)
return parakeet.get_model_file_url(model_name, files[0]) if files else ""
if engine == "faster_whisper":
files = faster_whisper.model_files(model_name)
return faster_whisper.get_model_file_url(model_name, files[0]) if files else ""
return ""
def _make_download_dialog(
dialog: "SettingsDialog", model_name: str, size_mb: int, engine: str
) -> "ModelDownloadDialog":
@@ -4641,8 +4675,12 @@ class SettingsDialog(AboutSectionMixin):
self.model_download_button.connect("clicked", self._on_download_model_clicked)
self.model_info_card.pack_start(self.model_download_button, False, False, 0)
# The recommendation used to be a plain label, which left the panel stating
# the right answer while the pickers kept the wrong one (#778).
model_selection_link = Gtk.LinkButton.new_with_label(
MODEL_SELECTION_DOC_URL, "How to choose a model"
)
model_selection_link.set_halign(Gtk.Align.START)
self.simple_page.pack_start(model_selection_link, False, False, 0)
self.model_recommendation_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.model_recommendation_box.set_no_show_all(True)
@@ -4650,12 +4688,6 @@ class SettingsDialog(AboutSectionMixin):
self.model_recommendation.get_style_context().add_class("model-info-subtitle")
self.model_recommendation_box.pack_start(self.model_recommendation, True, True, 0)
self.model_recommendation_button = Gtk.Button(label="Use it")
self.model_recommendation_button.set_valign(Gtk.Align.CENTER)
self.model_recommendation_button.set_no_show_all(True)
self.model_recommendation_button.connect("clicked", self._on_apply_recommendation)
self.model_recommendation_box.pack_end(self.model_recommendation_button, False, False, 0)
self.model_info_card.pack_start(self.model_recommendation_box, False, False, 0)
# Language warning (e.g. auto-detect, English-only models) lives in
@@ -7461,75 +7493,11 @@ class SettingsDialog(AboutSectionMixin):
cast(str, language_id),
)
def _downloaded_alternative_for(self, recommended: str) -> Optional[str]:
"""Return a model already on disk that can stand in for ``recommended``.
Every entry in these pickers is a download decision priced in gigabytes, so
suggesting a fresh download while something equivalent is already on disk
wastes the user's bandwidth (#778). Only candidates that are not smaller than
the recommendation qualify, so this never quietly downgrades accuracy, and
English-only weights are only offered when English is actually selected.
"""
if recommended in WHISPERCPP_MODEL_INFO and is_whispercpp_model_downloaded(recommended):
return None
language_id = _language_for_whispercpp_variant(self)
wants_english = _language_is_english(cast(str, language_id))
recommended_info = WHISPERCPP_MODEL_INFO.get(recommended, {})
recommended_mb = cast(int, recommended_info.get("size_mb", 0))
# An English-only recommendation must not be answered with multilingual
# weights: they weigh the same and recognise English worse, which is the
# accuracy loss #776 is about.
needs_english_only = is_english_only_whispercpp_model(recommended)
best = None
best_mb = None
for model_name in list_downloaded_whispercpp_models():
if model_name in NON_DICTATION_MODELS:
continue
info = cast(Optional[dict[str, Any]], WHISPERCPP_MODEL_INFO.get(model_name))
if not info:
continue
if is_english_only_whispercpp_model(model_name) and not wants_english:
continue
if needs_english_only and not is_english_only_whispercpp_model(model_name):
continue
size_mb = cast(int, info["size_mb"])
if size_mb < recommended_mb:
continue
if best_mb is None or size_mb < best_mb:
best, best_mb = model_name, size_mb
return best
def _get_default_whispercpp_variant_for_size(self, model_size: str) -> Optional[str]:
"""Return the default specialization for a user-selected size."""
language_id = _language_for_whispercpp_variant(self)
return _default_whispercpp_variant_for_size(model_size, cast(str, language_id))
def _on_apply_recommendation(self, _button: Any) -> None:
"""Set both pickers to the model the card is offering.
Size and specialization must land under ``_populating_models`` so
``_on_model_changed`` does not auto-apply the default size variant
(e.g. full ``large``) before the intended target (e.g. turbo q5_0).
"""
target = getattr(self, "_recommended_target_model", None)
if not target or target not in WHISPERCPP_MODEL_INFO:
return
model_size = get_whispercpp_model_size(target)
with _suppressed(self, "_populating_models"):
self.model_combo.set_active_id(model_size)
self._populate_whispercpp_variant_options(model_size, target)
self.model_variant_combo.set_active_id(target)
self._sync_language_options_for_selected_model()
self._update_model_info()
self._refresh_unused_downloads()
self._auto_apply_settings()
def _is_selected_whispercpp_model_english_only(self) -> bool:
"""Return whether the selected model is a whisper.cpp English-only variant."""
if self._get_selected_engine() != "whisper_cpp":
@@ -8321,9 +8289,7 @@ class SettingsDialog(AboutSectionMixin):
def _on_language_changed(self, widget: Any) -> None:
"""Handle language selection change.
Repaint the recommendation card after the size list rebuilds so
``_recommended_target_model`` / ★ / "Use it" track EN→non-EN flips
instead of applying a stale English-only target.
Repaint the model information after the size list rebuilds.
"""
if self._processing_language_change:
return
@@ -8393,11 +8359,16 @@ class SettingsDialog(AboutSectionMixin):
language = self.language_combo.get_active_id() or self.language or "auto"
if _is_following_layout(self):
# Neither simple question can say "follow the layout", and writing an
# answer here would let the next simple edit silently replace the mode
# with whatever those two controls happen to read. Refresh only the
# part that does not depend on the language and leave them alone.
# Neither simple question can say "follow the layout", so show the
# resolved language as a disabled readout without changing the mode.
with _suppressed(self, "_simple_syncing"):
if not self.simple_language_combo.set_active_id(language):
self.simple_language_combo.clear_active("Detected each time")
self.simple_language_row.set_sensitive(False)
self.simple_language_row.set_tooltip_text(
"Follows your keyboard layout. Turn off Follow keyboard layout "
"in Advanced to pick a language."
)
recommended, _ = self._get_recommended_whispercpp_model_for_language()
current = self._get_selected_whispercpp_model()
self.simple_priority_combo.set_active_id(
@@ -8409,6 +8380,8 @@ class SettingsDialog(AboutSectionMixin):
self._update_simple_visibility()
return
self.simple_language_row.set_sensitive(True)
self.simple_language_row.set_tooltip_text(LANGUAGE_TOOLTIP)
is_auto = language == "auto"
stored_second = self.config_manager.get("speech_recognition", "simple_second_language", "")
@@ -8487,16 +8460,22 @@ class SettingsDialog(AboutSectionMixin):
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 not _is_following_layout(self):
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
variant = self._on_disk_stand_in(variant, size, language)
variant_language = (
cast(str, _language_for_whispercpp_variant(self))
if _is_following_layout(self)
else language
)
variant = _default_whispercpp_variant_for_size(size, variant_language) or size
variant = self._on_disk_stand_in(variant, size, variant_language)
self.model_combo.set_active_id(size)
self._populate_whispercpp_variant_options(size, variant)
@@ -8729,6 +8708,14 @@ class SettingsDialog(AboutSectionMixin):
model_display_name = _model_display_name(model_name)
recommended_display_name = _model_display_name(recommended)
recommended_info = {
"whisper": WHISPER_MODEL_INFO,
"whisper_cpp": WHISPERCPP_MODEL_INFO,
"vosk": VOSK_MODEL_INFO,
"parakeet": parakeet.PARAKEET_MODEL_INFO,
"faster_whisper": FASTER_WHISPER_MODEL_INFO,
}[engine].get(recommended, {})
recommended_size_mb = cast(int, recommended_info.get("size_mb", 0))
self.model_info_title.set_markup(f"<b>{model_display_name}</b>: {info['desc']}")
@@ -8741,29 +8728,19 @@ class SettingsDialog(AboutSectionMixin):
# names a model that is not on disk (#894).
self.model_download_button.set_visible(not is_downloaded)
target = recommended
message = None
if engine == "whisper_cpp":
already_have = self._downloaded_alternative_for(recommended)
if already_have and already_have != model_name:
target = already_have
message = (
f"You already have {_model_display_name(already_have)} on disk. "
"Using it needs no download."
)
if target == model_name:
self._recommended_target_model = None
recommendation_differs = (
get_whispercpp_model_size(recommended) != get_whispercpp_model_size(model_name)
if engine == "whisper_cpp"
else recommended != model_name
)
if not recommendation_differs:
self.model_recommendation_box.hide()
self.model_recommendation_button.hide()
else:
self._recommended_target_model = target
if message is None:
message = f"Recommended: {_model_display_name(target)} ({reason})"
self.model_recommendation.set_text(message)
self.model_recommendation.set_text(
f"Suggested for this computer: {recommended_display_name} "
f"({_format_size(recommended_size_mb)}, {reason})"
)
self.model_recommendation.show()
can_apply = engine == "whisper_cpp" and target in WHISPERCPP_MODEL_INFO
self.model_recommendation_button.set_visible(can_apply)
self.model_recommendation_box.show()
self._update_model_picker_tooltips()
@@ -8858,12 +8835,17 @@ class SettingsDialog(AboutSectionMixin):
return
if missing_model is not None:
if not self._confirm_model_download(
engine, model_name, int(missing_model["size_mb"])
):
self._drop_staged_model_spec_and_resync()
return
if not self.speech_engine.try_begin_download():
# The tray is already downloading a model; a second download
# would fight it over the engine's progress callback and
# configuration. Put the picker back on the saved model.
self._show_download_busy_dialog()
self._resync_model_ui_from_config()
self._drop_staged_model_spec_and_resync()
return
logger.info(f"Model {model_name} needs download, showing progress dialog")
download_dialog = _make_download_dialog(
@@ -8880,7 +8862,7 @@ class SettingsDialog(AboutSectionMixin):
def check_cancelled():
if download_dialog.cancelled:
self.speech_engine.cancel_download()
return not download_dialog.cancelled
return True
cancel_check_id = GLib.timeout_add(100, check_cancelled)
@@ -8896,7 +8878,7 @@ class SettingsDialog(AboutSectionMixin):
# False return must not be read as success: it
# means nothing was saved and the pickers still
# show settings the engine never took.
GLib.idle_add(self._idle_resync_model_ui_from_config)
GLib.idle_add(self._idle_drop_staged_model_spec_and_resync)
GLib.idle_add(
download_dialog.set_complete,
False,
@@ -8912,7 +8894,7 @@ class SettingsDialog(AboutSectionMixin):
# The settings were never saved, so the previously working
# model is still the configured one; put the pickers back on
# it so the UI matches the config and a retry is possible.
GLib.idle_add(self._idle_resync_model_ui_from_config)
GLib.idle_add(self._idle_drop_staged_model_spec_and_resync)
if "cancelled" in error_msg.lower():
GLib.idle_add(
download_dialog.set_complete,
@@ -8934,7 +8916,7 @@ class SettingsDialog(AboutSectionMixin):
download_dialog.destroy()
# Whatever ended the modal — cancel, failure, or a path not
# covered above — the pickers must not outlive the config.
self._resync_engine_ui_if_unapplied()
self._drop_staged_model_spec_and_resync()
# Repaint the info card too: after a success it still showed the
# pre-download render (amber size line, Download action) until
# some other change repainted it (#894 follow-up).
@@ -8991,6 +8973,16 @@ class SettingsDialog(AboutSectionMixin):
self._resync_model_ui_from_config()
return False
def _drop_staged_model_spec_and_resync(self) -> None:
"""Forget a model intent that was not applied and restore saved pickers."""
self._staged_model_spec = None
self._resync_model_ui_from_config()
def _idle_drop_staged_model_spec_and_resync(self) -> bool:
"""Drop an unapplied model intent before resyncing from a worker thread."""
self._staged_model_spec = None
return self._idle_resync_model_ui_from_config()
def _resync_model_ui_from_config(self):
"""Put the pickers back on the settings that are actually saved.
@@ -9018,9 +9010,21 @@ class SettingsDialog(AboutSectionMixin):
self._applying_settings = was_applying
self._populate_model_options()
saved_language = sr_config.get("language")
if saved_language:
self._sync_language_options_for_selected_model(saved_language)
follow = saved_language == LANGUAGE_FOLLOWS_LAYOUT
self._follow_layout_saved = follow
resolved_language = (
language_for_active_layout(SUPPORTED_LANGUAGES) or "auto"
if follow
else saved_language
)
if resolved_language:
self._sync_language_options_for_selected_model(resolved_language)
with _suppressed(self, "_processing_language_change"):
self.follow_layout_switch.set_active(follow and self._follow_layout_supported())
self._sync_follow_layout_controls()
self._update_language_warning()
self._update_model_info()
self._sync_simple_from_advanced()
except Exception as e: # pragma: no cover - UI resync must never mask the original error
logger.debug(f"Could not resync model pickers: {e}")
@@ -9154,6 +9158,15 @@ class SettingsDialog(AboutSectionMixin):
selected_settings = self.get_selected_settings()
selected_engine = selected_settings.get("engine")
selected_model = selected_settings.get("model_size")
if selected_engine and selected_model:
missing_model = _undownloaded_model_info(self.language, selected_engine, selected_model)
if missing_model is not None:
self.test_output_revealer.set_reveal_child(True)
self.test_buffer.set_text(
f"{_model_display_name(selected_model)} is not downloaded yet. "
"Press Download on the Speech Model page first, or pick a downloaded model."
)
return
# Compare to the live engine too: UI can match the file while the
# in-memory manager is still on another engine/size (sidecar, pending apply).
live_engine = getattr(self.speech_engine, "engine", None)
@@ -9486,14 +9499,18 @@ For now, the engine has been reverted to VOSK."""
missing_model = _undownloaded_model_info(self.language, engine, model_name)
if missing_model is not None:
if not self._confirm_model_download(engine, model_name, int(missing_model["size_mb"])):
self._drop_staged_model_spec_and_resync()
return False
if not self.speech_engine.try_begin_download():
# Same guard as in _auto_apply_settings: one download at a time.
self._show_download_busy_dialog()
self._resync_model_ui_from_config()
self._drop_staged_model_spec_and_resync()
return False
download_dialog = _make_download_dialog(
self, model_name, missing_model["size_mb"], engine
)
download_outcome = {"applied": False}
def progress_callback(fraction, speed, status):
GLib.idle_add(download_dialog.update_progress, fraction, speed, status)
@@ -9505,7 +9522,7 @@ For now, the engine has been reverted to VOSK."""
def check_cancelled():
if download_dialog.cancelled:
self.speech_engine.cancel_download()
return not download_dialog.cancelled
return True
cancel_check_id = GLib.timeout_add(100, check_cancelled)
@@ -9514,9 +9531,10 @@ For now, the engine has been reverted to VOSK."""
settings, raise_errors=True, force_reinit=True
)
if applied:
download_outcome["applied"] = True
GLib.idle_add(download_dialog.set_complete, True, "")
else:
GLib.idle_add(self._idle_resync_model_ui_from_config)
GLib.idle_add(self._idle_drop_staged_model_spec_and_resync)
GLib.idle_add(
download_dialog.set_complete,
False,
@@ -9531,7 +9549,7 @@ For now, the engine has been reverted to VOSK."""
error_msg = str(e)
# Nothing was saved, so the config still names the previous
# engine and model; put the pickers back on them.
GLib.idle_add(self._idle_resync_model_ui_from_config)
GLib.idle_add(self._idle_drop_staged_model_spec_and_resync)
if "cancelled" in error_msg.lower():
GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
elif engine == "whisper" and "no module named" in error_msg.lower():
@@ -9545,14 +9563,41 @@ For now, the engine has been reverted to VOSK."""
download_dialog.destroy()
self._populate_model_options()
self._resync_engine_ui_if_unapplied()
if not download_outcome["applied"]:
self._drop_staged_model_spec_and_resync()
# Same repaint as the auto-apply path: a finished download must not
# leave the card offering a Download for the model just fetched.
self._update_model_info()
return True
return bool(download_outcome["applied"])
return self._apply_settings_internal(settings)
def _confirm_model_download(self, engine: str, model_name: str, size_mb: int) -> bool:
"""Ask before downloading a model selected in Settings."""
info = _undownloaded_model_info(self.language, engine, model_name) or {}
url = _model_download_source_url(engine, model_name, self.language, info)
host = urlparse(url).netloc
secondary = f"About {_format_size(size_mb)}"
if host:
secondary += f" from {host}"
secondary += ". Nothing is downloaded until you confirm."
dialog = Gtk.MessageDialog(
transient_for=self,
flags=0,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.NONE,
text=f"Download {_model_display_name(model_name)}?",
)
dialog.format_secondary_text(secondary)
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
dialog.add_button("Download", Gtk.ResponseType.OK)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
try:
return cast(bool, dialog.run() == Gtk.ResponseType.OK)
finally:
dialog.destroy()
def _show_download_busy_dialog(self):
"""Tell the user a model download is already running elsewhere."""
dialog = Gtk.MessageDialog(