feat(language): follow the active keyboard layout while dictating (#837)

* 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>
This commit is contained in:
Vivek A Nadig
2026-09-29 01:06:32 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 9646011e77
commit 7bdbedce0b
7 changed files with 1103 additions and 12 deletions
@@ -13,6 +13,7 @@ import logging
import os
import queue
import re
import subprocess
import sys
import threading
import time
@@ -24,6 +25,7 @@ if TYPE_CHECKING:
from ..common_types import RecognitionState
from ..ui.audio_feedback import play_error_sound, play_start_sound, play_stop_sound
from ..ui.config_manager import _multilingual_sibling
from ..utils import faster_whisper_model_info as faster_whisper
from ..utils import parakeet_model_info as parakeet
from ..utils.host_process import host_env
@@ -34,13 +36,19 @@ from ..utils.model_checksums import (
write_verification_stamp,
)
from ..utils.paths import models_dir
from ..utils.system_language import LANGUAGE_FOLLOWS_LAYOUT, language_for_active_layout
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO
from ..utils.whisper_model_info import (
migrate_legacy_checkpoint_names,
whisper_model_file,
whisper_model_url,
)
from ..utils.whispercpp_model_info import WHISPERCPP_MODEL_INFO, get_model_path, is_model_downloaded
from ..utils.whispercpp_model_info import (
WHISPERCPP_MODEL_INFO,
get_model_path,
is_english_only_model,
is_model_downloaded,
)
from ..version import __version__
from .command_processor import CommandProcessor
from .silero_vad import SILERO_CHUNK_SIZE, load_silero_vad
@@ -101,6 +109,35 @@ def normalize_language_for_engine(engine: str, language: str) -> str:
return language
def resolve_language_preference(language: str) -> str:
"""Turn a stored language preference into one an engine can consume (#821).
``layout`` is not a language: it asks for whatever the active keyboard
layout points at, which is why this resolves fresh on every call rather
than once at startup. A layout that cannot be read, or that maps to nothing
in the catalogue, degrades to ``auto`` -- the same answer the engine would
have reached on its own, and better than refusing to dictate.
"""
if language != LANGUAGE_FOLLOWS_LAYOUT:
return language
try:
resolved = language_for_active_layout(SUPPORTED_LANGUAGES)
except (
OSError,
FileNotFoundError,
subprocess.SubprocessError,
ValueError,
TypeError,
KeyError,
AttributeError,
) as exc:
logger.debug(f"Keyboard layout lookup failed: {exc}")
return "auto"
return resolved or "auto"
# ALSA error handler to suppress warnings during PyAudio initialization
def _setup_alsa_error_handler():
"""Set up an error handler to suppress ALSA warnings."""
@@ -1086,7 +1123,11 @@ class SpeechRecognitionManager:
"""
self.engine = engine
self.model_size = model_size
self.language = normalize_language_for_engine(engine, language)
# The stored preference, which may be the "layout" sentinel. self.language
# is always something an engine can consume, re-resolved from this at the
# start of every dictation while the sentinel is in force (#821).
self.language_preference = language
self.language = normalize_language_for_engine(engine, resolve_language_preference(language))
self.stop_sound_guard_ms = kwargs.get("stop_sound_guard_ms", 200)
self.state = RecognitionState.IDLE
self.audio_thread = None
@@ -1226,6 +1267,14 @@ class SpeechRecognitionManager:
else:
raise ValueError(f"Unsupported speech recognition engine: {self.engine}")
#: Class-level default so a manager built without ``__init__`` still answers
#: "not following the layout" instead of raising on the dictation path.
language_preference: str = "auto"
#: Logged once rather than per dictation, so an unsupported pairing does not
#: spam the log on every hotkey press.
_warned_follow_layout_unsupported: bool = False
# Every live field ``reconfigure()`` can write. A failed engine switch
# restores this set so dictation matches the persisted config, not the
# attempted-but-unsaved Settings values.
@@ -1233,6 +1282,7 @@ class SpeechRecognitionManager:
"engine",
"model_size",
"language",
"language_preference",
"vad_sensitivity",
"silence_timeout",
"audio_device_index",
@@ -1265,6 +1315,91 @@ class SpeechRecognitionManager:
for name, value in previous.items():
setattr(self, name, value)
#: Engines that read the language per utterance instead of baking it into the
#: loaded model, so changing it costs nothing.
_PER_UTTERANCE_LANGUAGE_ENGINES = ("whisper", "whisper_cpp", "faster_whisper", "remote_api")
def _can_relanguage_without_reload(self) -> bool:
"""Whether a language change can skip re-initialising the engine (#821).
The whisper family passes the language to each transcribe call, so the
loaded model does not care. VOSK loads a different model per language and
Parakeet never consumes a catalog language at all, so both keep the
restart. An English-only whisper model is excluded as well: it cannot
honour a non-English layout, and only a reload can fetch one that can.
"""
if self.engine not in self._PER_UTTERANCE_LANGUAGE_ENGINES:
return False
if self.engine == "remote_api":
# The language rides along in the request body; nothing is loaded here.
return True
return not is_english_only_model(self.model_size)
def _refresh_language_from_layout(self) -> None:
"""Re-point the engine at the active keyboard layout before dictating.
A no-op unless the user asked for the follow mode. Failures are logged
and swallowed: dictating in the previous language beats not dictating.
"""
if self.language_preference != LANGUAGE_FOLLOWS_LAYOUT:
return
target = normalize_language_for_engine(
self.engine, resolve_language_preference(LANGUAGE_FOLLOWS_LAYOUT)
)
if target == self.language:
return
if self._can_relanguage_without_reload():
logger.info(f"Keyboard layout changed: dictating in {target}")
self.language = target
self.command_processor.set_language(target)
if self._faster_whisper_engine is not None:
# Read per utterance by the engine's own _normalize_language.
self._faster_whisper_engine.language = target
return
# English-only whisper.cpp weights cannot honour a non-English layout.
# Swap to the multilingual sibling and keep the follow-mode sentinel;
# handing ``target`` to reconfigure() would store it as the new
# preference and disarm the mode after a single switch.
# is_model_downloaded / _multilingual_sibling are the whisper.cpp
# catalog: do not consult them for whisper / faster_whisper .en ids.
if self.engine == "whisper_cpp":
sibling = _multilingual_sibling(self.model_size)
# No surprise fetch on the hotkey: only swap if the sibling is already downloaded.
if sibling != self.model_size and is_model_downloaded(sibling):
try:
self.reconfigure(model_size=sibling, language=LANGUAGE_FOLLOWS_LAYOUT)
return
except (
RuntimeError,
ValueError,
FileNotFoundError,
OSError,
ImportError,
TypeError,
AttributeError,
ChecksumError,
):
logger.error(
"Failed to swap to multilingual sibling %r for layout "
"language %s (engine=%s, model=%s)",
sibling,
target,
self.engine,
self.model_size,
exc_info=True,
)
if not self._warned_follow_layout_unsupported:
self._warned_follow_layout_unsupported = True
logger.warning(
f"Cannot follow the keyboard layout to {target}: the loaded "
f"{self.model_size!r} model cannot transcribe it. Pick a "
"multilingual model in Settings to use this mode."
)
def _init_vosk(self):
"""Initialize the VOSK speech recognition engine."""
# VOSK doesn't support auto-detect, so fall back to en-us for "auto"
@@ -3217,6 +3352,10 @@ class SpeechRecognitionManager:
)
return False
# Last thing before listening, so the language matches the layout the
# user is typing in right now rather than the one they had at startup.
self._refresh_language_from_layout()
logger.info("Starting speech recognition")
self._update_state(RecognitionState.LISTENING)
@@ -3863,10 +4002,18 @@ class SpeechRecognitionManager:
# Whisper needs to know the language for transcription
# VOSK needs to load a different model for the new language
language_changed = False
if language is not None and language != self.language:
self.language = language
language_changed = True
restart_needed = True
if language is not None:
# Settings hands over the preference, which may be the sentinel. Keep
# it so the follow mode survives, and resolve what the engine gets.
self.language_preference = language
resolved = resolve_language_preference(language)
if resolved != self.language:
self.language = resolved
language_changed = True
# Only the engines that bake the language into the loaded model
# need the restart; see _can_relanguage_without_reload.
if not self._can_relanguage_without_reload():
restart_needed = True
# Parakeet never consumes catalog language. Apply after engine/language
# updates so switching TO parakeet also clears a leftover code.
@@ -3879,6 +4026,9 @@ class SpeechRecognitionManager:
# Command aliases follow the stored language (auto after Parakeet).
if language_changed:
self.command_processor.set_language(self.language)
if not restart_needed and self._faster_whisper_engine is not None:
# No re-init will rebuild it, so update the live engine in place.
self._faster_whisper_engine.language = self.language
# Update VOSK specific params if provided
if vad_sensitivity is not None:
+6 -1
View File
@@ -69,7 +69,9 @@ def normalize_sound_effect_tone(tone: Any) -> str:
DEFAULT_CONFIG = {
"speech_recognition": { # Changed section name
"engine": "whisper_cpp", # whisper_cpp is default; vosk/whisper/parakeet/faster_whisper/remote_api are optional
"language": "auto", # Auto-detect language (Whisper/whisper.cpp only)
# "auto" detects per utterance, "layout" follows the active keyboard
# layout (#821), anything else pins one catalog language.
"language": "auto",
"model_size": "tiny", # Current model size (for backward compatibility)
"vosk_model_size": "small", # Default model for VOSK engine
"whisper_model_size": "tiny", # Default model for Whisper engine
@@ -189,6 +191,9 @@ def resolve_whispercpp_variant(saved_model: str, pinned_variant: str, language_i
specialization. True leftover specializations (turbo, versioned large,
quantized multilingual) are still honoured.
"""
# The "layout" sentinel is deliberately not in the catalog, so it reads as
# non-English here and derives the multilingual variant -- which is what a
# mode that can land on any language needs (#821).
language_is_english = SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en"
pinned = pinned_variant.lower() if isinstance(pinned_variant, str) else ""
+170 -5
View File
@@ -56,6 +56,10 @@ from ..utils.model_choice import (
size_for_priority,
)
from ..utils.paths import models_dir # noqa: E402
from ..utils.system_language import ( # noqa: E402
LANGUAGE_FOLLOWS_LAYOUT,
language_for_active_layout,
)
from ..utils.update_checker import ( # noqa: E402
DEFAULT_UPDATE_CHANNEL,
ReleaseInfo,
@@ -277,6 +281,45 @@ def _model_specialization_display_name(model_name: str) -> str:
return "Standard multilingual"
#: Engines that can honour the follow-layout mode. VOSK loads a different model
#: per language, so following a layout would stall the hotkey on a model reload;
#: Parakeet never consumes a catalog language at all. Module level, not a class
#: attribute, so tests driving the methods against a Mock still reach it.
FOLLOW_LAYOUT_ENGINES = ("whisper", "whisper_cpp", "faster_whisper", "remote_api")
def _is_following_layout(dialog: Any) -> bool:
"""Whether the follow-keyboard-layout mode is on for this dialog (#821).
A module function reading a cached bool, deliberately not a method. Several
tests drive the dialog methods unbound against a ``Mock``, where a method
call on self resolves to a truthy Mock attribute and would invert every
guard below. The ``is True`` keeps a Mock attribute from passing either.
"""
return getattr(dialog, "_follow_layout_active", False) is True
def _language_for_whispercpp_variant(dialog: Any) -> Optional[str]:
"""Language that drives .en vs multilingual whisper.cpp derivation.
Follow-the-layout can land on any language, so it must keep multilingual
weights even when the picker is showing the current English layout. Using
that display as the derivation language selects ``.en``, Settings then
refuses the mode as English-only, and the next auto-apply persists a
concrete language and disarms follow (#821).
"""
if _is_following_layout(dialog):
return LANGUAGE_FOLLOWS_LAYOUT
# Load path: the saved mode is follow, but the switch has not been synced
# into ``_follow_layout_active`` yet. ``is True`` so a Mock stays out.
if (
getattr(dialog, "_follow_layout_saved", False) is True
and getattr(dialog, "_initializing", False) is True
):
return LANGUAGE_FOLLOWS_LAYOUT
return dialog.language_combo.get_active_id() or dialog.language
def _language_is_english(language_id: str) -> bool:
"""Return whether a language ID maps to English for Whisper."""
return SUPPORTED_LANGUAGES.get(language_id, {}).get("whisper") == "en"
@@ -640,6 +683,13 @@ MODEL_SPECIALIZATION_TOOLTIP = (
"Choose Standard multilingual unless you specifically need English-only accuracy, "
"lower-memory quantized models, Turbo speed, or a legacy large model."
)
FOLLOW_LAYOUT_TOOLTIP = (
"Re-reads your active keyboard layout at the start of every dictation and "
"picks the matching language. Useful if you switch layouts to work in more "
"than one language. Needs a multilingual model; not available for VOSK, "
"which loads a separate model per language."
)
LANGUAGE_TOOLTIP = (
"Choose the language you dictate in. Search the list. Picking a language "
"other than English switches off an English-only model."
@@ -2367,6 +2417,11 @@ class SettingsDialog(Gtk.Dialog):
# user returns to Whisper/cpp. Do not store engine-coerced fallbacks here.
self._last_non_parakeet_language = None
self._engine_for_language_memory = None
# Set from the saved config in _load_and_apply_settings; the switch itself
# is the source of truth once the dialog is built (#821).
self._follow_layout_saved = False
# Cached so Mock-driven tests and early calls read a real bool.
self._follow_layout_active = False
self._applying_settings = False # Flag to prevent recursive settings application
self._advanced_prompt_dirty = False
self._about_release_url = ""
@@ -3550,6 +3605,22 @@ class SettingsDialog(Gtk.Dialog):
self.language_row.set_tooltip_text(LANGUAGE_TOOLTIP)
group.add_row(self.language_row)
# Follow-the-layout is a mode, not a language, so it is a switch over the
# picker rather than another entry inside it (#821). On, the picker is
# disabled and shows what the layout currently resolves to; off, whatever
# it shows becomes the pinned language again.
self.follow_layout_switch = Gtk.Switch()
self.follow_layout_switch.set_valign(Gtk.Align.CENTER)
self.follow_layout_switch.set_tooltip_text(FOLLOW_LAYOUT_TOOLTIP)
self.follow_layout_row = PreferenceRow(
title="Follow keyboard layout",
subtitle="Dictate in the language of whichever layout you are typing in",
widget=self.follow_layout_switch,
keywords=("keyboard", "layout", "follow", "bilingual", "language", "switch"),
)
self.follow_layout_row.set_tooltip_text(FOLLOW_LAYOUT_TOOLTIP)
group.add_row(self.follow_layout_row)
# Lives inside the revealer built above, so the Advanced switch slides it out.
self.advanced_box.pack_start(group, False, False, 0)
@@ -3645,6 +3716,7 @@ class SettingsDialog(Gtk.Dialog):
self.model_combo.connect("changed", self._on_model_changed)
self.model_variant_combo.connect("changed", self._on_model_variant_changed)
self.language_combo.connect("changed", self._on_language_changed)
self.follow_layout_switch.connect("notify::active", self._on_follow_layout_toggled)
def _on_remote_api_settings_changed(self, widget):
"""Handle remote API URL/Key/endpoint changes."""
@@ -5361,6 +5433,12 @@ class SettingsDialog(Gtk.Dialog):
settings = self._get_current_settings()
self.current_engine = settings["engine"]
self.language = settings["language"]
# The picker holds languages, not modes. Remember the mode separately and
# seed the picker with whatever the layout resolves to right now, so the
# disabled row reads as a live answer instead of going blank (#821).
self._follow_layout_saved = self.language == LANGUAGE_FOLLOWS_LAYOUT
if self._follow_layout_saved:
self.language = language_for_active_layout(SUPPORTED_LANGUAGES) or "auto"
if self.current_engine == "parakeet":
# Config may already be auto; keep any non-auto leftover as restore seed.
self._last_non_parakeet_language = (
@@ -5464,6 +5542,10 @@ class SettingsDialog(Gtk.Dialog):
# Populate model and language options for the selected engine
self._populate_model_options()
self._sync_language_options_for_selected_model(self.language)
self.follow_layout_switch.set_active(
self._follow_layout_saved and self._follow_layout_supported()
)
self._sync_follow_layout_controls()
# Set spin button values
self.vad_spin.set_value(self.current_vad)
@@ -5546,7 +5628,7 @@ class SettingsDialog(Gtk.Dialog):
def _get_recommended_whispercpp_model_for_language(self) -> tuple[str, str]:
"""Return the recommended whisper.cpp variant for the selected language."""
recommended_model, reason = get_recommended_whispercpp_model()
language_id = self.language_combo.get_active_id() or self.language
language_id = _language_for_whispercpp_variant(self)
return _recommended_whispercpp_variant_for_language(
recommended_model,
reason,
@@ -5565,7 +5647,7 @@ class SettingsDialog(Gtk.Dialog):
if recommended in WHISPERCPP_MODEL_INFO and is_whispercpp_model_downloaded(recommended):
return None
language_id = self.language_combo.get_active_id() or self.language
language_id = _language_for_whispercpp_variant(self)
wants_english = _language_is_english(language_id)
recommended_mb = WHISPERCPP_MODEL_INFO.get(recommended, {}).get("size_mb", 0)
@@ -5593,7 +5675,7 @@ class SettingsDialog(Gtk.Dialog):
def _get_default_whispercpp_variant_for_size(self, model_size: str) -> Optional[str]:
"""Return the default specialization for a user-selected size."""
language_id = self.language_combo.get_active_id() or self.language
language_id = _language_for_whispercpp_variant(self)
return _default_whispercpp_variant_for_size(model_size, language_id)
def _on_apply_recommendation(self, _button: Any) -> None:
@@ -5701,6 +5783,7 @@ class SettingsDialog(Gtk.Dialog):
finally:
self._processing_language_change = False
self._sync_follow_layout_controls()
self._update_language_warning()
self._update_model_picker_tooltips()
@@ -5831,7 +5914,7 @@ class SettingsDialog(Gtk.Dialog):
specialization: it is re-derived from the language currently in the combo.
"""
pinned = self.config_manager.get_model_variant_for_engine("whisper_cpp")
language_id = self.language_combo.get_active_id() or self.language
language_id = _language_for_whispercpp_variant(self)
return resolve_whispercpp_variant(saved_model_for_engine, pinned, language_id)
def _populate_whispercpp_model_options(self, saved_model_for_engine: str):
@@ -5886,11 +5969,13 @@ class SettingsDialog(Gtk.Dialog):
)
self.model_variant_combo.append(model_name, display_text)
language_id = self.language_combo.get_active_id() or self.language
language_id = _language_for_whispercpp_variant(self)
model_to_set = selected_model if selected_model in variants else None
if model_to_set:
# Bare size ids double as multilingual variants; retarget for language
# so English picks .en instead of leaving Standard multilingual stuck.
# Follow mode uses the layout sentinel here so an English *display*
# does not retarget onto .en and then refuse the mode.
model_to_set = _whispercpp_variant_for_language(model_to_set, language_id)
if model_to_set not in variants:
model_to_set = None
@@ -6332,6 +6417,14 @@ class SettingsDialog(Gtk.Dialog):
"<span foreground='#e5a50a'>⚠ This model only understands English.</span>"
)
self.language_warning.show()
elif _is_following_layout(self):
resolved = language_for_active_layout(SUPPORTED_LANGUAGES)
resolved_name = SUPPORTED_LANGUAGES.get(resolved, {}).get("name") if resolved else None
self.language_warning.set_markup(
"<span foreground='#3584e4'>Follows your active keyboard layout. "
f"Right now that is {resolved_name or 'auto-detect'}.</span>"
)
self.language_warning.show()
elif lang_info.get("warning"):
lang_name = lang_info.get("name", "This language")
self.language_warning.set_markup(
@@ -6342,6 +6435,55 @@ class SettingsDialog(Gtk.Dialog):
self.language_warning.set_markup("")
self.language_warning.hide()
def _follow_layout_supported(self, engine: Optional[str] = None) -> bool:
"""Whether the selected engine can follow the keyboard layout."""
return (engine or self._get_selected_engine()) in FOLLOW_LAYOUT_ENGINES
def _sync_follow_layout_controls(self) -> None:
"""Enable/disable the switch for the engine, and the picker for the mode."""
# An English-only model has no non-English weights, so it cannot honour
# a layout that points anywhere else. Refusing the mode here is honest;
# the alternative is a switch that silently does nothing.
supported = self._follow_layout_supported() and not (
self._is_selected_whispercpp_model_english_only()
)
self.follow_layout_row.set_sensitive(supported)
if not supported and self.follow_layout_switch.get_active():
# An engine or model that cannot run the mode must not keep it on.
self._processing_language_change = True
try:
self.follow_layout_switch.set_active(False)
finally:
self._processing_language_change = False
self._follow_layout_active = bool(self.follow_layout_switch.get_active()) and supported
active = self._follow_layout_active
# The picker still shows a language while the mode drives it -- the one
# the layout resolves to right now -- so it reads as a live readout
# rather than going blank, and turning the mode off pins what is shown.
self.language_row.set_sensitive(not active)
def _on_follow_layout_toggled(self, *_args: Any) -> None:
"""Apply the follow mode, or pin whatever the picker shows when it goes off."""
if self._initializing or self._applying_settings or self._processing_language_change:
return
if self.follow_layout_switch.get_active():
# Show what it resolves to, so the greyed-out picker stays meaningful.
resolved = language_for_active_layout(SUPPORTED_LANGUAGES)
if resolved:
self._processing_language_change = True
try:
self._set_combo_active_id_or_first(self.language_combo, resolved)
finally:
self._processing_language_change = False
self._sync_follow_layout_controls()
self._update_language_warning()
self._refresh_simple_readout()
self._auto_apply_settings()
def _on_language_changed(self, widget: Any) -> None:
"""Handle language selection change.
@@ -6421,6 +6563,27 @@ class SettingsDialog(Gtk.Dialog):
pin and dropped when Advanced is auto.
"""
language = self.language_combo.get_active_id() or self.language or "auto"
if _is_following_layout(self):
# Neither simple question can say "follow the layout", and writing an
# answer here would let the next simple edit silently replace the mode
# with whatever those two controls happen to read. Refresh only the
# part that does not depend on the language and leave them alone.
self._simple_syncing = True
try:
recommended, _ = self._get_recommended_whispercpp_model_for_language()
current = self._get_selected_whispercpp_model()
self.simple_priority_combo.set_active_id(
priority_for_size(
get_whispercpp_model_size(recommended),
get_whispercpp_model_size(current),
)
)
finally:
self._simple_syncing = False
self._update_simple_visibility()
return
is_auto = language == "auto"
stored_second = self.config_manager.get("speech_recognition", "simple_second_language", "")
@@ -7079,6 +7242,8 @@ class SettingsDialog(Gtk.Dialog):
model_size = model_id.lower() if model_id else "small"
if engine == "parakeet":
language = "auto"
elif _is_following_layout(self):
language = LANGUAGE_FOLLOWS_LAYOUT
else:
language = language_id if language_id else self._default_language_for_engine(engine)
+220
View File
@@ -11,6 +11,7 @@ so the interface language is a weak signal for *spoken* language. What someone
types in tracks what they say far more closely.
"""
import ast
import logging
import os
import re
@@ -42,6 +43,26 @@ _LAYOUT_TO_LANGUAGE = {
"vn": "vi",
}
# Common GNOME IBus engine ids. Looked up case-insensitively.
# Mozc ships mozc-jp by default; mozc-us / mozc-on / mozc-off are documented
# extra engine names for US-layout and composition-mode variants -- still Japanese.
_IBUS_ENGINE_TO_LANGUAGE = {
"mozc-jp": "ja",
"mozc-us": "ja",
"mozc-on": "ja",
"mozc-off": "ja",
"anthy": "ja",
"kkc": "ja",
"skk": "ja",
"libpinyin": "zh",
"pinyin": "zh",
"rime": "zh",
"chewing": "zh",
"hangul": "ko",
"unikey": "vi",
"bamboo": "vi",
}
# Locale territory codes that pick a specific catalogue entry.
_LOCALE_TO_LANGUAGE = {
"en_in": "en-in",
@@ -53,6 +74,12 @@ _LOCALE_TO_LANGUAGE = {
_LOCALE_ENV_VARS = ("LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG")
#: Stored in ``speech_recognition.language`` to mean "whatever the active
#: keyboard layout points at", resolved fresh at the start of every dictation
#: (#821). It is never handed to an engine; ``resolve_language_preference``
#: turns it into a real catalogue id or ``auto`` first.
LANGUAGE_FOLLOWS_LAYOUT = "layout"
def _normalise(value: str) -> str:
return value.strip().lower().replace("-", "_")
@@ -200,3 +227,196 @@ def detect_system_language(
logger.info("Could not work out a language from the system")
return None
# GNOME exposes the configured input sources as ``(type, id)`` pairs. The id
# carries an optional variant, so "fr+oss" is the oss variant of the fr layout.
_INPUT_SOURCES_SCHEMA = "org.gnome.desktop.input-sources"
def _gsettings_get(key: str) -> Optional[str]:
"""Return a raw gsettings value from the input-sources schema, or None."""
try:
result = subprocess.run(
["gsettings", "get", _INPUT_SOURCES_SCHEMA, key],
capture_output=True,
text=True,
timeout=2,
env=host_env(),
)
except (FileNotFoundError, OSError, subprocess.SubprocessError) as exc:
logger.debug(f"Could not query gsettings {key}: {exc}")
return None
if result.returncode != 0:
logger.debug(f"gsettings {key} exited with {result.returncode}")
return None
return result.stdout.strip() or None
def _parse_sources(value: Optional[str]) -> list:
"""Parse a gsettings input-source list, tolerating the ``@a(ss)`` prefix."""
text = (value or "").strip()
if text.startswith("@a(ss) "):
text = text[len("@a(ss) ") :]
try:
parsed = ast.literal_eval(text)
except (ValueError, SyntaxError):
logger.debug(f"Could not parse an input-source list: {value!r}")
return []
return list(parsed) if isinstance(parsed, (list, tuple)) else []
def _xkb_layout_from_source(source: object) -> Optional[str]:
"""Return the xkb layout id from one ``(type, id)`` input-source pair."""
if not isinstance(source, (list, tuple)) or len(source) != 2:
return None
source_type, source_id = source
if not isinstance(source_type, str) or not isinstance(source_id, str) or not source_id:
return None
if source_type != "xkb":
# An IBus engine (mozc-jp, pinyin) is not an xkb layout, and its id is
# not something _LAYOUT_TO_LANGUAGE can map.
logger.debug(f"Active input source {source_id!r} is {source_type}, not xkb")
return None
return source_id.split("+", 1)[0].strip() or None
def _source_at_index(parsed: list, current: str) -> Optional[object]:
"""Return the ``(type, id)`` pair the ``current`` index points at."""
if not parsed:
return None
# "uint32 1" is what gsettings prints, so anchor on the trailing integer:
# a bare \d+ search matches the 32 in "uint32" and every lookup reads as
# index 32.
match = re.search(r"(\d+)\s*$", (current or "").strip())
index = int(match.group(1)) if match else 0
if index >= len(parsed):
# A stale index outlives a removed source. GNOME falls back to the first
# entry in that case and so do we, rather than reporting nothing.
logger.debug(f"Input source index {index} is out of range, using the first")
index = 0
return parsed[index]
def _parse_active_source(sources: str, current: str) -> Optional[str]:
"""Pick the active xkb layout out of the ``sources`` list and ``current`` index."""
return _xkb_layout_from_source(_source_at_index(_parse_sources(sources), current))
def _active_input_source() -> Optional[object]:
"""Return GNOME's active ``(type, id)`` input source, or None if unreadable.
None means "no answer available" -- not GNOME, or the keys are missing. An
answer that is an IBus engine rather than an xkb layout is still an answer,
and callers must not treat it as absence.
"""
mru = _parse_sources(_gsettings_get("mru-sources"))
if mru:
# Trust the MRU answer even when it is an unmappable IBus engine; the
# index fallback would only supply a different, wronger layout.
return mru[0]
sources = _gsettings_get("sources")
if not sources:
return None
return _source_at_index(_parse_sources(sources), _gsettings_get("current") or "")
def detect_active_keyboard_layout() -> Optional[str]:
"""Return the layout currently being typed with, or None (#821).
``detect_keyboard_layout`` answers a different question: it reports the
*configured* primary layout, which never changes as the user switches
between them. Someone who runs "fr,us" to work in two languages gets "fr"
from it forever, so it cannot drive a follow-the-layout mode.
``mru-sources`` is read first because GNOME moves the source actually in use
to the front of it. The ``current`` index is not equivalent and goes stale --
it has been observed still reading 0 after a switch to the second layout --
which is why the IBus injection path already trusts the MRU list for this
same question (#497, #738). ``current`` is kept only for a session that has
never switched and therefore has an empty MRU list.
GNOME is the only desktop asked here: under Wayland the compositor owns the
keyboard and exposes nothing standard, and the X11 XKB group is not readable
through the python-xlib we already depend on. Elsewhere this returns None and
``language_for_active_layout`` falls back to the configured layout.
"""
return _xkb_layout_from_source(_active_input_source())
def _language_for_ibus_source(source: object, supported: set[str] | dict) -> Optional[str]:
"""Map a GNOME IBus ``(type, id)`` pair onto a catalogue language."""
if not isinstance(source, (list, tuple)) or len(source) != 2:
return None
source_type, source_id = source
if not isinstance(source_type, str) or source_type.lower() != "ibus":
return None
if not isinstance(source_id, str) or not source_id:
return None
engine = source_id.strip().lower()
mapped = _IBUS_ENGINE_TO_LANGUAGE.get(engine)
if mapped and mapped in supported:
return mapped
if engine.startswith("m17n:"):
lang = engine.split(":", 2)[1]
if lang:
return _language_for_locale(lang, supported) or _language_for_layout(lang, supported)
return None
# Only *-jp / *-kr: a generic last-token lookup maps mozc-us and foo-us
# onto English via the xkb "us" layout, which is the IBus fallback bug.
if "-" in engine:
suffix = engine.rsplit("-", 1)[-1]
if suffix in ("jp", "kr"):
return _language_for_layout(suffix, supported)
return None
def language_for_active_layout(supported: set[str] | dict) -> Optional[str]:
"""Return the catalogue language for the active layout, or None (#821).
Falls back to the configured primary layout where the active one cannot be
read, which is what a single-layout install reports anyway. An active IBus
engine is mapped when we know it; otherwise auto-detect is used rather than
borrowing the configured xkb layout.
"""
active = _active_input_source()
if active is not None:
layout = _xkb_layout_from_source(active)
if layout is not None:
return _language_for_layout_logged(layout, supported, "active keyboard layout")
language = _language_for_ibus_source(active, supported)
if language:
logger.debug(f"Language {language} taken from IBus engine {active!r}")
return language
# An active IBus engine we cannot map is still a real answer. Falling
# back to the configured xkb layout here would pin English for someone
# typing Japanese, so say nothing and let the engine auto-detect.
logger.debug(f"Active input source {active!r} is not an xkb layout; using auto-detect")
return None
# Nothing readable -- not GNOME. The configured primary layout is the only
# signal left, and is what a single-layout install reports anyway.
layout = detect_keyboard_layout()
if not layout:
return None
return _language_for_layout_logged(layout, supported, "configured keyboard layout")
def _language_for_layout_logged(
layout: str, supported: set[str] | dict, source: str
) -> Optional[str]:
"""Map a layout onto the catalogue, saying where it came from."""
language = _language_for_layout(layout, supported)
if not language:
logger.debug(f"No catalogue language for {source} {layout!r}")
return None
logger.debug(f"Language {language} taken from {source} {layout!r}")
return language