Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ 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_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`);
Deepgram does not consume it. `onoats init` prompts for it in the local
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

First stable release. Closes out the 0.9.x series' 1.0.0 gates: pre-socket
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions src/onoats/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"|<code>,
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
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -219,6 +220,31 @@ 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_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.
"""
# 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_LANGUAGE",
_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.
@property
Expand Down
17 changes: 17 additions & 0 deletions src/onoats/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -203,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


Expand Down Expand Up @@ -293,6 +305,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"))}"')
Expand Down Expand Up @@ -500,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

Expand Down
44 changes: 30 additions & 14 deletions src/onoats/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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_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.
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":
Expand Down Expand Up @@ -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),
)


Expand Down
15 changes: 13 additions & 2 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -192,6 +201,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
Expand All @@ -215,6 +225,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):
Expand Down
72 changes: 72 additions & 0 deletions tests/test_stt_config_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,78 @@ 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_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", " ")
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.

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 (
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 ----------


Expand Down