feat(settings): simple setup for the speech model, with Advanced as an island (#801)

* 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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
Jatin K Malik
2026-09-07 14:38:30 -07:00
committed by GitHub
co-authored by Kacper Paczos Claude Fable 5.1
parent 8f4aeca2f1
commit affbf6a9af
6 changed files with 1185 additions and 9 deletions
+5
View File
@@ -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
+418 -7
View File
@@ -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.
+58
View File
@@ -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
+6 -2
View File
@@ -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)
+3
View File
@@ -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
+695
View File
@@ -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")