From f2900dac172e28f627e0c8153e83dc080b6399bf Mon Sep 17 00:00:00 2001 From: Varun Singh <382354+vr000m@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:42:47 -0700 Subject: [PATCH 1/5] feat(stt): make decode language a first-class config.toml key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [stt] language (env STT_WS_LANGUAGE > config.toml > "en") now feeds all three launch paths through the shared ConfigStore instead of a raw os.environ read in the websocket branch only. "auto" maps to None at the backend boundary (_resolve_stt_language) — whisper/mlx rejects the literal string. The local whisper/MLX branches honour it too (previously hardcoded "en"); Deepgram does not consume it. onoats init prompts for the language in the local STT branch and writes the key. --- CHANGELOG.md | 11 ++++++++ README.md | 5 ++-- src/onoats/config/__init__.py | 19 ++++++++++++-- src/onoats/init.py | 10 +++++++ src/onoats/runtime.py | 44 +++++++++++++++++++++---------- tests/test_init.py | 2 ++ tests/test_stt_config_wiring.py | 46 +++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 137a9cb..0da203d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ no backdated tags exist). PR numbers `#1`–`#7` refer to this repository; older history predates the extraction and is cited by merge-commit SHA. Annotated tags exist from `v0.9.0` forward. +## [Unreleased] + +### Added +- `[stt] language` in `config.toml`: the STT decode language is now a + first-class config key (env `STT_WS_LANGUAGE` > `[stt].language` > `en`), + shared by every launch path (CLI, menu-bar app, `onoats init`). `auto` + means auto-detect and maps to `None` at the backend boundary. The local + whisper/MLX branches now honour it too (they previously hardcoded `en`); + Deepgram does not consume it. `onoats init` prompts for it in the local + STT branch. + ## [1.0.0] - 2026-06-12 First stable release. Closes out the 0.9.x series' 1.0.0 gates: pre-socket diff --git a/README.md b/README.md index 43f0e05..30eaae7 100644 --- a/README.md +++ b/README.md @@ -182,8 +182,9 @@ the terminal instead). It lives in the menu bar with no Dock icon. `onoats init` writes: - `$XDG_CONFIG_HOME/onoats/config.toml` — `[storage]` (`data_dir`), `[devices]` - (by name), `[stt]`, `[speakers]` (render-only display labels), `[categories]`, - `[tuning]`. + (by name), `[stt]` (`service`, `model`, `language` — `"en"` default, `"auto"` + = detect; whisper + websocket backends only), `[speakers]` (render-only + display labels), `[categories]`, `[tuning]`. - `$XDG_CONFIG_HOME/onoats/secrets.env` — `0600`, STT secrets only (`DEEPGRAM_API_KEY` / `STT_WS_TOKEN`). **No LLM keys.** - `$XDG_CONFIG_HOME/onoats/dictionary.txt` — `wrong: correct` substitutions diff --git a/src/onoats/config/__init__.py b/src/onoats/config/__init__.py index b9ceb22..4722763 100644 --- a/src/onoats/config/__init__.py +++ b/src/onoats/config/__init__.py @@ -15,7 +15,8 @@ [storage] data_dir = "..." # recorder data root (else XDG) [devices] mic = "...", system = "..." # by stable device name - [stt] service = "...", model = "...", ws_socket/ws_host/ws_port/ws_uri = "..." + [stt] service = "...", model = "...", language = "en"|"auto"|, + ws_socket/ws_host/ws_port/ws_uri = "..." [speakers] me = "Me", them = "Them" # RENDER-ONLY display labels [categories] set = ["uncategorized", ...] [tuning] silence_timeout_sec / segment_hint_threshold / audio_heartbeat_sec @@ -70,7 +71,7 @@ def secrets_env_path() -> Path: _DEFAULTS: dict[str, dict[str, Any]] = { "audio": {"source": "portaudio"}, - "stt": {"service": "whisper", "model": ""}, + "stt": {"service": "whisper", "model": "", "language": "en"}, "speakers": {"me": "Me", "them": "Them"}, "categories": {"set": ["uncategorized"]}, "tuning": { @@ -219,6 +220,20 @@ def stt_model(self) -> str: or _DEFAULTS["stt"]["model"] ).strip() + @property + def stt_language(self) -> str: + """Decode language for the whisper/websocket backends. + + env ``STT_WS_LANGUAGE`` > config.toml ``[stt].language`` > ``"en"``. + ``"auto"`` (any case) means auto-detect — the runtime maps it to + ``None`` for the backend, never the literal string (whisper rejects + a literal "auto"). Not consumed by the Deepgram backend. + """ + return str( + _env_or("STT_WS_LANGUAGE", self.raw.get("stt", {}).get("language")) + or _DEFAULTS["stt"]["language"] + ).strip() + # websocket endpoint (env wins; else config.toml [stt].ws_*). None when # neither set — the runtime then falls back to the built-in default socket. @property diff --git a/src/onoats/init.py b/src/onoats/init.py index 158a142..3696d4c 100644 --- a/src/onoats/init.py +++ b/src/onoats/init.py @@ -193,6 +193,14 @@ def _configure_stt_interactive(existing: dict) -> tuple[dict, dict]: ) if model: stt["model"] = model + # Consumed by both local backends (whisper + websocket); Deepgram + # ignores it, so the prompt lives in the `local` branch only. + lang = _prompt( + "STT language (blank = en, 'auto' = detect)", + existing.get("language"), + ) + if lang: + stt["language"] = lang else: stt["service"] = _HOSTED_DEEPGRAM model = _prompt( @@ -293,6 +301,8 @@ def _render_config_toml( lines.append(f'model = "{_toml_escape(stt["model"])}"') if stt.get("ws_socket"): lines.append(f'ws_socket = "{_toml_escape(stt["ws_socket"])}"') + if stt.get("language"): + lines.append(f'language = "{_toml_escape(stt["language"])}"') lines.append("") lines.append("[speakers]") lines.append(f'me = "{_toml_escape(speakers.get("me", "Me"))}"') diff --git a/src/onoats/runtime.py b/src/onoats/runtime.py index 9d6665d..9bdd8fe 100644 --- a/src/onoats/runtime.py +++ b/src/onoats/runtime.py @@ -616,6 +616,21 @@ def _vocabulary_bias() -> list[str]: return [] +def _resolve_stt_language(cfg) -> str | None: + """Map ``cfg.stt_language`` to the value the STT backends expect. + + ``auto`` maps to ``None`` (omit the field) rather than the literal string + "auto": ``None`` is the only value that means auto-detect uniformly across + backends — whisper/mlx *rejects* a literal "auto" (ValueError -> failed + decode) and uses ``None`` for built-in detection, while nemotron maps + client-``None`` to its own "auto" language-ID. onoats is backend-agnostic + over the socket, so it cannot branch per backend. Resolved in one place so + the websocket and local whisper branches cannot drift. + """ + raw = cfg.stt_language + return None if raw.lower() == "auto" else raw + + # Canonical set of STT_SERVICE values dispatched by _create_stt_service below # ("whisper" is the fall-through default branch). The menu bar's STT picker # (native/onoats-menubar/Sources/RecorderModel.swift `sttServices`) mirrors @@ -646,6 +661,7 @@ async def _create_stt_service(): cfg = load_config() service = cfg.stt_service model_name = cfg.stt_model + language = _resolve_stt_language(cfg) vocabulary = _vocabulary_bias() # Enforce the canonical set, not just document it: a typo'd STT_SERVICE # used to silently fall through to the whisper branch — fail loud instead. @@ -669,18 +685,11 @@ async def _create_stt_service(): logger.info(f"STT: websocket (server={target})") await _preflight_stt_ws(kwargs, target) # The language is forwarded to the server's decoder via - # ``update_session`` (see ``WebSocketSTTService``). Default ``en`` - # preserves prior behavior. ``auto`` maps to ``None`` (omit the field) - # rather than the literal string "auto": ``None`` is the only value - # that means auto-detect uniformly across backends — whisper/mlx - # *rejects* a literal "auto" (ValueError -> failed decode) and uses - # ``None`` for built-in detection, while nemotron maps client-``None`` - # to its own "auto" language-ID. onoats is backend-agnostic over the - # socket, so it cannot branch per backend. Not threaded through + # ``update_session`` (see ``WebSocketSTTService``). Resolved from + # ``cfg.stt_language`` above (env STT_WS_LANGUAGE > config.toml + # [stt].language > "en"). Not threaded through # ``_resolve_stt_ws_target`` because that dict also feeds # ``TranscriptionClient``, which takes no ``language`` kwarg. - raw = (os.environ.get("STT_WS_LANGUAGE") or "en").strip() or "en" - language = None if raw.lower() == "auto" else raw return WebSocketSTTService(language=language, **kwargs) if service == "deepgram": @@ -739,20 +748,27 @@ async def _create_stt_service(): f"Unknown MLX model name '{model_name}', falling back to large-v3-turbo" ) mlx_model = MLXModel.LARGE_V3_TURBO - logger.info(f"STT: whisper-mlx (model={mlx_model.name}, device=Apple Silicon)") + logger.info( + f"STT: whisper-mlx (model={mlx_model.name}, device=Apple Silicon, " + f"language={language or 'auto'})" + ) + # language=None reaches mlx_whisper.transcribe unchanged, which then + # auto-detects per segment. return WhisperSTTServiceMLX( - settings=WhisperSTTServiceMLX.Settings(model=mlx_model.value, language="en") + settings=WhisperSTTServiceMLX.Settings( + model=mlx_model.value, language=language + ) ) else: from pipecat.services.whisper.stt import WhisperSTTService model = model_name or "base" - logger.info(f"STT: whisper-cpu (model={model})") + logger.info(f"STT: whisper-cpu (model={model}, language={language or 'auto'})") # device/compute_type are WhisperSTTService constructor kwargs, NOT # Settings fields — passing device into Settings raises TypeError. return WhisperSTTService( device="cpu", - settings=WhisperSTTService.Settings(model=model, language="en"), + settings=WhisperSTTService.Settings(model=model, language=language), ) diff --git a/tests/test_init.py b/tests/test_init.py index b0f90a0..70f07e1 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -192,6 +192,7 @@ def test_interactive_local_websocket_branch_runs_preflight(_isolate_env, monkeyp "y", # local STT? yes "y", # use websocket socket? yes "/tmp/stt.sock", # socket path + "auto", # STT language → [stt].language "", # categories (none) "Me", # me name "Them", # them label @@ -215,6 +216,7 @@ def fake_preflight(stt, secrets): cfg = _load_toml(config_toml_path()) assert cfg["stt"]["service"] == "websocket" assert cfg["stt"]["ws_socket"] == "/tmp/stt.sock" + assert cfg["stt"]["language"] == "auto" def test_interactive_warns_when_loopback_absent(_isolate_env, monkeypatch, capsys): diff --git a/tests/test_stt_config_wiring.py b/tests/test_stt_config_wiring.py index 7193e2b..f929d66 100644 --- a/tests/test_stt_config_wiring.py +++ b/tests/test_stt_config_wiring.py @@ -49,6 +49,52 @@ def test_stt_service_defaults_to_whisper_when_unset(monkeypatch): assert OnoatsConfig(raw={}).stt_service == "whisper" +# --- A1. STT language comes from config.toml (env wins), auto -> None ------- + + +def test_stt_language_defaults_to_en(monkeypatch): + monkeypatch.delenv("STT_WS_LANGUAGE", raising=False) + assert OnoatsConfig(raw={}).stt_language == "en" + + +def test_stt_language_from_config_toml(monkeypatch): + monkeypatch.delenv("STT_WS_LANGUAGE", raising=False) + cfg = OnoatsConfig(raw={"stt": {"language": "sv"}}) + assert cfg.stt_language == "sv" + + +def test_env_language_overrides_config(monkeypatch): + monkeypatch.setenv("STT_WS_LANGUAGE", "de") + cfg = OnoatsConfig(raw={"stt": {"language": "sv"}}) + assert cfg.stt_language == "de" + + +def test_resolve_stt_language_maps_auto_to_none(monkeypatch): + """``auto`` must reach the backends as None, never the literal string. + + whisper/mlx raises on a literal "auto"; None means auto-detect uniformly + (mlx built-in detection / nemotron's own auto language-ID). + """ + monkeypatch.delenv("STT_WS_LANGUAGE", raising=False) + assert runtime._resolve_stt_language(OnoatsConfig(raw={})) == "en" + assert ( + runtime._resolve_stt_language(OnoatsConfig(raw={"stt": {"language": "Auto"}})) + is None + ) + monkeypatch.setenv("STT_WS_LANGUAGE", "auto") + assert runtime._resolve_stt_language(OnoatsConfig(raw={})) is None + + +def test_whisper_settings_accept_language_none(): + """The auto-detect path builds Settings(language=None) — pin that the + pinned pipecat accepts it (assert_given rejects only NOT_GIVEN, not None). + """ + from pipecat.services.whisper.stt import WhisperSTTService + + settings = WhisperSTTService.Settings(model="base", language=None) + assert settings.language is None + + # --- A2. the RSS probe resolves the SAME endpoint as the data path ---------- From 5a440972db8b6cce27b43f684ec76690cff15874 Mon Sep 17 00:00:00 2001 From: Varun Singh <382354+vr000m@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:59:49 -0700 Subject: [PATCH 2/5] fix(stt): harden language resolution + init re-run preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the language-config feature: - whitespace-only [stt].language fell through as language="" — restore the strip-then-default guard the old inline code had (env values were already strip-guarded inside _env_or). - non-interactive init re-runs rebuilt the [stt] table without language, silently erasing a configured value (service/model/ws_socket carried, language did not). Carry it like the others; idempotent-rerun test now pins it. - switching to Deepgram in the wizard dropped the key — carry it forward so switching backends and back keeps the preference. --- src/onoats/config/__init__.py | 9 ++++++--- src/onoats/init.py | 7 +++++++ tests/test_init.py | 13 +++++++++++-- tests/test_stt_config_wiring.py | 10 ++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/onoats/config/__init__.py b/src/onoats/config/__init__.py index 4722763..2c414b1 100644 --- a/src/onoats/config/__init__.py +++ b/src/onoats/config/__init__.py @@ -229,10 +229,13 @@ def stt_language(self) -> str: ``None`` for the backend, never the literal string (whisper rejects a literal "auto"). Not consumed by the Deepgram backend. """ - return str( - _env_or("STT_WS_LANGUAGE", self.raw.get("stt", {}).get("language")) - or _DEFAULTS["stt"]["language"] + # strip-then-default: a whitespace-only file value must fall back to + # "en", not reach the backend as language="" (env values are already + # strip-guarded inside _env_or). + val = str( + _env_or("STT_WS_LANGUAGE", self.raw.get("stt", {}).get("language")) or "" ).strip() + return val or _DEFAULTS["stt"]["language"] # websocket endpoint (env wins; else config.toml [stt].ws_*). None when # neither set — the runtime then falls back to the built-in default socket. diff --git a/src/onoats/init.py b/src/onoats/init.py index 3696d4c..2a07dda 100644 --- a/src/onoats/init.py +++ b/src/onoats/init.py @@ -211,6 +211,10 @@ def _configure_stt_interactive(existing: dict) -> tuple[dict, dict]: key = _prompt("Deepgram API key (stored 0600 in secrets.env)") if key: secrets["DEEPGRAM_API_KEY"] = key + # Deepgram doesn't consume the language, but carry an existing value + # forward so switching backends and back doesn't silently drop it. + if existing.get("language"): + stt["language"] = existing["language"] return stt, secrets @@ -510,6 +514,9 @@ def main(argv: list[str] | None = None) -> int: ws_socket = args.ws_socket or existing_stt.get("ws_socket") if ws_socket: stt["ws_socket"] = ws_socket + language = existing_stt.get("language") + if language: + stt["language"] = language if args.deepgram_key: secrets["DEEPGRAM_API_KEY"] = args.deepgram_key diff --git a/tests/test_init.py b/tests/test_init.py index 70f07e1..49eb405 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -132,14 +132,23 @@ def test_idempotent_rerun_preserves_values(_isolate_env, monkeypatch): init_mod.main(["--categories", "work", "--me-name", "Ann", "--no-preflight"]) == 0 ) - # Re-run with no flags (non-TTY) — must keep the prior categories + me-name. + # Simulate a hand-edited (or wizard-written) language, then re-run with no + # flags (non-TTY) — must keep the prior categories + me-name + language. + from onoats.config import config_toml_path + + path = config_toml_path() + path.write_text( + path.read_text().replace( + '[stt]\nservice = "', '[stt]\nlanguage = "auto"\nservice = "' + ) + ) _force_tty(monkeypatch, value=False) assert init_mod.main(["--no-preflight"]) == 0 - from onoats.config import config_toml_path cfg = _load_toml(config_toml_path()) assert "work" in cfg["categories"]["set"] assert cfg["speakers"]["me"] == "Ann" + assert cfg["stt"]["language"] == "auto" # --------------------------------------------------------------------------- diff --git a/tests/test_stt_config_wiring.py b/tests/test_stt_config_wiring.py index f929d66..1dcf97d 100644 --- a/tests/test_stt_config_wiring.py +++ b/tests/test_stt_config_wiring.py @@ -69,6 +69,16 @@ def test_env_language_overrides_config(monkeypatch): assert cfg.stt_language == "de" +def test_whitespace_only_language_falls_back_to_en(monkeypatch): + """A whitespace-only file value must resolve to "en", never reach the + backend as language="" (the pre-config inline code had this guard too). + """ + monkeypatch.delenv("STT_WS_LANGUAGE", raising=False) + assert OnoatsConfig(raw={"stt": {"language": " "}}).stt_language == "en" + monkeypatch.setenv("STT_WS_LANGUAGE", " ") + assert OnoatsConfig(raw={}).stt_language == "en" + + def test_resolve_stt_language_maps_auto_to_none(monkeypatch): """``auto`` must reach the backends as None, never the literal string. From a53dd11e16af85c0fdc61a6ee6ed98fd5c73cf6f Mon Sep 17 00:00:00 2001 From: Varun Singh <382354+vr000m@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:58:33 -0700 Subject: [PATCH 3/5] docs(changelog): surface PR #22 review fixes under [Unreleased] Fixed --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da203d..1f086c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,14 @@ Annotated tags exist from `v0.9.0` forward. means auto-detect and maps to `None` at the backend boundary. The local whisper/MLX branches now honour it too (they previously hardcoded `en`); Deepgram does not consume it. `onoats init` prompts for it in the local - STT branch. + STT branch. (PR #22) + +### Fixed +- Review fixes on the language key (PR #22): a whitespace-only + `[stt].language` now falls back to `en` instead of reaching the backend as + `language=""`; non-interactive `onoats init` re-runs carry an existing + `language` forward instead of silently erasing it; switching the wizard to + Deepgram preserves the key for a later switch back. ## [1.0.0] - 2026-06-12 From 4723bbd9acb1a3fc5f4852f45414fefeb286ec22 Mon Sep 17 00:00:00 2001 From: Varun Singh <382354+vr000m@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:06:02 -0700 Subject: [PATCH 4/5] refactor(stt): canonical STT_LANGUAGE env var, STT_WS_LANGUAGE legacy alias Deep-review architecture finding: the env var now governs every STT backend, not just the websocket one, so the bare name matches the other cross-backend vars (STT_SERVICE / STT_MODEL). STT_WS_LANGUAGE stays as a lower-precedence alias for backward compatibility. Resolution: STT_LANGUAGE > STT_WS_LANGUAGE > [stt].language > "en". --- CHANGELOG.md | 3 ++- src/onoats/config/__init__.py | 12 ++++++++++-- src/onoats/runtime.py | 4 ++-- tests/test_stt_config_wiring.py | 16 ++++++++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f086c9..4fb9b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ Annotated tags exist from `v0.9.0` forward. ### Added - `[stt] language` in `config.toml`: the STT decode language is now a - first-class config key (env `STT_WS_LANGUAGE` > `[stt].language` > `en`), + first-class config key (env `STT_LANGUAGE` > legacy alias `STT_WS_LANGUAGE` + > `[stt].language` > `en`), shared by every launch path (CLI, menu-bar app, `onoats init`). `auto` means auto-detect and maps to `None` at the backend boundary. The local whisper/MLX branches now honour it too (they previously hardcoded `en`); diff --git a/src/onoats/config/__init__.py b/src/onoats/config/__init__.py index 2c414b1..92763a2 100644 --- a/src/onoats/config/__init__.py +++ b/src/onoats/config/__init__.py @@ -224,7 +224,11 @@ def stt_model(self) -> str: def stt_language(self) -> str: """Decode language for the whisper/websocket backends. - env ``STT_WS_LANGUAGE`` > config.toml ``[stt].language`` > ``"en"``. + env ``STT_LANGUAGE`` > env ``STT_WS_LANGUAGE`` (legacy alias) > + config.toml ``[stt].language`` > ``"en"``. The bare name matches the + other cross-backend vars (``STT_SERVICE`` / ``STT_MODEL``); the + ``STT_WS_``-prefixed alias predates the key applying beyond the + websocket backend and is kept for backward compatibility. ``"auto"`` (any case) means auto-detect — the runtime maps it to ``None`` for the backend, never the literal string (whisper rejects a literal "auto"). Not consumed by the Deepgram backend. @@ -233,7 +237,11 @@ def stt_language(self) -> str: # "en", not reach the backend as language="" (env values are already # strip-guarded inside _env_or). val = str( - _env_or("STT_WS_LANGUAGE", self.raw.get("stt", {}).get("language")) or "" + _env_or( + "STT_LANGUAGE", + _env_or("STT_WS_LANGUAGE", self.raw.get("stt", {}).get("language")), + ) + or "" ).strip() return val or _DEFAULTS["stt"]["language"] diff --git a/src/onoats/runtime.py b/src/onoats/runtime.py index 9bdd8fe..82a6a6b 100644 --- a/src/onoats/runtime.py +++ b/src/onoats/runtime.py @@ -686,8 +686,8 @@ async def _create_stt_service(): await _preflight_stt_ws(kwargs, target) # The language is forwarded to the server's decoder via # ``update_session`` (see ``WebSocketSTTService``). Resolved from - # ``cfg.stt_language`` above (env STT_WS_LANGUAGE > config.toml - # [stt].language > "en"). Not threaded through + # ``cfg.stt_language`` above (env STT_LANGUAGE > legacy STT_WS_LANGUAGE + # > config.toml [stt].language > "en"). Not threaded through # ``_resolve_stt_ws_target`` because that dict also feeds # ``TranscriptionClient``, which takes no ``language`` kwarg. return WebSocketSTTService(language=language, **kwargs) diff --git a/tests/test_stt_config_wiring.py b/tests/test_stt_config_wiring.py index 1dcf97d..67df3d3 100644 --- a/tests/test_stt_config_wiring.py +++ b/tests/test_stt_config_wiring.py @@ -53,26 +53,41 @@ def test_stt_service_defaults_to_whisper_when_unset(monkeypatch): def test_stt_language_defaults_to_en(monkeypatch): + monkeypatch.delenv("STT_LANGUAGE", raising=False) monkeypatch.delenv("STT_WS_LANGUAGE", raising=False) assert OnoatsConfig(raw={}).stt_language == "en" def test_stt_language_from_config_toml(monkeypatch): + monkeypatch.delenv("STT_LANGUAGE", raising=False) monkeypatch.delenv("STT_WS_LANGUAGE", raising=False) cfg = OnoatsConfig(raw={"stt": {"language": "sv"}}) assert cfg.stt_language == "sv" def test_env_language_overrides_config(monkeypatch): + monkeypatch.delenv("STT_LANGUAGE", raising=False) monkeypatch.setenv("STT_WS_LANGUAGE", "de") cfg = OnoatsConfig(raw={"stt": {"language": "sv"}}) assert cfg.stt_language == "de" +def test_stt_language_env_beats_legacy_alias_and_config(monkeypatch): + """STT_LANGUAGE is the canonical env var (cross-backend, like STT_SERVICE / + STT_MODEL); STT_WS_LANGUAGE survives as a legacy alias below it. + """ + monkeypatch.setenv("STT_LANGUAGE", "fi") + monkeypatch.setenv("STT_WS_LANGUAGE", "de") + assert OnoatsConfig(raw={"stt": {"language": "sv"}}).stt_language == "fi" + monkeypatch.delenv("STT_LANGUAGE") + assert OnoatsConfig(raw={"stt": {"language": "sv"}}).stt_language == "de" + + def test_whitespace_only_language_falls_back_to_en(monkeypatch): """A whitespace-only file value must resolve to "en", never reach the backend as language="" (the pre-config inline code had this guard too). """ + monkeypatch.delenv("STT_LANGUAGE", raising=False) monkeypatch.delenv("STT_WS_LANGUAGE", raising=False) assert OnoatsConfig(raw={"stt": {"language": " "}}).stt_language == "en" monkeypatch.setenv("STT_WS_LANGUAGE", " ") @@ -85,6 +100,7 @@ def test_resolve_stt_language_maps_auto_to_none(monkeypatch): whisper/mlx raises on a literal "auto"; None means auto-detect uniformly (mlx built-in detection / nemotron's own auto language-ID). """ + monkeypatch.delenv("STT_LANGUAGE", raising=False) monkeypatch.delenv("STT_WS_LANGUAGE", raising=False) assert runtime._resolve_stt_language(OnoatsConfig(raw={})) == "en" assert ( From a41b9236d3c698463e0c9a4d947b2a5793ae7384 Mon Sep 17 00:00:00 2001 From: Varun Singh <382354+vr000m@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:20:35 -0700 Subject: [PATCH 5/5] docs(agents): record deep-review won't-fix dispositions for [stt].language --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c89ed9c..2213aed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,3 +201,15 @@ should NOT re-flag go here, one per line: the data-flow comment near `device_state`" — it is: the comment block at the declaration site in `_supervise_socket_session` (directly under the `device_state` comment) documents who sets it and who reads it. (2026-06-11) +- **[Architecture] won't-fix**: the `auto`→`None` STT-language mapping lives in + `runtime._resolve_stt_language`, not in `OnoatsConfig.stt_language` — + deliberate: the property stays a plain `str` for parity with `stt_model`, + its docstring states the runtime does the mapping, and `_create_stt_service` + is the single consumer. Moving it would change the property's type contract + (`str | None`) for no caller. (2026-06-12) +- **[Architecture] won't-fix**: `[stt].language` has no Swift menu-bar picker + or parity-test entry — intentional: the key is CLI/file-managed (like + `ws_socket`); the menu bar exposes config.toml via its open-config dropdown, + which is the supported edit path, and `ConfigStore` round-trips arbitrary + keys without code changes. A GUI picker would be a separate feature. + (2026-06-12)