feat(language): start a first run from the language the system points at (#796)

* feat(language): start a first run from the language the system points at

Nothing asked the system what language the user speaks, so whisper.cpp always
began on auto-detect and VOSK on a hardcoded en-us. Both are wrong for anyone
who does not speak English, and neither improves on its own because nothing
ever suggests setting a language.

Seed the language on first run only, so a saved choice — including a deliberate
"auto" — is never touched. The keyboard layout is consulted before the locale:
running the desktop in English while speaking something else is common among
the people who install a Linux dictation tool, which makes the interface
language a weak signal for the spoken one. A "us" layout is the default on many
installs and says little, so the locale gets a say before it is accepted.

localectl is used rather than setxkbmap, which reports nothing useful on
Wayland. Detection failures leave the packaged default in place.

Closes #777

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKVa2qTLQYMCHffjp97yqT

* fix(language): prefer LANGUAGE, keep cs/ca, map nb_NO

LANG was consulted before LANGUAGE, so a gettext preference list lost to
the fallback locale. C/POSIX detection used startswith("c"), which
rejected Czech and Catalan. nb_NO normalised to nb_no and missed the
nb→no catalogue map, so Norwegian fell through to en-us.

Tests cover LANG+LANGUAGE, LANGUAGE=cs, and nb_NO.

* fix(language): walk LANGUAGE prefs until one is supported

split(":")[0] dropped later supported preferences, so first-run could
stay on auto when LANGUAGE started with an unsupported tag. Try each
preference through the catalogue map. Annotate _seed_language_from_system.

* style(language): keep _language_from_locale_env on one line for black

* fix(language): fall through unmapped LANGUAGE to LANG

When every LANGUAGE preference missed the catalogue, the helper returned
before LANG was examined, so first-run could stay on auto. Skip unmapped
env vars and keep looking. Regression: LANGUAGE=xx:yy with LANG=pl_PL → pl.

---------

Co-authored-by: Kacper Paczos <23406555+kacperpaczos@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
Kacper Paczos
2026-09-07 14:11:40 -07:00
committed by GitHub
co-authored by Kacper Paczos Claude Opus 5 Jatin K Malik
parent 7300ebb1c2
commit 688d9e9bd2
4 changed files with 413 additions and 0 deletions
+27
View File
@@ -173,6 +173,32 @@ class ConfigManager:
"""Ensure the configuration directory exists."""
os.makedirs(CONFIG_DIR, exist_ok=True)
def _seed_language_from_system(self) -> None:
"""Start a first run from the language the system points at (#777).
Only applied when there is no config file yet, so a saved choice — including
a deliberate "auto" — is never overwritten. When nothing decisive is found
the packaged default stays in place.
"""
try:
from ..utils.system_language import detect_system_language
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES
except ImportError as exc: # pragma: no cover - defensive
logger.debug(f"Language detection unavailable: {exc}")
return
try:
detected = detect_system_language(SUPPORTED_LANGUAGES)
except Exception as exc: # pragma: no cover - detection must never block startup
logger.debug(f"Language detection failed: {exc}")
return
if not detected:
return
self.config.setdefault("speech_recognition", {})["language"] = detected
logger.info(f"First run: starting with language {detected}")
def load_config(self):
"""
Load configuration from the config file.
@@ -181,6 +207,7 @@ class ConfigManager:
"""
if not os.path.exists(CONFIG_FILE):
logger.info(f"Config file not found at {CONFIG_FILE}. Using defaults.")
self._seed_language_from_system()
return
try:
+202
View File
@@ -0,0 +1,202 @@
"""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 logging
import os
import re
import subprocess
from typing import Optional
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",
"dk": "da",
"gr": "el",
"ua": "uk",
"jp": "ja",
"kr": "ko",
"cn": "zh",
"ir": "fa",
"il": "he",
"br": "pt",
"nb": "no",
"nn": "no",
"vn": "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")
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] = 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``.
"""
environ = os.environ if environ is None else environ
for name in _LOCALE_ENV_VARS:
value = environ.get(name)
if value:
return value
return None
def _language_from_locale_env(environ: dict, supported: set[str] | dict) -> 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, environ: Optional[dict] = 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
environ = os.environ if environ is None else environ
locale_language = _language_from_locale_env(environ, 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