stackvox 0.3.0__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 (28) hide show
  1. {stackvox-0.3.0/stackvox.egg-info → stackvox-0.4.0}/PKG-INFO +48 -4
  2. {stackvox-0.3.0 → stackvox-0.4.0}/README.md +46 -3
  3. {stackvox-0.3.0 → stackvox-0.4.0}/pyproject.toml +15 -10
  4. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/cli.py +82 -13
  5. stackvox-0.4.0/stackvox/config.py +80 -0
  6. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/daemon.py +4 -1
  7. {stackvox-0.3.0 → stackvox-0.4.0/stackvox.egg-info}/PKG-INFO +48 -4
  8. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/SOURCES.txt +5 -1
  9. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/requires.txt +3 -0
  10. stackvox-0.4.0/tests/test_cli.py +292 -0
  11. stackvox-0.4.0/tests/test_config.py +110 -0
  12. stackvox-0.4.0/tests/test_daemon.py +138 -0
  13. stackvox-0.4.0/tests/test_engine.py +241 -0
  14. stackvox-0.3.0/tests/test_cli.py +0 -91
  15. {stackvox-0.3.0 → stackvox-0.4.0}/LICENSE +0 -0
  16. {stackvox-0.3.0 → stackvox-0.4.0}/NOTICE +0 -0
  17. {stackvox-0.3.0 → stackvox-0.4.0}/setup.cfg +0 -0
  18. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/__init__.py +0 -0
  19. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/__main__.py +0 -0
  20. {stackvox-0.3.0/bin → stackvox-0.4.0/stackvox/data}/stackvox-say +0 -0
  21. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/engine.py +0 -0
  22. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/paths.py +0 -0
  23. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/py.typed +0 -0
  24. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/dependency_links.txt +0 -0
  25. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/entry_points.txt +0 -0
  26. {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/top_level.txt +0 -0
  27. {stackvox-0.3.0 → stackvox-0.4.0}/tests/test_daemon_protocol.py +0 -0
  28. {stackvox-0.3.0 → 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.0
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,16 +41,27 @@ 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:
46
49
 
47
50
  ```bash
48
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
51
+ pipx install stackvox # `stackvox` CLI on PATH
49
52
  # or
50
- pip install stackvox # use as a library
53
+ pip install stackvox # use as a library
54
+ ```
55
+
56
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
57
+
58
+ ```bash
59
+ stackvox install-helper # copies bash helper to ~/.local/bin
60
+ # use --prefix DIR to install elsewhere
51
61
  ```
52
62
 
63
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
64
+
53
65
  From git, if you want an unreleased commit:
54
66
 
55
67
  ```bash
@@ -87,7 +99,22 @@ stackvox completion bash > ~/.stackvox-completion.bash
87
99
  echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
88
100
  ```
89
101
 
90
- 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:
91
118
 
92
119
  ```bash
93
120
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
@@ -202,6 +229,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
202
229
 
203
230
  Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
204
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
+
205
249
  ## License & attributions
206
250
 
207
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,16 +7,27 @@
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:
13
15
 
14
16
  ```bash
15
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
17
+ pipx install stackvox # `stackvox` CLI on PATH
16
18
  # or
17
- pip install stackvox # use as a library
19
+ pip install stackvox # use as a library
20
+ ```
21
+
22
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
23
+
24
+ ```bash
25
+ stackvox install-helper # copies bash helper to ~/.local/bin
26
+ # use --prefix DIR to install elsewhere
18
27
  ```
19
28
 
29
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
30
+
20
31
  From git, if you want an unreleased commit:
21
32
 
22
33
  ```bash
@@ -54,7 +65,22 @@ stackvox completion bash > ~/.stackvox-completion.bash
54
65
  echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
55
66
  ```
56
67
 
57
- 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:
58
84
 
59
85
  ```bash
60
86
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
@@ -169,6 +195,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
169
195
 
170
196
  Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
171
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
+
172
215
  ## License & attributions
173
216
 
174
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.0"
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]
@@ -45,15 +46,16 @@ Issues = "https://github.com/StackOneHQ/stackvox/issues"
45
46
  [project.scripts]
46
47
  stackvox = "stackvox.cli:main"
47
48
 
48
- [tool.setuptools]
49
- script-files = ["bin/stackvox-say"]
50
-
51
49
  [tool.setuptools.packages.find]
52
50
  include = ["stackvox*"]
53
51
 
54
52
  [tool.setuptools.package-data]
55
- # Ship the PEP 561 marker so downstream type checkers trust our inline hints.
56
- stackvox = ["py.typed"]
53
+ # Ship the PEP 561 marker so downstream type checkers trust our inline hints,
54
+ # and the bash helper as package data — extracted onto PATH by
55
+ # `stackvox install-helper`. (We deliberately don't use [tool.setuptools]
56
+ # script-files = [...]: it's discouraged, has no PEP 621 equivalent, and
57
+ # doesn't round-trip through hatchling/uv if we ever migrate build backends.)
58
+ stackvox = ["py.typed", "data/stackvox-say"]
57
59
 
58
60
  [tool.ruff]
59
61
  line-length = 110
@@ -105,15 +107,18 @@ warn_redundant_casts = true
105
107
  warn_return_any = true
106
108
  check_untyped_defs = true
107
109
  no_implicit_optional = true
110
+ disallow_untyped_defs = true
108
111
 
109
112
  [[tool.mypy.overrides]]
110
- # Third-party deps without inline type stubs.
111
- 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"]
112
116
  ignore_missing_imports = true
113
117
 
114
118
  [[tool.mypy.overrides]]
115
- # Tests use pytest-mock's mocker.patch which returns Any-heavy values; don't
116
- # 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.
117
122
  module = "tests.*"
118
123
  disallow_untyped_defs = false
119
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:
@@ -21,7 +21,19 @@ def _configure_logging() -> None:
21
21
  )
22
22
 
23
23
 
24
- SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome", "completion"}
24
+ SUBCOMMANDS = {
25
+ "serve",
26
+ "stop",
27
+ "status",
28
+ "say",
29
+ "speak",
30
+ "voices",
31
+ "welcome",
32
+ "completion",
33
+ "install-helper",
34
+ }
35
+
36
+ DEFAULT_HELPER_PREFIX = Path.home() / ".local" / "bin"
25
37
 
26
38
  _BASH_COMPLETION = r"""# stackvox bash completion. Install with one of:
27
39
  # eval "$(stackvox completion bash)" # current shell
@@ -34,7 +46,7 @@ _stackvox_completion() {
34
46
  subcommand="${COMP_WORDS[1]:-}"
35
47
 
36
48
  if [[ ${COMP_CWORD} -eq 1 ]]; then
37
- COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion" -- "$cur") )
49
+ COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") )
38
50
  return 0
39
51
  fi
40
52
 
@@ -43,6 +55,10 @@ _stackvox_completion() {
43
55
  COMPREPLY=( $(compgen -f -- "$cur") )
44
56
  return 0
45
57
  ;;
58
+ --prefix)
59
+ COMPREPLY=( $(compgen -d -- "$cur") )
60
+ return 0
61
+ ;;
46
62
  --speed)
47
63
  COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") )
48
64
  return 0
@@ -66,6 +82,9 @@ _stackvox_completion() {
66
82
  completion)
67
83
  COMPREPLY=( $(compgen -W "bash" -- "$cur") )
68
84
  ;;
85
+ install-helper)
86
+ COMPREPLY=( $(compgen -W "--prefix --help" -- "$cur") )
87
+ ;;
69
88
  *)
70
89
  COMPREPLY=( $(compgen -W "--help" -- "$cur") )
71
90
  ;;
@@ -84,18 +103,20 @@ WELCOME_LINES = [
84
103
  ]
85
104
 
86
105
 
87
- 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()
88
109
  parser = argparse.ArgumentParser(prog="stackvox", description="Kokoro-82M TTS")
89
110
  sub = parser.add_subparsers(dest="cmd")
90
111
 
91
112
  p_speak = sub.add_parser("speak", help="Synthesize and play in-process (loads model each run)")
92
- _add_voice_args(p_speak)
113
+ _add_voice_args(p_speak, defaults)
93
114
  p_speak.add_argument("text", nargs="?")
94
115
  p_speak.add_argument("--file", type=Path)
95
116
  p_speak.add_argument("--out", type=Path, help="Write wav instead of playing")
96
117
 
97
118
  p_say = sub.add_parser("say", help="Send text to daemon (fast; fails if daemon not running)")
98
- _add_voice_args(p_say)
119
+ _add_voice_args(p_say, defaults)
99
120
  p_say.add_argument("text", nargs="?")
100
121
  p_say.add_argument("--file", type=Path)
101
122
  p_say.add_argument(
@@ -103,7 +124,7 @@ def _build_parser() -> argparse.ArgumentParser:
103
124
  )
104
125
 
105
126
  p_serve = sub.add_parser("serve", help="Run the daemon in the foreground")
106
- _add_voice_args(p_serve)
127
+ _add_voice_args(p_serve, defaults)
107
128
 
108
129
  sub.add_parser("stop", help="Stop the running daemon")
109
130
  sub.add_parser("status", help="Print daemon status")
@@ -113,13 +134,24 @@ def _build_parser() -> argparse.ArgumentParser:
113
134
  p_completion = sub.add_parser("completion", help="Print a shell completion script")
114
135
  p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
115
136
 
137
+ p_install_helper = sub.add_parser(
138
+ "install-helper",
139
+ help="Copy the stackvox-say bash helper onto PATH (default: ~/.local/bin)",
140
+ )
141
+ p_install_helper.add_argument(
142
+ "--prefix",
143
+ type=Path,
144
+ default=DEFAULT_HELPER_PREFIX,
145
+ help=f"Install directory (default: {DEFAULT_HELPER_PREFIX})",
146
+ )
147
+
116
148
  return parser
117
149
 
118
150
 
119
- def _add_voice_args(parser: argparse.ArgumentParser) -> None:
120
- parser.add_argument("--voice", default=DEFAULT_VOICE)
121
- parser.add_argument("--speed", type=float, default=DEFAULT_SPEED)
122
- 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)
123
155
 
124
156
 
125
157
  def _read_text(args: argparse.Namespace) -> str | None:
@@ -221,6 +253,42 @@ def _cmd_completion(args: argparse.Namespace) -> int:
221
253
  return 1
222
254
 
223
255
 
256
+ def _cmd_install_helper(args: argparse.Namespace) -> int:
257
+ """Copy the bundled `stackvox-say` bash helper onto PATH.
258
+
259
+ The helper is shipped as package data (`stackvox/data/stackvox-say`) rather
260
+ than installed automatically by setuptools — `script-files` is discouraged
261
+ and doesn't round-trip through modern build backends. This subcommand is
262
+ the explicit one-time install step.
263
+ """
264
+ import os
265
+ import shutil
266
+ import stat
267
+ from importlib.resources import as_file, files
268
+
269
+ prefix = Path(args.prefix).expanduser()
270
+ prefix.mkdir(parents=True, exist_ok=True)
271
+ dest = prefix / "stackvox-say"
272
+
273
+ src = files("stackvox").joinpath("data/stackvox-say")
274
+ with as_file(src) as src_path:
275
+ shutil.copy2(src_path, dest)
276
+
277
+ # Ensure executable bits regardless of how the wheel preserved them.
278
+ dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
279
+
280
+ print(f"Installed stackvox-say to {dest}", file=sys.stderr)
281
+
282
+ path_dirs = os.environ.get("PATH", "").split(os.pathsep)
283
+ if str(prefix) not in path_dirs:
284
+ print(
285
+ f"Note: {prefix} is not on your PATH. Either add it to PATH or invoke "
286
+ f"the helper via its full path: {dest}",
287
+ file=sys.stderr,
288
+ )
289
+ return 0
290
+
291
+
224
292
  def main() -> int:
225
293
  _configure_logging()
226
294
  argv = sys.argv[1:]
@@ -231,7 +299,7 @@ def main() -> int:
231
299
  elif not argv and not sys.stdin.isatty():
232
300
  argv = ["speak"]
233
301
 
234
- parser = _build_parser()
302
+ parser = _build_parser(config.load_defaults())
235
303
  args = parser.parse_args(argv)
236
304
 
237
305
  if not args.cmd:
@@ -247,6 +315,7 @@ def main() -> int:
247
315
  "voices": _cmd_voices,
248
316
  "welcome": _cmd_welcome,
249
317
  "completion": _cmd_completion,
318
+ "install-helper": _cmd_install_helper,
250
319
  }
251
320
  return handlers[args.cmd](args)
252
321
 
@@ -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.0
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,16 +41,27 @@ 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:
46
49
 
47
50
  ```bash
48
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
51
+ pipx install stackvox # `stackvox` CLI on PATH
49
52
  # or
50
- pip install stackvox # use as a library
53
+ pip install stackvox # use as a library
54
+ ```
55
+
56
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
57
+
58
+ ```bash
59
+ stackvox install-helper # copies bash helper to ~/.local/bin
60
+ # use --prefix DIR to install elsewhere
51
61
  ```
52
62
 
63
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
64
+
53
65
  From git, if you want an unreleased commit:
54
66
 
55
67
  ```bash
@@ -87,7 +99,22 @@ stackvox completion bash > ~/.stackvox-completion.bash
87
99
  echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
88
100
  ```
89
101
 
90
- 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:
91
118
 
92
119
  ```bash
93
120
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
@@ -202,6 +229,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
202
229
 
203
230
  Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
204
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
+
205
249
  ## License & attributions
206
250
 
207
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.
@@ -2,10 +2,10 @@ LICENSE
2
2
  NOTICE
3
3
  README.md
4
4
  pyproject.toml
5
- bin/stackvox-say
6
5
  stackvox/__init__.py
7
6
  stackvox/__main__.py
8
7
  stackvox/cli.py
8
+ stackvox/config.py
9
9
  stackvox/daemon.py
10
10
  stackvox/engine.py
11
11
  stackvox/paths.py
@@ -16,6 +16,10 @@ stackvox.egg-info/dependency_links.txt
16
16
  stackvox.egg-info/entry_points.txt
17
17
  stackvox.egg-info/requires.txt
18
18
  stackvox.egg-info/top_level.txt
19
+ stackvox/data/stackvox-say
19
20
  tests/test_cli.py
21
+ tests/test_config.py
22
+ tests/test_daemon.py
20
23
  tests/test_daemon_protocol.py
24
+ tests/test_engine.py
21
25
  tests/test_paths.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