Files
voculinux/src/vocalinux/utils/system_language.py
T
Jatin K MalikandGitHub 3abc106fdb feat(languages): add Slovenian (sl) to the supported languages (#995)
Slovenian was missing from SUPPORTED_LANGUAGES, so the Settings picker did not offer it and normalize_language_shortcuts dropped any shortcut bound to it. Add it as a Whisper-only entry (whisper code sl; alphacephei publishes no Slovenian VOSK model), map the Slovenian XKB layout si to it for follow-keyboard-layout, and list it on the website languages page.

Fixes #970
2026-10-20 23:29:47 -07:00

428 lines
16 KiB
Python

"""Work out which language the user probably speaks (#777).
Nothing used to ask the system this, so whisper.cpp always started on auto-detect
and VOSK on a hardcoded ``en-us``. Both are wrong for anyone who does not speak
English, and neither ever improves on its own.
The keyboard layout is consulted before the locale on purpose. Plenty of people
run their desktop in English and speak something else — among the people who
install a Linux dictation tool that is closer to the rule than the exception —
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
import subprocess
from typing import Any, Optional, cast
from .host_process import host_env
logger = logging.getLogger(__name__)
# xkb layout names that do not match the language id we use. Layouts not listed
# here are looked up as-is, which covers pl, de, fr, it, es, ru and the rest.
_LAYOUT_TO_LANGUAGE = {
"us": "en-us",
"gb": "en-us",
"cz": "cs",
"se": "sv",
"si": "sl",
"dk": "da",
"gr": "el",
"ua": "uk",
"jp": "ja",
"kr": "ko",
"cn": "zh",
"ir": "fa",
"il": "he",
"br": "pt",
"nb": "no",
"nn": "no",
"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",
"nb": "no",
"nn": "no",
"zh_cn": "zh",
"zh_tw": "zh",
}
_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("-", "_")
def _language_for_layout(layout: str, supported: set[str] | dict) -> Optional[str]:
layout = _normalise(layout)
if not layout:
return None
mapped = _LAYOUT_TO_LANGUAGE.get(layout, layout)
return mapped if mapped in supported else None
def _language_for_locale(value: str, supported: set[str] | dict) -> Optional[str]:
"""Map a locale string such as ``pl_PL.UTF-8`` onto a catalogue entry."""
value = _normalise(value)
if not value:
return None
# Strip the encoding and any modifier: pl_pl.utf_8@euro -> pl_pl
value = re.split(r"[.@]", value, maxsplit=1)[0]
# Exact C/POSIX only — startswith("c") wrongly rejects cs/ca/cy.
if value in ("c", "posix"):
return None
if value in _LOCALE_TO_LANGUAGE:
candidate = _LOCALE_TO_LANGUAGE[value]
return candidate if candidate in supported else None
if value in supported:
return value
base = value.split("_", 1)[0]
if base in _LOCALE_TO_LANGUAGE:
candidate = _LOCALE_TO_LANGUAGE[base]
return candidate if candidate in supported else None
if base == "en":
return "en-us" if "en-us" in supported else None
return base if base in supported else None
def detect_keyboard_layout() -> Optional[str]:
"""Return the primary xkb layout, or None when it cannot be read.
``localectl`` is used rather than ``setxkbmap`` because the latter reports
nothing useful on Wayland, where the compositor owns the keyboard.
"""
try:
result = subprocess.run(
["localectl", "status"],
capture_output=True,
text=True,
timeout=2,
env=host_env(),
)
except (FileNotFoundError, OSError, subprocess.SubprocessError) as exc:
logger.debug(f"Could not query localectl: {exc}")
return None
if result.returncode != 0:
logger.debug(f"localectl exited with {result.returncode}")
return None
layout = None
keymap = None
for line in result.stdout.splitlines():
key, _, value = line.partition(":")
key = key.strip().lower()
value = value.strip()
if key == "x11 layout" and value:
layout = value
elif key == "vc keymap" and value:
keymap = value
# A multi-layout setup lists the primary one first: "pl,us".
chosen = layout or keymap
if not chosen:
return None
return chosen.split(",")[0].strip() or None
def detect_locale_language(environ: Optional[dict[str, str]] = None) -> Optional[str]:
"""Return the first locale value the environment offers, or None.
LANGUAGE is returned intact (a colon-separated preference list). Mapping
that list onto a supported catalogue entry happens in
``_language_from_locale_env``.
"""
environment = cast(dict[str, str], os.environ if environ is None else environ)
for name in _LOCALE_ENV_VARS:
value = environment.get(name)
if value:
return value
return None
def _language_from_locale_env(
environ: dict[str, str], supported: set[str] | dict[str, dict[str, str]]
) -> Optional[str]:
"""Map locale env vars onto a catalogue entry, first supported win.
LANGUAGE is a colon-separated preference list: each entry is tried through
``_language_for_locale`` until one is supported. An env var whose values
are all unmapped is skipped so a later var (typically LANG) can still
win. LC_ALL, LC_MESSAGES, and LANG are single values and are not walked
as lists.
"""
for name in _LOCALE_ENV_VARS:
value = environ.get(name)
if not value:
continue
candidates = value.split(":") if name == "LANGUAGE" else (value,)
for candidate in candidates:
mapped = _language_for_locale(candidate, supported)
if mapped:
return mapped
continue
return None
def detect_system_language(
supported: set[str] | dict[str, dict[str, str]], environ: Optional[dict[str, str]] = None
) -> Optional[str]:
"""Return the language id to start from, or None when nothing is decisive.
A non-English keyboard layout is the strongest signal available and wins.
"us" is the fallback layout on a great many installs and therefore says very
little, so the locale gets a say before that layout is accepted.
"""
layout_language = None
layout = detect_keyboard_layout()
if layout:
layout_language = _language_for_layout(layout, supported)
if layout_language and not layout_language.startswith("en"):
logger.info(f"Language {layout_language} taken from keyboard layout {layout!r}")
return layout_language
environment = cast(dict[str, str], os.environ if environ is None else environ)
locale_language = _language_from_locale_env(environment, supported)
if locale_language:
logger.info(f"Language {locale_language} taken from locale environment")
return locale_language
if layout_language:
logger.info(f"Language {layout_language} taken from keyboard layout {layout!r}")
return layout_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[tuple[str, str]]:
"""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 []
if not isinstance(parsed, (list, tuple)):
return []
return cast(list[tuple[str, str]], list(parsed))
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[tuple[str, str]], current: str) -> Optional[tuple[str, str]]:
"""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[tuple[str, str]]:
"""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