stackvox 0.2.0__tar.gz → 0.3.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.2.0/stackvox.egg-info → stackvox-0.3.0}/PKG-INFO +34 -6
- {stackvox-0.2.0 → stackvox-0.3.0}/README.md +32 -4
- {stackvox-0.2.0 → stackvox-0.3.0}/pyproject.toml +2 -2
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox/cli.py +78 -2
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox/daemon.py +112 -2
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox/engine.py +48 -3
- {stackvox-0.2.0 → stackvox-0.3.0/stackvox.egg-info}/PKG-INFO +34 -6
- stackvox-0.3.0/tests/test_cli.py +91 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/tests/test_daemon_protocol.py +30 -6
- stackvox-0.2.0/tests/test_cli.py +0 -41
- {stackvox-0.2.0 → stackvox-0.3.0}/LICENSE +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/NOTICE +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/bin/stackvox-say +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/setup.cfg +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox/__init__.py +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox/__main__.py +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox/paths.py +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox/py.typed +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox.egg-info/SOURCES.txt +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.2.0 → stackvox-0.3.0}/tests/test_paths.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackvox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Offline TTS library using Kokoro-82M
|
|
5
|
-
Author-email: "StackOne Technologies Ltd." <
|
|
5
|
+
Author-email: "StackOne Technologies Ltd." <support@stackone.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/StackOneHQ/stackvox
|
|
8
8
|
Project-URL: Repository, https://github.com/StackOneHQ/stackvox
|
|
@@ -42,19 +42,26 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
|
|
|
42
42
|
|
|
43
43
|
## Install
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
From PyPI — recommended for most users:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
pipx install
|
|
48
|
+
pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
|
|
49
|
+
# or
|
|
50
|
+
pip install stackvox # use as a library
|
|
49
51
|
```
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
From git, if you want an unreleased commit:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pipx install git+https://github.com/StackOneHQ/stackvox.git
|
|
57
|
+
# upgrade later with: pipx install --force git+https://github.com/StackOneHQ/stackvox.git
|
|
58
|
+
```
|
|
52
59
|
|
|
53
60
|
Dev install from a clone:
|
|
54
61
|
|
|
55
62
|
```bash
|
|
56
63
|
python3 -m venv .venv && source .venv/bin/activate
|
|
57
|
-
pip install -e .
|
|
64
|
+
pip install -e ".[dev]"
|
|
58
65
|
```
|
|
59
66
|
|
|
60
67
|
Model + voice files auto-download to `~/.cache/stackvox/` on first use. Override with `STACKVOX_CACHE_DIR`.
|
|
@@ -67,6 +74,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
|
|
|
67
74
|
stackvox speak "save" --out a.wav # write wav instead of playing
|
|
68
75
|
stackvox welcome # multilingual welcome (6 languages)
|
|
69
76
|
stackvox voices # list all voice ids
|
|
77
|
+
echo "from a pipe" | stackvox # piped stdin works for speak/say
|
|
78
|
+
stackvox speak --file message.txt # read a whole file
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Bash completion:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
eval "$(stackvox completion bash)" # current shell
|
|
85
|
+
# or persist:
|
|
86
|
+
stackvox completion bash > ~/.stackvox-completion.bash
|
|
87
|
+
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
70
88
|
```
|
|
71
89
|
|
|
72
90
|
Daemon mode (keeps the model resident so each subsequent call is instant):
|
|
@@ -174,6 +192,16 @@ Before each utterance the daemon resets PortAudio so it picks up the current sys
|
|
|
174
192
|
- macOS or Linux
|
|
175
193
|
- `nc` (BSD netcat — default on macOS, `netcat-openbsd` on Linux) for the bash helper
|
|
176
194
|
|
|
195
|
+
## Security considerations
|
|
196
|
+
|
|
197
|
+
stackvox doesn't open any network port. The daemon binds a unix socket under `~/.cache/stackvox/` (default file-mode `0600`, i.e. user-only per the OS defaults for files in `$HOME`). Any process running as the same local user can send text to the daemon — there's no per-message authentication on the socket itself. That's the trust boundary: stackvox assumes anything running as your UID is allowed to speak on your behalf.
|
|
198
|
+
|
|
199
|
+
If you're exposing stackvox through a different surface (HTTP server, shared system service, container), authentication and rate-limiting are your responsibility at that layer.
|
|
200
|
+
|
|
201
|
+
Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx) GitHub release assets on first use and cached under `~/.cache/stackvox/`. If you operate in a restricted environment, pre-seed that directory offline.
|
|
202
|
+
|
|
203
|
+
Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
|
|
204
|
+
|
|
177
205
|
## License & attributions
|
|
178
206
|
|
|
179
207
|
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.
|
|
@@ -9,19 +9,26 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
From PyPI — recommended for most users:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
pipx install
|
|
15
|
+
pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
|
|
16
|
+
# or
|
|
17
|
+
pip install stackvox # use as a library
|
|
16
18
|
```
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
From git, if you want an unreleased commit:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pipx install git+https://github.com/StackOneHQ/stackvox.git
|
|
24
|
+
# upgrade later with: pipx install --force git+https://github.com/StackOneHQ/stackvox.git
|
|
25
|
+
```
|
|
19
26
|
|
|
20
27
|
Dev install from a clone:
|
|
21
28
|
|
|
22
29
|
```bash
|
|
23
30
|
python3 -m venv .venv && source .venv/bin/activate
|
|
24
|
-
pip install -e .
|
|
31
|
+
pip install -e ".[dev]"
|
|
25
32
|
```
|
|
26
33
|
|
|
27
34
|
Model + voice files auto-download to `~/.cache/stackvox/` on first use. Override with `STACKVOX_CACHE_DIR`.
|
|
@@ -34,6 +41,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
|
|
|
34
41
|
stackvox speak "save" --out a.wav # write wav instead of playing
|
|
35
42
|
stackvox welcome # multilingual welcome (6 languages)
|
|
36
43
|
stackvox voices # list all voice ids
|
|
44
|
+
echo "from a pipe" | stackvox # piped stdin works for speak/say
|
|
45
|
+
stackvox speak --file message.txt # read a whole file
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Bash completion:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
eval "$(stackvox completion bash)" # current shell
|
|
52
|
+
# or persist:
|
|
53
|
+
stackvox completion bash > ~/.stackvox-completion.bash
|
|
54
|
+
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
37
55
|
```
|
|
38
56
|
|
|
39
57
|
Daemon mode (keeps the model resident so each subsequent call is instant):
|
|
@@ -141,6 +159,16 @@ Before each utterance the daemon resets PortAudio so it picks up the current sys
|
|
|
141
159
|
- macOS or Linux
|
|
142
160
|
- `nc` (BSD netcat — default on macOS, `netcat-openbsd` on Linux) for the bash helper
|
|
143
161
|
|
|
162
|
+
## Security considerations
|
|
163
|
+
|
|
164
|
+
stackvox doesn't open any network port. The daemon binds a unix socket under `~/.cache/stackvox/` (default file-mode `0600`, i.e. user-only per the OS defaults for files in `$HOME`). Any process running as the same local user can send text to the daemon — there's no per-message authentication on the socket itself. That's the trust boundary: stackvox assumes anything running as your UID is allowed to speak on your behalf.
|
|
165
|
+
|
|
166
|
+
If you're exposing stackvox through a different surface (HTTP server, shared system service, container), authentication and rate-limiting are your responsibility at that layer.
|
|
167
|
+
|
|
168
|
+
Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx) GitHub release assets on first use and cached under `~/.cache/stackvox/`. If you operate in a restricted environment, pre-seed that directory offline.
|
|
169
|
+
|
|
170
|
+
Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
|
|
171
|
+
|
|
144
172
|
## License & attributions
|
|
145
173
|
|
|
146
174
|
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,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Offline TTS library using Kokoro-82M"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "Apache-2.0"
|
|
12
12
|
license-files = ["LICENSE", "NOTICE"]
|
|
13
|
-
authors = [{ name = "StackOne Technologies Ltd.", email = "
|
|
13
|
+
authors = [{ name = "StackOne Technologies Ltd.", email = "support@stackone.com" }]
|
|
14
14
|
keywords = ["tts", "kokoro", "onnx", "offline", "speech"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Operating System :: MacOS",
|
|
@@ -21,7 +21,58 @@ def _configure_logging() -> None:
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome"}
|
|
24
|
+
SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome", "completion"}
|
|
25
|
+
|
|
26
|
+
_BASH_COMPLETION = r"""# stackvox bash completion. Install with one of:
|
|
27
|
+
# eval "$(stackvox completion bash)" # current shell
|
|
28
|
+
# stackvox completion bash > ~/.stackvox-completion.bash && \
|
|
29
|
+
# echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
30
|
+
_stackvox_completion() {
|
|
31
|
+
local cur prev subcommand
|
|
32
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
33
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
34
|
+
subcommand="${COMP_WORDS[1]:-}"
|
|
35
|
+
|
|
36
|
+
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
|
37
|
+
COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion" -- "$cur") )
|
|
38
|
+
return 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
case "$prev" in
|
|
42
|
+
--file|--out)
|
|
43
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
44
|
+
return 0
|
|
45
|
+
;;
|
|
46
|
+
--speed)
|
|
47
|
+
COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") )
|
|
48
|
+
return 0
|
|
49
|
+
;;
|
|
50
|
+
--lang)
|
|
51
|
+
COMPREPLY=( $(compgen -W "en-us en-gb fr-fr it hi pt-br es ja zh" -- "$cur") )
|
|
52
|
+
return 0
|
|
53
|
+
;;
|
|
54
|
+
esac
|
|
55
|
+
|
|
56
|
+
case "$subcommand" in
|
|
57
|
+
speak)
|
|
58
|
+
COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --help" -- "$cur") )
|
|
59
|
+
;;
|
|
60
|
+
say)
|
|
61
|
+
COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --help" -- "$cur") )
|
|
62
|
+
;;
|
|
63
|
+
serve)
|
|
64
|
+
COMPREPLY=( $(compgen -W "--voice --speed --lang --help" -- "$cur") )
|
|
65
|
+
;;
|
|
66
|
+
completion)
|
|
67
|
+
COMPREPLY=( $(compgen -W "bash" -- "$cur") )
|
|
68
|
+
;;
|
|
69
|
+
*)
|
|
70
|
+
COMPREPLY=( $(compgen -W "--help" -- "$cur") )
|
|
71
|
+
;;
|
|
72
|
+
esac
|
|
73
|
+
}
|
|
74
|
+
complete -F _stackvox_completion stackvox
|
|
75
|
+
"""
|
|
25
76
|
|
|
26
77
|
WELCOME_LINES = [
|
|
27
78
|
("bf_emma", "en-gb", "Welcome to stackvox."),
|
|
@@ -59,6 +110,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
59
110
|
sub.add_parser("voices", help="List available voices")
|
|
60
111
|
sub.add_parser("welcome", help="Play a multilingual welcome message")
|
|
61
112
|
|
|
113
|
+
p_completion = sub.add_parser("completion", help="Print a shell completion script")
|
|
114
|
+
p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
|
|
115
|
+
|
|
62
116
|
return parser
|
|
63
117
|
|
|
64
118
|
|
|
@@ -69,11 +123,20 @@ def _add_voice_args(parser: argparse.ArgumentParser) -> None:
|
|
|
69
123
|
|
|
70
124
|
|
|
71
125
|
def _read_text(args: argparse.Namespace) -> str | None:
|
|
126
|
+
"""Resolve the text to speak from --file, the positional, or piped stdin.
|
|
127
|
+
|
|
128
|
+
Precedence: --file > positional text > stdin (when piped, not a TTY).
|
|
129
|
+
"""
|
|
72
130
|
file: Path | None = getattr(args, "file", None)
|
|
73
131
|
if file is not None:
|
|
74
132
|
return file.read_text(encoding="utf-8")
|
|
75
133
|
text: str | None = getattr(args, "text", None)
|
|
76
|
-
|
|
134
|
+
if text is not None:
|
|
135
|
+
return text
|
|
136
|
+
if not sys.stdin.isatty():
|
|
137
|
+
piped = sys.stdin.read()
|
|
138
|
+
return piped if piped.strip() else None
|
|
139
|
+
return None
|
|
77
140
|
|
|
78
141
|
|
|
79
142
|
def _cmd_speak(args: argparse.Namespace) -> int:
|
|
@@ -149,12 +212,24 @@ def _cmd_welcome(_: argparse.Namespace) -> int:
|
|
|
149
212
|
return 0
|
|
150
213
|
|
|
151
214
|
|
|
215
|
+
def _cmd_completion(args: argparse.Namespace) -> int:
|
|
216
|
+
if args.shell == "bash":
|
|
217
|
+
print(_BASH_COMPLETION)
|
|
218
|
+
return 0
|
|
219
|
+
# argparse `choices=` should prevent reaching here; defensive only.
|
|
220
|
+
print(f"unsupported shell: {args.shell}", file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
|
|
152
224
|
def main() -> int:
|
|
153
225
|
_configure_logging()
|
|
154
226
|
argv = sys.argv[1:]
|
|
155
227
|
# Back-compat: `stackvox "text"` with no subcommand → speak.
|
|
228
|
+
# Same shortcut for piped stdin: `echo hi | stackvox` → speak.
|
|
156
229
|
if argv and argv[0] not in SUBCOMMANDS and not argv[0].startswith("-"):
|
|
157
230
|
argv = ["speak", *argv]
|
|
231
|
+
elif not argv and not sys.stdin.isatty():
|
|
232
|
+
argv = ["speak"]
|
|
158
233
|
|
|
159
234
|
parser = _build_parser()
|
|
160
235
|
args = parser.parse_args(argv)
|
|
@@ -171,6 +246,7 @@ def main() -> int:
|
|
|
171
246
|
"status": _cmd_status,
|
|
172
247
|
"voices": _cmd_voices,
|
|
173
248
|
"welcome": _cmd_welcome,
|
|
249
|
+
"completion": _cmd_completion,
|
|
174
250
|
}
|
|
175
251
|
return handlers[args.cmd](args)
|
|
176
252
|
|
|
@@ -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,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackvox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Offline TTS library using Kokoro-82M
|
|
5
|
-
Author-email: "StackOne Technologies Ltd." <
|
|
5
|
+
Author-email: "StackOne Technologies Ltd." <support@stackone.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/StackOneHQ/stackvox
|
|
8
8
|
Project-URL: Repository, https://github.com/StackOneHQ/stackvox
|
|
@@ -42,19 +42,26 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
|
|
|
42
42
|
|
|
43
43
|
## Install
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
From PyPI — recommended for most users:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
pipx install
|
|
48
|
+
pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
|
|
49
|
+
# or
|
|
50
|
+
pip install stackvox # use as a library
|
|
49
51
|
```
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
From git, if you want an unreleased commit:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pipx install git+https://github.com/StackOneHQ/stackvox.git
|
|
57
|
+
# upgrade later with: pipx install --force git+https://github.com/StackOneHQ/stackvox.git
|
|
58
|
+
```
|
|
52
59
|
|
|
53
60
|
Dev install from a clone:
|
|
54
61
|
|
|
55
62
|
```bash
|
|
56
63
|
python3 -m venv .venv && source .venv/bin/activate
|
|
57
|
-
pip install -e .
|
|
64
|
+
pip install -e ".[dev]"
|
|
58
65
|
```
|
|
59
66
|
|
|
60
67
|
Model + voice files auto-download to `~/.cache/stackvox/` on first use. Override with `STACKVOX_CACHE_DIR`.
|
|
@@ -67,6 +74,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
|
|
|
67
74
|
stackvox speak "save" --out a.wav # write wav instead of playing
|
|
68
75
|
stackvox welcome # multilingual welcome (6 languages)
|
|
69
76
|
stackvox voices # list all voice ids
|
|
77
|
+
echo "from a pipe" | stackvox # piped stdin works for speak/say
|
|
78
|
+
stackvox speak --file message.txt # read a whole file
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Bash completion:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
eval "$(stackvox completion bash)" # current shell
|
|
85
|
+
# or persist:
|
|
86
|
+
stackvox completion bash > ~/.stackvox-completion.bash
|
|
87
|
+
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
70
88
|
```
|
|
71
89
|
|
|
72
90
|
Daemon mode (keeps the model resident so each subsequent call is instant):
|
|
@@ -174,6 +192,16 @@ Before each utterance the daemon resets PortAudio so it picks up the current sys
|
|
|
174
192
|
- macOS or Linux
|
|
175
193
|
- `nc` (BSD netcat — default on macOS, `netcat-openbsd` on Linux) for the bash helper
|
|
176
194
|
|
|
195
|
+
## Security considerations
|
|
196
|
+
|
|
197
|
+
stackvox doesn't open any network port. The daemon binds a unix socket under `~/.cache/stackvox/` (default file-mode `0600`, i.e. user-only per the OS defaults for files in `$HOME`). Any process running as the same local user can send text to the daemon — there's no per-message authentication on the socket itself. That's the trust boundary: stackvox assumes anything running as your UID is allowed to speak on your behalf.
|
|
198
|
+
|
|
199
|
+
If you're exposing stackvox through a different surface (HTTP server, shared system service, container), authentication and rate-limiting are your responsibility at that layer.
|
|
200
|
+
|
|
201
|
+
Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx) GitHub release assets on first use and cached under `~/.cache/stackvox/`. If you operate in a restricted environment, pre-seed that directory offline.
|
|
202
|
+
|
|
203
|
+
Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
|
|
204
|
+
|
|
177
205
|
## License & attributions
|
|
178
206
|
|
|
179
207
|
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.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""CLI argument parsing — no engine loaded, no audio played."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from stackvox import cli
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(autouse=True)
|
|
13
|
+
def _stdin_is_a_tty(mocker):
|
|
14
|
+
"""Default tests to a TTY stdin so the new piped-stdin shortcut is off.
|
|
15
|
+
|
|
16
|
+
Tests that exercise the piped path opt in by patching isatty to False
|
|
17
|
+
and providing a real stdin StringIO.
|
|
18
|
+
"""
|
|
19
|
+
mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_bare_text_routes_to_speak(mocker):
|
|
23
|
+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
24
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "hello world"])
|
|
25
|
+
assert cli.main() == 0
|
|
26
|
+
args = speak.call_args.args[0]
|
|
27
|
+
assert args.cmd == "speak"
|
|
28
|
+
assert args.text == "hello world"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_speak_subcommand_parses_voice_and_speed(mocker):
|
|
32
|
+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
33
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "--voice", "bf_emma", "--speed", "1.2", "hi"])
|
|
34
|
+
assert cli.main() == 0
|
|
35
|
+
args = speak.call_args.args[0]
|
|
36
|
+
assert args.voice == "bf_emma"
|
|
37
|
+
assert args.speed == pytest.approx(1.2)
|
|
38
|
+
assert args.text == "hi"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_no_subcommand_prints_help(mocker, capsys):
|
|
42
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox"])
|
|
43
|
+
assert cli.main() == 1
|
|
44
|
+
captured = capsys.readouterr()
|
|
45
|
+
assert "usage: stackvox" in captured.out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_unknown_subcommand_treated_as_speak_text(mocker):
|
|
49
|
+
"""`stackvox foo` is treated as `stackvox speak foo`, not an error."""
|
|
50
|
+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
51
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "hello"])
|
|
52
|
+
cli.main()
|
|
53
|
+
assert speak.call_args.args[0].text == "hello"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_piped_stdin_with_no_args_routes_to_speak(mocker):
|
|
57
|
+
"""`echo hi | stackvox` should default to speak."""
|
|
58
|
+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
59
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox"])
|
|
60
|
+
mocker.patch.object(cli.sys.stdin, "isatty", return_value=False)
|
|
61
|
+
mocker.patch.object(cli.sys, "stdin", io.StringIO("hello from stdin\n"))
|
|
62
|
+
assert cli.main() == 0
|
|
63
|
+
assert speak.call_args.args[0].cmd == "speak"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_read_text_prefers_file_then_positional_then_stdin(mocker, tmp_path):
|
|
67
|
+
"""_read_text precedence: --file > positional > piped stdin."""
|
|
68
|
+
file_arg = tmp_path / "src.txt"
|
|
69
|
+
file_arg.write_text("from-file", encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
# Both --file and positional present → file wins.
|
|
72
|
+
args = mocker.MagicMock(file=file_arg, text="from-positional")
|
|
73
|
+
assert cli._read_text(args) == "from-file"
|
|
74
|
+
|
|
75
|
+
# No --file, positional present → positional wins.
|
|
76
|
+
args = mocker.MagicMock(file=None, text="from-positional")
|
|
77
|
+
assert cli._read_text(args) == "from-positional"
|
|
78
|
+
|
|
79
|
+
# No --file, no positional, stdin piped → stdin wins.
|
|
80
|
+
mocker.patch.object(cli.sys.stdin, "isatty", return_value=False)
|
|
81
|
+
mocker.patch.object(cli.sys, "stdin", io.StringIO("from-stdin"))
|
|
82
|
+
args = mocker.MagicMock(file=None, text=None)
|
|
83
|
+
assert cli._read_text(args) == "from-stdin"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_completion_bash_emits_complete_script(mocker, capsys):
|
|
87
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "completion", "bash"])
|
|
88
|
+
assert cli.main() == 0
|
|
89
|
+
captured = capsys.readouterr()
|
|
90
|
+
assert "_stackvox_completion()" in captured.out
|
|
91
|
+
assert "complete -F _stackvox_completion stackvox" in captured.out
|
|
@@ -107,18 +107,42 @@ def test_full_queue_returns_busy(server: ServerHarness):
|
|
|
107
107
|
assert reply in {"ok", "busy"}
|
|
108
108
|
|
|
109
109
|
|
|
110
|
-
def
|
|
111
|
-
"""PortAudio is
|
|
110
|
+
def test_worker_refreshes_audio_only_when_dirty(server: ServerHarness):
|
|
111
|
+
"""PortAudio is refreshed once per dirty cycle, not before every play.
|
|
112
|
+
|
|
113
|
+
Initial dirty state at startup → first playback refreshes. A second
|
|
114
|
+
playback with no device change → no extra refresh. Re-marking dirty
|
|
115
|
+
(simulating a default-output-device change) → next playback refreshes
|
|
116
|
+
again.
|
|
117
|
+
"""
|
|
112
118
|
import time
|
|
113
119
|
|
|
120
|
+
from stackvox import daemon
|
|
121
|
+
|
|
122
|
+
# Ensure a known starting state regardless of prior-test ordering: dirty
|
|
123
|
+
# so the first playback in this test refreshes once.
|
|
124
|
+
daemon._audio_dirty.set()
|
|
125
|
+
|
|
126
|
+
def wait_for_speak_count(target: int) -> None:
|
|
127
|
+
deadline = time.monotonic() + 1.0
|
|
128
|
+
while server.tts.speak.call_count < target and time.monotonic() < deadline:
|
|
129
|
+
time.sleep(0.01)
|
|
130
|
+
|
|
131
|
+
# First playback consumes the dirty flag.
|
|
114
132
|
assert _roundtrip(server.sock, json.dumps({"text": "a"}) + "\n") == "ok"
|
|
133
|
+
wait_for_speak_count(1)
|
|
134
|
+
assert server.refresh.call_count == 1
|
|
135
|
+
|
|
136
|
+
# Second playback with no device change → no additional refresh.
|
|
115
137
|
assert _roundtrip(server.sock, json.dumps({"text": "b"}) + "\n") == "ok"
|
|
138
|
+
wait_for_speak_count(2)
|
|
139
|
+
assert server.refresh.call_count == 1
|
|
116
140
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
time.sleep(0.01)
|
|
141
|
+
# Simulate a real device change.
|
|
142
|
+
daemon._audio_dirty.set()
|
|
120
143
|
|
|
121
|
-
assert server.
|
|
144
|
+
assert _roundtrip(server.sock, json.dumps({"text": "c"}) + "\n") == "ok"
|
|
145
|
+
wait_for_speak_count(3)
|
|
122
146
|
assert server.refresh.call_count == 2
|
|
123
147
|
|
|
124
148
|
|
stackvox-0.2.0/tests/test_cli.py
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
"""CLI argument parsing — no engine loaded, no audio played."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from stackvox import cli
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_bare_text_routes_to_speak(mocker):
|
|
11
|
-
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
12
|
-
mocker.patch.object(cli.sys, "argv", ["stackvox", "hello world"])
|
|
13
|
-
assert cli.main() == 0
|
|
14
|
-
args = speak.call_args.args[0]
|
|
15
|
-
assert args.cmd == "speak"
|
|
16
|
-
assert args.text == "hello world"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_speak_subcommand_parses_voice_and_speed(mocker):
|
|
20
|
-
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
21
|
-
mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "--voice", "bf_emma", "--speed", "1.2", "hi"])
|
|
22
|
-
assert cli.main() == 0
|
|
23
|
-
args = speak.call_args.args[0]
|
|
24
|
-
assert args.voice == "bf_emma"
|
|
25
|
-
assert args.speed == pytest.approx(1.2)
|
|
26
|
-
assert args.text == "hi"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_no_subcommand_prints_help(mocker, capsys):
|
|
30
|
-
mocker.patch.object(cli.sys, "argv", ["stackvox"])
|
|
31
|
-
assert cli.main() == 1
|
|
32
|
-
captured = capsys.readouterr()
|
|
33
|
-
assert "usage: stackvox" in captured.out
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_unknown_subcommand_treated_as_speak_text(mocker):
|
|
37
|
-
"""`stackvox foo` is treated as `stackvox speak foo`, not an error."""
|
|
38
|
-
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
39
|
-
mocker.patch.object(cli.sys, "argv", ["stackvox", "hello"])
|
|
40
|
-
cli.main()
|
|
41
|
-
assert speak.call_args.args[0].text == "hello"
|
|
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
|