* feat(language): follow the active keyboard layout while dictating Adds a "Follow keyboard layout" switch to Settings -> Speech Model -> Advanced. With it on, the active keyboard layout is re-read at the start of every dictation and the matching language is used, so someone who switches layouts to work in two languages gets the right one without opening Settings. #796 already seeded the language once on first run, but from the *configured* primary layout, which never changes as the user switches between them: a "fr,us" setup reports fr forever. detect_active_keyboard_ layout() reads GNOME's input sources instead. It takes mru-sources rather than the current index, because that index goes stale -- observed still reading 0 after a switch to the second layout -- which is why the IBus injection path already trusts the MRU list (#497, #738). An active source that is an IBus engine rather than an xkb layout is a real answer that cannot be mapped, so the language is left to the engine's own auto-detect. Borrowing the configured xkb layout there would pin English for someone typing Japanese through mozc-jp. The mode is a switch over the language picker rather than an entry inside it, since it is a mode and not a language. While on, the picker is disabled and shows what the layout resolves to; turning it off pins that. A language change no longer re-initialises the engine when the loaded model takes the language per utterance, which is the whole whisper family, so following a layout costs a settings read rather than a model reload. VOSK loads one model per language, Parakeet ignores the picker, and an English-only model has no non-English weights, so Settings refuses the mode for all three rather than offering a switch that does nothing. Fixes #821 * fix(language): keep follow mode armed and map IBus engines Swap English-only whisper.cpp weights to the multilingual sibling while passing the layout sentinel into reconfigure, so follow mode stays armed. Map common GNOME IBus engines onto catalogue languages instead of leaving them unmapped. * fix(language): stop IBus hyphen false-maps and gate the sibling swap The last-token hyphen heuristic mapped mozc-us and foo-us to English. Restrict it to *-jp/*-kr and name Mozc's extra engines as Japanese. Only whisper.cpp may consult the whisper.cpp download catalog. * fix(language): keep multilingual weights while following layout The picker shows the active layout while follow is on, often en-us. Deriving whisper.cpp .en from that display made Settings refuse the mode as English-only, then the next auto-apply persisted a concrete language and disarmed follow. Use LANGUAGE_FOLLOWS_LAYOUT for .en vs multilingual derivation instead, including on the load path before the switch is synced. * fix(language): type follow helpers and log sibling-swap failures * fix(test): stub follow-layout model rec so mocked psutil does not blow up follow-mode English-only guard calls get_recommended_whispercpp_model, which reads RAM through psutil. Full CI leaves a MagicMock in sys.modules["psutil"], so ram_gb >= 16 raises TypeError. Patch the settings_dialog alias to return a multilingual tiny rec so the assertion still holds without real RAM. * fix(language): catch specific errors on layout lookup and sibling swap * fix(language): swallow more sibling-swap reconfigure failures ImportError, TypeError, AttributeError, and ChecksumError from the multilingual swap must not abort dictation or disarm follow mode. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
354 lines
13 KiB
Python
354 lines
13 KiB
Python
"""The follow-keyboard-layout mode is a switch over the language picker (#821)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
import vocalinux.ui
|
|
from vocalinux.utils.model_checksums import ChecksumError
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def settings_dialog() -> Iterator[Any]:
|
|
"""Import settings_dialog with real base classes for its GTK subclasses.
|
|
|
|
Same approach as tests/test_simple_model_settings.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: Any) -> type[Any]:
|
|
return settings_dialog.SettingsDialog
|
|
|
|
|
|
def test_mode_is_off_for_a_bare_mock_dialog(settings_dialog: Any) -> None:
|
|
"""The guard must not read truthy on a Mock, or it inverts every caller.
|
|
|
|
Written as a module function precisely because ``self._method()`` on a Mock
|
|
dialog returns a truthy Mock; an earlier version of this guard silently
|
|
skipped the whole simple-mode sync for that reason.
|
|
"""
|
|
assert settings_dialog._is_following_layout(Mock()) is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[(True, True), (False, False), (None, False), ("yes", False), (1, False)],
|
|
)
|
|
def test_mode_reads_the_cached_bool_strictly(
|
|
settings_dialog: Any, value: object, expected: bool
|
|
) -> None:
|
|
dialog = Mock()
|
|
dialog._follow_layout_active = value
|
|
assert settings_dialog._is_following_layout(dialog) is expected
|
|
|
|
|
|
def test_mode_is_off_when_the_attribute_is_absent(settings_dialog: Any) -> None:
|
|
class Bare:
|
|
pass
|
|
|
|
assert settings_dialog._is_following_layout(Bare()) is False
|
|
|
|
|
|
def _controls_stub(switch_on: bool, supported: bool, english_only: bool = False) -> Mock:
|
|
dialog = Mock()
|
|
dialog.follow_layout_switch.get_active.return_value = switch_on
|
|
dialog._follow_layout_supported.return_value = supported
|
|
dialog._is_selected_whispercpp_model_english_only.return_value = english_only
|
|
dialog._processing_language_change = False
|
|
return dialog
|
|
|
|
|
|
def test_turning_the_mode_on_disables_the_language_picker(dialog_class: type[Any]) -> None:
|
|
"""The picker still shows a language, but the mode owns it."""
|
|
dialog = _controls_stub(switch_on=True, supported=True)
|
|
|
|
dialog_class._sync_follow_layout_controls(dialog)
|
|
|
|
assert dialog._follow_layout_active is True
|
|
dialog.follow_layout_row.set_sensitive.assert_called_once_with(True)
|
|
dialog.language_row.set_sensitive.assert_called_once_with(False)
|
|
|
|
|
|
def test_leaving_the_mode_off_leaves_the_picker_usable(dialog_class: type[Any]) -> None:
|
|
dialog = _controls_stub(switch_on=False, supported=True)
|
|
|
|
dialog_class._sync_follow_layout_controls(dialog)
|
|
|
|
assert dialog._follow_layout_active is False
|
|
dialog.language_row.set_sensitive.assert_called_once_with(True)
|
|
|
|
|
|
def test_an_engine_that_cannot_follow_turns_the_mode_off(dialog_class: type[Any]) -> None:
|
|
"""VOSK loads a model per language; Parakeet ignores the picker entirely."""
|
|
dialog = _controls_stub(switch_on=True, supported=False)
|
|
|
|
dialog_class._sync_follow_layout_controls(dialog)
|
|
|
|
dialog.follow_layout_row.set_sensitive.assert_called_once_with(False)
|
|
dialog.follow_layout_switch.set_active.assert_called_once_with(False)
|
|
assert dialog._follow_layout_active is False
|
|
# The picker is the only way to choose a language on those engines.
|
|
dialog.language_row.set_sensitive.assert_called_once_with(True)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"engine,expected",
|
|
[
|
|
("whisper_cpp", True),
|
|
("whisper", True),
|
|
("faster_whisper", True),
|
|
("remote_api", True),
|
|
("vosk", False),
|
|
("parakeet", False),
|
|
],
|
|
)
|
|
def test_engine_support(dialog_class: type[Any], engine: str, expected: bool) -> None:
|
|
dialog = Mock()
|
|
assert dialog_class._follow_layout_supported(dialog, engine) is expected
|
|
|
|
|
|
def test_an_english_only_model_cannot_follow_a_layout(dialog_class: type[Any]) -> None:
|
|
"""small.en has no non-English weights, so the mode would silently do nothing."""
|
|
dialog = _controls_stub(switch_on=True, supported=True, english_only=True)
|
|
|
|
dialog_class._sync_follow_layout_controls(dialog)
|
|
|
|
dialog.follow_layout_row.set_sensitive.assert_called_once_with(False)
|
|
dialog.follow_layout_switch.set_active.assert_called_once_with(False)
|
|
assert dialog._follow_layout_active is False
|
|
|
|
|
|
# --- The mode must stay armed across dictations -----------------------------
|
|
|
|
|
|
def _manager_stub(model_size: str, language: str = "en-us") -> Any:
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
manager = SpeechRecognitionManager.__new__(SpeechRecognitionManager)
|
|
manager.engine = "whisper_cpp"
|
|
manager.model_size = model_size
|
|
manager.language = language
|
|
manager.language_preference = "layout"
|
|
manager._faster_whisper_engine = None
|
|
manager.command_processor = MagicMock()
|
|
manager.reconfigure = MagicMock()
|
|
return manager
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
OSError("gsettings gone"),
|
|
FileNotFoundError("gsettings"),
|
|
subprocess.SubprocessError(),
|
|
ValueError("bad parse"),
|
|
TypeError("unexpected type"),
|
|
KeyError("missing"),
|
|
AttributeError("no attr"),
|
|
],
|
|
)
|
|
def test_layout_lookup_failure_falls_back_to_auto(error: Exception) -> None:
|
|
"""A layout helper error must not block dictation."""
|
|
import vocalinux.speech_recognition.recognition_manager as rm
|
|
|
|
with patch.object(rm, "language_for_active_layout", side_effect=error):
|
|
assert rm.resolve_language_preference("layout") == "auto"
|
|
|
|
|
|
def test_following_a_layout_keeps_the_mode_armed() -> None:
|
|
"""A switch must not turn the sentinel into a concrete language.
|
|
|
|
reconfigure() stores whatever language it is handed as the new preference,
|
|
so calling it here would disarm the mode after a single switch and the
|
|
remaining dictations would silently stop following anything.
|
|
"""
|
|
import vocalinux.speech_recognition.recognition_manager as rm
|
|
|
|
manager = _manager_stub("small")
|
|
with patch.object(rm, "language_for_active_layout", return_value="fr"):
|
|
manager._refresh_language_from_layout()
|
|
assert manager.language == "fr"
|
|
assert manager.language_preference == "layout"
|
|
|
|
# And again, from the other direction.
|
|
with patch.object(rm, "language_for_active_layout", return_value="en-us"):
|
|
manager._refresh_language_from_layout()
|
|
assert manager.language == "en-us"
|
|
assert manager.language_preference == "layout"
|
|
manager.reconfigure.assert_not_called()
|
|
|
|
|
|
def test_an_english_only_model_reloads_the_multilingual_sibling() -> None:
|
|
"""English-only weights cannot honour a non-English layout; swap the sibling.
|
|
|
|
reconfigure() stores whatever language it is handed as the new preference,
|
|
so the resolved language must never be passed in -- only the layout sentinel
|
|
and the multilingual model id.
|
|
"""
|
|
import vocalinux.speech_recognition.recognition_manager as rm
|
|
|
|
manager = _manager_stub("small.en")
|
|
with patch.object(rm, "language_for_active_layout", return_value="fr"):
|
|
with patch.object(rm, "is_model_downloaded", return_value=True):
|
|
manager._refresh_language_from_layout()
|
|
|
|
manager.reconfigure.assert_called_once_with(model_size="small", language="layout")
|
|
assert manager.language_preference == "layout"
|
|
assert "fr" not in manager.reconfigure.call_args.args
|
|
|
|
|
|
def test_an_english_only_model_skips_reload_when_already_english() -> None:
|
|
"""The loaded English-only model already matches an English layout."""
|
|
import vocalinux.speech_recognition.recognition_manager as rm
|
|
|
|
manager = _manager_stub("small.en")
|
|
with patch.object(rm, "language_for_active_layout", return_value="en-us"):
|
|
manager._refresh_language_from_layout()
|
|
|
|
manager.reconfigure.assert_not_called()
|
|
assert manager.language == "en-us"
|
|
assert manager.language_preference == "layout"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
ImportError("missing backend"),
|
|
TypeError("bad args"),
|
|
AttributeError("no attr"),
|
|
ChecksumError("digest mismatch"),
|
|
],
|
|
)
|
|
def test_sibling_swap_reconfigure_failure_does_not_abort_dictation(
|
|
error: Exception,
|
|
) -> None:
|
|
"""A sibling-swap reconfigure error must not block dictation or disarm follow."""
|
|
import vocalinux.speech_recognition.recognition_manager as rm
|
|
|
|
manager = _manager_stub("small.en")
|
|
manager.reconfigure.side_effect = error
|
|
with patch.object(rm, "language_for_active_layout", return_value="fr"):
|
|
with patch.object(rm, "is_model_downloaded", return_value=True):
|
|
manager._refresh_language_from_layout()
|
|
|
|
manager.reconfigure.assert_called_once_with(model_size="small", language="layout")
|
|
assert manager.language_preference == "layout"
|
|
|
|
|
|
def test_an_english_only_model_skips_reload_when_sibling_is_missing() -> None:
|
|
"""Do not fetch the multilingual sibling on the dictation hotkey."""
|
|
import vocalinux.speech_recognition.recognition_manager as rm
|
|
|
|
manager = _manager_stub("small.en")
|
|
with patch.object(rm, "language_for_active_layout", return_value="fr"):
|
|
with patch.object(rm, "is_model_downloaded", return_value=False):
|
|
manager._refresh_language_from_layout()
|
|
|
|
manager.reconfigure.assert_not_called()
|
|
assert manager.language_preference == "layout"
|
|
|
|
|
|
@pytest.mark.parametrize("engine", ["whisper", "faster_whisper"])
|
|
def test_english_only_non_whispercpp_does_not_use_whispercpp_catalog(engine: str) -> None:
|
|
"""Follow is offered for these engines; the whisper.cpp download check is the wrong catalog."""
|
|
import vocalinux.speech_recognition.recognition_manager as rm
|
|
|
|
manager = _manager_stub("small.en")
|
|
manager.engine = engine
|
|
with patch.object(rm, "language_for_active_layout", return_value="fr"):
|
|
with patch.object(rm, "is_model_downloaded", return_value=True) as downloaded:
|
|
manager._refresh_language_from_layout()
|
|
|
|
downloaded.assert_not_called()
|
|
manager.reconfigure.assert_not_called()
|
|
assert manager.language_preference == "layout"
|
|
|
|
|
|
def _variant_dialog(
|
|
language: str = "en-us",
|
|
follow_active: bool = False,
|
|
follow_saved: bool = False,
|
|
initializing: bool = False,
|
|
) -> Mock:
|
|
dialog = Mock()
|
|
dialog.language = language
|
|
dialog.language_combo.get_active_id.return_value = language
|
|
dialog.config_manager.get_model_variant_for_engine.return_value = ""
|
|
dialog._follow_layout_active = follow_active
|
|
dialog._follow_layout_saved = follow_saved
|
|
dialog._initializing = initializing
|
|
return dialog
|
|
|
|
|
|
def test_follow_mode_does_not_derive_english_only_from_the_displayed_layout(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""The picker shows the current layout while follow is on, often English.
|
|
|
|
Deriving .en from that display would make Settings refuse the mode as
|
|
English-only, and the next auto-apply would persist a concrete language.
|
|
"""
|
|
dialog = _variant_dialog(follow_active=True)
|
|
|
|
assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny"
|
|
assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny"
|
|
with patch.object(
|
|
settings_dialog,
|
|
"get_recommended_whispercpp_model",
|
|
return_value=("tiny", "test"),
|
|
):
|
|
recommended, _ = dialog_class._get_recommended_whispercpp_model_for_language(dialog)
|
|
assert not settings_dialog.is_english_only_whispercpp_model(recommended)
|
|
|
|
|
|
def test_follow_mode_load_path_keeps_multilingual_before_the_switch_is_synced(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
"""On load the saved sentinel is follow, but _follow_layout_active is still false."""
|
|
dialog = _variant_dialog(follow_saved=True, initializing=True)
|
|
|
|
assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny"
|
|
assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny"
|
|
|
|
|
|
def test_follow_off_still_derives_english_only_from_an_english_picker(
|
|
settings_dialog: Any, dialog_class: type[Any]
|
|
) -> None:
|
|
dialog = _variant_dialog()
|
|
|
|
assert dialog_class._resolve_saved_whispercpp_variant(dialog, "tiny") == "tiny.en"
|
|
assert dialog_class._get_default_whispercpp_variant_for_size(dialog, "tiny") == "tiny.en"
|
|
|
|
|
|
def test_layout_sentinel_does_not_retarget_a_bare_size_onto_english_only(
|
|
settings_dialog: Any,
|
|
) -> None:
|
|
assert settings_dialog._whispercpp_variant_for_language("tiny", "layout") == "tiny"
|
|
assert settings_dialog._whispercpp_variant_for_language("tiny.en", "layout") == "tiny"
|