fix(settings): save the model only once the engine loaded it (#685)
* fix(settings): save the model only once the engine loaded it Applying settings wrote the config first and reconfigured the engine afterwards, and reconfiguring is what downloads a missing model. A download that failed, was cancelled, or was interrupted therefore left the config naming a model that never reached the disk: the next start reported "No Speech Model" while the previously working model sat on disk, deselected. Reconfigure first and persist only on success, so a failed switch keeps the settings that are actually running. On failure the pickers are put back on the saved model. They otherwise kept showing the rejected one, which also blocked a retry, since re-selecting the same entry emits no "changed" signal. Selecting an engine now applies as well. Every other control on the page applies on change, but the engine only repainted the pickers: the config and the recognizer stayed on the previous engine until another control was touched, and closing the dialog dropped the change without a word. Remote API is deferred until its server URL is filled in, since applying it empty can only fail validation. * fix(settings): restore the engine picker after a failed engine switch Fixes the known limitation called out in the previous commit (#692): when applying a newly selected engine failed, the resync restored the model pickers but left the engine combo displaying the engine that was never applied, so the page described a state that was not running. Set the combo back to the saved engine while holding the apply suppression flag, so _on_engine_changed() repaints the pickers without cascading into another apply or download prompt. * fix(settings): resync the engine picker on every path that never applied Review follow-ups on #685. #692 was not actually closed. The resync lived only in the download thread's except block, and _apply_settings_internal() reports failure by returning False as well as by raising, so the thread fell through to set_complete(True, "") and reported a switch that never happened. Act on the return value instead of depending on #690 to make that except live, and add the same handling to apply_settings()'s modal path, whose except never resynced at all. Add _resync_engine_ui_if_unapplied() as the backstop the review asked for: after the modal closes, and when the dialog closes, compare the engine on screen against the engine in the config and restore the picker when they disagree. That also covers Remote API left without a server URL, where applying stays deliberately deferred. _on_engine_changed now checks _initializing / _applying_settings itself, like every other control on this page, so the combo move made by a resync repaints without rewriting the user's language or cascading into another apply. Tests no longer touch sys.modules at collection time: the reimport moved into a module-scoped fixture that also restores the attribute on vocalinux.ui, which is what desynced test_model_deletion on 3.9/3.10. New cases cover the modal path that #692 is about — needs_download true with the apply returning False, and with it raising "Download cancelled" — plus the close hook. The stub now uses RecognitionState.IDLE rather than the string "idle", so tests stop taking the stop_recognition branch and sleeping 0.5s.
This commit is contained in:
@@ -4092,6 +4092,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
"""Persist deferred text edits before the settings dialog closes."""
|
||||
if response_id in (Gtk.ResponseType.CLOSE, Gtk.ResponseType.DELETE_EVENT):
|
||||
self._flush_advanced_prompt_if_dirty()
|
||||
self._resync_engine_ui_if_unapplied()
|
||||
|
||||
def _on_advanced_prompt_changed(self, buffer):
|
||||
"""Track prompt edits without applying settings on every keystroke."""
|
||||
@@ -4731,7 +4732,14 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
engine = _engine_from_display(engine_text)
|
||||
|
||||
current_lang = self.language_combo.get_active_id()
|
||||
# A programmatic change — the resync after a failed apply — must repaint
|
||||
# for the engine now shown, but must not rewrite the language the user
|
||||
# picked, and must not cascade into another apply. Every other control on
|
||||
# this page checks these flags itself rather than leaning on
|
||||
# _auto_apply_settings to do it.
|
||||
programmatic = self._initializing or self._applying_settings
|
||||
|
||||
current_lang = None if self._applying_settings else self.language_combo.get_active_id()
|
||||
if current_lang:
|
||||
if engine == "vosk" and (
|
||||
current_lang == "auto" or not SUPPORTED_LANGUAGES.get(current_lang, {}).get("vosk")
|
||||
@@ -4746,6 +4754,19 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._update_model_info()
|
||||
self._update_voice_commands_for_engine()
|
||||
|
||||
if programmatic:
|
||||
return
|
||||
|
||||
# Every other control on this page applies as soon as it changes. Without
|
||||
# this the new engine is only displayed: the config and the running
|
||||
# recognizer stay on the previous one until some other control is touched,
|
||||
# and closing the dialog drops the change silently.
|
||||
if engine == "remote_api" and not self.remote_api_url_entry.get_text().strip():
|
||||
# Applying now would only fail validation; _on_remote_api_settings_changed
|
||||
# applies once the server URL is filled in.
|
||||
return
|
||||
self._auto_apply_settings()
|
||||
|
||||
def _update_voice_commands_for_engine(self):
|
||||
"""Update voice commands switch based on current engine."""
|
||||
sr_config = self.config_manager.get_settings().get("speech_recognition", {})
|
||||
@@ -5100,15 +5121,31 @@ class SettingsDialog(Gtk.Dialog):
|
||||
cancel_check_id = GLib.timeout_add(100, check_cancelled)
|
||||
|
||||
try:
|
||||
self._apply_settings_internal(settings, raise_errors=True)
|
||||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||||
GLib.idle_add(self._populate_model_options)
|
||||
applied = self._apply_settings_internal(settings, raise_errors=True)
|
||||
if applied:
|
||||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||||
GLib.idle_add(self._populate_model_options)
|
||||
else:
|
||||
# raise_errors makes failures raise today, but a
|
||||
# False return must not be read as success: it
|
||||
# means nothing was saved and the pickers still
|
||||
# show settings the engine never took.
|
||||
GLib.idle_add(self._resync_model_ui_from_config)
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete,
|
||||
False,
|
||||
"Could not apply the new settings",
|
||||
)
|
||||
finally:
|
||||
GLib.source_remove(cancel_check_id)
|
||||
self.speech_engine.set_download_progress_callback(None)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
# The settings were never saved, so the previously working
|
||||
# model is still the configured one; put the pickers back on
|
||||
# it so the UI matches the config and a retry is possible.
|
||||
GLib.idle_add(self._resync_model_ui_from_config)
|
||||
if "cancelled" in error_msg.lower():
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete,
|
||||
@@ -5128,23 +5165,75 @@ class SettingsDialog(Gtk.Dialog):
|
||||
threading.Thread(target=download_and_apply, daemon=True).start()
|
||||
download_dialog.run()
|
||||
download_dialog.destroy()
|
||||
# Whatever ended the modal — cancel, failure, or a path not
|
||||
# covered above — the pickers must not outlive the config.
|
||||
self._resync_engine_ui_if_unapplied()
|
||||
return
|
||||
|
||||
logger.info(f"Auto-applying settings: {settings}")
|
||||
|
||||
self._save_selected_settings(settings)
|
||||
|
||||
was_running = self.speech_engine.state != RecognitionState.IDLE
|
||||
if was_running:
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
self.speech_engine.reconfigure(**settings)
|
||||
self._save_selected_settings(settings)
|
||||
logger.info("Settings auto-applied successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-apply settings: {e}")
|
||||
self._resync_model_ui_from_config()
|
||||
finally:
|
||||
self._applying_settings = False
|
||||
|
||||
def _resync_model_ui_from_config(self):
|
||||
"""Put the pickers back on the settings that are actually saved.
|
||||
|
||||
Called when applying failed: the config still names the previous
|
||||
engine and model, and leaving the pickers on the rejected ones also
|
||||
blocks a retry, since re-selecting the same entry emits no "changed"
|
||||
signal.
|
||||
"""
|
||||
try:
|
||||
saved_engine = (
|
||||
self.config_manager.get_settings().get("speech_recognition", {}).get("engine")
|
||||
)
|
||||
if saved_engine:
|
||||
display = _engine_display_name(saved_engine)
|
||||
if self.engine_combo.get_active_text() != display:
|
||||
# Hold the apply-suppression flag: restoring the combo fires
|
||||
# _on_engine_changed(), which must repaint the pickers but
|
||||
# not cascade into another apply or download prompt.
|
||||
was_applying = self._applying_settings
|
||||
self._applying_settings = True
|
||||
try:
|
||||
self.engine_combo.set_active_id(display)
|
||||
finally:
|
||||
self._applying_settings = was_applying
|
||||
self._populate_model_options()
|
||||
self._update_model_info()
|
||||
except Exception as e: # pragma: no cover - UI resync must never mask the original error
|
||||
logger.debug(f"Could not resync model pickers: {e}")
|
||||
|
||||
def _resync_engine_ui_if_unapplied(self):
|
||||
"""Resync when the engine on screen is not the engine that got saved.
|
||||
|
||||
Covers every way an apply can end without writing the config: a
|
||||
cancelled download, a failure, and Remote API left without a server
|
||||
URL, where applying is deliberately deferred. Leaving the combo on
|
||||
the unapplied engine misreports what dictation will actually use.
|
||||
"""
|
||||
try:
|
||||
saved_engine = (
|
||||
self.config_manager.get_settings().get("speech_recognition", {}).get("engine")
|
||||
)
|
||||
if not saved_engine:
|
||||
return
|
||||
if self._get_selected_engine() == saved_engine:
|
||||
return
|
||||
self._resync_model_ui_from_config()
|
||||
except Exception as e: # pragma: no cover - a resync must never mask the real error
|
||||
logger.debug(f"Could not check the engine picker against the config: {e}")
|
||||
|
||||
def _save_selected_settings(self, settings: dict):
|
||||
"""Persist selected settings to their appropriate config sections."""
|
||||
sr_settings = {k: v for k, v in settings.items() if not k.startswith("whispercpp_")}
|
||||
@@ -5418,14 +5507,25 @@ For now, the engine has been reverted to VOSK."""
|
||||
cancel_check_id = GLib.timeout_add(100, check_cancelled)
|
||||
|
||||
try:
|
||||
self._apply_settings_internal(settings, raise_errors=True)
|
||||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||||
applied = self._apply_settings_internal(settings, raise_errors=True)
|
||||
if applied:
|
||||
GLib.idle_add(download_dialog.set_complete, True, "")
|
||||
else:
|
||||
GLib.idle_add(self._resync_model_ui_from_config)
|
||||
GLib.idle_add(
|
||||
download_dialog.set_complete,
|
||||
False,
|
||||
"Could not apply the new settings",
|
||||
)
|
||||
finally:
|
||||
GLib.source_remove(cancel_check_id)
|
||||
self.speech_engine.set_download_progress_callback(None)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
# Nothing was saved, so the config still names the previous
|
||||
# engine and model; put the pickers back on them.
|
||||
GLib.idle_add(self._resync_model_ui_from_config)
|
||||
if "cancelled" in error_msg.lower():
|
||||
GLib.idle_add(download_dialog.set_complete, False, "Download cancelled")
|
||||
elif engine == "whisper" and "no module named" in error_msg.lower():
|
||||
@@ -5439,6 +5539,7 @@ For now, the engine has been reverted to VOSK."""
|
||||
download_dialog.destroy()
|
||||
|
||||
self._populate_model_options()
|
||||
self._resync_engine_ui_if_unapplied()
|
||||
return True
|
||||
|
||||
return self._apply_settings_internal(settings)
|
||||
@@ -5454,14 +5555,16 @@ For now, the engine has been reverted to VOSK."""
|
||||
off the main loop is not safe anyway.
|
||||
"""
|
||||
try:
|
||||
self._save_selected_settings(settings)
|
||||
|
||||
was_running = self.speech_engine.state != RecognitionState.IDLE
|
||||
if was_running:
|
||||
self.speech_engine.stop_recognition()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Persist only once the engine really runs these settings: this call
|
||||
# downloads missing models, and a config saved up front would keep
|
||||
# pointing at a model that never made it to disk.
|
||||
self.speech_engine.reconfigure(**settings)
|
||||
self._save_selected_settings(settings)
|
||||
|
||||
logger.info("Settings applied successfully.")
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user