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.
- {stackvox-0.3.1/stackvox.egg-info → stackvox-0.4.0}/PKG-INFO +37 -2
- {stackvox-0.3.1 → stackvox-0.4.0}/README.md +35 -1
- {stackvox-0.3.1 → stackvox-0.4.0}/pyproject.toml +9 -5
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/cli.py +13 -11
- stackvox-0.4.0/stackvox/config.py +80 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/daemon.py +4 -1
- {stackvox-0.3.1 → stackvox-0.4.0/stackvox.egg-info}/PKG-INFO +37 -2
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/SOURCES.txt +2 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/requires.txt +3 -0
- stackvox-0.4.0/tests/test_config.py +110 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/LICENSE +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/NOTICE +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/setup.cfg +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/__init__.py +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/__main__.py +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/engine.py +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/paths.py +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox/py.typed +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_cli.py +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_daemon.py +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.3.1 → stackvox-0.4.0}/tests/test_engine.py +0 -0
- {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
|
+
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
117
|
-
#
|
|
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
|
|
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=
|
|
151
|
-
parser.add_argument("--speed", type=float, default=
|
|
152
|
-
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)
|
|
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
|
+
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
|
-
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|