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:
co-authored by
Jatin K Malik
parent
f013b7ff88
commit
3e4230ffc2
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user