feat(settings): bilingual dictation support with deferred settings edits (#424)

* feat: add configurable Whisper language candidates

* fix(settings): defer language candidate edits and tolerate list config

The candidates entry applied settings on every keystroke, restarting the
whisper.cpp model per character, and edits typed while an apply held the
guard were dropped so the saved value could differ from the entry. Defer
application like the initial prompt: mark edits dirty and apply on page
change or close; _finish_auto_apply re-applies edits that landed mid-apply,
and _persist_pending_text_edits saves them if the dialog closed first.

A JSON list in config also crashed the dialog on open because
Gtk.Entry.set_text needs a string; populate the entry through the engine's
_normalize_language_candidates so both stored forms render the same text.

* fix(recognition): validate whisper.cpp language candidates before selection

auto_detect_language's probability map names every language the model can
return, but candidate selection scored configured codes against it with a
0.0 default, so a typo or "auto" could still win the max() and reach
transcription. Only codes present in the probability map are eligible now;
unsupported codes are logged and skipped, and when none qualify the
unrestricted detection result is used instead of forcing a code the model
never scored.

Also add the missing input annotation on _normalize_language_candidates.

* fix(settings): persist deferred text edits off the GTK main loop

_finish_auto_apply runs on the main loop and called
_persist_pending_text_edits synchronously, so reconfigure() restarted
the model in-process and froze the UI when the dialog closed mid-apply.
Run the reconfigure + config save on a worker thread, matching the
normal apply path.

Also annotate _on_language_candidates_changed's signature.

* fix(settings): guard deferred saves and annotate new candidate tests

* fix(settings): reconcile the engine to winning settings after a mid-restart apply

* fix(settings): drop superseded deferred edits instead of saving them

A dialog closed mid-apply stashes its pending text edits for a worker to
persist after the running apply finishes. The worker's mtime check could
not tell the in-flight apply from a newer one started in a reopened
dialog, so the older snapshot could reconfigure the shared engine and
save over the settings that won.

Serialize applies and the deferred persist on a shared lock and order
them with a generation counter bumped when an apply begins. The worker
now skips reconfigure and the save entirely when a newer apply has
begun, and skips the restart when the running apply already landed the
same values.

* fix(settings): merge deferred text edits per key instead of dropping them

Closing the dialog mid-apply stashes the typed prompt/candidate values
with the apply's generation. When a reopened dialog applies newer
settings first, the generation check used to discard the whole stash —
losing the closed dialog's edits even where the newer apply never
touched those keys.

Persist keeps a pre-edit baseline per key: after a newer apply lands,
only keys it actually changed are dropped; the rest still reconfigure
the engine and save, so a stale value can never reach the shared engine
while untouched edits are no longer lost.

* refactor(settings): narrow deferred-edit persist exception with context

* chore: retrigger ci

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
juanfradb
2026-09-30 00:16:44 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent b71164a214
commit 5c031e64fb
8 changed files with 386 additions and 13 deletions
+3
View File
@@ -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),
@@ -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
+1
View File
@@ -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,
+163 -9
View File
@@ -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
+3
View File
@@ -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")
+1
View File
@@ -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,
@@ -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):
+54 -3
View File
@@ -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()