Skip to content

Commit 2eaefff

Browse files
StuBehanclaude
andauthored
feat: per-user TOML config for default voice/speed/lang (#19)
Adds a small config-file layer so users don't have to repeat `--voice bf_emma --speed 1.1` on every invocation. - Reads `$XDG_CONFIG_HOME/stackvox/config.toml` (falling back to `~/.config/stackvox/config.toml`) or whatever `STACKVOX_CONFIG` points at if set. - File format: a [defaults] table with `voice`, `speed`, `lang` keys. Per-key fallback to built-in engine defaults — set only what you care about. - Tolerant: missing file → built-ins, malformed TOML → warning + built-ins, wrong shape → warning + built-ins. Never blocks startup. - Argparse priority: CLI flag > config file > engine default. - tomllib on 3.11+, conditional `tomli>=2.0` dep on 3.10. - tests/test_config.py: path resolution (env + XDG + ~/.config), loading (missing/empty/full/partial/malformed/wrong-shape), and smoke tests that argparse defaults pick up the config (and that explicit flags still override it). - README gets a Configuration section showing the file. - mypy `ignore_missing_imports` extended to `tomli` so the 3.10 fallback branch type-checks on 3.11+. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a2072af commit 2eaefff

5 files changed

Lines changed: 223 additions & 14 deletions

File tree

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,22 @@ stackvox completion bash > ~/.stackvox-completion.bash
6565
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
6666
```
6767

68-
Daemon mode (keeps the model resident so each subsequent call is instant):
68+
## Configuration
69+
70+
stackvox reads per-user defaults from a TOML file, so you don't need to repeat `--voice bf_emma --speed 1.1` on every invocation. Set values in `~/.config/stackvox/config.toml` (or `$XDG_CONFIG_HOME/stackvox/config.toml`, or wherever `STACKVOX_CONFIG` points):
71+
72+
```toml
73+
[defaults]
74+
voice = "bf_emma"
75+
speed = 1.1
76+
lang = "en-gb"
77+
```
78+
79+
CLI flags always win over config values, and config values always win over the built-in defaults. A missing file is fine — built-ins apply. A malformed file logs a warning and is ignored.
80+
81+
## Daemon mode
82+
83+
Keeps the model resident so each subsequent call is instant:
6984

7085
```bash
7186
stackvox serve # foreground; run with `nohup stackvox serve &` to background

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies = [
2424
"soundfile>=0.12.1",
2525
"sounddevice>=0.4.6",
2626
"numpy>=1.24",
27+
"tomli>=2.0;python_version<'3.11'",
2728
]
2829

2930
[project.optional-dependencies]
@@ -109,8 +110,9 @@ no_implicit_optional = true
109110
disallow_untyped_defs = true
110111

111112
[[tool.mypy.overrides]]
112-
# Third-party deps without inline type stubs.
113-
module = ["kokoro_onnx", "sounddevice", "soundfile"]
113+
# Third-party deps without inline type stubs (or, for `tomli`, a 3.10-only
114+
# fallback that's not installed on the typechecker's Python).
115+
module = ["kokoro_onnx", "sounddevice", "soundfile", "tomli"]
114116
ignore_missing_imports = true
115117

116118
[[tool.mypy.overrides]]

stackvox/cli.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
import soundfile as sf
1111

12-
from stackvox import daemon
13-
from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE, Stackvox
12+
from stackvox import config, daemon
13+
from stackvox.engine import Stackvox
1414

1515

1616
def _configure_logging() -> None:
@@ -103,26 +103,28 @@ def _configure_logging() -> None:
103103
]
104104

105105

106-
def _build_parser() -> argparse.ArgumentParser:
106+
def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentParser:
107+
if defaults is None:
108+
defaults = config.Defaults()
107109
parser = argparse.ArgumentParser(prog="stackvox", description="Kokoro-82M TTS")
108110
sub = parser.add_subparsers(dest="cmd")
109111

110112
p_speak = sub.add_parser("speak", help="Synthesize and play in-process (loads model each run)")
111-
_add_voice_args(p_speak)
113+
_add_voice_args(p_speak, defaults)
112114
p_speak.add_argument("text", nargs="?")
113115
p_speak.add_argument("--file", type=Path)
114116
p_speak.add_argument("--out", type=Path, help="Write wav instead of playing")
115117

116118
p_say = sub.add_parser("say", help="Send text to daemon (fast; fails if daemon not running)")
117-
_add_voice_args(p_say)
119+
_add_voice_args(p_say, defaults)
118120
p_say.add_argument("text", nargs="?")
119121
p_say.add_argument("--file", type=Path)
120122
p_say.add_argument(
121123
"--fallback-say", action="store_true", help="Shell out to macOS `say` if daemon unreachable"
122124
)
123125

124126
p_serve = sub.add_parser("serve", help="Run the daemon in the foreground")
125-
_add_voice_args(p_serve)
127+
_add_voice_args(p_serve, defaults)
126128

127129
sub.add_parser("stop", help="Stop the running daemon")
128130
sub.add_parser("status", help="Print daemon status")
@@ -146,10 +148,10 @@ def _build_parser() -> argparse.ArgumentParser:
146148
return parser
147149

148150

149-
def _add_voice_args(parser: argparse.ArgumentParser) -> None:
150-
parser.add_argument("--voice", default=DEFAULT_VOICE)
151-
parser.add_argument("--speed", type=float, default=DEFAULT_SPEED)
152-
parser.add_argument("--lang", default=DEFAULT_LANG)
151+
def _add_voice_args(parser: argparse.ArgumentParser, defaults: config.Defaults) -> None:
152+
parser.add_argument("--voice", default=defaults.voice)
153+
parser.add_argument("--speed", type=float, default=defaults.speed)
154+
parser.add_argument("--lang", default=defaults.lang)
153155

154156

155157
def _read_text(args: argparse.Namespace) -> str | None:
@@ -297,7 +299,7 @@ def main() -> int:
297299
elif not argv and not sys.stdin.isatty():
298300
argv = ["speak"]
299301

300-
parser = _build_parser()
302+
parser = _build_parser(config.load_defaults())
301303
args = parser.parse_args(argv)
302304

303305
if not args.cmd:

stackvox/config.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""User config file: per-user defaults for voice / speed / lang.
2+
3+
Lives at `$XDG_CONFIG_HOME/stackvox/config.toml` (falling back to
4+
`~/.config/stackvox/config.toml`), or wherever `STACKVOX_CONFIG` points if set.
5+
A missing file is fine — defaults from `stackvox.engine` apply. A malformed
6+
file logs a warning and is otherwise ignored.
7+
8+
File format::
9+
10+
[defaults]
11+
voice = "bf_emma"
12+
speed = 1.1
13+
lang = "en-gb"
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import logging
19+
import os
20+
import sys
21+
from dataclasses import dataclass
22+
from pathlib import Path
23+
24+
if sys.version_info >= (3, 11):
25+
import tomllib
26+
else: # pragma: no cover - covered by 3.10 CI
27+
import tomli as tomllib
28+
29+
from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE
30+
31+
logger = logging.getLogger(__name__)
32+
33+
34+
@dataclass(frozen=True)
35+
class Defaults:
36+
"""Resolved default values for synthesis parameters."""
37+
38+
voice: str = DEFAULT_VOICE
39+
speed: float = DEFAULT_SPEED
40+
lang: str = DEFAULT_LANG
41+
42+
43+
def config_path() -> Path:
44+
"""Resolve where the config file lives.
45+
46+
Honours `STACKVOX_CONFIG` first; otherwise XDG (`$XDG_CONFIG_HOME` →
47+
`~/.config`).
48+
"""
49+
override = os.environ.get("STACKVOX_CONFIG")
50+
if override:
51+
return Path(override).expanduser()
52+
xdg = os.environ.get("XDG_CONFIG_HOME")
53+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
54+
return base / "stackvox" / "config.toml"
55+
56+
57+
def load_defaults(path: Path | None = None) -> Defaults:
58+
"""Read the config file and return resolved defaults.
59+
60+
Missing file → built-in defaults. Malformed file → warning logged,
61+
built-in defaults used. Per-key fallback so a config that only sets
62+
`voice` keeps the built-in `speed` and `lang`.
63+
"""
64+
p = path or config_path()
65+
if not p.is_file():
66+
return Defaults()
67+
try:
68+
data = tomllib.loads(p.read_text(encoding="utf-8"))
69+
except (OSError, tomllib.TOMLDecodeError) as exc:
70+
logger.warning("ignoring malformed stackvox config at %s: %s", p, exc)
71+
return Defaults()
72+
section = data.get("defaults", {})
73+
if not isinstance(section, dict):
74+
logger.warning("config %s: [defaults] must be a table; ignoring", p)
75+
return Defaults()
76+
return Defaults(
77+
voice=str(section.get("voice", DEFAULT_VOICE)),
78+
speed=float(section.get("speed", DEFAULT_SPEED)),
79+
lang=str(section.get("lang", DEFAULT_LANG)),
80+
)

tests/test_config.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Config loader tests — pure file/env logic, no engine touched."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from stackvox import config
8+
from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE
9+
10+
11+
class TestConfigPath:
12+
def test_stackvox_config_env_takes_priority(self, monkeypatch, tmp_path):
13+
monkeypatch.setenv("STACKVOX_CONFIG", str(tmp_path / "elsewhere.toml"))
14+
assert config.config_path() == tmp_path / "elsewhere.toml"
15+
16+
def test_xdg_config_home_when_set(self, monkeypatch, tmp_path):
17+
monkeypatch.delenv("STACKVOX_CONFIG", raising=False)
18+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
19+
assert config.config_path() == tmp_path / "xdg" / "stackvox" / "config.toml"
20+
21+
def test_falls_back_to_home_dotconfig(self, monkeypatch):
22+
monkeypatch.delenv("STACKVOX_CONFIG", raising=False)
23+
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
24+
from pathlib import Path
25+
26+
assert config.config_path() == Path.home() / ".config" / "stackvox" / "config.toml"
27+
28+
29+
class TestLoadDefaults:
30+
def test_missing_file_returns_built_in_defaults(self, tmp_path):
31+
actual = config.load_defaults(tmp_path / "absent.toml")
32+
assert actual == config.Defaults()
33+
assert actual.voice == DEFAULT_VOICE
34+
assert actual.speed == DEFAULT_SPEED
35+
assert actual.lang == DEFAULT_LANG
36+
37+
def test_full_config_overrides_all_three(self, tmp_path):
38+
path = tmp_path / "config.toml"
39+
path.write_text('[defaults]\nvoice = "bf_emma"\nspeed = 1.25\nlang = "en-gb"\n', encoding="utf-8")
40+
actual = config.load_defaults(path)
41+
assert actual.voice == "bf_emma"
42+
assert actual.speed == 1.25
43+
assert actual.lang == "en-gb"
44+
45+
def test_partial_config_keeps_built_in_for_missing_keys(self, tmp_path):
46+
path = tmp_path / "config.toml"
47+
path.write_text('[defaults]\nvoice = "bf_emma"\n', encoding="utf-8")
48+
actual = config.load_defaults(path)
49+
assert actual.voice == "bf_emma"
50+
# speed and lang come from the engine defaults.
51+
assert actual.speed == DEFAULT_SPEED
52+
assert actual.lang == DEFAULT_LANG
53+
54+
def test_empty_file_returns_built_in_defaults(self, tmp_path):
55+
path = tmp_path / "config.toml"
56+
path.write_text("", encoding="utf-8")
57+
assert config.load_defaults(path) == config.Defaults()
58+
59+
def test_malformed_toml_logs_warning_and_returns_defaults(self, tmp_path, caplog):
60+
path = tmp_path / "config.toml"
61+
path.write_text("this is not valid = toml\n[defaults\nvoice =", encoding="utf-8")
62+
with caplog.at_level(logging.WARNING, logger="stackvox.config"):
63+
actual = config.load_defaults(path)
64+
assert actual == config.Defaults()
65+
assert any("malformed stackvox config" in r.message for r in caplog.records)
66+
67+
def test_defaults_section_must_be_a_table(self, tmp_path, caplog):
68+
"""`defaults = "string"` is parseable TOML but the wrong shape — log and ignore."""
69+
path = tmp_path / "config.toml"
70+
path.write_text('defaults = "not-a-table"\n', encoding="utf-8")
71+
with caplog.at_level(logging.WARNING, logger="stackvox.config"):
72+
actual = config.load_defaults(path)
73+
assert actual == config.Defaults()
74+
assert any("must be a table" in r.message for r in caplog.records)
75+
76+
77+
class TestCLIPicksUpConfig:
78+
"""Smoke test: argparse defaults reflect the user's config file."""
79+
80+
def test_voice_default_comes_from_config(self, mocker, monkeypatch, tmp_path):
81+
path = tmp_path / "config.toml"
82+
path.write_text('[defaults]\nvoice = "bf_emma"\nspeed = 1.3\n', encoding="utf-8")
83+
monkeypatch.setenv("STACKVOX_CONFIG", str(path))
84+
85+
from stackvox import cli
86+
87+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
88+
mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "hello"])
89+
mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
90+
91+
assert cli.main() == 0
92+
args = speak.call_args.args[0]
93+
assert args.voice == "bf_emma"
94+
assert args.speed == 1.3
95+
# Lang wasn't in config; should fall through to built-in default.
96+
assert args.lang == DEFAULT_LANG
97+
98+
def test_explicit_flag_overrides_config(self, mocker, monkeypatch, tmp_path):
99+
path = tmp_path / "config.toml"
100+
path.write_text('[defaults]\nvoice = "bf_emma"\n', encoding="utf-8")
101+
monkeypatch.setenv("STACKVOX_CONFIG", str(path))
102+
103+
from stackvox import cli
104+
105+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
106+
mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "--voice", "af_sarah", "hello"])
107+
mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
108+
109+
assert cli.main() == 0
110+
assert speak.call_args.args[0].voice == "af_sarah"

0 commit comments

Comments
 (0)