stackvox 0.3.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {stackvox-0.3.1/stackvox.egg-info → stackvox-0.4.0}/PKG-INFO +37 -2
  2. {stackvox-0.3.1 → stackvox-0.4.0}/README.md +35 -1
  3. {stackvox-0.3.1 → stackvox-0.4.0}/pyproject.toml +9 -5
  4. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/cli.py +13 -11
  5. stackvox-0.4.0/stackvox/config.py +80 -0
  6. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/daemon.py +4 -1
  7. {stackvox-0.3.1 → stackvox-0.4.0/stackvox.egg-info}/PKG-INFO +37 -2
  8. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/SOURCES.txt +2 -0
  9. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/requires.txt +3 -0
  10. stackvox-0.4.0/tests/test_config.py +110 -0
  11. {stackvox-0.3.1 → stackvox-0.4.0}/LICENSE +0 -0
  12. {stackvox-0.3.1 → stackvox-0.4.0}/NOTICE +0 -0
  13. {stackvox-0.3.1 → stackvox-0.4.0}/setup.cfg +0 -0
  14. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/__init__.py +0 -0
  15. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/__main__.py +0 -0
  16. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/data/stackvox-say +0 -0
  17. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/engine.py +0 -0
  18. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/paths.py +0 -0
  19. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/py.typed +0 -0
  20. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/dependency_links.txt +0 -0
  21. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/entry_points.txt +0 -0
  22. {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/top_level.txt +0 -0
  23. {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_cli.py +0 -0
  24. {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_daemon.py +0 -0
  25. {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_daemon_protocol.py +0 -0
  26. {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_engine.py +0 -0
  27. {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,7 @@ Requires-Dist: kokoro-onnx>=0.4.0
21
21
  Requires-Dist: soundfile>=0.12.1
22
22
  Requires-Dist: sounddevice>=0.4.6
23
23
  Requires-Dist: numpy>=1.24
24
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: pytest>=8.0; extra == "dev"
26
27
  Requires-Dist: pytest-mock>=3.12; extra == "dev"
@@ -40,6 +41,8 @@ Dynamic: license-file
40
41
 
41
42
  Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx). Apache 2.0 model, ~340MB, CPU real-time, plays straight to system audio. Designed to be importable as a Python library, drivable as a CLI, or poked via a unix socket for ~13ms speech requests from shell scripts.
42
43
 
44
+ 🔊 **Hear it:** [docs/demo.wav](./docs/demo.wav) — five seconds of two voices speaking the tagline (`af_heart` then `bf_emma`).
45
+
43
46
  ## Install
44
47
 
45
48
  From PyPI — recommended for most users:
@@ -96,7 +99,22 @@ stackvox completion bash > ~/.stackvox-completion.bash
96
99
  echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
97
100
  ```
98
101
 
99
- Daemon mode (keeps the model resident so each subsequent call is instant):
102
+ ## Configuration
103
+
104
+ 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):
105
+
106
+ ```toml
107
+ [defaults]
108
+ voice = "bf_emma"
109
+ speed = 1.1
110
+ lang = "en-gb"
111
+ ```
112
+
113
+ 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.
114
+
115
+ ## Daemon mode
116
+
117
+ Keeps the model resident so each subsequent call is instant:
100
118
 
101
119
  ```bash
102
120
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
@@ -211,6 +229,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
211
229
 
212
230
  Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
213
231
 
232
+ ## How does this compare to other TTS?
233
+
234
+ stackvox is a fairly opinionated narrow slice of the TTS space. Here's where it sits next to the obvious neighbours:
235
+
236
+ | Tool | Offline? | Quality | Latency (typical) | License | Best for |
237
+ |---|---|---|---|---|---|
238
+ | **stackvox** (Kokoro-82M) | ✅ | High (24kHz, 50+ voices, 9 languages) | ~300ms in-process · ~13ms via daemon helper | Apache 2.0 | Local apps, shell hooks, anything that wants natural voice without the cloud |
239
+ | macOS `say` | ✅ | OK | ~50ms | macOS only | macOS-only scripts, "good enough" voice |
240
+ | `espeak-ng` | ✅ | Robotic | ~10ms | GPL-3.0 | Accessibility, screen readers, embedded |
241
+ | [Piper](https://github.com/rhasspy/piper) | ✅ | High | ~100ms | MIT | Similar use-case to stackvox; ONNX-based, more voices in some languages |
242
+ | [Coqui TTS](https://github.com/coqui-ai/TTS) | ✅ | Very high (research models) | seconds | MPL-2.0 | Research, fine-tuning, voice cloning |
243
+ | OpenAI / ElevenLabs / etc. | ❌ | Highest | network-bound | Proprietary | Production apps that can pay per-call and accept network dependency |
244
+
245
+ Where stackvox tries to be different from Piper specifically: a **resident daemon + bash helper** path that gets you sub-15ms speech requests from shell scripts (CI hooks, terminal notifications, status announcements) without paying Python's startup cost on every call. That's basically the point — voice quality alone wouldn't be enough to switch off Piper, but the IPC story makes a difference for shell-driven workflows.
246
+
247
+ Pick stackvox if you want **good voices, fully offline, with a fast shell-friendly API**.
248
+
214
249
  ## License & attributions
215
250
 
216
251
  stackvox itself is licensed under the **Apache License, Version 2.0** — see [`LICENSE`](./LICENSE). Third-party attributions are collected in [`NOTICE`](./NOTICE); the summary below is informational.
@@ -7,6 +7,8 @@
7
7
 
8
8
  Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx). Apache 2.0 model, ~340MB, CPU real-time, plays straight to system audio. Designed to be importable as a Python library, drivable as a CLI, or poked via a unix socket for ~13ms speech requests from shell scripts.
9
9
 
10
+ 🔊 **Hear it:** [docs/demo.wav](./docs/demo.wav) — five seconds of two voices speaking the tagline (`af_heart` then `bf_emma`).
11
+
10
12
  ## Install
11
13
 
12
14
  From PyPI — recommended for most users:
@@ -63,7 +65,22 @@ stackvox completion bash > ~/.stackvox-completion.bash
63
65
  echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
64
66
  ```
65
67
 
66
- 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:
67
84
 
68
85
  ```bash
69
86
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
@@ -178,6 +195,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
178
195
 
179
196
  Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
180
197
 
198
+ ## How does this compare to other TTS?
199
+
200
+ stackvox is a fairly opinionated narrow slice of the TTS space. Here's where it sits next to the obvious neighbours:
201
+
202
+ | Tool | Offline? | Quality | Latency (typical) | License | Best for |
203
+ |---|---|---|---|---|---|
204
+ | **stackvox** (Kokoro-82M) | ✅ | High (24kHz, 50+ voices, 9 languages) | ~300ms in-process · ~13ms via daemon helper | Apache 2.0 | Local apps, shell hooks, anything that wants natural voice without the cloud |
205
+ | macOS `say` | ✅ | OK | ~50ms | macOS only | macOS-only scripts, "good enough" voice |
206
+ | `espeak-ng` | ✅ | Robotic | ~10ms | GPL-3.0 | Accessibility, screen readers, embedded |
207
+ | [Piper](https://github.com/rhasspy/piper) | ✅ | High | ~100ms | MIT | Similar use-case to stackvox; ONNX-based, more voices in some languages |
208
+ | [Coqui TTS](https://github.com/coqui-ai/TTS) | ✅ | Very high (research models) | seconds | MPL-2.0 | Research, fine-tuning, voice cloning |
209
+ | OpenAI / ElevenLabs / etc. | ❌ | Highest | network-bound | Proprietary | Production apps that can pay per-call and accept network dependency |
210
+
211
+ Where stackvox tries to be different from Piper specifically: a **resident daemon + bash helper** path that gets you sub-15ms speech requests from shell scripts (CI hooks, terminal notifications, status announcements) without paying Python's startup cost on every call. That's basically the point — voice quality alone wouldn't be enough to switch off Piper, but the IPC story makes a difference for shell-driven workflows.
212
+
213
+ Pick stackvox if you want **good voices, fully offline, with a fast shell-friendly API**.
214
+
181
215
  ## License & attributions
182
216
 
183
217
  stackvox itself is licensed under the **Apache License, Version 2.0** — see [`LICENSE`](./LICENSE). Third-party attributions are collected in [`NOTICE`](./NOTICE); the summary below is informational.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -24,6 +24,7 @@ dependencies = [
24
24
  "soundfile>=0.12.1",
25
25
  "sounddevice>=0.4.6",
26
26
  "numpy>=1.24",
27
+ "tomli>=2.0;python_version<'3.11'",
27
28
  ]
28
29
 
29
30
  [project.optional-dependencies]
@@ -106,15 +107,18 @@ warn_redundant_casts = true
106
107
  warn_return_any = true
107
108
  check_untyped_defs = true
108
109
  no_implicit_optional = true
110
+ disallow_untyped_defs = true
109
111
 
110
112
  [[tool.mypy.overrides]]
111
- # Third-party deps without inline type stubs.
112
- 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"]
113
116
  ignore_missing_imports = true
114
117
 
115
118
  [[tool.mypy.overrides]]
116
- # Tests use pytest-mock's mocker.patch which returns Any-heavy values; don't
117
- # require full strictness there.
119
+ # Tests rely on pytest-mock's `mocker` (Any-heavy) and pytest fixtures with
120
+ # implicit Any-typed kwargs. Strict untyped-def bans buy little here, so
121
+ # tests stay on the looser config.
118
122
  module = "tests.*"
119
123
  disallow_untyped_defs = false
120
124
 
@@ -9,8 +9,8 @@ from pathlib import Path
9
9
 
10
10
  import soundfile as sf
11
11
 
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
14
14
 
15
15
 
16
16
  def _configure_logging() -> None:
@@ -103,18 +103,20 @@ WELCOME_LINES = [
103
103
  ]
104
104
 
105
105
 
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()
107
109
  parser = argparse.ArgumentParser(prog="stackvox", description="Kokoro-82M TTS")
108
110
  sub = parser.add_subparsers(dest="cmd")
109
111
 
110
112
  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)
112
114
  p_speak.add_argument("text", nargs="?")
113
115
  p_speak.add_argument("--file", type=Path)
114
116
  p_speak.add_argument("--out", type=Path, help="Write wav instead of playing")
115
117
 
116
118
  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)
118
120
  p_say.add_argument("text", nargs="?")
119
121
  p_say.add_argument("--file", type=Path)
120
122
  p_say.add_argument(
@@ -122,7 +124,7 @@ def _build_parser() -> argparse.ArgumentParser:
122
124
  )
123
125
 
124
126
  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)
126
128
 
127
129
  sub.add_parser("stop", help="Stop the running daemon")
128
130
  sub.add_parser("status", help="Print daemon status")
@@ -146,10 +148,10 @@ def _build_parser() -> argparse.ArgumentParser:
146
148
  return parser
147
149
 
148
150
 
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)
153
155
 
154
156
 
155
157
  def _read_text(args: argparse.Namespace) -> str | None:
@@ -297,7 +299,7 @@ def main() -> int:
297
299
  elif not argv and not sys.stdin.isatty():
298
300
  argv = ["speak"]
299
301
 
300
- parser = _build_parser()
302
+ parser = _build_parser(config.load_defaults())
301
303
  args = parser.parse_args(argv)
302
304
 
303
305
  if not args.cmd:
@@ -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
+ )
@@ -130,7 +130,10 @@ def _start_device_watcher() -> None:
130
130
 
131
131
  last_device = [_read_default_device()]
132
132
 
133
- def _on_device_change(obj_id: int, n: int, addrs, data) -> int:
133
+ def _on_device_change(obj_id: int, n: int, addrs: Any, data: Any) -> int:
134
+ # `addrs` is a ctypes.POINTER(_PropAddr); `data` is a c_void_p — neither
135
+ # is needed because we already know which property fired (only one is
136
+ # registered) and we re-read the device ID directly.
134
137
  try:
135
138
  current = _read_default_device()
136
139
  if current and current != last_device[0]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,7 @@ Requires-Dist: kokoro-onnx>=0.4.0
21
21
  Requires-Dist: soundfile>=0.12.1
22
22
  Requires-Dist: sounddevice>=0.4.6
23
23
  Requires-Dist: numpy>=1.24
24
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: pytest>=8.0; extra == "dev"
26
27
  Requires-Dist: pytest-mock>=3.12; extra == "dev"
@@ -40,6 +41,8 @@ Dynamic: license-file
40
41
 
41
42
  Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx). Apache 2.0 model, ~340MB, CPU real-time, plays straight to system audio. Designed to be importable as a Python library, drivable as a CLI, or poked via a unix socket for ~13ms speech requests from shell scripts.
42
43
 
44
+ 🔊 **Hear it:** [docs/demo.wav](./docs/demo.wav) — five seconds of two voices speaking the tagline (`af_heart` then `bf_emma`).
45
+
43
46
  ## Install
44
47
 
45
48
  From PyPI — recommended for most users:
@@ -96,7 +99,22 @@ stackvox completion bash > ~/.stackvox-completion.bash
96
99
  echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
97
100
  ```
98
101
 
99
- Daemon mode (keeps the model resident so each subsequent call is instant):
102
+ ## Configuration
103
+
104
+ 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):
105
+
106
+ ```toml
107
+ [defaults]
108
+ voice = "bf_emma"
109
+ speed = 1.1
110
+ lang = "en-gb"
111
+ ```
112
+
113
+ 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.
114
+
115
+ ## Daemon mode
116
+
117
+ Keeps the model resident so each subsequent call is instant:
100
118
 
101
119
  ```bash
102
120
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
@@ -211,6 +229,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
211
229
 
212
230
  Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
213
231
 
232
+ ## How does this compare to other TTS?
233
+
234
+ stackvox is a fairly opinionated narrow slice of the TTS space. Here's where it sits next to the obvious neighbours:
235
+
236
+ | Tool | Offline? | Quality | Latency (typical) | License | Best for |
237
+ |---|---|---|---|---|---|
238
+ | **stackvox** (Kokoro-82M) | ✅ | High (24kHz, 50+ voices, 9 languages) | ~300ms in-process · ~13ms via daemon helper | Apache 2.0 | Local apps, shell hooks, anything that wants natural voice without the cloud |
239
+ | macOS `say` | ✅ | OK | ~50ms | macOS only | macOS-only scripts, "good enough" voice |
240
+ | `espeak-ng` | ✅ | Robotic | ~10ms | GPL-3.0 | Accessibility, screen readers, embedded |
241
+ | [Piper](https://github.com/rhasspy/piper) | ✅ | High | ~100ms | MIT | Similar use-case to stackvox; ONNX-based, more voices in some languages |
242
+ | [Coqui TTS](https://github.com/coqui-ai/TTS) | ✅ | Very high (research models) | seconds | MPL-2.0 | Research, fine-tuning, voice cloning |
243
+ | OpenAI / ElevenLabs / etc. | ❌ | Highest | network-bound | Proprietary | Production apps that can pay per-call and accept network dependency |
244
+
245
+ Where stackvox tries to be different from Piper specifically: a **resident daemon + bash helper** path that gets you sub-15ms speech requests from shell scripts (CI hooks, terminal notifications, status announcements) without paying Python's startup cost on every call. That's basically the point — voice quality alone wouldn't be enough to switch off Piper, but the IPC story makes a difference for shell-driven workflows.
246
+
247
+ Pick stackvox if you want **good voices, fully offline, with a fast shell-friendly API**.
248
+
214
249
  ## License & attributions
215
250
 
216
251
  stackvox itself is licensed under the **Apache License, Version 2.0** — see [`LICENSE`](./LICENSE). Third-party attributions are collected in [`NOTICE`](./NOTICE); the summary below is informational.
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  stackvox/__init__.py
6
6
  stackvox/__main__.py
7
7
  stackvox/cli.py
8
+ stackvox/config.py
8
9
  stackvox/daemon.py
9
10
  stackvox/engine.py
10
11
  stackvox/paths.py
@@ -17,6 +18,7 @@ stackvox.egg-info/requires.txt
17
18
  stackvox.egg-info/top_level.txt
18
19
  stackvox/data/stackvox-say
19
20
  tests/test_cli.py
21
+ tests/test_config.py
20
22
  tests/test_daemon.py
21
23
  tests/test_daemon_protocol.py
22
24
  tests/test_engine.py
@@ -3,6 +3,9 @@ soundfile>=0.12.1
3
3
  sounddevice>=0.4.6
4
4
  numpy>=1.24
5
5
 
6
+ [:python_version < "3.11"]
7
+ tomli>=2.0
8
+
6
9
  [dev]
7
10
  pytest>=8.0
8
11
  pytest-mock>=3.12
@@ -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"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes