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.
- {stackvox-0.3.0/stackvox.egg-info → stackvox-0.4.0}/PKG-INFO +48 -4
- {stackvox-0.3.0 → stackvox-0.4.0}/README.md +46 -3
- {stackvox-0.3.0 → stackvox-0.4.0}/pyproject.toml +15 -10
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/cli.py +82 -13
- stackvox-0.4.0/stackvox/config.py +80 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/daemon.py +4 -1
- {stackvox-0.3.0 → stackvox-0.4.0/stackvox.egg-info}/PKG-INFO +48 -4
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/SOURCES.txt +5 -1
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/requires.txt +3 -0
- stackvox-0.4.0/tests/test_cli.py +292 -0
- stackvox-0.4.0/tests/test_config.py +110 -0
- stackvox-0.4.0/tests/test_daemon.py +138 -0
- stackvox-0.4.0/tests/test_engine.py +241 -0
- stackvox-0.3.0/tests/test_cli.py +0 -91
- {stackvox-0.3.0 → stackvox-0.4.0}/LICENSE +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/NOTICE +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/setup.cfg +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/__init__.py +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/__main__.py +0 -0
- {stackvox-0.3.0/bin → stackvox-0.4.0/stackvox/data}/stackvox-say +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/engine.py +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/paths.py +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox/py.typed +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.3.0 → stackvox-0.4.0}/tests/test_daemon_protocol.py +0 -0
- {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
|
+
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
|
|
51
|
+
pipx install stackvox # `stackvox` CLI on PATH
|
|
49
52
|
# or
|
|
50
|
-
pip install stackvox
|
|
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
|
-
|
|
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
|
|
17
|
+
pipx install stackvox # `stackvox` CLI on PATH
|
|
16
18
|
# or
|
|
17
|
-
pip install stackvox
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
#
|
|
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
|
|
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 = {
|
|
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=
|
|
121
|
-
parser.add_argument("--speed", type=float, default=
|
|
122
|
-
parser.add_argument("--lang", default=
|
|
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
|
+
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
|
|
51
|
+
pipx install stackvox # `stackvox` CLI on PATH
|
|
49
52
|
# or
|
|
50
|
-
pip install stackvox
|
|
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
|
-
|
|
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
|