stackvox 0.2.1__tar.gz → 0.3.1__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.2.1/stackvox.egg-info → stackvox-0.3.1}/PKG-INFO +23 -3
- {stackvox-0.2.1 → stackvox-0.3.1}/README.md +22 -2
- {stackvox-0.2.1 → stackvox-0.3.1}/pyproject.toml +7 -6
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/cli.py +145 -2
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/daemon.py +112 -2
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/engine.py +48 -3
- {stackvox-0.2.1 → stackvox-0.3.1/stackvox.egg-info}/PKG-INFO +23 -3
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/SOURCES.txt +3 -1
- stackvox-0.3.1/tests/test_cli.py +292 -0
- stackvox-0.3.1/tests/test_daemon.py +138 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/tests/test_daemon_protocol.py +30 -6
- stackvox-0.3.1/tests/test_engine.py +241 -0
- stackvox-0.2.1/tests/test_cli.py +0 -41
- {stackvox-0.2.1 → stackvox-0.3.1}/LICENSE +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/NOTICE +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/setup.cfg +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/__init__.py +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/__main__.py +0 -0
- {stackvox-0.2.1/bin → stackvox-0.3.1/stackvox/data}/stackvox-say +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/paths.py +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/py.typed +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.2.1 → stackvox-0.3.1}/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.3.1
|
|
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
|
|
@@ -45,11 +45,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
|
|
|
45
45
|
From PyPI — recommended for most users:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
pipx install stackvox
|
|
48
|
+
pipx install stackvox # `stackvox` CLI on PATH
|
|
49
49
|
# or
|
|
50
|
-
pip install stackvox
|
|
50
|
+
pip install stackvox # use as a library
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
stackvox install-helper # copies bash helper to ~/.local/bin
|
|
57
|
+
# use --prefix DIR to install elsewhere
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
53
62
|
From git, if you want an unreleased commit:
|
|
54
63
|
|
|
55
64
|
```bash
|
|
@@ -74,6 +83,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
|
|
|
74
83
|
stackvox speak "save" --out a.wav # write wav instead of playing
|
|
75
84
|
stackvox welcome # multilingual welcome (6 languages)
|
|
76
85
|
stackvox voices # list all voice ids
|
|
86
|
+
echo "from a pipe" | stackvox # piped stdin works for speak/say
|
|
87
|
+
stackvox speak --file message.txt # read a whole file
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Bash completion:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
eval "$(stackvox completion bash)" # current shell
|
|
94
|
+
# or persist:
|
|
95
|
+
stackvox completion bash > ~/.stackvox-completion.bash
|
|
96
|
+
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
77
97
|
```
|
|
78
98
|
|
|
79
99
|
Daemon mode (keeps the model resident so each subsequent call is instant):
|
|
@@ -12,11 +12,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
|
|
|
12
12
|
From PyPI — recommended for most users:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
pipx install stackvox
|
|
15
|
+
pipx install stackvox # `stackvox` CLI on PATH
|
|
16
16
|
# or
|
|
17
|
-
pip install stackvox
|
|
17
|
+
pip install stackvox # use as a library
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
stackvox install-helper # copies bash helper to ~/.local/bin
|
|
24
|
+
# use --prefix DIR to install elsewhere
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
20
29
|
From git, if you want an unreleased commit:
|
|
21
30
|
|
|
22
31
|
```bash
|
|
@@ -41,6 +50,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
|
|
|
41
50
|
stackvox speak "save" --out a.wav # write wav instead of playing
|
|
42
51
|
stackvox welcome # multilingual welcome (6 languages)
|
|
43
52
|
stackvox voices # list all voice ids
|
|
53
|
+
echo "from a pipe" | stackvox # piped stdin works for speak/say
|
|
54
|
+
stackvox speak --file message.txt # read a whole file
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Bash completion:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
eval "$(stackvox completion bash)" # current shell
|
|
61
|
+
# or persist:
|
|
62
|
+
stackvox completion bash > ~/.stackvox-completion.bash
|
|
63
|
+
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
44
64
|
```
|
|
45
65
|
|
|
46
66
|
Daemon mode (keeps the model resident so each subsequent call is instant):
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Offline TTS library using Kokoro-82M"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -45,15 +45,16 @@ Issues = "https://github.com/StackOneHQ/stackvox/issues"
|
|
|
45
45
|
[project.scripts]
|
|
46
46
|
stackvox = "stackvox.cli:main"
|
|
47
47
|
|
|
48
|
-
[tool.setuptools]
|
|
49
|
-
script-files = ["bin/stackvox-say"]
|
|
50
|
-
|
|
51
48
|
[tool.setuptools.packages.find]
|
|
52
49
|
include = ["stackvox*"]
|
|
53
50
|
|
|
54
51
|
[tool.setuptools.package-data]
|
|
55
|
-
# Ship the PEP 561 marker so downstream type checkers trust our inline hints
|
|
56
|
-
|
|
52
|
+
# Ship the PEP 561 marker so downstream type checkers trust our inline hints,
|
|
53
|
+
# and the bash helper as package data — extracted onto PATH by
|
|
54
|
+
# `stackvox install-helper`. (We deliberately don't use [tool.setuptools]
|
|
55
|
+
# script-files = [...]: it's discouraged, has no PEP 621 equivalent, and
|
|
56
|
+
# doesn't round-trip through hatchling/uv if we ever migrate build backends.)
|
|
57
|
+
stackvox = ["py.typed", "data/stackvox-say"]
|
|
57
58
|
|
|
58
59
|
[tool.ruff]
|
|
59
60
|
line-length = 110
|
|
@@ -21,7 +21,77 @@ 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"
|
|
37
|
+
|
|
38
|
+
_BASH_COMPLETION = r"""# stackvox bash completion. Install with one of:
|
|
39
|
+
# eval "$(stackvox completion bash)" # current shell
|
|
40
|
+
# stackvox completion bash > ~/.stackvox-completion.bash && \
|
|
41
|
+
# echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
42
|
+
_stackvox_completion() {
|
|
43
|
+
local cur prev subcommand
|
|
44
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
45
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
46
|
+
subcommand="${COMP_WORDS[1]:-}"
|
|
47
|
+
|
|
48
|
+
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
|
49
|
+
COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") )
|
|
50
|
+
return 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
case "$prev" in
|
|
54
|
+
--file|--out)
|
|
55
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
56
|
+
return 0
|
|
57
|
+
;;
|
|
58
|
+
--prefix)
|
|
59
|
+
COMPREPLY=( $(compgen -d -- "$cur") )
|
|
60
|
+
return 0
|
|
61
|
+
;;
|
|
62
|
+
--speed)
|
|
63
|
+
COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") )
|
|
64
|
+
return 0
|
|
65
|
+
;;
|
|
66
|
+
--lang)
|
|
67
|
+
COMPREPLY=( $(compgen -W "en-us en-gb fr-fr it hi pt-br es ja zh" -- "$cur") )
|
|
68
|
+
return 0
|
|
69
|
+
;;
|
|
70
|
+
esac
|
|
71
|
+
|
|
72
|
+
case "$subcommand" in
|
|
73
|
+
speak)
|
|
74
|
+
COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --help" -- "$cur") )
|
|
75
|
+
;;
|
|
76
|
+
say)
|
|
77
|
+
COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --help" -- "$cur") )
|
|
78
|
+
;;
|
|
79
|
+
serve)
|
|
80
|
+
COMPREPLY=( $(compgen -W "--voice --speed --lang --help" -- "$cur") )
|
|
81
|
+
;;
|
|
82
|
+
completion)
|
|
83
|
+
COMPREPLY=( $(compgen -W "bash" -- "$cur") )
|
|
84
|
+
;;
|
|
85
|
+
install-helper)
|
|
86
|
+
COMPREPLY=( $(compgen -W "--prefix --help" -- "$cur") )
|
|
87
|
+
;;
|
|
88
|
+
*)
|
|
89
|
+
COMPREPLY=( $(compgen -W "--help" -- "$cur") )
|
|
90
|
+
;;
|
|
91
|
+
esac
|
|
92
|
+
}
|
|
93
|
+
complete -F _stackvox_completion stackvox
|
|
94
|
+
"""
|
|
25
95
|
|
|
26
96
|
WELCOME_LINES = [
|
|
27
97
|
("bf_emma", "en-gb", "Welcome to stackvox."),
|
|
@@ -59,6 +129,20 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
59
129
|
sub.add_parser("voices", help="List available voices")
|
|
60
130
|
sub.add_parser("welcome", help="Play a multilingual welcome message")
|
|
61
131
|
|
|
132
|
+
p_completion = sub.add_parser("completion", help="Print a shell completion script")
|
|
133
|
+
p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
|
|
134
|
+
|
|
135
|
+
p_install_helper = sub.add_parser(
|
|
136
|
+
"install-helper",
|
|
137
|
+
help="Copy the stackvox-say bash helper onto PATH (default: ~/.local/bin)",
|
|
138
|
+
)
|
|
139
|
+
p_install_helper.add_argument(
|
|
140
|
+
"--prefix",
|
|
141
|
+
type=Path,
|
|
142
|
+
default=DEFAULT_HELPER_PREFIX,
|
|
143
|
+
help=f"Install directory (default: {DEFAULT_HELPER_PREFIX})",
|
|
144
|
+
)
|
|
145
|
+
|
|
62
146
|
return parser
|
|
63
147
|
|
|
64
148
|
|
|
@@ -69,11 +153,20 @@ def _add_voice_args(parser: argparse.ArgumentParser) -> None:
|
|
|
69
153
|
|
|
70
154
|
|
|
71
155
|
def _read_text(args: argparse.Namespace) -> str | None:
|
|
156
|
+
"""Resolve the text to speak from --file, the positional, or piped stdin.
|
|
157
|
+
|
|
158
|
+
Precedence: --file > positional text > stdin (when piped, not a TTY).
|
|
159
|
+
"""
|
|
72
160
|
file: Path | None = getattr(args, "file", None)
|
|
73
161
|
if file is not None:
|
|
74
162
|
return file.read_text(encoding="utf-8")
|
|
75
163
|
text: str | None = getattr(args, "text", None)
|
|
76
|
-
|
|
164
|
+
if text is not None:
|
|
165
|
+
return text
|
|
166
|
+
if not sys.stdin.isatty():
|
|
167
|
+
piped = sys.stdin.read()
|
|
168
|
+
return piped if piped.strip() else None
|
|
169
|
+
return None
|
|
77
170
|
|
|
78
171
|
|
|
79
172
|
def _cmd_speak(args: argparse.Namespace) -> int:
|
|
@@ -149,12 +242,60 @@ def _cmd_welcome(_: argparse.Namespace) -> int:
|
|
|
149
242
|
return 0
|
|
150
243
|
|
|
151
244
|
|
|
245
|
+
def _cmd_completion(args: argparse.Namespace) -> int:
|
|
246
|
+
if args.shell == "bash":
|
|
247
|
+
print(_BASH_COMPLETION)
|
|
248
|
+
return 0
|
|
249
|
+
# argparse `choices=` should prevent reaching here; defensive only.
|
|
250
|
+
print(f"unsupported shell: {args.shell}", file=sys.stderr)
|
|
251
|
+
return 1
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _cmd_install_helper(args: argparse.Namespace) -> int:
|
|
255
|
+
"""Copy the bundled `stackvox-say` bash helper onto PATH.
|
|
256
|
+
|
|
257
|
+
The helper is shipped as package data (`stackvox/data/stackvox-say`) rather
|
|
258
|
+
than installed automatically by setuptools — `script-files` is discouraged
|
|
259
|
+
and doesn't round-trip through modern build backends. This subcommand is
|
|
260
|
+
the explicit one-time install step.
|
|
261
|
+
"""
|
|
262
|
+
import os
|
|
263
|
+
import shutil
|
|
264
|
+
import stat
|
|
265
|
+
from importlib.resources import as_file, files
|
|
266
|
+
|
|
267
|
+
prefix = Path(args.prefix).expanduser()
|
|
268
|
+
prefix.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
dest = prefix / "stackvox-say"
|
|
270
|
+
|
|
271
|
+
src = files("stackvox").joinpath("data/stackvox-say")
|
|
272
|
+
with as_file(src) as src_path:
|
|
273
|
+
shutil.copy2(src_path, dest)
|
|
274
|
+
|
|
275
|
+
# Ensure executable bits regardless of how the wheel preserved them.
|
|
276
|
+
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
277
|
+
|
|
278
|
+
print(f"Installed stackvox-say to {dest}", file=sys.stderr)
|
|
279
|
+
|
|
280
|
+
path_dirs = os.environ.get("PATH", "").split(os.pathsep)
|
|
281
|
+
if str(prefix) not in path_dirs:
|
|
282
|
+
print(
|
|
283
|
+
f"Note: {prefix} is not on your PATH. Either add it to PATH or invoke "
|
|
284
|
+
f"the helper via its full path: {dest}",
|
|
285
|
+
file=sys.stderr,
|
|
286
|
+
)
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
152
290
|
def main() -> int:
|
|
153
291
|
_configure_logging()
|
|
154
292
|
argv = sys.argv[1:]
|
|
155
293
|
# Back-compat: `stackvox "text"` with no subcommand → speak.
|
|
294
|
+
# Same shortcut for piped stdin: `echo hi | stackvox` → speak.
|
|
156
295
|
if argv and argv[0] not in SUBCOMMANDS and not argv[0].startswith("-"):
|
|
157
296
|
argv = ["speak", *argv]
|
|
297
|
+
elif not argv and not sys.stdin.isatty():
|
|
298
|
+
argv = ["speak"]
|
|
158
299
|
|
|
159
300
|
parser = _build_parser()
|
|
160
301
|
args = parser.parse_args(argv)
|
|
@@ -171,6 +312,8 @@ def main() -> int:
|
|
|
171
312
|
"status": _cmd_status,
|
|
172
313
|
"voices": _cmd_voices,
|
|
173
314
|
"welcome": _cmd_welcome,
|
|
315
|
+
"completion": _cmd_completion,
|
|
316
|
+
"install-helper": _cmd_install_helper,
|
|
174
317
|
}
|
|
175
318
|
return handlers[args.cmd](args)
|
|
176
319
|
|
|
@@ -13,6 +13,8 @@ Replies: "ok", "busy", "err: <msg>".
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import ctypes
|
|
17
|
+
import ctypes.util
|
|
16
18
|
import json
|
|
17
19
|
import logging
|
|
18
20
|
import os
|
|
@@ -20,7 +22,9 @@ import queue
|
|
|
20
22
|
import signal
|
|
21
23
|
import socket
|
|
22
24
|
import socketserver
|
|
25
|
+
import sys
|
|
23
26
|
import threading
|
|
27
|
+
from typing import Any
|
|
24
28
|
|
|
25
29
|
import sounddevice as sd
|
|
26
30
|
|
|
@@ -38,6 +42,18 @@ PING_TIMEOUT_SECONDS = 0.5
|
|
|
38
42
|
RECV_BYTES = 1024
|
|
39
43
|
|
|
40
44
|
|
|
45
|
+
# Set when PortAudio's cached default-output device is suspected stale and
|
|
46
|
+
# needs Pa_Terminate / Pa_Initialize before the next playback. Initial state
|
|
47
|
+
# is True so the first playback always refreshes; the macOS device watcher
|
|
48
|
+
# re-sets it on real device changes; the worker also re-sets on playback
|
|
49
|
+
# failure as a belt-and-suspenders retry path.
|
|
50
|
+
_audio_dirty = threading.Event()
|
|
51
|
+
_audio_dirty.set()
|
|
52
|
+
|
|
53
|
+
# Holds CoreAudio callback references so they aren't garbage collected.
|
|
54
|
+
_ca_refs: list[Any] = []
|
|
55
|
+
|
|
56
|
+
|
|
41
57
|
def _refresh_audio_devices() -> None:
|
|
42
58
|
"""Reset PortAudio so the next play picks up the current system default.
|
|
43
59
|
|
|
@@ -45,7 +61,7 @@ def _refresh_audio_devices() -> None:
|
|
|
45
61
|
daemon keeps playing to whatever was default when it started (e.g. the
|
|
46
62
|
built-in speakers after the user swapped to Bluetooth). Terminating and
|
|
47
63
|
re-initialising is the only portable way to refresh that cache. Costs
|
|
48
|
-
~10-50ms per call
|
|
64
|
+
~10-50ms per call.
|
|
49
65
|
"""
|
|
50
66
|
try:
|
|
51
67
|
sd._terminate()
|
|
@@ -54,6 +70,94 @@ def _refresh_audio_devices() -> None:
|
|
|
54
70
|
logger.exception("failed to refresh audio devices")
|
|
55
71
|
|
|
56
72
|
|
|
73
|
+
def _start_device_watcher() -> None:
|
|
74
|
+
"""macOS only: mark `_audio_dirty` when the default output device changes.
|
|
75
|
+
|
|
76
|
+
Avoids reinitialising PortAudio on every playback (the simpler approach,
|
|
77
|
+
which adds 10-50ms of latency before each speech). macOS notifies the
|
|
78
|
+
property listener on more than just real device changes — playback start,
|
|
79
|
+
volume changes, and other side effects all fire it — so we compare the
|
|
80
|
+
current default-output device ID against the last seen one and only mark
|
|
81
|
+
dirty on actual changes.
|
|
82
|
+
|
|
83
|
+
No-ops on non-macOS; the dirty flag stays at its initial state (set), so
|
|
84
|
+
the first playback refreshes once and subsequent playbacks reuse the
|
|
85
|
+
PortAudio context. Device changes on those platforms are handled by the
|
|
86
|
+
worker's failure-retry path.
|
|
87
|
+
"""
|
|
88
|
+
if sys.platform != "darwin":
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
ca = ctypes.CDLL(ctypes.util.find_library("CoreAudio") or "")
|
|
92
|
+
cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation") or "")
|
|
93
|
+
except Exception:
|
|
94
|
+
logger.debug("CoreAudio unavailable; device watcher disabled")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
class _PropAddr(ctypes.Structure):
|
|
98
|
+
_fields_ = [
|
|
99
|
+
("mSelector", ctypes.c_uint32),
|
|
100
|
+
("mScope", ctypes.c_uint32),
|
|
101
|
+
("mElement", ctypes.c_uint32),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
_ListenerProc = ctypes.CFUNCTYPE(
|
|
105
|
+
ctypes.c_int32,
|
|
106
|
+
ctypes.c_uint32, # inObjectID
|
|
107
|
+
ctypes.c_uint32, # inNumberAddresses
|
|
108
|
+
ctypes.POINTER(_PropAddr),
|
|
109
|
+
ctypes.c_void_p,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
prop = _PropAddr(
|
|
113
|
+
0x644F7574, # kAudioHardwarePropertyDefaultOutputDevice 'dOut'
|
|
114
|
+
0x676C6F62, # kAudioObjectPropertyScopeGlobal 'glob'
|
|
115
|
+
0, # kAudioObjectPropertyElementMain
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _read_default_device() -> int:
|
|
119
|
+
device = ctypes.c_uint32(0)
|
|
120
|
+
size = ctypes.c_uint32(ctypes.sizeof(device))
|
|
121
|
+
status = ca.AudioObjectGetPropertyData(
|
|
122
|
+
1, # kAudioObjectSystemObject
|
|
123
|
+
ctypes.byref(prop),
|
|
124
|
+
0,
|
|
125
|
+
None,
|
|
126
|
+
ctypes.byref(size),
|
|
127
|
+
ctypes.byref(device),
|
|
128
|
+
)
|
|
129
|
+
return device.value if status == 0 else 0
|
|
130
|
+
|
|
131
|
+
last_device = [_read_default_device()]
|
|
132
|
+
|
|
133
|
+
def _on_device_change(obj_id: int, n: int, addrs, data) -> int:
|
|
134
|
+
try:
|
|
135
|
+
current = _read_default_device()
|
|
136
|
+
if current and current != last_device[0]:
|
|
137
|
+
last_device[0] = current
|
|
138
|
+
_audio_dirty.set()
|
|
139
|
+
except Exception:
|
|
140
|
+
logger.debug("device-change callback error", exc_info=True)
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
cb = _ListenerProc(_on_device_change)
|
|
144
|
+
_ca_refs.append(cb) # prevent GC
|
|
145
|
+
status = ca.AudioObjectAddPropertyListener(1, ctypes.byref(prop), cb, None)
|
|
146
|
+
if status != 0:
|
|
147
|
+
# If registration silently fails the watcher would never fire and
|
|
148
|
+
# device switches would only recover via the worker's failure-retry
|
|
149
|
+
# path. Surface it so the failure mode is observable.
|
|
150
|
+
logger.warning("AudioObjectAddPropertyListener failed (status=%d); device watcher inactive", status)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
threading.Thread(
|
|
154
|
+
target=cf.CFRunLoopRun,
|
|
155
|
+
daemon=True,
|
|
156
|
+
name="audio-device-watcher",
|
|
157
|
+
).start()
|
|
158
|
+
logger.debug("audio device watcher started")
|
|
159
|
+
|
|
160
|
+
|
|
57
161
|
class _DaemonState:
|
|
58
162
|
def __init__(self, voice: str, speed: float, lang: str) -> None:
|
|
59
163
|
self.tts = Stackvox(voice=voice, speed=speed, lang=lang)
|
|
@@ -61,6 +165,7 @@ class _DaemonState:
|
|
|
61
165
|
self.stop_event = threading.Event()
|
|
62
166
|
self.worker = threading.Thread(target=self._worker, daemon=True)
|
|
63
167
|
self.worker.start()
|
|
168
|
+
_start_device_watcher()
|
|
64
169
|
|
|
65
170
|
def _worker(self) -> None:
|
|
66
171
|
while not self.stop_event.is_set():
|
|
@@ -68,7 +173,9 @@ class _DaemonState:
|
|
|
68
173
|
req = self.queue.get(timeout=WORKER_POLL_SECONDS)
|
|
69
174
|
except queue.Empty:
|
|
70
175
|
continue
|
|
71
|
-
|
|
176
|
+
if _audio_dirty.is_set():
|
|
177
|
+
_refresh_audio_devices()
|
|
178
|
+
_audio_dirty.clear()
|
|
72
179
|
try:
|
|
73
180
|
self.tts.speak(
|
|
74
181
|
req["text"],
|
|
@@ -78,6 +185,9 @@ class _DaemonState:
|
|
|
78
185
|
)
|
|
79
186
|
except Exception:
|
|
80
187
|
logger.exception("playback error")
|
|
188
|
+
# Failed playback might be a stale audio context; mark dirty
|
|
189
|
+
# so the next request refreshes before trying again.
|
|
190
|
+
_audio_dirty.set()
|
|
81
191
|
|
|
82
192
|
def submit(self, req: dict) -> bool:
|
|
83
193
|
try:
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
6
8
|
import urllib.request
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
@@ -22,6 +24,40 @@ _MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/mode
|
|
|
22
24
|
_VOICES_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
def _download_with_progress(url: str, dest: Path) -> None:
|
|
28
|
+
"""Stream a URL to a file, printing percentage updates to stderr.
|
|
29
|
+
|
|
30
|
+
The ~340 MB Kokoro model takes long enough on first run that a silent
|
|
31
|
+
download looks like a hang; this gives users feedback without pulling
|
|
32
|
+
in tqdm. Falls back to a single line when the server doesn't report
|
|
33
|
+
Content-Length.
|
|
34
|
+
"""
|
|
35
|
+
last_pct = -1
|
|
36
|
+
label = dest.name
|
|
37
|
+
|
|
38
|
+
def hook(blocks: int, blocksize: int, totalsize: int) -> None:
|
|
39
|
+
nonlocal last_pct
|
|
40
|
+
if totalsize <= 0:
|
|
41
|
+
return
|
|
42
|
+
pct = min(100, int(blocks * blocksize * 100 / totalsize))
|
|
43
|
+
if pct != last_pct:
|
|
44
|
+
mb_total = totalsize / 1_000_000
|
|
45
|
+
print(
|
|
46
|
+
f"\r[stackvox] downloading {label} {pct:3d}% ({mb_total:.0f} MB)",
|
|
47
|
+
end="",
|
|
48
|
+
file=sys.stderr,
|
|
49
|
+
flush=True,
|
|
50
|
+
)
|
|
51
|
+
last_pct = pct
|
|
52
|
+
|
|
53
|
+
urllib.request.urlretrieve(url, dest, reporthook=hook)
|
|
54
|
+
if last_pct >= 0 and sys.stderr.isatty():
|
|
55
|
+
# Finish the carriage-returned line so subsequent output starts on
|
|
56
|
+
# its own line. On non-TTY (e.g. CI logs) stderr is line-buffered
|
|
57
|
+
# and the \r writes already land on separate lines.
|
|
58
|
+
print("", file=sys.stderr, flush=True)
|
|
59
|
+
|
|
60
|
+
|
|
25
61
|
def _ensure_models(cache_dir: Path) -> tuple[Path, Path]:
|
|
26
62
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
27
63
|
model_path = cache_dir / "kokoro-v1.0.onnx"
|
|
@@ -29,8 +65,7 @@ def _ensure_models(cache_dir: Path) -> tuple[Path, Path]:
|
|
|
29
65
|
for path, url in [(model_path, _MODEL_URL), (voices_path, _VOICES_URL)]:
|
|
30
66
|
if path.exists():
|
|
31
67
|
continue
|
|
32
|
-
|
|
33
|
-
urllib.request.urlretrieve(url, path)
|
|
68
|
+
_download_with_progress(url, path)
|
|
34
69
|
return model_path, voices_path
|
|
35
70
|
|
|
36
71
|
|
|
@@ -134,12 +169,22 @@ class Stackvox:
|
|
|
134
169
|
|
|
135
170
|
|
|
136
171
|
_default: Stackvox | None = None
|
|
172
|
+
_default_lock = threading.Lock()
|
|
137
173
|
|
|
138
174
|
|
|
139
175
|
def _get_default() -> Stackvox:
|
|
176
|
+
"""Lazily build a module-level engine, safe under concurrent first calls.
|
|
177
|
+
|
|
178
|
+
Without the lock, two threads racing to call speak() / synthesize() at
|
|
179
|
+
process start could each instantiate Stackvox — meaning two 340 MB model
|
|
180
|
+
loads. Double-checked locking keeps the fast path lock-free once
|
|
181
|
+
initialised.
|
|
182
|
+
"""
|
|
140
183
|
global _default
|
|
141
184
|
if _default is None:
|
|
142
|
-
|
|
185
|
+
with _default_lock:
|
|
186
|
+
if _default is None:
|
|
187
|
+
_default = Stackvox()
|
|
143
188
|
return _default
|
|
144
189
|
|
|
145
190
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackvox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -45,11 +45,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
|
|
|
45
45
|
From PyPI — recommended for most users:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
pipx install stackvox
|
|
48
|
+
pipx install stackvox # `stackvox` CLI on PATH
|
|
49
49
|
# or
|
|
50
|
-
pip install stackvox
|
|
50
|
+
pip install stackvox # use as a library
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
stackvox install-helper # copies bash helper to ~/.local/bin
|
|
57
|
+
# use --prefix DIR to install elsewhere
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
53
62
|
From git, if you want an unreleased commit:
|
|
54
63
|
|
|
55
64
|
```bash
|
|
@@ -74,6 +83,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
|
|
|
74
83
|
stackvox speak "save" --out a.wav # write wav instead of playing
|
|
75
84
|
stackvox welcome # multilingual welcome (6 languages)
|
|
76
85
|
stackvox voices # list all voice ids
|
|
86
|
+
echo "from a pipe" | stackvox # piped stdin works for speak/say
|
|
87
|
+
stackvox speak --file message.txt # read a whole file
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Bash completion:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
eval "$(stackvox completion bash)" # current shell
|
|
94
|
+
# or persist:
|
|
95
|
+
stackvox completion bash > ~/.stackvox-completion.bash
|
|
96
|
+
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
77
97
|
```
|
|
78
98
|
|
|
79
99
|
Daemon mode (keeps the model resident so each subsequent call is instant):
|
|
@@ -2,7 +2,6 @@ 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
|
|
@@ -16,6 +15,9 @@ stackvox.egg-info/dependency_links.txt
|
|
|
16
15
|
stackvox.egg-info/entry_points.txt
|
|
17
16
|
stackvox.egg-info/requires.txt
|
|
18
17
|
stackvox.egg-info/top_level.txt
|
|
18
|
+
stackvox/data/stackvox-say
|
|
19
19
|
tests/test_cli.py
|
|
20
|
+
tests/test_daemon.py
|
|
20
21
|
tests/test_daemon_protocol.py
|
|
22
|
+
tests/test_engine.py
|
|
21
23
|
tests/test_paths.py
|