From affbf6a9af6b7f4f249dda57d2c884dc84c61e94 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Mon, 7 Sep 2026 14:38:30 -0700 Subject: [PATCH] feat(settings): simple setup for the speech model, with Advanced as an island (#801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): make the language picker searchable while its list is open Entry completion only ever covered typing into the closed entry. Open the dropdown and the keyboard goes to GTK's own first-letter jump, so nothing typed reaches a filter, and with thirty-odd languages scrolling is the only way through. The first attempt at fixing this (a filtered ComboBox model) had the same limitation for the same reason. Replace the combo with a picker that opens a popover holding a search entry, focused on open, above the list. Every keystroke narrows the rows, Enter takes the first match, a click takes that row. It keeps the GtkComboBoxText methods the dialog calls, mirrors the store's column order in base_model for the shared row helpers, and emits "changed" only when the selection actually changes, so re-selecting the saved language on open is not taken for a user edit. Visibility uses the matcher the typed-text resolver already uses, so the list and Enter cannot disagree. The completion helper and the combo styling skip the picker: it filters its own list, and the ComboBox-only calls do not exist on a Box. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * feat(settings): add a simple mode that derives the model from the language The Speech Model panel asked four questions, but only two of them can be answered without knowing the hardware: what language you speak and whether you want it faster or more accurate. Size follows from the machine and the specialization follows from the language, and the app already computed both — it just printed the answer beside the pickers instead of using it. Simple mode asks for the main language, whether whole texts are also dictated in other languages, and a priority. There is deliberately no second language field: whisper takes one language or none, so a pair cannot be passed, and with a language pinned the multilingual weights already handle inserted foreign words. The switch is the "none" case. Priority moves relative to what hardware detection recommends rather than naming an absolute size, so "most accurate" means something different on a laptop than on a workstation. Opening simple mode reads the priority back from the size already configured, so switching modes never changes the loaded model on its own. Simple mode steers the existing controls rather than writing the configuration itself, so applying, downloading and the info card keep going through one path. Advanced mode is the previous panel, unchanged. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): stop simple mode reloading the engine four times per pick Reported against the installed build: choosing a language locked the window for a moment. Simple mode steers the engine, language, size and variant controls, and every one of those change handlers ends in _auto_apply_settings, so a single pick reconfigured the speech engine up to four times on the UI thread, reloading the model each time. Hold a guard across the steering and apply exactly once when it is done. The simple language box was also a plain list with no entry, so it could not be typed into — a step backwards from the advanced row, and unusable with over thirty languages. It now uses the same searchable combo, with the same commit and restore behaviour for text that resolves to nothing. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): make simple the panel and Advanced a reveal The Simple/Advanced dropdown framed the two as alternatives, which put a mode decision in front of the user before any of the questions they can answer. They are not alternatives: simple is the panel, and advanced is more detail about the same choice. The mode selector is gone. The simple questions are always on screen, with an Advanced switch that slides the engine, size and specialization rows out underneath. Because simple mode steers those rows, they also work as a readout of what the simple answers resolved to, which the old either/or hid. The stored settings_mode becomes a show_advanced flag, remembering whether the detail was left open. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): second language list, and Advanced in its own window Reworked to what the panel is actually for. The main language comes from the keyboard layout. Turning on "I also dictate whole texts in other languages" reveals a second list rather than silently flipping to detection, so the choice is visible. Advanced is a button that opens the detailed controls in their own window, instead of an inline reveal that pushed the page around. Naming a second language resolves to automatic detection, because whisper takes one language or none. The one case where both can still be honoured is two English entries — en-US plus en-IN — where English stays pinned and the English-only weights, same size and better at English, remain available. Also fixes a name collision the reveal had: advanced_revealer already belonged to the power-user toggle on the Advanced page, and the section built later won the attribute. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * feat(settings): use the filtering picker for both simple language lists The main language and the second language are the same thirty-odd-row lists as the advanced one, so they get the same dropdown filtering rather than a completion popup that only reacts while the entry has focus. Refs #779 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): make simple mode do what it says on screen Four things reported from the installed build, all real. The second language list never appeared: the row is flagged no_show_all, and show_all() is a no-op on such a widget, so the flag has to be cleared first — the dance _set_custom_shortcut_row_visible already does. There was no multilingual answer. "Any language (auto-detect)" is now the first entry of the second list and the default when the switch is turned on, so the switch alone visibly does something. Changing the priority showed nothing, because the info card — the only surface that says which model was picked and what it costs — had been moved into the advanced window. It is back on the page under the simple questions. Opening Advanced crashed the compositor. KWin 6.7.4 (Wayland) died with unbounded recursion in KWin::Window::findModal(), a cycle in its transient-for chain, when the advanced window — a long-lived toplevel transient for this dialog, hidden on close and shown again with present() — was opened. Coredumps at 2026-09-06 20:20:10 and 20:39:49; the crashing state is kept as tag kwin-crash-repro. The window now stands on its own, is built on every open and destroyed on close with the controls re-parented out first. KDE Bug 483265. Refs #779 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): bring the advanced window up in front of the dialog Reported: it opened behind Settings. Dropping the transient parent (which crashed KWin) also dropped the only reason the compositor had to stack it over the dialog, and on Wayland a toplevel mapped without an activation token lands behind the focused window. Present it with the timestamp of the click that opened it: that asks the compositor for focus and stacking, and needs no transient link. Refs #779 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * refactor(settings): Advanced as an island under the simple card, not a window A second toplevel went wrong twice on KWin 6.7 / Wayland. Transient for the dialog it crashed the compositor (unbounded recursion in KWin::Window::findModal, tag kwin-crash-repro, KDE Bug 483265). Standing on its own it could not be raised above the dialog at all, not even by hand. Advanced is now its own card under the simple questions: collapsed to a header by default, expanding in place, remembered in show_advanced. Both cards are on screen at once, so the expanded rows double as a readout of what the simple answers resolved to, and a change made in the advanced rows is read back into the simple ones — skipped while simple mode is itself steering them. The info card sits between the two, where a priority or language change shows its effect and its cost. Refs #779 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): let a same-size weight on disk stand in before downloading Reported: turning the "I also dictate whole texts in other languages" switch off went from base to base.en — same size, different weights — and sat the user through a modal 141 MB download for it, with the window blocked behind the progress dialog. Turning it on had done the same in the other direction. Simple mode now looks at the disk before settling on a variant. A downloaded weight of the same size that can serve the language stands in: English-only first when English is wanted, the plain multilingual next, quantized ones last. English-only weights never stand in for another language or for detection. When nothing of that size is on disk the derived variant stays and the download runs as before. The info card still offers the better variant to download; it just no longer forces it. Refs #779 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT * fix(settings): keep Advanced language pin over stale simple multi _sync_simple_from_advanced restored the other-languages switch and persisted simple_second_language even when Advanced had a pinned language. The next simple edit then decoded to auto and silently replaced the pin. Clear/resync simple multi on a pin, refresh that readout when Advanced language changes, and drop the second-list Enter hook that double-applied through SearchablePicker. Refs #779 * fix(settings): keep two-English simple multi across Advanced pin sync Clearing every leftover second language on a pinned Advanced readout was too broad: en-US plus en-IN still pins English, and wiping that answer forgot the second English choice on the next open. Restore multi only when the stored second would keep the pin; otherwise drop it so a later simple edit cannot decode to auto. Refs #779 * fix(settings): restore simple multi only when it matches live language The pin-side keep left the auto branch restoring any stored second. Two English over Advanced auto still decodes to en-us, so the next simple edit silently replaced detection. Keep leftover multi only when it still decodes to the live Advanced language either way. Refs #779 * fix(settings): keep language-change tests honest after the main merge Mock dialogs treated _initializing as truthy and skipped the handler. Also call _refresh_simple_readout explicitly so the simple card updates. * fix(settings): type the language-entry GTK callbacks Greptile flagged missing param and return annotations on the new activate and focus-out handlers. Match the surrounding Any/bool style. Refs #779 * fix(settings): sort imports for isort after Parakeet merge --------- Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- src/vocalinux/ui/config_manager.py | 5 + src/vocalinux/ui/settings_dialog.py | 425 ++++++++++++++++- src/vocalinux/utils/model_choice.py | 58 +++ tests/test_settings_dialog.py | 8 +- tests/test_settings_model_state.py | 3 + tests/test_simple_model_settings.py | 695 ++++++++++++++++++++++++++++ 6 files changed, 1185 insertions(+), 9 deletions(-) create mode 100644 src/vocalinux/utils/model_choice.py create mode 100644 tests/test_simple_model_settings.py diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index cbef910..87cfc69 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -77,6 +77,11 @@ DEFAULT_CONFIG = { # the variant is then derived from the selected language, because a bare size # name ("medium") is indistinguishable from the multilingual variant id. "whisper_cpp_model_variant": "", + # The second language offered once "I also dictate in other languages" is + # on. Empty means the main language is pinned on its own (#779). + "simple_second_language": "", + # Whether the Advanced island under the simple questions is left open. + "show_advanced": False, "parakeet_model_size": "v3-european", # Parakeet TDT 0.6B v3 (25 European languages) "vad_sensitivity": 3, # Voice Activity Detection sensitivity (1-5) "silence_timeout": 2.0, # Seconds of silence before stopping diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 3fa85c2..6bf7a7a 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -35,6 +35,13 @@ from gi.repository import Gdk, GLib, GObject, Gtk, Pango # noqa: E402 from ..common_types import RecognitionState # noqa: E402 from ..speech_recognition.silero_vad import is_silero_available # noqa: E402 from ..utils import parakeet_model_info as parakeet # noqa: E402 +from ..utils.model_choice import ( + BALANCED, + PRIORITIES, + PRIORITY_LABELS, + priority_for_size, + size_for_priority, +) from ..utils.paths import models_dir # noqa: E402 from ..utils.update_checker import ( # noqa: E402 DEFAULT_UPDATE_CHANNEL, @@ -249,6 +256,25 @@ def _language_is_english(language_id: str) -> bool: return SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en" +def _decode_simple_languages(primary: str, wants_second: bool, secondary: Optional[str]) -> str: + """Resolve the two simple language answers into what the engine accepts. + + whisper takes one language or none, so naming a second language has to mean + automatic detection. The one exception is two English entries - en-US plus + en-IN, say - where English can still be pinned and the English-only weights, + which are the same size and better at English, stay available. + """ + if not wants_second: + return primary + if not secondary or secondary == primary: + return primary + if secondary == "auto": + return "auto" + if _language_is_english(primary) and _language_is_english(secondary): + return primary + return "auto" + + def _vosk_display_is_coerced_fallback( previous_engine: Optional[str], displayed: Optional[str], @@ -1931,6 +1957,18 @@ class SettingsDialog(Gtk.Dialog): self._test_result = "" self._initializing = True # Flag to prevent auto-apply during initialization self._populating_models = False # Flag to prevent model change handler during population + # Guards the simple-mode widgets while they are being pointed at the + # live configuration, so syncing them does not look like a user edit. + self._simple_syncing = False + # Set while simple mode steers the advanced controls, so their own change + # handlers do not each trigger a separate engine reload. + self._simple_driving = False + self.advanced_box = None + self.advanced_island = None + self.advanced_expander = None + self.simple_page = None + self.simple_group = None + self.engine_group = None self._processing_language_change = ( False # Flag to prevent recursive language change handling ) @@ -2051,6 +2089,7 @@ class SettingsDialog(Gtk.Dialog): # Build UI sections into their topic pages self._build_shortcuts_section() self._build_recognition_section() + self._build_simple_model_section() self._build_engine_section() self._build_remote_server_section() self._build_audio_section() @@ -2085,6 +2124,13 @@ class SettingsDialog(Gtk.Dialog): else: self.sidebar_listbox.select_row(self.sidebar_listbox.get_row_at_index(0)) + # Restore the saved mode and point the simple questions at the live model + # before the first visibility pass, so nothing flashes the wrong group. + self._sync_simple_from_advanced() + self.advanced_expander.set_expanded( + bool(self.config_manager.get("speech_recognition", "show_advanced", False)) + ) + # Then update visibility of engine-specific elements self._update_engine_specific_ui() @@ -2937,9 +2983,134 @@ class SettingsDialog(Gtk.Dialog): self._tone_preview_kind = "stop" if kind == "start" else "start" self._sync_tone_preview_button(tone_id) + def _build_simple_model_section(self): + """Build the simple questions and the Advanced reveal (#779).""" + self.simple_group = PreferencesGroup(title="What you dictate") + + # Searchable, like the advanced row: over thirty languages is too many to + # scroll, and a list you cannot type into is a step backwards. + self.simple_language_combo = SearchablePicker() + _style_combo(self.simple_language_combo) + _prevent_scroll_on_hover(self.simple_language_combo) + for language_id, info in SUPPORTED_LANGUAGES.items(): + # Auto-detect is the switch below, not a language you speak. + if language_id != "auto": + self.simple_language_combo.append(language_id, info["name"]) + _attach_language_combo_search(self.simple_language_combo) + simple_language_entry = self.simple_language_combo.get_child() + if simple_language_entry is not None: + simple_language_entry.connect("activate", self._on_simple_language_entry_activate) + simple_language_entry.connect( + "focus-out-event", self._on_simple_language_entry_focus_out + ) + self.simple_language_row = PreferenceRow( + title="Main language", + subtitle="Type to search, or pick from the list", + widget=self.simple_language_combo, + keywords=("language", "speak"), + ) + self.simple_group.add_row(self.simple_language_row) + + # The engine takes one language or none, so this is the only other option + # that exists. A second language field would promise something it cannot do. + self.simple_multi_switch = Gtk.Switch() + self.simple_multi_switch.set_valign(Gtk.Align.CENTER) + self.simple_multi_row = PreferenceRow( + title="I also dictate whole texts in other languages", + subtitle="Detects the language per utterance; can be wrong on short ones", + widget=self.simple_multi_switch, + keywords=("multilingual", "auto", "detect"), + ) + self.simple_group.add_row(self.simple_multi_row) + + self.simple_second_language_combo = SearchablePicker() + _style_combo(self.simple_second_language_combo) + _prevent_scroll_on_hover(self.simple_second_language_combo) + # First entry is the multilingual answer: any language, detected per + # utterance. Naming one specific second language means the same thing + # to the engine, but lets the user say which one they had in mind. + self.simple_second_language_combo.append("auto", "Any language (auto-detect)") + for language_id, info in SUPPORTED_LANGUAGES.items(): + if language_id != "auto": + self.simple_second_language_combo.append(language_id, info["name"]) + _attach_language_combo_search(self.simple_second_language_combo) + # SearchablePicker already emits "changed" on Enter/row pick; do not also + # hook activate or a single choice would apply twice. + self.simple_second_language_row = PreferenceRow( + title="Other language", + # Honest about what the engine does: whisper takes one language or + # none, so any second language means detection per utterance. + subtitle="Recognition detects the language of each utterance", + widget=self.simple_second_language_combo, + keywords=("second", "language", "other"), + ) + self.simple_second_language_row.set_no_show_all(True) + self.simple_group.add_row(self.simple_second_language_row) + + self.simple_priority_combo = Gtk.ComboBoxText() + _style_combo(self.simple_priority_combo) + _prevent_scroll_on_hover(self.simple_priority_combo) + for priority in PRIORITIES: + self.simple_priority_combo.append(priority, PRIORITY_LABELS[priority]) + self.simple_priority_row = PreferenceRow( + title="Priority", + subtitle="Balanced follows what your hardware can run", + widget=self.simple_priority_combo, + keywords=("speed", "accuracy", "priority"), + ) + self.simple_group.add_row(self.simple_priority_row) + + # The simple card and, under it, the info card the engine section adds. + self.simple_page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.simple_page.pack_start(self.simple_group, False, False, 0) + self.content_box.pack_start(self.simple_page, False, False, 0) + + # Advanced is its own island under the simple card: collapsed to a + # header by default, expanding in place. Not a second window — that went + # wrong twice on KWin/Wayland: transient for the dialog it crashed the + # compositor (findModal recursion, tag kwin-crash-repro); standing alone + # it could not be raised above the dialog at all. Both cards visible at + # once also makes the expanded rows a readout of what the simple answers + # resolved to. + self.advanced_island = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.advanced_island.get_style_context().add_class("preferences-group") + + self.advanced_expander = Gtk.Expander() + header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + title = Gtk.Label(label="Advanced", xalign=0) + title.get_style_context().add_class("preferences-group-title") + subtitle = Gtk.Label( + label="Engine, model size, specialization, downloads and the remote server", + xalign=0, + wrap=True, + ) + subtitle.get_style_context().add_class("preference-row-subtitle") + header.pack_start(title, False, False, 0) + header.pack_start(subtitle, False, False, 0) + self.advanced_expander.set_label_widget(header) + self.advanced_expander.set_margin_top(12) + self.advanced_expander.set_margin_bottom(12) + self.advanced_expander.set_margin_start(16) + self.advanced_expander.set_margin_end(16) + + # Everything the detailed view holds is packed in here; the sections + # built after this one append to it. + self.advanced_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.advanced_box.set_margin_top(8) + self.advanced_expander.add(self.advanced_box) + self.advanced_island.pack_start(self.advanced_expander, False, False, 0) + self.content_box.pack_start(self.advanced_island, False, False, 0) + + self.advanced_expander.connect("notify::expanded", self._on_advanced_expanded) + self.simple_language_combo.connect("changed", self._on_simple_choice_changed) + self.simple_second_language_combo.connect("changed", self._on_simple_choice_changed) + self.simple_multi_switch.connect("notify::active", self._on_simple_choice_changed) + self.simple_priority_combo.connect("changed", self._on_simple_choice_changed) + def _build_engine_section(self): """Build the Speech Engine section.""" group = PreferencesGroup(title="Speech Engine") + self.engine_group = group # Engine selection self.engine_combo = Gtk.ComboBoxText() @@ -2996,7 +3167,8 @@ class SettingsDialog(Gtk.Dialog): self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP) group.add_row(self.language_row) - self.content_box.pack_start(group, False, False, 0) + # Lives inside the revealer built above, so the Advanced switch slides it out. + self.advanced_box.pack_start(group, False, False, 0) # Model info card (shown below the group) self.model_info_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) @@ -3036,7 +3208,9 @@ class SettingsDialog(Gtk.Dialog): self.language_warning.set_no_show_all(True) self.model_info_card.pack_start(self.language_warning, False, False, 0) - self.content_box.pack_start(self.model_info_card, False, False, 0) + # On the page, under the simple questions: it is the only feedback that a + # priority or language change did anything, and what it will cost. + self.simple_page.pack_start(self.model_info_card, False, False, 0) self.unused_models_group = PreferencesGroup( keywords=("delete", "remove", "unused", "disk", "storage", "downloaded"), @@ -3075,7 +3249,7 @@ class SettingsDialog(Gtk.Dialog): "notify::expanded", lambda *_args: self._fit_unused_downloads_height() ) self.unused_models_group.pack_start(self.unused_expander, False, False, 0) - self.content_box.pack_start(self.unused_models_group, False, False, 0) + self.advanced_box.pack_start(self.unused_models_group, False, False, 0) # Connect signals self.engine_combo.connect("changed", self._on_engine_changed) @@ -4636,14 +4810,14 @@ class SettingsDialog(Gtk.Dialog): ) self.remote_server_group.add_row(remote_test_row) - self.content_box.pack_start(self.remote_server_group, False, False, 0) + self.advanced_box.pack_start(self.remote_server_group, False, False, 0) # Status label below the group self.remote_status_label = Gtk.Label(label="", use_markup=True, xalign=0) self.remote_status_label.set_margin_start(16) self.remote_status_label.set_margin_top(4) self.remote_status_label.get_style_context().add_class("status-info") - self.content_box.pack_start(self.remote_status_label, False, False, 0) + self.advanced_box.pack_start(self.remote_status_label, False, False, 0) # Load saved values into the widgets saved_url = self.config_manager.get("speech_recognition", "remote_api_url", "") @@ -5730,16 +5904,19 @@ class SettingsDialog(Gtk.Dialog): self._last_non_parakeet_language = lang_code self._populate_model_options() self._update_language_warning() + # Explicit refresh: Mock tests assert this call; _update_model_info + # also refreshes on a real dialog. + self._refresh_simple_readout() self._update_model_info() self._auto_apply_settings() finally: self._processing_language_change = False - def _on_language_entry_activate(self, entry): + def _on_language_entry_activate(self, entry: Any) -> None: """Commit a unique typed match when Enter is pressed in the language entry.""" self._commit_or_restore_language_entry() - def _on_language_entry_focus_out(self, entry, event): + def _on_language_entry_focus_out(self, entry: Any, event: Any) -> bool: """Commit or restore the language after the entry loses focus.""" # Defer so a completion click can set the active id before we restore. GLib.idle_add(self._commit_or_restore_language_entry) @@ -5765,6 +5942,232 @@ class SettingsDialog(Gtk.Dialog): self._processing_language_change = False return False + def _sync_simple_from_advanced(self) -> None: + """Point the simple questions at the configuration that is actually live. + + Switching modes must not change the model on its own, so the priority is + read back from the size already chosen rather than reset to a default. + + A leftover simple multi answer is restored only when it still decodes + to the live Advanced language. Otherwise the next simple edit would + silently replace it: a named second language over a pin becomes auto, + and two English answers over Advanced auto pin English. Two English + answers still pin English, so that configuration is kept on an English + pin and dropped when Advanced is auto. + """ + language = self.language_combo.get_active_id() or self.language or "auto" + is_auto = language == "auto" + + stored_second = self.config_manager.get("speech_recognition", "simple_second_language", "") + + self._simple_syncing = True + try: + if is_auto: + self.simple_multi_switch.set_active(True) + primary = self.simple_language_combo.get_active_id() + if not primary: + self.simple_language_combo.set_active_id("en-us") + primary = "en-us" + else: + self.simple_language_combo.set_active_id(language) + primary = language + + keeps_live = bool(stored_second) and ( + _decode_simple_languages(primary, True, stored_second) == language + ) + if keeps_live: + if not is_auto: + self.simple_multi_switch.set_active(True) + self.simple_second_language_combo.set_active_id(stored_second) + else: + if not is_auto: + self.simple_multi_switch.set_active(False) + if stored_second: + self.config_manager.set("speech_recognition", "simple_second_language", "") + # Reset so turning the switch on later starts from "any", not a + # stale pick that Advanced already superseded. + self.simple_second_language_combo.set_active_id("auto") + + recommended, _ = self._get_recommended_whispercpp_model_for_language() + current = self._get_selected_whispercpp_model() + priority = priority_for_size( + get_whispercpp_model_size(recommended), get_whispercpp_model_size(current) + ) + self.simple_priority_combo.set_active_id(priority) + finally: + self._simple_syncing = False + self._update_simple_visibility() + + def _simple_decoding_language(self) -> str: + """Resolve the two simple language answers into what the engine accepts.""" + primary = self.simple_language_combo.get_active_id() or "en-us" + return _decode_simple_languages( + primary, + self.simple_multi_switch.get_active(), + self.simple_second_language_combo.get_active_id(), + ) + + def _on_disk_stand_in(self, variant: str, size: str, language: str) -> str: + """Prefer a downloaded weight of the same size over fetching a sibling. + + Flipping the "other languages" switch swapped base for base.en, or back: + same size, different weights, and a modal download each way, blocking the + window for it. A same-size weight already on disk that can serve the + language stands in instead; the info card still offers the better variant + as a download, it just no longer forces it. + """ + if is_whispercpp_model_downloaded(variant): + return variant + wants_english = _language_is_english(language) + candidates = [ + name + for name in get_whispercpp_model_variants(size) + if is_whispercpp_model_downloaded(name) + and (wants_english or not is_english_only_whispercpp_model(name)) + ] + if not candidates: + return variant + + def rank(name: str) -> tuple: + # Closest to what was derived: English-only first when English is + # wanted, the plain multilingual next, quantized ones last. + english_first = 0 if wants_english and is_english_only_whispercpp_model(name) else 1 + quantized = 1 if "-q" in name else 0 + return (english_first, quantized, name) + + return min(candidates, key=rank) + + def _apply_simple_choice(self) -> None: + """Drive the advanced controls from the simple questions. + + 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. + """ + language = self._simple_decoding_language() + priority = self.simple_priority_combo.get_active_id() or BALANCED + + self.engine_combo.set_active_id("whisper_cpp") + self._set_combo_active_id_or_first(self.language_combo, language) + self.language = language + + 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) + + self.model_combo.set_active_id(size) + self._populate_whispercpp_variant_options(size, variant) + self.model_variant_combo.set_active_id(variant) + + def _on_advanced_expanded(self, expander, _param) -> None: + """Expand or collapse the advanced island, remembering the choice.""" + expanded = expander.get_expanded() + if expanded: + self.advanced_box.show_all() + # Per-engine visibility has to run after show_all, which would + # otherwise reveal rows the active engine does not use. + self._update_engine_specific_ui() + else: + self._sync_simple_from_advanced() + if not self._initializing: + self.config_manager.set("speech_recognition", "show_advanced", expanded) + self.config_manager.save_settings() + + def _refresh_simple_readout(self) -> None: + """Keep the simple answers describing the live model. + + With both cards on screen, a change made in the advanced rows has to show + in the simple ones too, or the two would contradict each other. Skipped + while simple mode is itself steering the advanced rows, and during init. + """ + if self._initializing or self._simple_driving or self._simple_syncing: + return + self._sync_simple_from_advanced() + + def _on_simple_choice_changed(self, *_args) -> None: + """React to one of the simple questions changing. + + Driving the four advanced controls emits "changed" on each of them, and + every one of those handlers ends in _auto_apply_settings, so a single pick + used to reconfigure the engine up to four times and freeze the window while + each reload ran. Suppress those while steering, then apply exactly once. + """ + if self._initializing or self._simple_syncing or self._applying_settings: + return + if self._simple_driving: + return + + # Turning the switch on with nothing picked yet means "any language"; + # otherwise the switch alone would visibly do nothing. + if ( + self.simple_multi_switch.get_active() + and not self.simple_second_language_combo.get_active_id() + ): + self._simple_syncing = True + try: + self.simple_second_language_combo.set_active_id("auto") + finally: + self._simple_syncing = False + + self._simple_driving = True + try: + self._apply_simple_choice() + finally: + self._simple_driving = False + + second = ( + self.simple_second_language_combo.get_active_id() + if self.simple_multi_switch.get_active() + else "" + ) + self.config_manager.set("speech_recognition", "simple_second_language", second or "") + self._update_simple_visibility() + self._auto_apply_settings() + + def _commit_or_restore_simple_language_entry(self) -> bool: + """Resolve text typed into the simple language box, or restore the last pick.""" + if self._initializing or self._simple_syncing: + return False + if self.simple_language_combo.get_active_id(): + return False + + entry = self.simple_language_combo.get_child() + typed = entry.get_text() if entry is not None else "" + match_id = _resolve_combo_text_query(typed, _combo_text_rows(self.simple_language_combo)) + if match_id: + self.simple_language_combo.set_active_id(match_id) + return False + + # Restoring the same language must not re-apply settings. + self._simple_syncing = True + try: + fallback = self.language if self.language != "auto" else "en-us" + self._set_combo_active_id_or_first(self.simple_language_combo, fallback) + finally: + self._simple_syncing = False + return False + + def _on_simple_language_entry_activate(self, _entry: Any) -> None: + self._commit_or_restore_simple_language_entry() + + def _on_simple_language_entry_focus_out(self, _entry: Any, _event: Any) -> bool: + return self._commit_or_restore_simple_language_entry() + + def _update_simple_visibility(self) -> None: + """Show the simple questions, with the second language only when asked for.""" + self.simple_group.show_all() + wants_second = self.simple_multi_switch.get_active() + # show_all() is a no-op on a widget flagged no_show_all, so the flag has + # to be cleared before showing and restored after hiding — the same + # dance _set_custom_shortcut_row_visible does. + if wants_second: + self.simple_second_language_row.set_no_show_all(False) + self.simple_second_language_row.show_all() + else: + self.simple_second_language_row.hide() + self.simple_second_language_row.set_no_show_all(True) + def _update_engine_specific_ui(self): """Show/hide UI elements driven by the active engine.""" engine_text = self.engine_combo.get_active_text() @@ -5787,6 +6190,8 @@ class SettingsDialog(Gtk.Dialog): self.remote_server_group.hide() self.remote_status_label.hide() + self._update_simple_visibility() + self._update_model_info() self._refresh_unused_downloads() self._update_language_warning() @@ -5825,6 +6230,7 @@ class SettingsDialog(Gtk.Dialog): def _update_model_info(self): """Update the model info card display.""" + self._refresh_simple_readout() engine_text = self.engine_combo.get_active_text() if not engine_text: self.model_info_card.hide() @@ -5935,6 +6341,11 @@ class SettingsDialog(Gtk.Dialog): if self._populating_models: return + # Simple mode is mid-way through steering the advanced controls; it applies + # once itself when it is done. + if self._simple_driving: + return + self._applying_settings = True # Already-downloaded apply is handed to a worker that clears this flag # via GLib.idle_add. Download still holds it for the modal run() below. diff --git a/src/vocalinux/utils/model_choice.py b/src/vocalinux/utils/model_choice.py new file mode 100644 index 0000000..324ff6c --- /dev/null +++ b/src/vocalinux/utils/model_choice.py @@ -0,0 +1,58 @@ +"""Turn what the user actually knows into a model choice (#779). + +The Speech Model panel asks four questions, but only two of them are answerable +without knowing the hardware: what language you speak, and whether you want it +faster or more accurate. Size follows from the machine, and the specialization +follows from the language. These helpers do the size half; the language half is +the variant derivation that already lives in the settings dialog. + +Priority is expressed relative to what the hardware detection recommends rather +than as an absolute size, so "most accurate" means something different on a +laptop and on a workstation, which is the point. +""" + +from .whispercpp_model_info import MODEL_SIZES + +FASTEST = "fastest" +BALANCED = "balanced" +ACCURATE = "accurate" + +PRIORITIES = (FASTEST, BALANCED, ACCURATE) + +PRIORITY_LABELS = { + FASTEST: "Fastest", + BALANCED: "Balanced", + ACCURATE: "Most accurate", +} + +# How far to move from the hardware recommendation, in size steps. +_PRIORITY_OFFSET = {FASTEST: -1, BALANCED: 0, ACCURATE: 1} + + +def _clamp(index: int) -> int: + return max(0, min(len(MODEL_SIZES) - 1, index)) + + +def size_for_priority(recommended_size: str, priority: str) -> str: + """Return the size a priority asks for, relative to the recommendation.""" + if recommended_size not in MODEL_SIZES: + recommended_size = MODEL_SIZES[0] + offset = _PRIORITY_OFFSET.get(priority, 0) + return MODEL_SIZES[_clamp(MODEL_SIZES.index(recommended_size) + offset)] + + +def priority_for_size(recommended_size: str, size: str) -> str: + """Return the priority that best describes an already-chosen size. + + Used to open simple mode showing the truth about the current configuration + instead of resetting it, so switching modes never silently changes the model. + """ + if recommended_size not in MODEL_SIZES or size not in MODEL_SIZES: + return BALANCED + + delta = MODEL_SIZES.index(size) - MODEL_SIZES.index(recommended_size) + if delta < 0: + return FASTEST + if delta > 0: + return ACCURATE + return BALANCED diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index d67d3c6..6f36850 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -528,8 +528,12 @@ class TestSettingsDialogInstantApply(unittest.TestCase): with open(source_path, "r") as f: source_code = f.read() - self.assertIn("self.content_box.pack_start(self.remote_server_group", source_code) - self.assertIn("self.content_box.pack_start(self.remote_status_label", source_code) + # Reached from the Speech Model page: the detailed controls now live in the + # window the Advanced button opens, next to the engine picker that selects + # remote_api. The point of this test is that they are not exiled to the + # separate Advanced page, which the advanced_tab assertions below still guard. + self.assertIn("self.advanced_box.pack_start(self.remote_server_group", source_code) + self.assertIn("self.advanced_box.pack_start(self.remote_status_label", source_code) self.assertIn("self.remote_api_model_entry", source_code) self.assertIn("OpenAI/FunASR", source_code) self.assertNotIn("advanced_tab.pack_start(self.remote_server_group", source_code) diff --git a/tests/test_settings_model_state.py b/tests/test_settings_model_state.py index b26e172..b5f12f0 100644 --- a/tests/test_settings_model_state.py +++ b/tests/test_settings_model_state.py @@ -61,6 +61,9 @@ def _dialog_stub() -> Mock: dialog._test_active = False dialog._populating_models = False dialog._processing_language_change = False + # A Mock attribute is truthy, which would make _auto_apply_settings think + # simple mode is mid-way through steering the controls (#779). + dialog._simple_driving = False dialog.language = "en-us" dialog._last_non_parakeet_language = None dialog._engine_for_language_memory = None diff --git a/tests/test_simple_model_settings.py b/tests/test_simple_model_settings.py new file mode 100644 index 0000000..258fa2a --- /dev/null +++ b/tests/test_simple_model_settings.py @@ -0,0 +1,695 @@ +"""Simple mode asks what the user knows and derives the rest (#779).""" + +import importlib +import sys +from unittest.mock import MagicMock, Mock, patch + +import pytest + +import vocalinux.ui +from vocalinux.utils.model_choice import ( + ACCURATE, + BALANCED, + FASTEST, + priority_for_size, + size_for_priority, +) + + +@pytest.fixture(scope="module") +def settings_dialog(): + """Import settings_dialog with real base classes for its GTK subclasses. + + Same reasoning as tests/test_settings_model_state.py; both sys.modules and the + package attribute are restored so later tests patching the module by name do + not reach a second module object (#686). + """ + 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 + + +@pytest.fixture +def dialog_class(settings_dialog): + return settings_dialog.SettingsDialog + + +def _dialog_stub(language="pl", multi=False, priority=BALANCED, recommended="small", second=None): + dialog = Mock() + dialog._initializing = False + dialog._simple_syncing = False + dialog._applying_settings = False + # A Mock attribute is truthy, which would trip the steering guard. + dialog._simple_driving = False + dialog.language = language + dialog.simple_language_combo.get_active_id.return_value = language + dialog.simple_multi_switch.get_active.return_value = multi + 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") + # 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) + ) + # Disk lookups are covered separately; here the derived variant stands. + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + return dialog + + +def _sd(): + import vocalinux.ui.settings_dialog as module + + return module + + +# --- the size half of the derivation ------------------------------------- + + +@pytest.mark.parametrize( + "recommended,priority,expected", + [ + ("small", FASTEST, "base"), + ("small", BALANCED, "small"), + ("small", ACCURATE, "medium"), + ("tiny", FASTEST, "tiny"), # clamped at the bottom + ("large", ACCURATE, "large"), # clamped at the top + ], +) +def test_priority_moves_relative_to_the_hardware_recommendation(recommended, priority, expected): + assert size_for_priority(recommended, priority) == expected + + +def test_priority_reads_back_from_an_already_chosen_size(): + """Opening simple mode must describe the current model, not reset it.""" + assert priority_for_size("small", "base") == FASTEST + assert priority_for_size("small", "small") == BALANCED + assert priority_for_size("small", "medium") == ACCURATE + + +# --- simple mode driving the advanced controls --------------------------- + + +def test_a_single_language_pins_it_and_picks_the_matching_variant(settings_dialog, dialog_class): + dialog = _dialog_stub(language="pl", multi=False, priority=BALANCED, recommended="small") + + 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") + # Polish has no English-only weights, so the multilingual variant is correct. + dialog.model_variant_combo.set_active_id.assert_called_once_with("small") + + +def test_english_gets_the_english_only_variant(settings_dialog, dialog_class): + """Same size, better recognition, so simple mode must not leave it on multilingual.""" + dialog = _dialog_stub(language="en-us", multi=False, priority=BALANCED, recommended="small") + + dialog_class._apply_simple_choice(dialog) + + dialog.model_variant_combo.set_active_id.assert_called_once_with("small.en") + + +def test_naming_a_second_language_turns_on_auto_detect(settings_dialog, dialog_class): + """Two languages cannot be pinned, so the engine gets detection instead.""" + dialog = _dialog_stub(language="pl", multi=True, second="en-us", recommended="small") + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "auto") + assert dialog.language == "auto" + # Detection cannot use English-only weights. + dialog.model_variant_combo.set_active_id.assert_called_once_with("small") + + +def test_most_accurate_moves_a_size_up(settings_dialog, dialog_class): + dialog = _dialog_stub(language="en-us", multi=False, priority=ACCURATE, recommended="small") + + dialog_class._apply_simple_choice(dialog) + + dialog.model_combo.set_active_id.assert_called_once_with("medium") + dialog.model_variant_combo.set_active_id.assert_called_once_with("medium.en") + + +def test_most_accurate_on_large_falls_back_to_multilingual_for_english( + settings_dialog, dialog_class +): + """large ships no English-only weights, so the variant must stay real.""" + dialog = _dialog_stub(language="en-us", multi=False, priority=ACCURATE, recommended="large") + + dialog_class._apply_simple_choice(dialog) + + dialog.model_combo.set_active_id.assert_called_once_with("large") + chosen = dialog.model_variant_combo.set_active_id.call_args[0][0] + assert chosen in settings_dialog.WHISPERCPP_MODEL_INFO + assert not settings_dialog.is_english_only_whispercpp_model(chosen) + + +# --- guards --------------------------------------------------------------- + + +def test_syncing_the_widgets_does_not_count_as_a_user_edit(settings_dialog, dialog_class): + """Otherwise opening the page would re-apply and trigger a download.""" + dialog = _dialog_stub() + dialog._simple_syncing = True + + dialog_class._on_simple_choice_changed(dialog) + + dialog._apply_simple_choice.assert_not_called() + dialog._auto_apply_settings.assert_not_called() + + +def test_a_real_edit_applies_and_saves(settings_dialog, dialog_class): + dialog = _dialog_stub() + + dialog_class._on_simple_choice_changed(dialog) + + dialog._apply_simple_choice.assert_called_once() + dialog._auto_apply_settings.assert_called_once() + + +def test_the_second_language_list_appears_only_when_asked_for(settings_dialog, dialog_class): + """show_all() is a no-op on a no_show_all widget: the flag must go first. + + Reported from the installed build — the switch turned on and nothing + appeared, because the row was shown with the flag still set. + """ + dialog = Mock() + dialog.simple_multi_switch.get_active.return_value = True + row = dialog.simple_second_language_row + order = [] + row.set_no_show_all.side_effect = lambda flag: order.append(("no_show_all", flag)) + row.show_all.side_effect = lambda: order.append(("show_all", None)) + + dialog_class._update_simple_visibility(dialog) + + assert order == [("no_show_all", False), ("show_all", None)] + + +def test_the_second_language_list_is_hidden_by_default(settings_dialog, dialog_class): + dialog = Mock() + dialog.simple_multi_switch.get_active.return_value = False + row = dialog.simple_second_language_row + order = [] + row.hide.side_effect = lambda: order.append("hide") + row.set_no_show_all.side_effect = lambda flag: order.append(("no_show_all", flag)) + + dialog_class._update_simple_visibility(dialog) + + assert order == ["hide", ("no_show_all", True)] + + +def test_any_language_as_the_second_answer_means_detection(settings_dialog, dialog_class): + """The explicit multilingual option.""" + dialog = _dialog_stub(language="pl", multi=True, second="auto") + + assert dialog_class._simple_decoding_language(dialog) == "auto" + + +def test_turning_the_switch_on_defaults_the_second_list_to_any_language( + settings_dialog, dialog_class +): + """Otherwise flipping the switch alone would visibly do nothing.""" + dialog = _dialog_stub(language="pl", multi=True, second=None) + + dialog_class._on_simple_choice_changed(dialog) + + dialog.simple_second_language_combo.set_active_id.assert_called_once_with("auto") + dialog._apply_simple_choice.assert_called_once() + + +def test_the_info_card_stays_on_the_page(settings_dialog): + """It is the only feedback that a priority change did anything, and its cost.""" + import inspect + + source = inspect.getsource(settings_dialog.SettingsDialog._build_engine_section) + + assert "self.simple_page.pack_start(self.model_info_card" in source + assert "self.advanced_box.pack_start(self.model_info_card" not in source + + +def test_a_second_language_switches_decoding_to_detection(settings_dialog, dialog_class): + """whisper takes one language or none, so two means automatic detection.""" + dialog = _dialog_stub(language="pl", multi=True, second="en-us") + + assert dialog_class._simple_decoding_language(dialog) == "auto" + + +def test_two_english_entries_still_pin_english(settings_dialog, dialog_class): + """en-US plus en-IN is still English, so the .en weights stay usable.""" + dialog = _dialog_stub(language="en-us", multi=True, second="en-in") + + assert dialog_class._simple_decoding_language(dialog) == "en-us" + + +def test_the_switch_without_a_second_language_keeps_the_main_one(settings_dialog, dialog_class): + dialog = _dialog_stub(language="pl", multi=True, second=None) + + assert dialog_class._simple_decoding_language(dialog) == "pl" + + +def test_no_switch_pins_the_main_language(settings_dialog, dialog_class): + dialog = _dialog_stub(language="pl", multi=False, second="en-us") + + assert dialog_class._simple_decoding_language(dialog) == "pl" + + +def test_expanding_the_island_shows_the_rows_for_the_active_engine(settings_dialog, dialog_class): + """show_all() first, then the per-engine pass, or rows the engine does not + use would be revealed.""" + dialog = Mock() + dialog._initializing = False + expander = Mock() + expander.get_expanded.return_value = True + order = [] + dialog.advanced_box.show_all.side_effect = lambda: order.append("show_all") + dialog._update_engine_specific_ui.side_effect = lambda: order.append("engine_ui") + + dialog_class._on_advanced_expanded(dialog, expander, None) + + assert order == ["show_all", "engine_ui"] + dialog.config_manager.set.assert_called_once_with("speech_recognition", "show_advanced", True) + + +def test_collapsing_the_island_rereads_the_live_model(settings_dialog, dialog_class): + dialog = Mock() + dialog._initializing = False + expander = Mock() + expander.get_expanded.return_value = False + + dialog_class._on_advanced_expanded(dialog, expander, None) + + dialog._sync_simple_from_advanced.assert_called_once() + dialog.config_manager.set.assert_called_once_with("speech_recognition", "show_advanced", False) + + +def test_restoring_the_island_on_open_does_not_write_the_config(settings_dialog, dialog_class): + """Setting the saved state during init must not count as a user choice.""" + dialog = Mock() + dialog._initializing = True + expander = Mock() + expander.get_expanded.return_value = True + + dialog_class._on_advanced_expanded(dialog, expander, None) + + dialog.config_manager.set.assert_not_called() + + +def test_a_change_in_the_advanced_rows_shows_up_in_the_simple_answers( + settings_dialog, dialog_class +): + """Both cards are on screen; they must not contradict each other.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_driving = False + dialog._simple_syncing = False + + dialog_class._refresh_simple_readout(dialog) + + dialog._sync_simple_from_advanced.assert_called_once() + + +@pytest.mark.parametrize("flag", ["_initializing", "_simple_driving", "_simple_syncing"]) +def test_the_readout_stays_quiet_while_simple_mode_is_steering(settings_dialog, dialog_class, flag): + """Re-syncing mid-steer would read half-set controls back into the answers.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_driving = False + dialog._simple_syncing = False + setattr(dialog, flag, True) + + dialog_class._refresh_simple_readout(dialog) + + dialog._sync_simple_from_advanced.assert_not_called() + + +def test_no_second_toplevel_is_created_for_advanced(settings_dialog, dialog_class): + """A second window failed twice on KWin/Wayland: transient for the dialog it + crashed the compositor (tag kwin-crash-repro); standing alone it could not + be raised above the dialog. The island lives in the same window.""" + import inspect + + source = inspect.getsource(dialog_class._build_simple_model_section) + + assert "Gtk.Window(" not in source + assert "set_transient_for" not in source + assert "Gtk.Expander" in source + + +def test_opening_simple_mode_describes_the_current_model_instead_of_resetting_it( + settings_dialog, dialog_class +): + """Switching to simple must not silently change which model is loaded.""" + dialog = Mock() + dialog.language = "en-us" + dialog.language_combo.get_active_id.return_value = "en-us" + dialog.config_manager.get.return_value = "" + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small.en", "reason") + dialog._get_selected_whispercpp_model.return_value = "medium.en" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(False) + dialog.simple_language_combo.set_active_id.assert_called_once_with("en-us") + # medium sits one step above the recommended small, which is "most accurate". + dialog.simple_priority_combo.set_active_id.assert_called_once_with(ACCURATE) + assert dialog._simple_syncing is False + + +def test_auto_detect_shows_up_as_the_other_languages_switch(settings_dialog, dialog_class): + dialog = Mock() + dialog.language = "auto" + dialog.language_combo.get_active_id.return_value = "auto" + dialog.simple_language_combo.get_active_id.return_value = None + dialog.config_manager.get.return_value = "" + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason") + dialog._get_selected_whispercpp_model.return_value = "small" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(True) + + +@pytest.mark.parametrize("stale_second", ["auto", "en-us"]) +def test_pinned_advanced_language_plus_stale_stored_second_does_not_replace_the_pin( + settings_dialog, dialog_class, stale_second +): + """A leftover simple second answer must not override a pinned Advanced language. + + Sync used to re-enable the multi switch from persisted ``simple_second_language``. + The next simple edit then decoded to auto and silently replaced the pin. + """ + dialog = Mock() + dialog.language = "pl" + dialog.language_combo.get_active_id.return_value = "pl" + dialog.config_manager.get.return_value = stale_second + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason") + dialog._get_selected_whispercpp_model.return_value = "small" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(False) + dialog.simple_language_combo.set_active_id.assert_called_once_with("pl") + dialog.config_manager.set.assert_called_with("speech_recognition", "simple_second_language", "") + dialog.simple_second_language_combo.set_active_id.assert_called_with("auto") + + # Widgets after sync: multi off, main language pinned. A later simple apply + # must keep the pin rather than silently writing auto. + dialog.simple_multi_switch.get_active.return_value = False + dialog.simple_language_combo.get_active_id.return_value = "pl" + dialog.simple_second_language_combo.get_active_id.return_value = "auto" + dialog.simple_priority_combo.get_active_id.return_value = BALANCED + dialog._simple_decoding_language.side_effect = lambda: dialog_class._simple_decoding_language( + dialog + ) + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "pl") + assert dialog.language == "pl" + + +def test_two_english_second_language_survives_sync_from_a_pin(settings_dialog, dialog_class): + """en-US plus en-IN still pins English; sync must not treat that as leftover auto. + + Dropping the switch and wiping ``simple_second_language`` on every pinned + readout would forget the second English answer the next time settings open. + """ + dialog = Mock() + dialog.language = "en-us" + dialog.language_combo.get_active_id.return_value = "en-us" + dialog.config_manager.get.return_value = "en-in" + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small.en", "reason") + dialog._get_selected_whispercpp_model.return_value = "small.en" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(True) + dialog.simple_language_combo.set_active_id.assert_called_once_with("en-us") + dialog.simple_second_language_combo.set_active_id.assert_called_once_with("en-in") + dialog.config_manager.set.assert_not_called() + + dialog.simple_multi_switch.get_active.return_value = True + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.simple_second_language_combo.get_active_id.return_value = "en-in" + dialog.simple_priority_combo.get_active_id.return_value = BALANCED + dialog._simple_decoding_language.side_effect = lambda: dialog_class._simple_decoding_language( + dialog + ) + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "en-us") + assert dialog.language == "en-us" + + +@pytest.mark.parametrize("stale_second", ["en-in", "en-us"]) +def test_two_english_second_language_does_not_override_advanced_auto( + settings_dialog, dialog_class, stale_second +): + """en-US plus another English still pins English, so it cannot sit on auto. + + Restoring that leftover over Advanced auto would make the next simple edit + silently replace auto with en-us. Duplicate en-US is the same pin. + """ + dialog = Mock() + dialog.language = "auto" + dialog.language_combo.get_active_id.return_value = "auto" + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.config_manager.get.return_value = stale_second + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason") + dialog._get_selected_whispercpp_model.return_value = "small" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(True) + dialog.simple_language_combo.set_active_id.assert_not_called() + dialog.simple_second_language_combo.set_active_id.assert_called_once_with("auto") + dialog.config_manager.set.assert_called_with("speech_recognition", "simple_second_language", "") + + dialog.simple_multi_switch.get_active.return_value = True + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.simple_second_language_combo.get_active_id.return_value = "auto" + dialog.simple_priority_combo.get_active_id.return_value = BALANCED + dialog._simple_decoding_language.side_effect = lambda: dialog_class._simple_decoding_language( + dialog + ) + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "auto") + assert dialog.language == "auto" + + +def test_named_second_language_that_still_means_auto_survives_auto_sync( + settings_dialog, dialog_class +): + """English plus Polish is detection; Advanced auto must keep that second pick.""" + dialog = Mock() + dialog.language = "auto" + dialog.language_combo.get_active_id.return_value = "auto" + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.config_manager.get.return_value = "pl" + dialog._get_recommended_whispercpp_model_for_language.return_value = ("small", "reason") + dialog._get_selected_whispercpp_model.return_value = "small" + + dialog_class._sync_simple_from_advanced(dialog) + + dialog.simple_multi_switch.set_active.assert_called_once_with(True) + dialog.simple_second_language_combo.set_active_id.assert_called_once_with("pl") + dialog.config_manager.set.assert_not_called() + + dialog.simple_multi_switch.get_active.return_value = True + dialog.simple_language_combo.get_active_id.return_value = "en-us" + dialog.simple_second_language_combo.get_active_id.return_value = "pl" + dialog.simple_priority_combo.get_active_id.return_value = BALANCED + dialog._simple_decoding_language.side_effect = lambda: dialog_class._simple_decoding_language( + dialog + ) + dialog._on_disk_stand_in.side_effect = lambda variant, size, language: variant + + dialog_class._apply_simple_choice(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.language_combo, "auto") + assert dialog.language == "auto" + + +def test_changing_advanced_language_refreshes_the_simple_readout(settings_dialog, dialog_class): + """Otherwise a pin in Advanced leaves a stale multi switch on screen.""" + dialog = Mock() + dialog._processing_language_change = False + dialog._initializing = False + dialog._applying_settings = False + dialog.language_combo.get_active_id.return_value = "pl" + dialog.engine_combo.get_active_text.return_value = "Local (whisper.cpp)" + + dialog_class._on_language_changed(dialog, None) + + dialog._refresh_simple_readout.assert_called_once() + + +# --- regressions reported from the installed build ----------------------- + + +def test_the_steering_guard_is_held_for_the_whole_pick(settings_dialog, dialog_class): + """Picking a language froze the window: each steered combo re-applied. + + _apply_simple_choice sets engine, language, size and variant, and every one of + those handlers ends in _auto_apply_settings, so one pick reconfigured the engine + up to four times on the UI thread. The guard has to be held for the whole of the + steering and released before the single apply that follows. + """ + dialog = _dialog_stub() + held = [] + dialog._apply_simple_choice.side_effect = lambda: held.append(dialog._simple_driving) + + dialog_class._on_simple_choice_changed(dialog) + + assert held == [True], "guard was not held while the controls were being steered" + assert dialog._simple_driving is False, "guard was not released afterwards" + dialog._auto_apply_settings.assert_called_once() + + +def test_auto_apply_is_suppressed_while_simple_mode_is_steering(settings_dialog, dialog_class): + """The guard itself: nothing runs while the controls are being pointed.""" + dialog = Mock() + dialog._applying_settings = False + dialog._initializing = False + dialog._test_active = False + dialog._populating_models = False + dialog._simple_driving = True + + dialog_class._auto_apply_settings(dialog) + + dialog.get_selected_settings.assert_not_called() + + +def test_the_simple_language_box_accepts_typing(settings_dialog, dialog_class): + """A list of thirty-plus languages you cannot type into is not usable.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_syncing = False + dialog.simple_language_combo.get_active_id.return_value = None + dialog.simple_language_combo.get_child.return_value.get_text.return_value = "Pol" + + with patch.object(settings_dialog, "_combo_text_rows", return_value=[("pl", "Polish")]): + dialog_class._commit_or_restore_simple_language_entry(dialog) + + dialog.simple_language_combo.set_active_id.assert_called_once_with("pl") + + +def test_unresolvable_typing_restores_the_previous_language(settings_dialog, dialog_class): + """Typing nonsense must not leave the box in a state the config never had.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_syncing = False + dialog.language = "pl" + dialog.simple_language_combo.get_active_id.return_value = None + dialog.simple_language_combo.get_child.return_value.get_text.return_value = "zzzz" + + with patch.object(settings_dialog, "_combo_text_rows", return_value=[("pl", "Polish")]): + dialog_class._commit_or_restore_simple_language_entry(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with(dialog.simple_language_combo, "pl") + + +def test_restoring_after_auto_detect_falls_back_to_a_real_language(settings_dialog, dialog_class): + """ "auto" is the switch, not an entry in this list, so it cannot be restored.""" + dialog = Mock() + dialog._initializing = False + dialog._simple_syncing = False + dialog.language = "auto" + dialog.simple_language_combo.get_active_id.return_value = None + dialog.simple_language_combo.get_child.return_value.get_text.return_value = "" + + with patch.object(settings_dialog, "_combo_text_rows", return_value=[("pl", "Polish")]): + dialog_class._commit_or_restore_simple_language_entry(dialog) + + dialog._set_combo_active_id_or_first.assert_called_once_with( + dialog.simple_language_combo, "en-us" + ) + + +# --- reuse what is on disk instead of downloading a sibling --------------- + + +def _with_disk(settings_dialog, downloaded): + return ( + patch.object( + settings_dialog, + "is_whispercpp_model_downloaded", + side_effect=lambda name: name in downloaded, + ), + patch.object( + settings_dialog, + "get_whispercpp_model_variants", + return_value=["base", "base.en", "base-q5_1", "base.en-q5_1", "base-q8_0"], + ), + ) + + +def test_a_same_size_weight_on_disk_stands_in_for_a_missing_sibling(settings_dialog, dialog_class): + """Reported: switching the other-languages switch off went from base to + base.en and sat through a 141 MB modal download for the same size.""" + on_disk, variants = _with_disk(settings_dialog, ["base"]) + with on_disk, variants: + chosen = dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") + assert chosen == "base" + + +def test_the_derived_variant_is_kept_when_it_is_already_on_disk(settings_dialog, dialog_class): + on_disk, variants = _with_disk(settings_dialog, ["base", "base.en"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") == "base.en" + + +def test_english_only_weights_never_stand_in_for_another_language(settings_dialog, dialog_class): + on_disk, variants = _with_disk(settings_dialog, ["base.en"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base", "base", "pl") == "base" + + +def test_english_only_weights_never_stand_in_for_detection(settings_dialog, dialog_class): + on_disk, variants = _with_disk(settings_dialog, ["base.en"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base", "base", "auto") == "base" + + +def test_nothing_of_that_size_on_disk_means_the_derived_variant_and_a_download( + settings_dialog, dialog_class +): + on_disk, variants = _with_disk(settings_dialog, ["medium.en"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") == "base.en" + + +def test_a_plain_weight_is_preferred_over_a_quantized_one(settings_dialog, dialog_class): + on_disk, variants = _with_disk(settings_dialog, ["base-q5_1", "base"]) + with on_disk, variants: + assert dialog_class._on_disk_stand_in(Mock(), "base.en", "base", "en-us") == "base" + + +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" + + dialog_class._apply_simple_choice(dialog) + + dialog._on_disk_stand_in.assert_called_once_with("small.en", "small", "en-us") + dialog.model_variant_combo.set_active_id.assert_called_once_with("small")