stackvox 0.5.0__tar.gz → 0.7.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.5.0/stackvox.egg-info → stackvox-0.7.0}/PKG-INFO +16 -1
- {stackvox-0.5.0 → stackvox-0.7.0}/README.md +15 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/pyproject.toml +6 -2
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/__init__.py +2 -1
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/cli.py +183 -5
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/engine.py +118 -4
- stackvox-0.7.0/stackvox/text.py +310 -0
- {stackvox-0.5.0 → stackvox-0.7.0/stackvox.egg-info}/PKG-INFO +16 -1
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/SOURCES.txt +2 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_cli.py +149 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_engine.py +95 -13
- stackvox-0.7.0/tests/test_text.py +197 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/LICENSE +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/NOTICE +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/setup.cfg +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/__main__.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/config.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/daemon.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/paths.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/py.typed +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/updates.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_config.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_daemon.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_paths.py +0 -0
- {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_updates.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackvox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Offline TTS library using Kokoro-82M
|
|
5
5
|
Author-email: "StackOne Technologies Ltd." <support@stackone.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -88,8 +88,23 @@ stackvox welcome # multilingual welcome (6 languages)
|
|
|
88
88
|
stackvox voices # list all voice ids
|
|
89
89
|
echo "from a pipe" | stackvox # piped stdin works for speak/say
|
|
90
90
|
stackvox speak --file message.txt # read a whole file
|
|
91
|
+
stackvox paths # print resolved cache / socket / pid paths
|
|
92
|
+
stackvox config # print resolved default voice/speed/lang
|
|
91
93
|
```
|
|
92
94
|
|
|
95
|
+
### Speech normalization
|
|
96
|
+
|
|
97
|
+
`normalize_for_speech` (Markdown → speakable prose: strip formatting, expand units/numbers, shape pauses, apply a pronunciation dictionary) is available from the shell — no Python import required:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
stackvox normalize --file post.md # print normalized text (pipe / debug)
|
|
101
|
+
stackvox normalize --tables csv --strip-emoji < response.md
|
|
102
|
+
stackvox speak --normalize --file post.md # normalize, then synthesize
|
|
103
|
+
stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
|
|
107
|
+
|
|
93
108
|
Bash completion:
|
|
94
109
|
|
|
95
110
|
```bash
|
|
@@ -54,8 +54,23 @@ stackvox welcome # multilingual welcome (6 languages)
|
|
|
54
54
|
stackvox voices # list all voice ids
|
|
55
55
|
echo "from a pipe" | stackvox # piped stdin works for speak/say
|
|
56
56
|
stackvox speak --file message.txt # read a whole file
|
|
57
|
+
stackvox paths # print resolved cache / socket / pid paths
|
|
58
|
+
stackvox config # print resolved default voice/speed/lang
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
### Speech normalization
|
|
62
|
+
|
|
63
|
+
`normalize_for_speech` (Markdown → speakable prose: strip formatting, expand units/numbers, shape pauses, apply a pronunciation dictionary) is available from the shell — no Python import required:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
stackvox normalize --file post.md # print normalized text (pipe / debug)
|
|
67
|
+
stackvox normalize --tables csv --strip-emoji < response.md
|
|
68
|
+
stackvox speak --normalize --file post.md # normalize, then synthesize
|
|
69
|
+
stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
|
|
73
|
+
|
|
59
74
|
Bash completion:
|
|
60
75
|
|
|
61
76
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Offline TTS library using Kokoro-82M"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -100,7 +100,11 @@ show_missing = true
|
|
|
100
100
|
skip_covered = false
|
|
101
101
|
|
|
102
102
|
[tool.mypy]
|
|
103
|
-
|
|
103
|
+
# Matches the interpreter CI runs mypy under, and must be >= 3.12: numpy's
|
|
104
|
+
# bundled stubs use PEP 695 `type` aliases, which mypy rejects as a syntax
|
|
105
|
+
# error when told to target an older Python. Runtime support for 3.10+ is
|
|
106
|
+
# still verified by the pytest matrix (py3.10–3.13).
|
|
107
|
+
python_version = "3.12"
|
|
104
108
|
files = ["stackvox", "tests"]
|
|
105
109
|
warn_unused_ignores = true
|
|
106
110
|
warn_redundant_casts = true
|
|
@@ -5,6 +5,7 @@ from importlib.metadata import version as _pkg_version
|
|
|
5
5
|
|
|
6
6
|
from stackvox import daemon
|
|
7
7
|
from stackvox.engine import Stackvox, speak, synthesize
|
|
8
|
+
from stackvox.text import normalize_for_speech
|
|
8
9
|
|
|
9
10
|
try:
|
|
10
11
|
__version__ = _pkg_version("stackvox")
|
|
@@ -13,4 +14,4 @@ except PackageNotFoundError:
|
|
|
13
14
|
# bootstrap before `pip install -e .` has completed).
|
|
14
15
|
__version__ = "0.0.0+unknown"
|
|
15
16
|
|
|
16
|
-
__all__ = ["Stackvox", "daemon", "speak", "synthesize"]
|
|
17
|
+
__all__ = ["Stackvox", "daemon", "normalize_for_speech", "speak", "synthesize"]
|
|
@@ -10,8 +10,9 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
import soundfile as sf
|
|
12
12
|
|
|
13
|
-
from stackvox import config, daemon, updates
|
|
13
|
+
from stackvox import config, daemon, paths, updates
|
|
14
14
|
from stackvox.engine import Stackvox
|
|
15
|
+
from stackvox.text import normalize_for_speech
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def _configure_logging() -> None:
|
|
@@ -28,8 +29,11 @@ SUBCOMMANDS = {
|
|
|
28
29
|
"status",
|
|
29
30
|
"say",
|
|
30
31
|
"speak",
|
|
32
|
+
"normalize",
|
|
31
33
|
"voices",
|
|
32
34
|
"welcome",
|
|
35
|
+
"paths",
|
|
36
|
+
"config",
|
|
33
37
|
"completion",
|
|
34
38
|
"install-helper",
|
|
35
39
|
}
|
|
@@ -47,12 +51,12 @@ _stackvox_completion() {
|
|
|
47
51
|
subcommand="${COMP_WORDS[1]:-}"
|
|
48
52
|
|
|
49
53
|
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
|
50
|
-
COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") )
|
|
54
|
+
COMPREPLY=( $(compgen -W "speak say normalize serve stop status voices welcome paths config completion install-helper" -- "$cur") )
|
|
51
55
|
return 0
|
|
52
56
|
fi
|
|
53
57
|
|
|
54
58
|
case "$prev" in
|
|
55
|
-
--file|--out)
|
|
59
|
+
--file|--out|--pronunciations)
|
|
56
60
|
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
57
61
|
return 0
|
|
58
62
|
;;
|
|
@@ -68,14 +72,27 @@ _stackvox_completion() {
|
|
|
68
72
|
COMPREPLY=( $(compgen -W "en-us en-gb fr-fr it hi pt-br es ja zh" -- "$cur") )
|
|
69
73
|
return 0
|
|
70
74
|
;;
|
|
75
|
+
--tables)
|
|
76
|
+
COMPREPLY=( $(compgen -W "drop csv" -- "$cur") )
|
|
77
|
+
return 0
|
|
78
|
+
;;
|
|
79
|
+
--locale)
|
|
80
|
+
COMPREPLY=( $(compgen -W "en-GB" -- "$cur") )
|
|
81
|
+
return 0
|
|
82
|
+
;;
|
|
71
83
|
esac
|
|
72
84
|
|
|
85
|
+
local norm_flags="--no-markdown --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --strip-emoji --no-terminal-stops --locale"
|
|
86
|
+
|
|
73
87
|
case "$subcommand" in
|
|
74
88
|
speak)
|
|
75
|
-
COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --help" -- "$cur") )
|
|
89
|
+
COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --normalize $norm_flags --help" -- "$cur") )
|
|
76
90
|
;;
|
|
77
91
|
say)
|
|
78
|
-
COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --help" -- "$cur") )
|
|
92
|
+
COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --normalize $norm_flags --help" -- "$cur") )
|
|
93
|
+
;;
|
|
94
|
+
normalize)
|
|
95
|
+
COMPREPLY=( $(compgen -W "--file $norm_flags --help" -- "$cur") )
|
|
79
96
|
;;
|
|
80
97
|
serve)
|
|
81
98
|
COMPREPLY=( $(compgen -W "--voice --speed --lang --help" -- "$cur") )
|
|
@@ -115,6 +132,7 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
|
|
|
115
132
|
p_speak.add_argument("text", nargs="?")
|
|
116
133
|
p_speak.add_argument("--file", type=Path)
|
|
117
134
|
p_speak.add_argument("--out", type=Path, help="Write wav instead of playing")
|
|
135
|
+
_add_normalize_args(p_speak, with_switch=True)
|
|
118
136
|
|
|
119
137
|
p_say = sub.add_parser("say", help="Send text to daemon (fast; fails if daemon not running)")
|
|
120
138
|
_add_voice_args(p_say, defaults)
|
|
@@ -123,6 +141,15 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
|
|
|
123
141
|
p_say.add_argument(
|
|
124
142
|
"--fallback-say", action="store_true", help="Shell out to macOS `say` if daemon unreachable"
|
|
125
143
|
)
|
|
144
|
+
_add_normalize_args(p_say, with_switch=True)
|
|
145
|
+
|
|
146
|
+
p_normalize = sub.add_parser(
|
|
147
|
+
"normalize",
|
|
148
|
+
help="Print speech-normalized text (Markdown -> speakable prose); no synthesis",
|
|
149
|
+
)
|
|
150
|
+
p_normalize.add_argument("text", nargs="?")
|
|
151
|
+
p_normalize.add_argument("--file", type=Path)
|
|
152
|
+
_add_normalize_args(p_normalize, with_switch=False)
|
|
126
153
|
|
|
127
154
|
p_serve = sub.add_parser("serve", help="Run the daemon in the foreground")
|
|
128
155
|
_add_voice_args(p_serve, defaults)
|
|
@@ -131,6 +158,8 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
|
|
|
131
158
|
sub.add_parser("status", help="Print daemon status")
|
|
132
159
|
sub.add_parser("voices", help="List available voices")
|
|
133
160
|
sub.add_parser("welcome", help="Play a multilingual welcome message")
|
|
161
|
+
sub.add_parser("paths", help="Print resolved cache / socket / pid file paths")
|
|
162
|
+
sub.add_parser("config", help="Print resolved default voice/speed/lang and config path")
|
|
134
163
|
|
|
135
164
|
p_completion = sub.add_parser("completion", help="Print a shell completion script")
|
|
136
165
|
p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
|
|
@@ -155,6 +184,114 @@ def _add_voice_args(parser: argparse.ArgumentParser, defaults: config.Defaults)
|
|
|
155
184
|
parser.add_argument("--lang", default=defaults.lang)
|
|
156
185
|
|
|
157
186
|
|
|
187
|
+
def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -> None:
|
|
188
|
+
"""Flags mapping one-to-one onto ``stackvox.text.normalize_for_speech``.
|
|
189
|
+
|
|
190
|
+
Defaults mirror the library: markdown / units / numbers / pauses /
|
|
191
|
+
terminal-stops on, emoji-strip off, tables dropped, ``en-GB`` locale.
|
|
192
|
+
|
|
193
|
+
``with_switch`` adds the ``--normalize`` master toggle used by ``speak`` and
|
|
194
|
+
``say`` (off by default, so notification phrases synth exactly as before).
|
|
195
|
+
The ``normalize`` subcommand always normalizes, so it omits the switch.
|
|
196
|
+
"""
|
|
197
|
+
if with_switch:
|
|
198
|
+
parser.add_argument(
|
|
199
|
+
"--normalize",
|
|
200
|
+
action="store_true",
|
|
201
|
+
help="Normalize text for speech before synthesizing (see `stackvox normalize`)",
|
|
202
|
+
)
|
|
203
|
+
parser.add_argument(
|
|
204
|
+
"--no-markdown", dest="markdown", action="store_false", help="Treat input as plain text, not Markdown"
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--pronunciations",
|
|
208
|
+
type=Path,
|
|
209
|
+
help="JSON file mapping written -> spoken forms (whole-word, case-insensitive)",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--no-expand-units",
|
|
213
|
+
dest="expand_units",
|
|
214
|
+
action="store_false",
|
|
215
|
+
help="Leave £/p/kWh/MPG/kg/km and ÷ × = as written",
|
|
216
|
+
)
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--no-expand-numbers",
|
|
219
|
+
dest="expand_numbers",
|
|
220
|
+
action="store_false",
|
|
221
|
+
help="Leave thousands separators and decimals as written",
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--no-pauses",
|
|
225
|
+
dest="pauses",
|
|
226
|
+
action="store_false",
|
|
227
|
+
help="Do not reshape dashes / parentheses into pauses",
|
|
228
|
+
)
|
|
229
|
+
parser.add_argument(
|
|
230
|
+
"--tables",
|
|
231
|
+
choices=["drop", "csv"],
|
|
232
|
+
default="drop",
|
|
233
|
+
help="How Markdown tables are voiced (default: drop)",
|
|
234
|
+
)
|
|
235
|
+
parser.add_argument(
|
|
236
|
+
"--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking"
|
|
237
|
+
)
|
|
238
|
+
parser.add_argument(
|
|
239
|
+
"--no-terminal-stops",
|
|
240
|
+
dest="terminal_stops",
|
|
241
|
+
action="store_false",
|
|
242
|
+
help="Do not add terminal punctuation so a pause lands between lines",
|
|
243
|
+
)
|
|
244
|
+
parser.add_argument("--locale", default="en-GB", help="Locale for unit/currency rules (default: en-GB)")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _load_pronunciations(path: Path | None) -> dict[str, str] | None:
|
|
248
|
+
"""Load a ``{written: spoken}`` map from a JSON file.
|
|
249
|
+
|
|
250
|
+
Returns ``None`` when no path is given. Raises ``ValueError`` if the file
|
|
251
|
+
is not a flat table of string -> string entries, so a mistyped dict fails
|
|
252
|
+
loudly rather than silently voicing the wrong thing.
|
|
253
|
+
"""
|
|
254
|
+
if path is None:
|
|
255
|
+
return None
|
|
256
|
+
import json
|
|
257
|
+
|
|
258
|
+
data = json.loads(path.expanduser().read_text(encoding="utf-8"))
|
|
259
|
+
if not isinstance(data, dict) or not all(
|
|
260
|
+
isinstance(key, str) and isinstance(value, str) for key, value in data.items()
|
|
261
|
+
):
|
|
262
|
+
raise ValueError(f"{path}: expected a flat JSON object of string -> string entries")
|
|
263
|
+
return data
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _normalize_kwargs(args: argparse.Namespace) -> dict:
|
|
267
|
+
"""Collect the normalize_for_speech keyword arguments from parsed flags."""
|
|
268
|
+
return {
|
|
269
|
+
"markdown": args.markdown,
|
|
270
|
+
"pronunciations": _load_pronunciations(args.pronunciations),
|
|
271
|
+
"expand_units": args.expand_units,
|
|
272
|
+
"expand_numbers": args.expand_numbers,
|
|
273
|
+
"pauses": args.pauses,
|
|
274
|
+
"tables": args.tables,
|
|
275
|
+
"strip_emoji": args.strip_emoji,
|
|
276
|
+
"terminal_stops": args.terminal_stops,
|
|
277
|
+
"locale": args.locale,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _resolve_normalized(text: str, args: argparse.Namespace) -> str | None:
|
|
282
|
+
"""Normalize ``text`` for speech, or print a clean error and return None.
|
|
283
|
+
|
|
284
|
+
A missing or malformed ``--pronunciations`` file surfaces here as an
|
|
285
|
+
``OSError`` / ``ValueError`` (``json.JSONDecodeError`` is a ``ValueError``);
|
|
286
|
+
turn it into a one-line ``[stackvox] …`` message rather than a traceback.
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
return normalize_for_speech(text, **_normalize_kwargs(args))
|
|
290
|
+
except (OSError, ValueError) as exc:
|
|
291
|
+
print(f"[stackvox] {exc}", file=sys.stderr)
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
158
295
|
def _read_text(args: argparse.Namespace) -> str | None:
|
|
159
296
|
"""Resolve the text to speak from --file, the positional, or piped stdin.
|
|
160
297
|
|
|
@@ -177,6 +314,10 @@ def _cmd_speak(args: argparse.Namespace) -> int:
|
|
|
177
314
|
if not text or not text.strip():
|
|
178
315
|
print("Error: provide text or --file", file=sys.stderr)
|
|
179
316
|
return 1
|
|
317
|
+
if getattr(args, "normalize", False):
|
|
318
|
+
text = _resolve_normalized(text, args)
|
|
319
|
+
if text is None:
|
|
320
|
+
return 1
|
|
180
321
|
tts = Stackvox(voice=args.voice, speed=args.speed, lang=args.lang)
|
|
181
322
|
if args.out:
|
|
182
323
|
samples, sr = tts.synthesize(text)
|
|
@@ -192,6 +333,10 @@ def _cmd_say(args: argparse.Namespace) -> int:
|
|
|
192
333
|
if not text or not text.strip():
|
|
193
334
|
print("Error: provide text or --file", file=sys.stderr)
|
|
194
335
|
return 1
|
|
336
|
+
if getattr(args, "normalize", False):
|
|
337
|
+
text = _resolve_normalized(text, args)
|
|
338
|
+
if text is None:
|
|
339
|
+
return 1
|
|
195
340
|
ok, resp = daemon.say(text, voice=args.voice, speed=args.speed, lang=args.lang)
|
|
196
341
|
if ok:
|
|
197
342
|
return 0
|
|
@@ -206,6 +351,18 @@ def _cmd_say(args: argparse.Namespace) -> int:
|
|
|
206
351
|
return 2
|
|
207
352
|
|
|
208
353
|
|
|
354
|
+
def _cmd_normalize(args: argparse.Namespace) -> int:
|
|
355
|
+
text = _read_text(args)
|
|
356
|
+
if not text or not text.strip():
|
|
357
|
+
print("Error: provide text or --file", file=sys.stderr)
|
|
358
|
+
return 1
|
|
359
|
+
normalized = _resolve_normalized(text, args)
|
|
360
|
+
if normalized is None:
|
|
361
|
+
return 1
|
|
362
|
+
print(normalized)
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
209
366
|
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
210
367
|
try:
|
|
211
368
|
daemon.serve(voice=args.voice, speed=args.speed, lang=args.lang)
|
|
@@ -248,6 +405,24 @@ def _cmd_voices(args: argparse.Namespace) -> int:
|
|
|
248
405
|
return 0
|
|
249
406
|
|
|
250
407
|
|
|
408
|
+
def _cmd_paths(_: argparse.Namespace) -> int:
|
|
409
|
+
print(f"cache_dir: {paths.cache_dir()}")
|
|
410
|
+
print(f"socket_path: {paths.socket_path()}")
|
|
411
|
+
print(f"pid_path: {paths.pid_path()}")
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _cmd_config(_: argparse.Namespace) -> int:
|
|
416
|
+
path = config.config_path()
|
|
417
|
+
present = "" if path.is_file() else " (not present; built-in defaults apply)"
|
|
418
|
+
defaults = config.load_defaults()
|
|
419
|
+
print(f"config_path: {path}{present}")
|
|
420
|
+
print(f"voice: {defaults.voice}")
|
|
421
|
+
print(f"speed: {defaults.speed}")
|
|
422
|
+
print(f"lang: {defaults.lang}")
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
|
|
251
426
|
def _cmd_welcome(_: argparse.Namespace) -> int:
|
|
252
427
|
tts = Stackvox()
|
|
253
428
|
tts.speak_sequence([{"text": text, "voice": voice, "lang": lang} for voice, lang, text in WELCOME_LINES])
|
|
@@ -336,11 +511,14 @@ def main() -> int:
|
|
|
336
511
|
handlers = {
|
|
337
512
|
"speak": _cmd_speak,
|
|
338
513
|
"say": _cmd_say,
|
|
514
|
+
"normalize": _cmd_normalize,
|
|
339
515
|
"serve": _cmd_serve,
|
|
340
516
|
"stop": _cmd_stop,
|
|
341
517
|
"status": _cmd_status,
|
|
342
518
|
"voices": _cmd_voices,
|
|
343
519
|
"welcome": _cmd_welcome,
|
|
520
|
+
"paths": _cmd_paths,
|
|
521
|
+
"config": _cmd_config,
|
|
344
522
|
"completion": _cmd_completion,
|
|
345
523
|
"install-helper": _cmd_install_helper,
|
|
346
524
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
6
8
|
import sys
|
|
7
9
|
import threading
|
|
8
10
|
import urllib.request
|
|
@@ -69,6 +71,17 @@ def _ensure_models(cache_dir: Path) -> tuple[Path, Path]:
|
|
|
69
71
|
return model_path, voices_path
|
|
70
72
|
|
|
71
73
|
|
|
74
|
+
# Split after ., !, or ? that is followed by whitespace, and on newlines. The
|
|
75
|
+
# whitespace requirement leaves decimals ("0.95") and mid-token dots
|
|
76
|
+
# ("file.ts") intact, which is good enough for speech chunking.
|
|
77
|
+
_SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+|\n+")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _split_sentences(text: str) -> list[str]:
|
|
81
|
+
"""Break text into sentence-ish chunks for low-latency streaming playback."""
|
|
82
|
+
return [part.strip() for part in _SENTENCE_BOUNDARY.split(text.strip()) if part.strip()]
|
|
83
|
+
|
|
84
|
+
|
|
72
85
|
class Stackvox:
|
|
73
86
|
"""Reusable TTS engine. Load the model once, speak many times.
|
|
74
87
|
|
|
@@ -89,6 +102,8 @@ class Stackvox:
|
|
|
89
102
|
self.lang = lang
|
|
90
103
|
model_path, voices_path = _ensure_models(cache_dir or _default_cache_dir())
|
|
91
104
|
self._kokoro = Kokoro(str(model_path), str(voices_path))
|
|
105
|
+
self._stop_event = threading.Event()
|
|
106
|
+
self._play_thread: threading.Thread | None = None
|
|
92
107
|
|
|
93
108
|
def synthesize(
|
|
94
109
|
self,
|
|
@@ -106,6 +121,21 @@ class Stackvox:
|
|
|
106
121
|
)
|
|
107
122
|
return samples, sample_rate
|
|
108
123
|
|
|
124
|
+
def _cancel_active(self) -> None:
|
|
125
|
+
"""Supersede any in-progress stream so a new ``speak`` starts clean.
|
|
126
|
+
|
|
127
|
+
Signals the running stream's stop event and joins its thread, so we
|
|
128
|
+
never overlap two streams on the shared output device or orphan the
|
|
129
|
+
previous playback thread.
|
|
130
|
+
"""
|
|
131
|
+
thread = self._play_thread
|
|
132
|
+
self._play_thread = None
|
|
133
|
+
if thread is None or thread is threading.current_thread() or not thread.is_alive():
|
|
134
|
+
return
|
|
135
|
+
self._stop_event.set()
|
|
136
|
+
sd.stop()
|
|
137
|
+
thread.join()
|
|
138
|
+
|
|
109
139
|
def speak(
|
|
110
140
|
self,
|
|
111
141
|
text: str,
|
|
@@ -114,14 +144,98 @@ class Stackvox:
|
|
|
114
144
|
lang: str | None = None,
|
|
115
145
|
blocking: bool = True,
|
|
116
146
|
) -> None:
|
|
117
|
-
"""Synthesize and play through the system default output device.
|
|
118
|
-
|
|
119
|
-
|
|
147
|
+
"""Synthesize and play through the system default output device.
|
|
148
|
+
|
|
149
|
+
Streams sentence by sentence: the first sentence starts playing as soon
|
|
150
|
+
as it is synthesized (~0.2s) while the rest synthesize in the background,
|
|
151
|
+
rather than waiting for the whole text to synthesize first.
|
|
152
|
+
"""
|
|
153
|
+
self._cancel_active()
|
|
154
|
+
# Each stream gets its own cancellation token, so a later speak() (or a
|
|
155
|
+
# stop() in between) can never clear an older stream's cancellation.
|
|
156
|
+
stop_event = threading.Event()
|
|
157
|
+
self._stop_event = stop_event
|
|
120
158
|
if blocking:
|
|
121
|
-
|
|
159
|
+
self._stream_play(text, stop_event, voice=voice, speed=speed, lang=lang)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
def _run() -> None:
|
|
163
|
+
try:
|
|
164
|
+
self._stream_play(text, stop_event, voice=voice, speed=speed, lang=lang)
|
|
165
|
+
except Exception:
|
|
166
|
+
logger.exception("stackvox playback failed")
|
|
167
|
+
|
|
168
|
+
self._play_thread = threading.Thread(target=_run, daemon=True, name="stackvox-play")
|
|
169
|
+
self._play_thread.start()
|
|
170
|
+
|
|
171
|
+
def _stream_play(
|
|
172
|
+
self,
|
|
173
|
+
text: str,
|
|
174
|
+
stop_event: threading.Event,
|
|
175
|
+
voice: str | None = None,
|
|
176
|
+
speed: float | None = None,
|
|
177
|
+
lang: str | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Synthesize sentence by sentence and play each as it is ready.
|
|
180
|
+
|
|
181
|
+
Kokoro batches by phoneme count (~510), so a whole paragraph synthesizes
|
|
182
|
+
as one blob before any audio — the source of the lead-in lag. Splitting
|
|
183
|
+
into sentences ourselves means the first sentence (~0.2s to synthesize)
|
|
184
|
+
plays almost immediately. A producer thread synthesizes ahead into a
|
|
185
|
+
bounded queue while this thread plays, so later sentences are usually
|
|
186
|
+
ready by the time the previous one finishes.
|
|
187
|
+
|
|
188
|
+
A synthesis failure is re-raised rather than swallowed, so a blocking
|
|
189
|
+
caller sees the error instead of silent success; ``stop_event`` cancels
|
|
190
|
+
the stream between sentences.
|
|
191
|
+
"""
|
|
192
|
+
sentences = _split_sentences(text)
|
|
193
|
+
if not sentences:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
chunks: queue.Queue = queue.Queue(maxsize=8)
|
|
197
|
+
sentinel = object()
|
|
198
|
+
|
|
199
|
+
def _produce() -> None:
|
|
200
|
+
try:
|
|
201
|
+
for sentence in sentences:
|
|
202
|
+
if stop_event.is_set():
|
|
203
|
+
break
|
|
204
|
+
chunks.put(self.synthesize(sentence, voice=voice, speed=speed, lang=lang))
|
|
205
|
+
except Exception as exc: # hand the failure to the consumer to re-raise
|
|
206
|
+
chunks.put(exc)
|
|
207
|
+
finally:
|
|
208
|
+
chunks.put(sentinel)
|
|
209
|
+
|
|
210
|
+
producer = threading.Thread(target=_produce, daemon=True, name="stackvox-synth")
|
|
211
|
+
producer.start()
|
|
212
|
+
error: Exception | None = None
|
|
213
|
+
try:
|
|
214
|
+
while not stop_event.is_set():
|
|
215
|
+
item = chunks.get()
|
|
216
|
+
if item is sentinel:
|
|
217
|
+
break
|
|
218
|
+
if isinstance(item, Exception):
|
|
219
|
+
error = item
|
|
220
|
+
break
|
|
221
|
+
samples, sample_rate = item
|
|
222
|
+
sd.play(samples, sample_rate)
|
|
223
|
+
sd.wait()
|
|
224
|
+
finally:
|
|
225
|
+
# Stop the producer and keep draining so it can never stay parked on
|
|
226
|
+
# a full queue — that's what guarantees the synth thread always exits.
|
|
227
|
+
stop_event.set()
|
|
228
|
+
while producer.is_alive():
|
|
229
|
+
try:
|
|
230
|
+
chunks.get_nowait()
|
|
231
|
+
except queue.Empty:
|
|
232
|
+
producer.join(timeout=0.05)
|
|
233
|
+
if error is not None:
|
|
234
|
+
raise error
|
|
122
235
|
|
|
123
236
|
def stop(self) -> None:
|
|
124
237
|
"""Stop any in-progress playback started with blocking=False."""
|
|
238
|
+
self._stop_event.set()
|
|
125
239
|
sd.stop()
|
|
126
240
|
|
|
127
241
|
def voices(self) -> list[str]:
|