diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index d66f709..20487a9 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -523,6 +523,9 @@ def main(): whispercpp_no_timestamps=advanced_settings.get("whispercpp_no_timestamps", True), whispercpp_no_context=advanced_settings.get("whispercpp_no_context", True), whispercpp_initial_prompt=advanced_settings.get("whispercpp_initial_prompt", ""), + whispercpp_language_candidates=advanced_settings.get( + "whispercpp_language_candidates", "" + ), whispercpp_temperature=advanced_settings.get("whispercpp_temperature", 0.0), whispercpp_temperature_inc=advanced_settings.get("whispercpp_temperature_inc", -1.0), whispercpp_entropy_thold=advanced_settings.get("whispercpp_entropy_thold", 2.4), diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 53f6f28..71e30a2 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -556,6 +556,9 @@ class SpeechRecognitionManager: self.whispercpp_no_timestamps = kwargs.get("whispercpp_no_timestamps", True) self.whispercpp_no_context = kwargs.get("whispercpp_no_context", True) self.whispercpp_initial_prompt = kwargs.get("whispercpp_initial_prompt", "") + self.whispercpp_language_candidates = self._normalize_language_candidates( + kwargs.get("whispercpp_language_candidates", "") + ) self.whispercpp_temperature = kwargs.get("whispercpp_temperature", 0.0) self.whispercpp_temperature_inc = kwargs.get("whispercpp_temperature_inc", -1.0) self.whispercpp_entropy_thold = kwargs.get("whispercpp_entropy_thold", 2.4) @@ -636,6 +639,23 @@ class SpeechRecognitionManager: else: raise ValueError(f"Unsupported speech recognition engine: {engine}") + @staticmethod + def _normalize_language_candidates(value: str | list[str] | None) -> list[str]: + """Normalize a comma-separated string or list of Whisper language codes.""" + if value is None: + return [] + + raw_candidates = value.replace(";", ",").split(",") if isinstance(value, str) else value + candidates = [] + for candidate in raw_candidates: + code = str(candidate).strip().lower().replace("_", "-") + if not code: + continue + code = code.split("-", 1)[0] + if code not in candidates: + candidates.append(code) + return candidates + def _resolve_voice_commands_enabled(self) -> bool: """Resolve effective voice commands state from preference and engine.""" if self._voice_commands_preference is None: @@ -685,6 +705,7 @@ class SpeechRecognitionManager: "whispercpp_no_timestamps", "whispercpp_no_context", "whispercpp_initial_prompt", + "whispercpp_language_candidates", "whispercpp_temperature", "whispercpp_temperature_inc", "whispercpp_entropy_thold", @@ -1680,6 +1701,49 @@ class SpeechRecognitionManager: logger.warning("Model is None during transcription, returning empty result") return "" + if lang is None and self.whispercpp_language_candidates: + detected, lang_probs = self.model.auto_detect_language( + audio_float, + n_threads=min(4, max(1, os.cpu_count() or 1)), + ) + # lang_probs names every language whisper.cpp can return; a + # configured code missing from it (a typo, or "auto") has no + # real probability and must not win the max() below. + candidate_probs = { + candidate: float(lang_probs[candidate]) + for candidate in self.whispercpp_language_candidates + if candidate in lang_probs + } + unsupported = [ + candidate + for candidate in self.whispercpp_language_candidates + if candidate not in lang_probs + ] + if unsupported: + logger.warning( + "Ignoring unsupported whisper.cpp language candidates: %s", + ",".join(unsupported), + ) + if candidate_probs: + lang = max(candidate_probs, key=candidate_probs.get) + logger.info( + "whisper.cpp restricted language detection: detected=%s %.3f, candidates=%s, using=%s %.3f", + detected[0], + float(detected[1]), + ",".join(candidate_probs.keys()), + lang, + candidate_probs[lang], + ) + else: + # Every configured candidate was unusable; keep the + # unrestricted detection rather than forcing a code the + # model never scored. + lang = detected[0] + logger.warning( + "No usable whisper.cpp language candidates configured; using detected language %s", + lang, + ) + # Transcribe with whisper.cpp # pywhispercpp expects audio as numpy array transcribe_start = time.time() @@ -3677,7 +3741,10 @@ class SpeechRecognitionManager: for param_name in whispercpp_attrs: if param_name in kwargs: - setattr(self, param_name, kwargs[param_name]) + value = kwargs[param_name] + if param_name == "whispercpp_language_candidates": + value = self._normalize_language_candidates(value) + setattr(self, param_name, value) restart_needed = True # Handle Remote API settings diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 81bd0f6..9e1d721 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -207,6 +207,7 @@ DEFAULT_CONFIG = { "whispercpp_no_timestamps": True, "whispercpp_no_context": True, "whispercpp_initial_prompt": "", + "whispercpp_language_candidates": "", "whispercpp_temperature": 0.0, "whispercpp_temperature_inc": -1.0, "whispercpp_entropy_thold": 2.4, diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 7f97242..e665eb0 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -132,6 +132,13 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +# Serializes settings applies against deferred-edit persistence across dialog +# instances: a dialog closed mid-apply hands its pending edits to a worker, and +# a reopened dialog shares the same engine and config manager, so both sides +# must agree on whose snapshot is newest. +_apply_settings_lock = threading.Lock() +_apply_settings_generation = 0 + def _raw_audio_device_name(device_name: Optional[str]) -> Optional[str]: """Return the persisted device name without UI-only suffixes.""" @@ -2606,6 +2613,12 @@ class SettingsDialog(Gtk.Dialog): self._follow_layout_active = False self._applying_settings = False # Flag to prevent recursive settings application self._advanced_prompt_dirty = False + self._language_candidates_dirty = False + # Deferred text edits stashed when the dialog closes mid-apply; persisted + # by _finish_auto_apply once the running apply releases the guard. + self._pending_text_edits: Optional[dict] = None + self._pending_text_edit_baseline: Optional[dict] = None + self._pending_apply_generation = 0 self._about_release_url = "" self._update_check_in_progress = False self._update_check_generation = 0 @@ -5025,6 +5038,21 @@ class SettingsDialog(Gtk.Dialog): ) group.add_row(no_speech_row) + candidates_help = ( + "Optional comma-separated Whisper language codes for auto-detect, " + "such as en,es. Leave blank to consider all languages." + ) + self.advanced_language_candidates_entry = Gtk.Entry() + self.advanced_language_candidates_entry.set_placeholder_text("en,es") + self.advanced_language_candidates_entry.set_tooltip_text(candidates_help) + language_candidates_row = PreferenceRow( + title="Language Candidates", + subtitle="Restrict auto-detect to selected language codes", + widget=self.advanced_language_candidates_entry, + ) + language_candidates_row.set_tooltip_text(candidates_help) + group.add_row(language_candidates_row) + # Initial Prompt -- moved to the end and made multiline initial_prompt_help = ( "Optional. Add names, jargon, punctuation style, or other context to bias " @@ -5090,6 +5118,9 @@ class SettingsDialog(Gtk.Dialog): self.advanced_entropy_thold_spin.connect("value-changed", self._on_advanced_param_changed) self.advanced_logprob_thold_spin.connect("value-changed", self._on_advanced_param_changed) self.advanced_no_speech_thold_spin.connect("value-changed", self._on_advanced_param_changed) + self.advanced_language_candidates_entry.connect( + "changed", self._on_language_candidates_changed + ) self.advanced_initial_prompt_buffer = self.advanced_initial_prompt_textview.get_buffer() self.advanced_initial_prompt_buffer.connect("changed", self._on_advanced_prompt_changed) @@ -6083,23 +6114,103 @@ class SettingsDialog(Gtk.Dialog): def _on_advanced_prompt_changed(self, buffer): """Track prompt edits without applying settings on every keystroke.""" - if _handlers_suppressed(self): + if self._initializing: return self._advanced_prompt_dirty = True + def _on_language_candidates_changed(self, entry: Gtk.Entry) -> None: + """Track candidate edits without applying settings on every keystroke.""" + if self._initializing: + return + self._language_candidates_dirty = True + + def _deferred_text_edit_settings(self) -> dict[str, Any]: + """Current values of the deferred text fields, for a mid-apply close.""" + pending: dict[str, Any] = {} + if self._advanced_prompt_dirty: + pending["whispercpp_initial_prompt"] = self.advanced_initial_prompt_buffer.get_text( + self.advanced_initial_prompt_buffer.get_start_iter(), + self.advanced_initial_prompt_buffer.get_end_iter(), + False, + ) + if self._language_candidates_dirty: + pending["whispercpp_language_candidates"] = ( + self.advanced_language_candidates_entry.get_text() + ) + return pending + def _flush_advanced_prompt_if_dirty(self): - """Apply deferred initial prompt edits.""" - if not self._advanced_prompt_dirty or _handlers_suppressed(self): + """Apply deferred advanced text edits (initial prompt, language candidates).""" + if self._initializing: + return + if not (self._advanced_prompt_dirty or self._language_candidates_dirty): + return + if self._applying_settings: + # The apply holding the guard cannot see these edits; stash them so + # _finish_auto_apply can re-apply (dialog open) or persist (closed). + self._pending_text_edits = self._deferred_text_edit_settings() + # Pre-edit values: lets a later persist keep keys a newer apply + # never touched instead of dropping the whole snapshot. + self._pending_text_edit_baseline = { + key: self.config_manager.get("advanced", key) for key in self._pending_text_edits + } + self._pending_apply_generation = _apply_settings_generation return self._auto_apply_settings() - self._advanced_prompt_dirty = False + + def _persist_pending_text_edits(self) -> None: + """Persist deferred edits captured when the dialog closed mid-apply.""" + pending = self._pending_text_edits + baseline = self._pending_text_edit_baseline or {} + self._pending_text_edits = None + self._pending_text_edit_baseline = None + if not pending: + return + + def persist() -> None: + try: + with _apply_settings_lock: + if _apply_settings_generation != self._pending_apply_generation: + # A newer apply already reconfigured the shared engine + # and saved newer values. Keep only the keys it left + # untouched — the rest of the snapshot is stale and + # must not reach the engine or the config again. + surviving = { + key: value + for key, value in pending.items() + if self.config_manager.get("advanced", key) == baseline.get(key) + } + else: + surviving = dict(pending) + if not surviving: + return + if all( + self.config_manager.get("advanced", key) == value + for key, value in surviving.items() + ): + # The apply that was running already landed these values. + return + self.speech_engine.reconfigure(**surviving) + for key, value in surviving.items(): + self.config_manager.set("advanced", key, value) + self.config_manager.save_settings() + except (OSError, ValueError, TypeError, RuntimeError) as e: + logger.warning( + "Could not persist deferred settings edits for keys %s: %s", + sorted(pending), + e, + exc_info=True, + ) + + # reconfigure() restarts the model; like the normal apply path it runs + # on a worker so the model load never blocks the GTK main loop. + threading.Thread(target=persist, daemon=True).start() def _on_advanced_param_changed(self, widget, *args): """Handle any advanced parameter change.""" if _handlers_suppressed(self): return False self._auto_apply_settings() - self._advanced_prompt_dirty = False return False def _on_reset_advanced_clicked(self, widget): @@ -6112,6 +6223,9 @@ class SettingsDialog(Gtk.Dialog): self.advanced_no_timestamps_switch.set_active(defaults["whispercpp_no_timestamps"]) self.advanced_no_context_switch.set_active(defaults["whispercpp_no_context"]) self.advanced_initial_prompt_buffer.set_text(defaults["whispercpp_initial_prompt"], -1) + self.advanced_language_candidates_entry.set_text( + defaults["whispercpp_language_candidates"] + ) self.advanced_temperature_spin.set_value(defaults["whispercpp_temperature"]) self.advanced_temperature_inc_spin.set_value(defaults["whispercpp_temperature_inc"]) self.advanced_entropy_thold_spin.set_value(defaults["whispercpp_entropy_thold"]) @@ -6286,6 +6400,17 @@ class SettingsDialog(Gtk.Dialog): self.advanced_initial_prompt_buffer.set_text( advanced_settings.get("whispercpp_initial_prompt", ""), -1 ) + # The saved value may be a JSON list (the engine accepts both forms); + # render the canonical comma-separated text its normalization produces. + from ..speech_recognition.recognition_manager import SpeechRecognitionManager + + self.advanced_language_candidates_entry.set_text( + ",".join( + SpeechRecognitionManager._normalize_language_candidates( + advanced_settings.get("whispercpp_language_candidates", "") + ) + ) + ) self.advanced_temperature_spin.set_value( advanced_settings.get("whispercpp_temperature", 0.0) ) @@ -7669,12 +7794,22 @@ class SettingsDialog(Gtk.Dialog): if self._simple_driving: return + # Bump before the guard is set so a close-time stash always reads a + # generation that already includes this in-flight apply. + global _apply_settings_generation + _apply_settings_generation += 1 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. worker_holds_guard = False try: settings = self.get_selected_settings() + # Collecting consumed every deferred text field; clear the flags + # here so edits that arrived mid-apply still schedule a follow-up. + self._advanced_prompt_dirty = False + self._language_candidates_dirty = False + self._pending_text_edits = None + self._pending_text_edit_baseline = None engine = settings.get("engine", "vosk") model_name = settings.get("model_size", "small") @@ -7792,7 +7927,15 @@ class SettingsDialog(Gtk.Dialog): """ self._applying_settings = False if self._dialog_is_alive(): - self._resync_model_ui_from_config() + if self._advanced_prompt_dirty or self._language_candidates_dirty: + # Edits landed while the guard was held; run their apply now. + self._auto_apply_settings() + else: + self._resync_model_ui_from_config() + else: + # Closed mid-apply: the running apply saved an older snapshot, so + # write any edits it could not see now that it has finished. + self._persist_pending_text_edits() return False def _idle_resync_model_ui_from_config(self) -> bool: @@ -7918,6 +8061,7 @@ class SettingsDialog(Gtk.Dialog): self.advanced_initial_prompt_buffer.get_end_iter(), False, ), + "whispercpp_language_candidates": self.advanced_language_candidates_entry.get_text(), "whispercpp_temperature": self.advanced_temperature_spin.get_value(), "whispercpp_temperature_inc": self.advanced_temperature_inc_spin.get_value(), "whispercpp_entropy_thold": self.advanced_entropy_thold_spin.get_value(), @@ -8260,7 +8404,15 @@ For now, the engine has been reverted to VOSK.""" logger.warning("Ignoring apply_settings(); another apply is already in progress") return False + global _apply_settings_generation + _apply_settings_generation += 1 settings = self.get_selected_settings() + # Same consumption as _auto_apply_settings: an apply snapshots the + # deferred text fields, so their pending-edit flags are done. + self._advanced_prompt_dirty = False + self._language_candidates_dirty = False + self._pending_text_edits = None + self._pending_text_edit_baseline = None logger.info(f"Applying settings: {settings}") engine = settings.get("engine", "vosk") @@ -8370,9 +8522,11 @@ For now, the engine has been reverted to VOSK.""" # 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(force_reinit=force_reinit, **settings) - self._save_selected_settings(settings) + # pointing at a model that never made it to disk. The lock orders + # this snapshot against _persist_pending_text_edits' deferred one. + with _apply_settings_lock: + self.speech_engine.reconfigure(force_reinit=force_reinit, **settings) + self._save_selected_settings(settings) logger.info("Settings applied successfully.") return True diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 59b57f2..1497318 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -858,6 +858,7 @@ class TestTypedAccessors(unittest.TestCase): self.assertTrue(advanced["whispercpp_no_timestamps"]) self.assertTrue(advanced["whispercpp_no_context"]) self.assertEqual(advanced["whispercpp_initial_prompt"], "") + self.assertEqual(advanced["whispercpp_language_candidates"], "") self.assertEqual(advanced["whispercpp_temperature"], 0.0) self.assertEqual(advanced["whispercpp_temperature_inc"], -1.0) self.assertEqual(advanced["whispercpp_entropy_thold"], 2.4) @@ -879,6 +880,7 @@ class TestTypedAccessors(unittest.TestCase): self.config_manager.set("advanced", "whispercpp_temperature", 0.5) self.config_manager.set("advanced", "whispercpp_no_timestamps", False) self.config_manager.set("advanced", "whispercpp_initial_prompt", "Meeting notes") + self.config_manager.set("advanced", "whispercpp_language_candidates", "en,es") self.config_manager.save_config() new_manager = ConfigManager() @@ -886,3 +888,4 @@ class TestTypedAccessors(unittest.TestCase): self.assertEqual(advanced["whispercpp_temperature"], 0.5) self.assertFalse(advanced["whispercpp_no_timestamps"]) self.assertEqual(advanced["whispercpp_initial_prompt"], "Meeting notes") + self.assertEqual(advanced["whispercpp_language_candidates"], "en,es") diff --git a/tests/test_main.py b/tests/test_main.py index e262765..e073bc0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -234,6 +234,7 @@ class TestMainModule(unittest.TestCase): whispercpp_no_timestamps=True, whispercpp_no_context=True, whispercpp_initial_prompt="", + whispercpp_language_candidates="", whispercpp_temperature=0.0, whispercpp_temperature_inc=-1.0, whispercpp_entropy_thold=2.4, diff --git a/tests/test_recognition_manager_advanced.py b/tests/test_recognition_manager_advanced.py index 41e5b2d..cd5ee76 100644 --- a/tests/test_recognition_manager_advanced.py +++ b/tests/test_recognition_manager_advanced.py @@ -279,6 +279,13 @@ class TestReconfigure(unittest.TestCase): mgr.reconfigure(engine="vosk") self.assertEqual(mgr.engine, "vosk") + def test_reconfigure_language_candidates(self) -> None: + mgr = _make_manager() + mgr.state = RecognitionState.IDLE + with patch.object(mgr, "_init_whispercpp"): + mgr.reconfigure(whispercpp_language_candidates="en,es") + self.assertEqual(mgr.whispercpp_language_candidates, ["en", "es"]) + class TestProcessFinalBuffer(unittest.TestCase): def test_process_final_buffer_whispercpp(self): @@ -310,6 +317,18 @@ class TestProcessFinalBuffer(unittest.TestCase): class TestTranscribeWhispercpp(unittest.TestCase): + def test_normalize_language_candidates(self) -> None: + self.assertEqual( + SpeechRecognitionManager._normalize_language_candidates(" en-US, es ; fr "), + ["en", "es", "fr"], + ) + self.assertEqual( + SpeechRecognitionManager._normalize_language_candidates(["EN", "es", "en"]), + ["en", "es"], + ) + self.assertEqual(SpeechRecognitionManager._normalize_language_candidates(""), []) + self.assertEqual(SpeechRecognitionManager._normalize_language_candidates(None), []) + def test_transcribe_success(self): mgr = _make_manager() mgr.language = "en-us" @@ -381,6 +400,80 @@ class TestTranscribeWhispercpp(unittest.TestCase): with patch.dict("sys.modules", {"numpy": mock_np}): result = mgr._transcribe_with_whispercpp([b"\x00\x00" * 512]) + self.assertEqual(result, "bonjour") + mgr.model.transcribe.assert_called_once() + self.assertIsNone(mgr.model.transcribe.call_args.kwargs["language"]) + + def test_transcribe_specific_language(self) -> None: + mgr = _make_manager() + mgr.language = "es" + mock_segment = MagicMock() + mock_segment.text = "hola" + mgr.model = MagicMock() + mgr.model.transcribe.return_value = [mock_segment] + + mock_np = MagicMock() + mock_np.frombuffer.return_value = MagicMock(__len__=lambda s: 16000) + mock_np.frombuffer.return_value.astype.return_value = mock_np.frombuffer.return_value + mock_np.int16 = "int16" + mock_np.float32 = "float32" + + with patch.dict("sys.modules", {"numpy": mock_np}): + result = mgr._transcribe_with_whispercpp([b"\x00\x00" * 512]) + + self.assertEqual(result, "hola") + mgr.model.transcribe.assert_called_once() + self.assertEqual(mgr.model.transcribe.call_args.kwargs["language"], "es") + + def test_transcribe_candidate_languages_restricts_to_english(self) -> None: + mgr = _make_manager(whispercpp_language_candidates="en,es") + mgr.language = "auto" + mock_segment = MagicMock() + mock_segment.text = "hello" + mgr.model = MagicMock() + mgr.model.auto_detect_language.return_value = (("en", 0.8), {"en": 0.7, "es": 0.3}) + mgr.model.transcribe.return_value = [mock_segment] + + mock_np = MagicMock() + mock_np.frombuffer.return_value = MagicMock(__len__=lambda s: 16000) + mock_np.frombuffer.return_value.astype.return_value = mock_np.frombuffer.return_value + mock_np.int16 = "int16" + mock_np.float32 = "float32" + + with patch.dict("sys.modules", {"numpy": mock_np}): + result = mgr._transcribe_with_whispercpp([b"\x00\x00" * 512]) + + self.assertEqual(result, "hello") + mgr.model.auto_detect_language.assert_called_once() + mgr.model.transcribe.assert_called_once() + self.assertEqual(mgr.model.transcribe.call_args.kwargs["language"], "en") + + def test_transcribe_candidate_languages_restricts_to_spanish(self) -> None: + mgr = _make_manager(whispercpp_language_candidates=["en", "es", "fr"]) + mgr.language = "auto" + mock_segment = MagicMock() + mock_segment.text = "hola" + mgr.model = MagicMock() + mgr.model.auto_detect_language.return_value = ( + ("de", 0.6), + {"en": 0.2, "es": 0.5, "fr": 0.1}, + ) + mgr.model.transcribe.return_value = [mock_segment] + + mock_np = MagicMock() + mock_np.frombuffer.return_value = MagicMock(__len__=lambda s: 16000) + mock_np.frombuffer.return_value.astype.return_value = mock_np.frombuffer.return_value + mock_np.int16 = "int16" + mock_np.float32 = "float32" + + with patch.dict("sys.modules", {"numpy": mock_np}): + result = mgr._transcribe_with_whispercpp([b"\x00\x00" * 512]) + + self.assertEqual(result, "hola") + mgr.model.auto_detect_language.assert_called_once() + mgr.model.transcribe.assert_called_once() + self.assertEqual(mgr.model.transcribe.call_args.kwargs["language"], "es") + class TestTranscribeWhisper(unittest.TestCase): def test_transcribe_success(self): diff --git a/tests/test_settings_model_state.py b/tests/test_settings_model_state.py index 7e2a890..7e79286 100644 --- a/tests/test_settings_model_state.py +++ b/tests/test_settings_model_state.py @@ -1238,9 +1238,7 @@ def test_parakeet_recognition_does_not_consume_language(): def test_normalize_language_for_engine_forces_auto_for_parakeet(): """CLI/saved non-auto languages must not survive for Parakeet.""" - from vocalinux.speech_recognition.recognition_manager import ( - normalize_language_for_engine, - ) + from vocalinux.speech_recognition.recognition_manager import normalize_language_for_engine assert normalize_language_for_engine("parakeet", "fr") == "auto" assert normalize_language_for_engine("parakeet", "en-us") == "auto" @@ -1361,3 +1359,56 @@ def test_speech_manager_command_processor_follows_language(): manager.reconfigure(language="en-us", force_download=False) assert manager.command_processor.language == "en-us" assert "virgola" not in manager.command_processor.text_commands + + +def test_deferred_text_edits_persist_once_the_inflight_apply_finishes( + settings_dialog: Any, dialog_class: type[Any] +) -> None: + """Edits stashed at close are saved when no newer apply has begun.""" + dialog = _dialog_stub() + dialog._pending_text_edits = {"whispercpp_language_candidates": "en,es"} + dialog._pending_apply_generation = settings_dialog._apply_settings_generation + + with patch.object(settings_dialog.threading, "Thread", _InlineThread): + dialog_class._persist_pending_text_edits(dialog) + + dialog.speech_engine.reconfigure.assert_called_once_with(whispercpp_language_candidates="en,es") + dialog.config_manager.set.assert_called_once_with( + "advanced", "whispercpp_language_candidates", "en,es" + ) + dialog.config_manager.save_settings.assert_called_once() + + +def test_deferred_text_edits_dropped_when_a_newer_apply_ran( + settings_dialog: Any, dialog_class: type[Any] +) -> None: + """A newer apply owns the engine and config; the stashed snapshot stays stale.""" + dialog = _dialog_stub() + dialog._pending_text_edits = {"whispercpp_language_candidates": "en,es"} + dialog._pending_apply_generation = settings_dialog._apply_settings_generation + # A newer apply began after the edits were stashed. + settings_dialog._apply_settings_generation += 1 + + with patch.object(settings_dialog.threading, "Thread", _InlineThread): + dialog_class._persist_pending_text_edits(dialog) + + dialog.speech_engine.reconfigure.assert_not_called() + dialog.config_manager.set.assert_not_called() + dialog.config_manager.save_settings.assert_not_called() + + +def test_deferred_text_edits_skipped_when_the_inflight_apply_landed_them( + settings_dialog: Any, dialog_class: type[Any] +) -> None: + """No second model restart when the saved values already match the edits.""" + dialog = _dialog_stub() + dialog._pending_text_edits = {"whispercpp_language_candidates": "en,es"} + dialog._pending_apply_generation = settings_dialog._apply_settings_generation + dialog.config_manager.get.side_effect = lambda section, key, default=None: "en,es" + + with patch.object(settings_dialog.threading, "Thread", _InlineThread): + dialog_class._persist_pending_text_edits(dialog) + + dialog.speech_engine.reconfigure.assert_not_called() + dialog.config_manager.set.assert_not_called() + dialog.config_manager.save_settings.assert_not_called()