stackvox 0.3.0__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.3.0/stackvox.egg-info → stackvox-0.3.1}/PKG-INFO +12 -3
- {stackvox-0.3.0 → stackvox-0.3.1}/README.md +11 -2
- {stackvox-0.3.0 → stackvox-0.3.1}/pyproject.toml +7 -6
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/cli.py +69 -2
- {stackvox-0.3.0 → stackvox-0.3.1/stackvox.egg-info}/PKG-INFO +12 -3
- {stackvox-0.3.0 → 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.3.1/tests/test_engine.py +241 -0
- stackvox-0.3.0/tests/test_cli.py +0 -91
- {stackvox-0.3.0 → stackvox-0.3.1}/LICENSE +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/NOTICE +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/setup.cfg +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/__init__.py +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/__main__.py +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/daemon.py +0 -0
- {stackvox-0.3.0/bin → stackvox-0.3.1/stackvox/data}/stackvox-say +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/engine.py +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/paths.py +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/py.typed +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.3.0 → stackvox-0.3.1}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.3.0 → 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.
|
|
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
|
|
@@ -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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "0.3.
|
|
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,19 @@ def _configure_logging() -> None:
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
SUBCOMMANDS = {
|
|
24
|
+
SUBCOMMANDS = {
|
|
25
|
+
"serve",
|
|
26
|
+
"stop",
|
|
27
|
+
"status",
|
|
28
|
+
"say",
|
|
29
|
+
"speak",
|
|
30
|
+
"voices",
|
|
31
|
+
"welcome",
|
|
32
|
+
"completion",
|
|
33
|
+
"install-helper",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
DEFAULT_HELPER_PREFIX = Path.home() / ".local" / "bin"
|
|
25
37
|
|
|
26
38
|
_BASH_COMPLETION = r"""# stackvox bash completion. Install with one of:
|
|
27
39
|
# eval "$(stackvox completion bash)" # current shell
|
|
@@ -34,7 +46,7 @@ _stackvox_completion() {
|
|
|
34
46
|
subcommand="${COMP_WORDS[1]:-}"
|
|
35
47
|
|
|
36
48
|
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
|
37
|
-
COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion" -- "$cur") )
|
|
49
|
+
COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") )
|
|
38
50
|
return 0
|
|
39
51
|
fi
|
|
40
52
|
|
|
@@ -43,6 +55,10 @@ _stackvox_completion() {
|
|
|
43
55
|
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
44
56
|
return 0
|
|
45
57
|
;;
|
|
58
|
+
--prefix)
|
|
59
|
+
COMPREPLY=( $(compgen -d -- "$cur") )
|
|
60
|
+
return 0
|
|
61
|
+
;;
|
|
46
62
|
--speed)
|
|
47
63
|
COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") )
|
|
48
64
|
return 0
|
|
@@ -66,6 +82,9 @@ _stackvox_completion() {
|
|
|
66
82
|
completion)
|
|
67
83
|
COMPREPLY=( $(compgen -W "bash" -- "$cur") )
|
|
68
84
|
;;
|
|
85
|
+
install-helper)
|
|
86
|
+
COMPREPLY=( $(compgen -W "--prefix --help" -- "$cur") )
|
|
87
|
+
;;
|
|
69
88
|
*)
|
|
70
89
|
COMPREPLY=( $(compgen -W "--help" -- "$cur") )
|
|
71
90
|
;;
|
|
@@ -113,6 +132,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
113
132
|
p_completion = sub.add_parser("completion", help="Print a shell completion script")
|
|
114
133
|
p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
|
|
115
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
|
+
|
|
116
146
|
return parser
|
|
117
147
|
|
|
118
148
|
|
|
@@ -221,6 +251,42 @@ def _cmd_completion(args: argparse.Namespace) -> int:
|
|
|
221
251
|
return 1
|
|
222
252
|
|
|
223
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
|
+
|
|
224
290
|
def main() -> int:
|
|
225
291
|
_configure_logging()
|
|
226
292
|
argv = sys.argv[1:]
|
|
@@ -247,6 +313,7 @@ def main() -> int:
|
|
|
247
313
|
"voices": _cmd_voices,
|
|
248
314
|
"welcome": _cmd_welcome,
|
|
249
315
|
"completion": _cmd_completion,
|
|
316
|
+
"install-helper": _cmd_install_helper,
|
|
250
317
|
}
|
|
251
318
|
return handlers[args.cmd](args)
|
|
252
319
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackvox
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,292 @@
|
|
|
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
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- Subcommand handlers ----------------------------------------------------
|
|
95
|
+
#
|
|
96
|
+
# Each subcommand handler is exercised directly with a constructed Namespace
|
|
97
|
+
# so we don't have to reason about argparse behavior for every test. The
|
|
98
|
+
# Stackvox class and the daemon module's surface are mocked out.
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.fixture
|
|
102
|
+
def fake_stackvox(mocker):
|
|
103
|
+
return mocker.patch.object(cli, "Stackvox")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ns(**kwargs):
|
|
107
|
+
"""Build a minimal argparse.Namespace for handler tests."""
|
|
108
|
+
import argparse
|
|
109
|
+
|
|
110
|
+
return argparse.Namespace(**kwargs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestCmdSpeak:
|
|
114
|
+
def test_with_text_calls_engine_speak(self, fake_stackvox):
|
|
115
|
+
rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hello", out=None))
|
|
116
|
+
assert rc == 0
|
|
117
|
+
fake_stackvox.return_value.speak.assert_called_once_with("hello")
|
|
118
|
+
|
|
119
|
+
def test_with_out_writes_wav_instead_of_playing(self, fake_stackvox, mocker, tmp_path):
|
|
120
|
+
import numpy as np
|
|
121
|
+
|
|
122
|
+
fake_stackvox.return_value.synthesize.return_value = (
|
|
123
|
+
np.zeros(10, dtype=np.float32),
|
|
124
|
+
24000,
|
|
125
|
+
)
|
|
126
|
+
sf_write = mocker.patch.object(cli.sf, "write")
|
|
127
|
+
out = tmp_path / "out.wav"
|
|
128
|
+
|
|
129
|
+
rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", out=out))
|
|
130
|
+
|
|
131
|
+
assert rc == 0
|
|
132
|
+
sf_write.assert_called_once()
|
|
133
|
+
fake_stackvox.return_value.speak.assert_not_called()
|
|
134
|
+
|
|
135
|
+
def test_blank_input_returns_error(self, fake_stackvox, capsys):
|
|
136
|
+
rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text=" ", out=None))
|
|
137
|
+
assert rc == 1
|
|
138
|
+
assert "provide text" in capsys.readouterr().err
|
|
139
|
+
fake_stackvox.return_value.speak.assert_not_called()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestCmdSay:
|
|
143
|
+
def test_returns_zero_when_daemon_accepts(self, mocker):
|
|
144
|
+
mocker.patch.object(cli.daemon, "say", return_value=(True, "ok"))
|
|
145
|
+
rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", fallback_say=False))
|
|
146
|
+
assert rc == 0
|
|
147
|
+
|
|
148
|
+
def test_returns_two_when_daemon_unreachable_and_no_fallback(self, mocker, capsys):
|
|
149
|
+
mocker.patch.object(cli.daemon, "say", return_value=(False, "daemon not running"))
|
|
150
|
+
rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", fallback_say=False))
|
|
151
|
+
assert rc == 2
|
|
152
|
+
assert "daemon not running" in capsys.readouterr().err
|
|
153
|
+
|
|
154
|
+
def test_fallback_say_shells_out_on_macos(self, mocker):
|
|
155
|
+
mocker.patch.object(cli.daemon, "say", return_value=(False, "daemon not running"))
|
|
156
|
+
# Must use which because cli imports inside the function body.
|
|
157
|
+
mocker.patch("shutil.which", return_value="/usr/bin/say")
|
|
158
|
+
run = mocker.patch("subprocess.run")
|
|
159
|
+
rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", fallback_say=True))
|
|
160
|
+
assert rc == 0
|
|
161
|
+
run.assert_called_once()
|
|
162
|
+
assert run.call_args.args[0][0] == "say"
|
|
163
|
+
|
|
164
|
+
def test_fallback_say_without_say_binary_returns_two(self, mocker, capsys):
|
|
165
|
+
mocker.patch.object(cli.daemon, "say", return_value=(False, "daemon not running"))
|
|
166
|
+
mocker.patch("shutil.which", return_value=None)
|
|
167
|
+
rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", fallback_say=True))
|
|
168
|
+
assert rc == 2
|
|
169
|
+
|
|
170
|
+
def test_blank_input_returns_one(self, capsys):
|
|
171
|
+
rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="", fallback_say=False))
|
|
172
|
+
assert rc == 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestCmdServe:
|
|
176
|
+
def test_propagates_serve_args(self, mocker):
|
|
177
|
+
serve = mocker.patch.object(cli.daemon, "serve")
|
|
178
|
+
rc = cli._cmd_serve(_ns(voice="bf_emma", speed=1.1, lang="en-gb"))
|
|
179
|
+
assert rc == 0
|
|
180
|
+
serve.assert_called_once_with(voice="bf_emma", speed=1.1, lang="en-gb")
|
|
181
|
+
|
|
182
|
+
def test_returns_one_when_daemon_already_running(self, mocker, capsys):
|
|
183
|
+
mocker.patch.object(cli.daemon, "serve", side_effect=RuntimeError("daemon already running"))
|
|
184
|
+
rc = cli._cmd_serve(_ns(voice="af_sarah", speed=1.0, lang="en-us"))
|
|
185
|
+
assert rc == 1
|
|
186
|
+
assert "already running" in capsys.readouterr().err
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TestCmdStop:
|
|
190
|
+
def test_returns_zero_when_daemon_already_stopped(self, mocker, capsys):
|
|
191
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=False)
|
|
192
|
+
stop = mocker.patch.object(cli.daemon, "stop")
|
|
193
|
+
rc = cli._cmd_stop(_ns())
|
|
194
|
+
assert rc == 0
|
|
195
|
+
stop.assert_not_called()
|
|
196
|
+
assert "not running" in capsys.readouterr().err
|
|
197
|
+
|
|
198
|
+
def test_calls_daemon_stop_when_running(self, mocker):
|
|
199
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=True)
|
|
200
|
+
mocker.patch.object(cli.daemon, "stop", return_value=(True, "ok"))
|
|
201
|
+
assert cli._cmd_stop(_ns()) == 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestCmdStatus:
|
|
205
|
+
def test_running_prints_pid_and_returns_zero(self, mocker, capsys):
|
|
206
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=True)
|
|
207
|
+
# PID_PATH and SOCKET_PATH are read at module level — mock as needed.
|
|
208
|
+
pid_path = mocker.MagicMock()
|
|
209
|
+
pid_path.read_text.return_value = "12345\n"
|
|
210
|
+
mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
|
|
211
|
+
mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
|
|
212
|
+
|
|
213
|
+
rc = cli._cmd_status(_ns())
|
|
214
|
+
|
|
215
|
+
assert rc == 0
|
|
216
|
+
out = capsys.readouterr().out
|
|
217
|
+
assert "running" in out
|
|
218
|
+
assert "12345" in out
|
|
219
|
+
|
|
220
|
+
def test_stopped_returns_one(self, mocker, capsys):
|
|
221
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=False)
|
|
222
|
+
rc = cli._cmd_status(_ns())
|
|
223
|
+
assert rc == 1
|
|
224
|
+
assert "stopped" in capsys.readouterr().out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TestCmdVoices:
|
|
228
|
+
def test_prints_one_voice_per_line(self, fake_stackvox, capsys):
|
|
229
|
+
fake_stackvox.return_value.voices.return_value = ["af_sarah", "bf_emma"]
|
|
230
|
+
rc = cli._cmd_voices(_ns())
|
|
231
|
+
assert rc == 0
|
|
232
|
+
assert capsys.readouterr().out.split() == ["af_sarah", "bf_emma"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestCmdWelcome:
|
|
236
|
+
def test_calls_speak_sequence_with_welcome_lines(self, fake_stackvox):
|
|
237
|
+
cli._cmd_welcome(_ns())
|
|
238
|
+
fake_stackvox.return_value.speak_sequence.assert_called_once()
|
|
239
|
+
lines = fake_stackvox.return_value.speak_sequence.call_args.args[0]
|
|
240
|
+
# All WELCOME_LINES rows present.
|
|
241
|
+
assert len(lines) == len(cli.WELCOME_LINES)
|
|
242
|
+
# Every entry has text/voice/lang.
|
|
243
|
+
for entry in lines:
|
|
244
|
+
assert {"text", "voice", "lang"} <= entry.keys()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class TestCmdCompletion:
|
|
248
|
+
def test_unsupported_shell_returns_one(self, capsys):
|
|
249
|
+
# argparse choices=["bash"] means we'd never reach here normally;
|
|
250
|
+
# exercise the defensive branch directly.
|
|
251
|
+
rc = cli._cmd_completion(_ns(shell="fish"))
|
|
252
|
+
assert rc == 1
|
|
253
|
+
assert "unsupported shell" in capsys.readouterr().err
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class TestCmdInstallHelper:
|
|
257
|
+
def test_copies_helper_to_prefix_with_exec_bit(self, tmp_path):
|
|
258
|
+
prefix = tmp_path / "bin"
|
|
259
|
+
rc = cli._cmd_install_helper(_ns(prefix=prefix))
|
|
260
|
+
|
|
261
|
+
assert rc == 0
|
|
262
|
+
dest = prefix / "stackvox-say"
|
|
263
|
+
assert dest.exists()
|
|
264
|
+
# First line should be the bash shebang from the bundled script.
|
|
265
|
+
assert dest.read_text(encoding="utf-8").startswith("#!/bin/bash")
|
|
266
|
+
# Owner-execute bit is set.
|
|
267
|
+
import stat as st
|
|
268
|
+
|
|
269
|
+
assert dest.stat().st_mode & st.S_IXUSR
|
|
270
|
+
|
|
271
|
+
def test_creates_prefix_dir_if_missing(self, tmp_path):
|
|
272
|
+
prefix = tmp_path / "deep" / "nested" / "bin"
|
|
273
|
+
assert not prefix.exists()
|
|
274
|
+
rc = cli._cmd_install_helper(_ns(prefix=prefix))
|
|
275
|
+
assert rc == 0
|
|
276
|
+
assert (prefix / "stackvox-say").is_file()
|
|
277
|
+
|
|
278
|
+
def test_warns_when_prefix_not_on_path(self, tmp_path, mocker, capsys):
|
|
279
|
+
prefix = tmp_path / "out-of-path"
|
|
280
|
+
# Force PATH to a value that definitely doesn't include `prefix`.
|
|
281
|
+
mocker.patch.dict("os.environ", {"PATH": "/usr/bin:/bin"}, clear=False)
|
|
282
|
+
cli._cmd_install_helper(_ns(prefix=prefix))
|
|
283
|
+
err = capsys.readouterr().err
|
|
284
|
+
assert "not on your PATH" in err
|
|
285
|
+
|
|
286
|
+
def test_no_warning_when_prefix_on_path(self, tmp_path, mocker, capsys):
|
|
287
|
+
prefix = tmp_path / "on-path"
|
|
288
|
+
prefix.mkdir(parents=True)
|
|
289
|
+
# Put `prefix` on PATH so the warning shouldn't fire.
|
|
290
|
+
mocker.patch.dict("os.environ", {"PATH": f"{prefix}:/usr/bin"}, clear=False)
|
|
291
|
+
cli._cmd_install_helper(_ns(prefix=prefix))
|
|
292
|
+
assert "not on your PATH" not in capsys.readouterr().err
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Daemon tests for non-protocol surface — pid/socket helpers, send/say/stop/ping
|
|
2
|
+
client, _refresh_audio_devices, and the cross-platform watcher entry point.
|
|
3
|
+
The protocol/handler tests live in test_daemon_protocol.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from stackvox import daemon
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestPidAlive:
|
|
16
|
+
def test_self_pid_is_alive(self):
|
|
17
|
+
assert daemon._pid_alive(os.getpid()) is True
|
|
18
|
+
|
|
19
|
+
def test_unused_pid_is_not_alive(self):
|
|
20
|
+
# PID 0 is reserved on macOS/Linux and never owned by a real process.
|
|
21
|
+
# `os.kill(0, 0)` is special-cased (signals the whole process group),
|
|
22
|
+
# so use a high PID we can be confident is unused.
|
|
23
|
+
assert daemon._pid_alive(2**31 - 1) is False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestIsRunning:
|
|
27
|
+
def test_returns_false_when_pid_file_missing(self, mocker, tmp_path):
|
|
28
|
+
mocker.patch.object(daemon, "PID_PATH", tmp_path / "missing.pid")
|
|
29
|
+
assert daemon.is_running() is False
|
|
30
|
+
|
|
31
|
+
def test_returns_false_when_pid_file_unreadable(self, mocker, tmp_path):
|
|
32
|
+
pid = tmp_path / "garbage.pid"
|
|
33
|
+
pid.write_text("not-a-number")
|
|
34
|
+
mocker.patch.object(daemon, "PID_PATH", pid)
|
|
35
|
+
assert daemon.is_running() is False
|
|
36
|
+
|
|
37
|
+
def test_returns_true_when_pid_alive(self, mocker, tmp_path):
|
|
38
|
+
pid = tmp_path / "live.pid"
|
|
39
|
+
pid.write_text(str(os.getpid()))
|
|
40
|
+
mocker.patch.object(daemon, "PID_PATH", pid)
|
|
41
|
+
assert daemon.is_running() is True
|
|
42
|
+
|
|
43
|
+
def test_returns_false_when_pid_dead(self, mocker, tmp_path):
|
|
44
|
+
pid = tmp_path / "dead.pid"
|
|
45
|
+
pid.write_text(str(2**31 - 1))
|
|
46
|
+
mocker.patch.object(daemon, "PID_PATH", pid)
|
|
47
|
+
assert daemon.is_running() is False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestSendHelpers:
|
|
51
|
+
"""The thin send/say/stop/ping wrappers around the unix-socket protocol."""
|
|
52
|
+
|
|
53
|
+
def test_send_returns_failure_when_socket_missing(self, mocker, tmp_path):
|
|
54
|
+
mocker.patch.object(daemon, "SOCKET_PATH", tmp_path / "nope.sock")
|
|
55
|
+
ok, resp = daemon.send({"command": "ping"})
|
|
56
|
+
assert ok is False
|
|
57
|
+
assert resp == "daemon not running"
|
|
58
|
+
|
|
59
|
+
def test_say_includes_only_supplied_overrides(self, mocker):
|
|
60
|
+
send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
61
|
+
daemon.say("hello", voice="bf_emma")
|
|
62
|
+
payload = send.call_args.args[0]
|
|
63
|
+
assert payload == {"text": "hello", "voice": "bf_emma"}
|
|
64
|
+
|
|
65
|
+
def test_say_with_all_overrides(self, mocker):
|
|
66
|
+
send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
67
|
+
daemon.say("hi", voice="af_sarah", speed=1.5, lang="en-us")
|
|
68
|
+
assert send.call_args.args[0] == {
|
|
69
|
+
"text": "hi",
|
|
70
|
+
"voice": "af_sarah",
|
|
71
|
+
"speed": 1.5,
|
|
72
|
+
"lang": "en-us",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def test_say_without_overrides_passes_only_text(self, mocker):
|
|
76
|
+
send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
77
|
+
daemon.say("hi")
|
|
78
|
+
assert send.call_args.args[0] == {"text": "hi"}
|
|
79
|
+
|
|
80
|
+
def test_stop_sends_command_stop(self, mocker):
|
|
81
|
+
send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
82
|
+
daemon.stop()
|
|
83
|
+
assert send.call_args.args[0] == {"command": "stop"}
|
|
84
|
+
|
|
85
|
+
def test_ping_uses_short_timeout(self, mocker):
|
|
86
|
+
send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
|
|
87
|
+
daemon.ping()
|
|
88
|
+
assert send.call_args.kwargs["timeout"] == daemon.PING_TIMEOUT_SECONDS
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestRefreshAudioDevices:
|
|
92
|
+
def test_calls_terminate_then_initialize(self, mocker):
|
|
93
|
+
terminate = mocker.patch.object(daemon.sd, "_terminate")
|
|
94
|
+
initialize = mocker.patch.object(daemon.sd, "_initialize")
|
|
95
|
+
|
|
96
|
+
daemon._refresh_audio_devices()
|
|
97
|
+
|
|
98
|
+
terminate.assert_called_once()
|
|
99
|
+
initialize.assert_called_once()
|
|
100
|
+
|
|
101
|
+
def test_swallows_and_logs_errors(self, mocker, caplog):
|
|
102
|
+
"""A failure in the audio reset must not propagate out — the worker
|
|
103
|
+
retries on the next request."""
|
|
104
|
+
import logging
|
|
105
|
+
|
|
106
|
+
mocker.patch.object(daemon.sd, "_terminate", side_effect=RuntimeError("portaudio gone"))
|
|
107
|
+
mocker.patch.object(daemon.sd, "_initialize")
|
|
108
|
+
|
|
109
|
+
with caplog.at_level(logging.ERROR, logger="stackvox.daemon"):
|
|
110
|
+
daemon._refresh_audio_devices() # must not raise
|
|
111
|
+
|
|
112
|
+
assert any("failed to refresh audio devices" in r.message for r in caplog.records)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestStartDeviceWatcher:
|
|
116
|
+
def test_no_op_on_non_darwin(self, mocker):
|
|
117
|
+
"""On Linux/Windows the function returns immediately; nothing should
|
|
118
|
+
be loaded from CoreAudio and no thread should be started."""
|
|
119
|
+
mocker.patch.object(daemon.sys, "platform", "linux")
|
|
120
|
+
cdll = mocker.patch.object(daemon.ctypes, "CDLL")
|
|
121
|
+
|
|
122
|
+
daemon._start_device_watcher()
|
|
123
|
+
|
|
124
|
+
cdll.assert_not_called()
|
|
125
|
+
|
|
126
|
+
@pytest.mark.skipif(
|
|
127
|
+
not hasattr(daemon.sys, "platform") or daemon.sys.platform != "darwin",
|
|
128
|
+
reason="macOS-only behaviour",
|
|
129
|
+
)
|
|
130
|
+
def test_disabled_when_coreaudio_unavailable(self, mocker, caplog):
|
|
131
|
+
"""If CDLL fails to load CoreAudio (unusual on real macOS but possible
|
|
132
|
+
in stripped environments), the watcher should bail without raising."""
|
|
133
|
+
import logging
|
|
134
|
+
|
|
135
|
+
mocker.patch.object(daemon.ctypes, "CDLL", side_effect=OSError("no coreaudio"))
|
|
136
|
+
with caplog.at_level(logging.DEBUG, logger="stackvox.daemon"):
|
|
137
|
+
daemon._start_device_watcher() # must not raise
|
|
138
|
+
assert any("CoreAudio unavailable" in r.message for r in caplog.records)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Engine tests — no real model load, no audio device.
|
|
2
|
+
|
|
3
|
+
We mock at two boundaries: `Kokoro` (so `Stackvox.__init__` doesn't pull the
|
|
4
|
+
model) and `sounddevice` (so `speak()` doesn't try to talk to PortAudio).
|
|
5
|
+
This lets us verify call-shape and side effects without any of the heavy
|
|
6
|
+
runtime dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from stackvox import engine
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def fake_ensure_models(mocker, tmp_path):
|
|
21
|
+
"""Short-circuit _ensure_models so Stackvox.__init__ skips the download."""
|
|
22
|
+
model = tmp_path / "kokoro.onnx"
|
|
23
|
+
voices = tmp_path / "voices.bin"
|
|
24
|
+
model.touch()
|
|
25
|
+
voices.touch()
|
|
26
|
+
return mocker.patch.object(engine, "_ensure_models", return_value=(model, voices))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def fake_kokoro(mocker, fake_ensure_models):
|
|
31
|
+
"""Mock Kokoro on top of fake_ensure_models — most tests want both."""
|
|
32
|
+
return mocker.patch.object(engine, "Kokoro")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def fake_audio(mocker):
|
|
37
|
+
"""Mock the sounddevice surface used by Stackvox."""
|
|
38
|
+
mocker.patch.object(engine.sd, "play")
|
|
39
|
+
mocker.patch.object(engine.sd, "wait")
|
|
40
|
+
mocker.patch.object(engine.sd, "stop")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture(autouse=True)
|
|
44
|
+
def reset_default():
|
|
45
|
+
"""Each test starts with no module-level singleton."""
|
|
46
|
+
engine._default = None
|
|
47
|
+
yield
|
|
48
|
+
engine._default = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestStackvoxInit:
|
|
52
|
+
def test_stores_voice_speed_lang(self, fake_kokoro):
|
|
53
|
+
tts = engine.Stackvox(voice="bf_emma", speed=1.2, lang="en-gb")
|
|
54
|
+
assert tts.voice == "bf_emma"
|
|
55
|
+
assert tts.speed == 1.2
|
|
56
|
+
assert tts.lang == "en-gb"
|
|
57
|
+
|
|
58
|
+
def test_uses_custom_cache_dir(self, fake_kokoro, fake_ensure_models, tmp_path):
|
|
59
|
+
custom = tmp_path / "custom-cache"
|
|
60
|
+
engine.Stackvox(cache_dir=custom)
|
|
61
|
+
fake_ensure_models.assert_called_once_with(custom)
|
|
62
|
+
|
|
63
|
+
def test_falls_back_to_default_cache_dir(self, fake_kokoro, fake_ensure_models, mocker, tmp_path):
|
|
64
|
+
default = tmp_path / "default"
|
|
65
|
+
mocker.patch.object(engine, "_default_cache_dir", return_value=default)
|
|
66
|
+
engine.Stackvox()
|
|
67
|
+
fake_ensure_models.assert_called_once_with(default)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestSynthesize:
|
|
71
|
+
def test_passes_engine_defaults_to_kokoro(self, fake_kokoro):
|
|
72
|
+
samples = np.zeros(10, dtype=np.float32)
|
|
73
|
+
fake_kokoro.return_value.create.return_value = (samples, 24000)
|
|
74
|
+
|
|
75
|
+
tts = engine.Stackvox(voice="af_sarah", speed=1.0, lang="en-us")
|
|
76
|
+
result_samples, result_sr = tts.synthesize("hi")
|
|
77
|
+
|
|
78
|
+
fake_kokoro.return_value.create.assert_called_once_with(
|
|
79
|
+
"hi", voice="af_sarah", speed=1.0, lang="en-us"
|
|
80
|
+
)
|
|
81
|
+
assert result_sr == 24000
|
|
82
|
+
assert np.array_equal(result_samples, samples)
|
|
83
|
+
|
|
84
|
+
def test_per_call_overrides_take_priority(self, fake_kokoro):
|
|
85
|
+
fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
|
|
86
|
+
tts = engine.Stackvox(voice="af_sarah", speed=1.0, lang="en-us")
|
|
87
|
+
tts.synthesize("hi", voice="bf_emma", speed=1.5, lang="en-gb")
|
|
88
|
+
fake_kokoro.return_value.create.assert_called_once_with(
|
|
89
|
+
"hi", voice="bf_emma", speed=1.5, lang="en-gb"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def test_speed_zero_override_is_respected(self, fake_kokoro):
|
|
93
|
+
"""speed=0 is falsy but a valid override; ensure it's not silently dropped."""
|
|
94
|
+
fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
|
|
95
|
+
tts = engine.Stackvox(speed=1.0)
|
|
96
|
+
tts.synthesize("hi", speed=0.5)
|
|
97
|
+
kwargs = fake_kokoro.return_value.create.call_args.kwargs
|
|
98
|
+
assert kwargs["speed"] == 0.5
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestSpeak:
|
|
102
|
+
def test_blocking_calls_play_and_wait(self, fake_kokoro, fake_audio):
|
|
103
|
+
fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
|
|
104
|
+
tts = engine.Stackvox()
|
|
105
|
+
tts.speak("hi")
|
|
106
|
+
engine.sd.play.assert_called_once()
|
|
107
|
+
engine.sd.wait.assert_called_once()
|
|
108
|
+
|
|
109
|
+
def test_non_blocking_skips_wait(self, fake_kokoro, fake_audio):
|
|
110
|
+
fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
|
|
111
|
+
tts = engine.Stackvox()
|
|
112
|
+
tts.speak("hi", blocking=False)
|
|
113
|
+
engine.sd.play.assert_called_once()
|
|
114
|
+
engine.sd.wait.assert_not_called()
|
|
115
|
+
|
|
116
|
+
def test_stop_calls_sounddevice_stop(self, fake_kokoro, fake_audio):
|
|
117
|
+
engine.Stackvox().stop()
|
|
118
|
+
engine.sd.stop.assert_called_once()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestVoices:
|
|
122
|
+
def test_returns_sorted_voice_ids(self, fake_kokoro):
|
|
123
|
+
fake_kokoro.return_value.get_voices.return_value = ["bf_emma", "af_sarah", "am_adam"]
|
|
124
|
+
tts = engine.Stackvox()
|
|
125
|
+
assert tts.voices() == ["af_sarah", "am_adam", "bf_emma"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestSpeakSequence:
|
|
129
|
+
def test_empty_lines_returns_early(self, fake_kokoro, fake_audio):
|
|
130
|
+
engine.Stackvox().speak_sequence([])
|
|
131
|
+
engine.sd.play.assert_not_called()
|
|
132
|
+
|
|
133
|
+
def test_concatenates_segments_with_optional_gap(self, fake_kokoro, fake_audio):
|
|
134
|
+
a = np.array([1.0, 1.0, 1.0], dtype=np.float32)
|
|
135
|
+
b = np.array([2.0, 2.0], dtype=np.float32)
|
|
136
|
+
fake_kokoro.return_value.create.side_effect = [(a, 100), (b, 100)]
|
|
137
|
+
|
|
138
|
+
tts = engine.Stackvox()
|
|
139
|
+
tts.speak_sequence([{"text": "first"}, {"text": "second"}], gap_seconds=0.05, concurrent=False)
|
|
140
|
+
|
|
141
|
+
played = engine.sd.play.call_args.args[0]
|
|
142
|
+
# Length: 3 + (100 * 0.05) silence + 2 = 10 samples.
|
|
143
|
+
assert len(played) == 3 + 5 + 2
|
|
144
|
+
# First 3 samples are `a`, last 2 are `b`, middle 5 are silence.
|
|
145
|
+
np.testing.assert_array_equal(played[:3], a)
|
|
146
|
+
np.testing.assert_array_equal(played[-2:], b)
|
|
147
|
+
np.testing.assert_array_equal(played[3:8], np.zeros(5, dtype=np.float32))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestModuleLevelHelpers:
|
|
151
|
+
def test_speak_reuses_module_singleton(self, fake_kokoro, fake_audio):
|
|
152
|
+
fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
|
|
153
|
+
engine.speak("first")
|
|
154
|
+
engine.speak("second")
|
|
155
|
+
# Stackvox class only instantiated once → Kokoro only constructed once.
|
|
156
|
+
assert fake_kokoro.call_count == 1
|
|
157
|
+
|
|
158
|
+
def test_synthesize_reuses_module_singleton(self, fake_kokoro):
|
|
159
|
+
fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
|
|
160
|
+
engine.synthesize("first")
|
|
161
|
+
engine.synthesize("second")
|
|
162
|
+
assert fake_kokoro.call_count == 1
|
|
163
|
+
|
|
164
|
+
def test_get_default_is_thread_safe(self, fake_kokoro):
|
|
165
|
+
"""Concurrent first calls must not double-instantiate Stackvox.
|
|
166
|
+
|
|
167
|
+
Without the lock around _get_default, multiple threads calling
|
|
168
|
+
speak()/synthesize() at process start can both observe `_default is
|
|
169
|
+
None` and each construct a Stackvox (each loading the 340 MB model).
|
|
170
|
+
"""
|
|
171
|
+
fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
|
|
172
|
+
barrier = threading.Barrier(8)
|
|
173
|
+
|
|
174
|
+
def race():
|
|
175
|
+
barrier.wait()
|
|
176
|
+
engine._get_default()
|
|
177
|
+
|
|
178
|
+
threads = [threading.Thread(target=race) for _ in range(8)]
|
|
179
|
+
for t in threads:
|
|
180
|
+
t.start()
|
|
181
|
+
for t in threads:
|
|
182
|
+
t.join()
|
|
183
|
+
|
|
184
|
+
assert fake_kokoro.call_count == 1
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestDownloadProgress:
|
|
188
|
+
def test_reporthook_prints_percentage_when_size_known(self, mocker, capsys, tmp_path):
|
|
189
|
+
"""The hook should print updating progress lines while downloading."""
|
|
190
|
+
captured_hook = {}
|
|
191
|
+
|
|
192
|
+
def fake_urlretrieve(url, dest, reporthook=None):
|
|
193
|
+
captured_hook["fn"] = reporthook
|
|
194
|
+
# Simulate three chunks of a 1000-byte download.
|
|
195
|
+
if reporthook:
|
|
196
|
+
reporthook(0, 100, 1000)
|
|
197
|
+
reporthook(5, 100, 1000)
|
|
198
|
+
reporthook(10, 100, 1000)
|
|
199
|
+
|
|
200
|
+
mocker.patch.object(engine.urllib.request, "urlretrieve", side_effect=fake_urlretrieve)
|
|
201
|
+
engine._download_with_progress("http://example/m.bin", tmp_path / "m.bin")
|
|
202
|
+
|
|
203
|
+
err = capsys.readouterr().err
|
|
204
|
+
assert "downloading m.bin" in err
|
|
205
|
+
assert "100%" in err
|
|
206
|
+
|
|
207
|
+
def test_unknown_size_does_not_print(self, mocker, capsys, tmp_path):
|
|
208
|
+
"""totalsize <= 0 means Content-Length absent; hook should be a no-op."""
|
|
209
|
+
|
|
210
|
+
def fake_urlretrieve(url, dest, reporthook=None):
|
|
211
|
+
if reporthook:
|
|
212
|
+
reporthook(0, 100, -1)
|
|
213
|
+
|
|
214
|
+
mocker.patch.object(engine.urllib.request, "urlretrieve", side_effect=fake_urlretrieve)
|
|
215
|
+
engine._download_with_progress("http://example/m.bin", tmp_path / "m.bin")
|
|
216
|
+
|
|
217
|
+
# No percentage should have been emitted because total size was unknown.
|
|
218
|
+
assert "downloading" not in capsys.readouterr().err
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TestEnsureModels:
|
|
222
|
+
def test_skips_files_that_already_exist(self, mocker, tmp_path):
|
|
223
|
+
download = mocker.patch.object(engine, "_download_with_progress")
|
|
224
|
+
(tmp_path / "kokoro-v1.0.onnx").touch()
|
|
225
|
+
(tmp_path / "voices-v1.0.bin").touch()
|
|
226
|
+
|
|
227
|
+
engine._ensure_models(tmp_path)
|
|
228
|
+
|
|
229
|
+
download.assert_not_called()
|
|
230
|
+
|
|
231
|
+
def test_downloads_only_missing_files(self, mocker, tmp_path):
|
|
232
|
+
download = mocker.patch.object(engine, "_download_with_progress")
|
|
233
|
+
# Only the model exists; voices needs downloading.
|
|
234
|
+
(tmp_path / "kokoro-v1.0.onnx").touch()
|
|
235
|
+
|
|
236
|
+
engine._ensure_models(tmp_path)
|
|
237
|
+
|
|
238
|
+
# Exactly one download, for the voices file.
|
|
239
|
+
assert download.call_count == 1
|
|
240
|
+
url_arg, dest_arg = download.call_args.args
|
|
241
|
+
assert dest_arg.name == "voices-v1.0.bin"
|
stackvox-0.3.0/tests/test_cli.py
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
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
|
|
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
|