stackvox 0.6.0__tar.gz → 0.8.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.6.0/stackvox.egg-info → stackvox-0.8.0}/PKG-INFO +16 -1
- {stackvox-0.6.0 → stackvox-0.8.0}/README.md +15 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/pyproject.toml +1 -1
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/cli.py +190 -5
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/engine.py +54 -2
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/text.py +29 -1
- {stackvox-0.6.0 → stackvox-0.8.0/stackvox.egg-info}/PKG-INFO +16 -1
- {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_cli.py +150 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_engine.py +28 -7
- {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_text.py +25 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/LICENSE +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/NOTICE +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/setup.cfg +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/__init__.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/__main__.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/config.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/daemon.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/paths.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/py.typed +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/updates.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/SOURCES.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_config.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_daemon.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_paths.py +0 -0
- {stackvox-0.6.0 → stackvox-0.8.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.8.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
|
|
@@ -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,121 @@ 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-dev-terms",
|
|
213
|
+
dest="dev_terms",
|
|
214
|
+
action="store_false",
|
|
215
|
+
help="Do not spell out dev acronyms espeak mispronounces (CLI, CI, IDE, AWS, URI, IAM, ...)",
|
|
216
|
+
)
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--no-expand-units",
|
|
219
|
+
dest="expand_units",
|
|
220
|
+
action="store_false",
|
|
221
|
+
help="Leave £/p/kWh/MPG/kg/km and ÷ × = as written",
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--no-expand-numbers",
|
|
225
|
+
dest="expand_numbers",
|
|
226
|
+
action="store_false",
|
|
227
|
+
help="Leave thousands separators and decimals as written",
|
|
228
|
+
)
|
|
229
|
+
parser.add_argument(
|
|
230
|
+
"--no-pauses",
|
|
231
|
+
dest="pauses",
|
|
232
|
+
action="store_false",
|
|
233
|
+
help="Do not reshape dashes / parentheses into pauses",
|
|
234
|
+
)
|
|
235
|
+
parser.add_argument(
|
|
236
|
+
"--tables",
|
|
237
|
+
choices=["drop", "csv"],
|
|
238
|
+
default="drop",
|
|
239
|
+
help="How Markdown tables are voiced (default: drop)",
|
|
240
|
+
)
|
|
241
|
+
parser.add_argument(
|
|
242
|
+
"--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking"
|
|
243
|
+
)
|
|
244
|
+
parser.add_argument(
|
|
245
|
+
"--no-terminal-stops",
|
|
246
|
+
dest="terminal_stops",
|
|
247
|
+
action="store_false",
|
|
248
|
+
help="Do not add terminal punctuation so a pause lands between lines",
|
|
249
|
+
)
|
|
250
|
+
parser.add_argument("--locale", default="en-GB", help="Locale for unit/currency rules (default: en-GB)")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _load_pronunciations(path: Path | None) -> dict[str, str] | None:
|
|
254
|
+
"""Load a ``{written: spoken}`` map from a JSON file.
|
|
255
|
+
|
|
256
|
+
Returns ``None`` when no path is given. Raises ``ValueError`` if the file
|
|
257
|
+
is not a flat table of string -> string entries, so a mistyped dict fails
|
|
258
|
+
loudly rather than silently voicing the wrong thing.
|
|
259
|
+
"""
|
|
260
|
+
if path is None:
|
|
261
|
+
return None
|
|
262
|
+
import json
|
|
263
|
+
|
|
264
|
+
data = json.loads(path.expanduser().read_text(encoding="utf-8"))
|
|
265
|
+
if not isinstance(data, dict) or not all(
|
|
266
|
+
isinstance(key, str) and isinstance(value, str) for key, value in data.items()
|
|
267
|
+
):
|
|
268
|
+
raise ValueError(f"{path}: expected a flat JSON object of string -> string entries")
|
|
269
|
+
return data
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _normalize_kwargs(args: argparse.Namespace) -> dict:
|
|
273
|
+
"""Collect the normalize_for_speech keyword arguments from parsed flags."""
|
|
274
|
+
return {
|
|
275
|
+
"markdown": args.markdown,
|
|
276
|
+
"pronunciations": _load_pronunciations(args.pronunciations),
|
|
277
|
+
"dev_terms": args.dev_terms,
|
|
278
|
+
"expand_units": args.expand_units,
|
|
279
|
+
"expand_numbers": args.expand_numbers,
|
|
280
|
+
"pauses": args.pauses,
|
|
281
|
+
"tables": args.tables,
|
|
282
|
+
"strip_emoji": args.strip_emoji,
|
|
283
|
+
"terminal_stops": args.terminal_stops,
|
|
284
|
+
"locale": args.locale,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _resolve_normalized(text: str, args: argparse.Namespace) -> str | None:
|
|
289
|
+
"""Normalize ``text`` for speech, or print a clean error and return None.
|
|
290
|
+
|
|
291
|
+
A missing or malformed ``--pronunciations`` file surfaces here as an
|
|
292
|
+
``OSError`` / ``ValueError`` (``json.JSONDecodeError`` is a ``ValueError``);
|
|
293
|
+
turn it into a one-line ``[stackvox] …`` message rather than a traceback.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
return normalize_for_speech(text, **_normalize_kwargs(args))
|
|
297
|
+
except (OSError, ValueError) as exc:
|
|
298
|
+
print(f"[stackvox] {exc}", file=sys.stderr)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
158
302
|
def _read_text(args: argparse.Namespace) -> str | None:
|
|
159
303
|
"""Resolve the text to speak from --file, the positional, or piped stdin.
|
|
160
304
|
|
|
@@ -177,6 +321,10 @@ def _cmd_speak(args: argparse.Namespace) -> int:
|
|
|
177
321
|
if not text or not text.strip():
|
|
178
322
|
print("Error: provide text or --file", file=sys.stderr)
|
|
179
323
|
return 1
|
|
324
|
+
if getattr(args, "normalize", False):
|
|
325
|
+
text = _resolve_normalized(text, args)
|
|
326
|
+
if text is None:
|
|
327
|
+
return 1
|
|
180
328
|
tts = Stackvox(voice=args.voice, speed=args.speed, lang=args.lang)
|
|
181
329
|
if args.out:
|
|
182
330
|
samples, sr = tts.synthesize(text)
|
|
@@ -192,6 +340,10 @@ def _cmd_say(args: argparse.Namespace) -> int:
|
|
|
192
340
|
if not text or not text.strip():
|
|
193
341
|
print("Error: provide text or --file", file=sys.stderr)
|
|
194
342
|
return 1
|
|
343
|
+
if getattr(args, "normalize", False):
|
|
344
|
+
text = _resolve_normalized(text, args)
|
|
345
|
+
if text is None:
|
|
346
|
+
return 1
|
|
195
347
|
ok, resp = daemon.say(text, voice=args.voice, speed=args.speed, lang=args.lang)
|
|
196
348
|
if ok:
|
|
197
349
|
return 0
|
|
@@ -206,6 +358,18 @@ def _cmd_say(args: argparse.Namespace) -> int:
|
|
|
206
358
|
return 2
|
|
207
359
|
|
|
208
360
|
|
|
361
|
+
def _cmd_normalize(args: argparse.Namespace) -> int:
|
|
362
|
+
text = _read_text(args)
|
|
363
|
+
if not text or not text.strip():
|
|
364
|
+
print("Error: provide text or --file", file=sys.stderr)
|
|
365
|
+
return 1
|
|
366
|
+
normalized = _resolve_normalized(text, args)
|
|
367
|
+
if normalized is None:
|
|
368
|
+
return 1
|
|
369
|
+
print(normalized)
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
209
373
|
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
210
374
|
try:
|
|
211
375
|
daemon.serve(voice=args.voice, speed=args.speed, lang=args.lang)
|
|
@@ -248,6 +412,24 @@ def _cmd_voices(args: argparse.Namespace) -> int:
|
|
|
248
412
|
return 0
|
|
249
413
|
|
|
250
414
|
|
|
415
|
+
def _cmd_paths(_: argparse.Namespace) -> int:
|
|
416
|
+
print(f"cache_dir: {paths.cache_dir()}")
|
|
417
|
+
print(f"socket_path: {paths.socket_path()}")
|
|
418
|
+
print(f"pid_path: {paths.pid_path()}")
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _cmd_config(_: argparse.Namespace) -> int:
|
|
423
|
+
path = config.config_path()
|
|
424
|
+
present = "" if path.is_file() else " (not present; built-in defaults apply)"
|
|
425
|
+
defaults = config.load_defaults()
|
|
426
|
+
print(f"config_path: {path}{present}")
|
|
427
|
+
print(f"voice: {defaults.voice}")
|
|
428
|
+
print(f"speed: {defaults.speed}")
|
|
429
|
+
print(f"lang: {defaults.lang}")
|
|
430
|
+
return 0
|
|
431
|
+
|
|
432
|
+
|
|
251
433
|
def _cmd_welcome(_: argparse.Namespace) -> int:
|
|
252
434
|
tts = Stackvox()
|
|
253
435
|
tts.speak_sequence([{"text": text, "voice": voice, "lang": lang} for voice, lang, text in WELCOME_LINES])
|
|
@@ -336,11 +518,14 @@ def main() -> int:
|
|
|
336
518
|
handlers = {
|
|
337
519
|
"speak": _cmd_speak,
|
|
338
520
|
"say": _cmd_say,
|
|
521
|
+
"normalize": _cmd_normalize,
|
|
339
522
|
"serve": _cmd_serve,
|
|
340
523
|
"stop": _cmd_stop,
|
|
341
524
|
"status": _cmd_status,
|
|
342
525
|
"voices": _cmd_voices,
|
|
343
526
|
"welcome": _cmd_welcome,
|
|
527
|
+
"paths": _cmd_paths,
|
|
528
|
+
"config": _cmd_config,
|
|
344
529
|
"completion": _cmd_completion,
|
|
345
530
|
"install-helper": _cmd_install_helper,
|
|
346
531
|
}
|
|
@@ -22,6 +22,12 @@ DEFAULT_VOICE = "af_sarah"
|
|
|
22
22
|
DEFAULT_SPEED = 1.0
|
|
23
23
|
DEFAULT_LANG = "en-us"
|
|
24
24
|
|
|
25
|
+
# Streaming playback smoothing: play a touch of silence before the first word so
|
|
26
|
+
# output-device warm-up (notably a Bluetooth codec/profile switch) lands during
|
|
27
|
+
# silence, and ramp the opening samples up from zero to avoid an onset click.
|
|
28
|
+
_PRIME_SILENCE_SECONDS = 0.12
|
|
29
|
+
_FADE_IN_SECONDS = 0.008
|
|
30
|
+
|
|
25
31
|
_MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx"
|
|
26
32
|
_VOICES_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
|
|
27
33
|
|
|
@@ -82,6 +88,16 @@ def _split_sentences(text: str) -> list[str]:
|
|
|
82
88
|
return [part.strip() for part in _SENTENCE_BOUNDARY.split(text.strip()) if part.strip()]
|
|
83
89
|
|
|
84
90
|
|
|
91
|
+
def _fade_in(samples: np.ndarray, sample_rate: int) -> np.ndarray:
|
|
92
|
+
"""Ramp the opening samples up from zero so playback doesn't begin with a click."""
|
|
93
|
+
ramp_len = min(len(samples), int(sample_rate * _FADE_IN_SECONDS))
|
|
94
|
+
if ramp_len <= 0:
|
|
95
|
+
return samples
|
|
96
|
+
faded = np.array(samples, dtype="float32", copy=True)
|
|
97
|
+
faded[:ramp_len] *= np.linspace(0.0, 1.0, ramp_len, dtype="float32")
|
|
98
|
+
return faded
|
|
99
|
+
|
|
100
|
+
|
|
85
101
|
class Stackvox:
|
|
86
102
|
"""Reusable TTS engine. Load the model once, speak many times.
|
|
87
103
|
|
|
@@ -104,6 +120,8 @@ class Stackvox:
|
|
|
104
120
|
self._kokoro = Kokoro(str(model_path), str(voices_path))
|
|
105
121
|
self._stop_event = threading.Event()
|
|
106
122
|
self._play_thread: threading.Thread | None = None
|
|
123
|
+
self._stream: sd.OutputStream | None = None
|
|
124
|
+
self._stream_lock = threading.Lock()
|
|
107
125
|
|
|
108
126
|
def synthesize(
|
|
109
127
|
self,
|
|
@@ -210,6 +228,7 @@ class Stackvox:
|
|
|
210
228
|
producer = threading.Thread(target=_produce, daemon=True, name="stackvox-synth")
|
|
211
229
|
producer.start()
|
|
212
230
|
error: Exception | None = None
|
|
231
|
+
stream: sd.OutputStream | None = None
|
|
213
232
|
try:
|
|
214
233
|
while not stop_event.is_set():
|
|
215
234
|
item = chunks.get()
|
|
@@ -219,9 +238,36 @@ class Stackvox:
|
|
|
219
238
|
error = item
|
|
220
239
|
break
|
|
221
240
|
samples, sample_rate = item
|
|
222
|
-
|
|
223
|
-
|
|
241
|
+
samples = np.ascontiguousarray(samples, dtype="float32")
|
|
242
|
+
if stream is None:
|
|
243
|
+
# One stream for the whole utterance: writing chunks into it is
|
|
244
|
+
# gapless and avoids the per-sentence open/close that crackles at
|
|
245
|
+
# the start. Prime with silence so device warm-up (e.g. a
|
|
246
|
+
# Bluetooth codec switch) lands before the first word, and fade
|
|
247
|
+
# the opening samples in to avoid an onset click.
|
|
248
|
+
stream = sd.OutputStream(samplerate=sample_rate, channels=1, dtype="float32")
|
|
249
|
+
stream.start()
|
|
250
|
+
with self._stream_lock:
|
|
251
|
+
self._stream = stream
|
|
252
|
+
prime = int(sample_rate * _PRIME_SILENCE_SECONDS)
|
|
253
|
+
if prime > 0:
|
|
254
|
+
stream.write(np.zeros(prime, dtype="float32"))
|
|
255
|
+
samples = _fade_in(samples, sample_rate)
|
|
256
|
+
try:
|
|
257
|
+
stream.write(samples)
|
|
258
|
+
except Exception:
|
|
259
|
+
if stop_event.is_set():
|
|
260
|
+
break # aborted by stop(); expected
|
|
261
|
+
raise
|
|
224
262
|
finally:
|
|
263
|
+
with self._stream_lock:
|
|
264
|
+
self._stream = None
|
|
265
|
+
if stream is not None:
|
|
266
|
+
try:
|
|
267
|
+
stream.stop()
|
|
268
|
+
stream.close()
|
|
269
|
+
except Exception:
|
|
270
|
+
logger.debug("stream teardown failed", exc_info=True)
|
|
225
271
|
# Stop the producer and keep draining so it can never stay parked on
|
|
226
272
|
# a full queue — that's what guarantees the synth thread always exits.
|
|
227
273
|
stop_event.set()
|
|
@@ -236,6 +282,12 @@ class Stackvox:
|
|
|
236
282
|
def stop(self) -> None:
|
|
237
283
|
"""Stop any in-progress playback started with blocking=False."""
|
|
238
284
|
self._stop_event.set()
|
|
285
|
+
with self._stream_lock:
|
|
286
|
+
if self._stream is not None:
|
|
287
|
+
try:
|
|
288
|
+
self._stream.abort()
|
|
289
|
+
except Exception:
|
|
290
|
+
logger.debug("stream abort failed", exc_info=True)
|
|
239
291
|
sd.stop()
|
|
240
292
|
|
|
241
293
|
def voices(self) -> list[str]:
|
|
@@ -106,6 +106,28 @@ def apply_pronunciations(text: str, mapping: dict[str, str] | None) -> str:
|
|
|
106
106
|
return text
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
# Dev acronyms/terms espeak mispronounces — it reads them as a word ("CLI" ->
|
|
110
|
+
# "kligh", "AWS" -> "awz", "URI" -> "yuri") instead of spelling them out.
|
|
111
|
+
# Applied by default (dev_terms=True), whole-word and case-insensitive, so
|
|
112
|
+
# lowercase "cli" is fixed too. Compound keys (ci/cd) precede their parts (ci)
|
|
113
|
+
# so the specific form wins. Only terms espeak gets WRONG are here — API, URL,
|
|
114
|
+
# JSON, YAML, HTTP, CRUD, nginx, etc. already voice correctly and are left alone.
|
|
115
|
+
_DEV_PRONUNCIATIONS: dict[str, str] = {
|
|
116
|
+
"ci/cd": "C I C D",
|
|
117
|
+
"cli": "C L I",
|
|
118
|
+
"ci": "C I",
|
|
119
|
+
"ide": "I D E",
|
|
120
|
+
"aws": "A W S",
|
|
121
|
+
"uri": "U R I",
|
|
122
|
+
"iam": "I A M",
|
|
123
|
+
"saas": "sass",
|
|
124
|
+
"paas": "pass",
|
|
125
|
+
"tui": "T U I",
|
|
126
|
+
"postgresql": "postgres",
|
|
127
|
+
"kubectl": "kube control",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
109
131
|
# --------------------------------------------------------------------------- #
|
|
110
132
|
# Pauses #
|
|
111
133
|
# --------------------------------------------------------------------------- #
|
|
@@ -274,6 +296,7 @@ def normalize_for_speech(
|
|
|
274
296
|
*,
|
|
275
297
|
markdown: bool = True,
|
|
276
298
|
pronunciations: dict[str, str] | None = None,
|
|
299
|
+
dev_terms: bool = True,
|
|
277
300
|
expand_units: bool = True,
|
|
278
301
|
expand_numbers: bool = True,
|
|
279
302
|
pauses: bool = True,
|
|
@@ -285,6 +308,11 @@ def normalize_for_speech(
|
|
|
285
308
|
"""Normalize ``text`` into speakable prose. Returns paragraphs joined by
|
|
286
309
|
newlines. See ``docs/speech-normalization.md`` for the full contract."""
|
|
287
310
|
expand_units_flag, expand_numbers_flag = expand_units, expand_numbers
|
|
311
|
+
# Built-in dev-term fixes first; caller-supplied pronunciations override them
|
|
312
|
+
# (keyed case-insensitively, so a caller's "CLI" beats the default "cli").
|
|
313
|
+
effective_pronunciations: dict[str, str] = dict(_DEV_PRONUNCIATIONS) if dev_terms else {}
|
|
314
|
+
for written, spoken in (pronunciations or {}).items():
|
|
315
|
+
effective_pronunciations[written.lower()] = spoken
|
|
288
316
|
|
|
289
317
|
if markdown:
|
|
290
318
|
paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji)
|
|
@@ -298,7 +326,7 @@ def normalize_for_speech(
|
|
|
298
326
|
for para in paragraphs:
|
|
299
327
|
shaped = _shape_paragraph(
|
|
300
328
|
para,
|
|
301
|
-
pronunciations=
|
|
329
|
+
pronunciations=effective_pronunciations,
|
|
302
330
|
expand_units_flag=expand_units_flag,
|
|
303
331
|
expand_numbers_flag=expand_numbers_flag,
|
|
304
332
|
pauses_flag=pauses,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackvox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -121,6 +121,26 @@ def _ns(**kwargs):
|
|
|
121
121
|
return argparse.Namespace(**kwargs)
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def _norm_ns(text=None, file=None, **overrides):
|
|
125
|
+
"""Namespace carrying the normalize flags at their library defaults."""
|
|
126
|
+
base = dict(
|
|
127
|
+
text=text,
|
|
128
|
+
file=file,
|
|
129
|
+
markdown=True,
|
|
130
|
+
pronunciations=None,
|
|
131
|
+
dev_terms=True,
|
|
132
|
+
expand_units=True,
|
|
133
|
+
expand_numbers=True,
|
|
134
|
+
pauses=True,
|
|
135
|
+
tables="drop",
|
|
136
|
+
strip_emoji=False,
|
|
137
|
+
terminal_stops=True,
|
|
138
|
+
locale="en-GB",
|
|
139
|
+
)
|
|
140
|
+
base.update(overrides)
|
|
141
|
+
return _ns(**base)
|
|
142
|
+
|
|
143
|
+
|
|
124
144
|
class TestCmdSpeak:
|
|
125
145
|
def test_with_text_calls_engine_speak(self, fake_stackvox):
|
|
126
146
|
rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hello", out=None))
|
|
@@ -149,6 +169,18 @@ class TestCmdSpeak:
|
|
|
149
169
|
assert "provide text" in capsys.readouterr().err
|
|
150
170
|
fake_stackvox.return_value.speak.assert_not_called()
|
|
151
171
|
|
|
172
|
+
def test_normalize_transforms_text_before_engine(self, fake_stackvox):
|
|
173
|
+
args = _norm_ns(text="# Heading", voice="af_sarah", speed=1.0, lang="en-us", out=None, normalize=True)
|
|
174
|
+
rc = cli._cmd_speak(args)
|
|
175
|
+
assert rc == 0
|
|
176
|
+
fake_stackvox.return_value.speak.assert_called_once_with("Heading.")
|
|
177
|
+
|
|
178
|
+
def test_no_normalize_leaves_text_untouched(self, fake_stackvox):
|
|
179
|
+
# `normalize` absent from the namespace mirrors the flag being off.
|
|
180
|
+
rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="# Heading", out=None))
|
|
181
|
+
assert rc == 0
|
|
182
|
+
fake_stackvox.return_value.speak.assert_called_once_with("# Heading")
|
|
183
|
+
|
|
152
184
|
|
|
153
185
|
class TestCmdSay:
|
|
154
186
|
def test_returns_zero_when_daemon_accepts(self, mocker):
|
|
@@ -182,6 +214,15 @@ class TestCmdSay:
|
|
|
182
214
|
rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="", fallback_say=False))
|
|
183
215
|
assert rc == 1
|
|
184
216
|
|
|
217
|
+
def test_normalize_transforms_text_before_daemon(self, mocker):
|
|
218
|
+
say = mocker.patch.object(cli.daemon, "say", return_value=(True, "ok"))
|
|
219
|
+
args = _norm_ns(
|
|
220
|
+
text="# Heading", voice="af_sarah", speed=1.0, lang="en-us", fallback_say=False, normalize=True
|
|
221
|
+
)
|
|
222
|
+
rc = cli._cmd_say(args)
|
|
223
|
+
assert rc == 0
|
|
224
|
+
assert say.call_args.args[0] == "Heading."
|
|
225
|
+
|
|
185
226
|
|
|
186
227
|
class TestCmdServe:
|
|
187
228
|
def test_propagates_serve_args(self, mocker):
|
|
@@ -353,3 +394,112 @@ class TestCmdInstallHelper:
|
|
|
353
394
|
mocker.patch.dict("os.environ", {"PATH": f"{prefix}:/usr/bin"}, clear=False)
|
|
354
395
|
cli._cmd_install_helper(_ns(prefix=prefix))
|
|
355
396
|
assert "not on your PATH" not in capsys.readouterr().err
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class TestCmdNormalize:
|
|
400
|
+
def test_markdown_reduces_to_prose_with_terminal_stops(self, capsys):
|
|
401
|
+
rc = cli._cmd_normalize(_norm_ns(text="# Heading\n\nBody"))
|
|
402
|
+
assert rc == 0
|
|
403
|
+
assert capsys.readouterr().out.splitlines() == ["Heading.", "Body."]
|
|
404
|
+
|
|
405
|
+
def test_number_and_decimal_expansion(self, capsys):
|
|
406
|
+
rc = cli._cmd_normalize(_norm_ns(text="It was 1,198.9 total"))
|
|
407
|
+
assert rc == 0
|
|
408
|
+
assert capsys.readouterr().out.strip() == "It was 1198 point 9 total."
|
|
409
|
+
|
|
410
|
+
def test_pronunciations_file_is_applied(self, capsys, tmp_path):
|
|
411
|
+
pron = tmp_path / "dict.json"
|
|
412
|
+
pron.write_text('{"Redis": "ree-diss"}', encoding="utf-8")
|
|
413
|
+
rc = cli._cmd_normalize(_norm_ns(text="Redis is fast", pronunciations=pron))
|
|
414
|
+
assert rc == 0
|
|
415
|
+
assert capsys.readouterr().out.strip() == "ree-diss is fast."
|
|
416
|
+
|
|
417
|
+
def test_blank_input_returns_one(self, capsys):
|
|
418
|
+
rc = cli._cmd_normalize(_norm_ns(text=" "))
|
|
419
|
+
assert rc == 1
|
|
420
|
+
assert "provide text" in capsys.readouterr().err
|
|
421
|
+
|
|
422
|
+
def test_bad_pronunciations_file_errors_cleanly(self, capsys, tmp_path):
|
|
423
|
+
bad = tmp_path / "bad.json"
|
|
424
|
+
bad.write_text('["not", "a", "map"]', encoding="utf-8")
|
|
425
|
+
rc = cli._cmd_normalize(_norm_ns(text="hi", pronunciations=bad))
|
|
426
|
+
assert rc == 1
|
|
427
|
+
assert "string -> string" in capsys.readouterr().err
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class TestLoadPronunciations:
|
|
431
|
+
def test_none_path_returns_none(self):
|
|
432
|
+
assert cli._load_pronunciations(None) is None
|
|
433
|
+
|
|
434
|
+
def test_valid_json_returns_mapping(self, tmp_path):
|
|
435
|
+
path = tmp_path / "d.json"
|
|
436
|
+
path.write_text('{"CLI": "see ell eye"}', encoding="utf-8")
|
|
437
|
+
assert cli._load_pronunciations(path) == {"CLI": "see ell eye"}
|
|
438
|
+
|
|
439
|
+
def test_non_object_json_raises(self, tmp_path):
|
|
440
|
+
path = tmp_path / "d.json"
|
|
441
|
+
path.write_text('["not", "a", "map"]', encoding="utf-8")
|
|
442
|
+
with pytest.raises(ValueError, match="string -> string"):
|
|
443
|
+
cli._load_pronunciations(path)
|
|
444
|
+
|
|
445
|
+
def test_non_string_values_raise(self, tmp_path):
|
|
446
|
+
path = tmp_path / "d.json"
|
|
447
|
+
path.write_text('{"n": 3}', encoding="utf-8")
|
|
448
|
+
with pytest.raises(ValueError, match="string -> string"):
|
|
449
|
+
cli._load_pronunciations(path)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class TestCmdPaths:
|
|
453
|
+
def test_prints_the_three_paths(self, capsys):
|
|
454
|
+
rc = cli._cmd_paths(_ns())
|
|
455
|
+
assert rc == 0
|
|
456
|
+
out = capsys.readouterr().out
|
|
457
|
+
assert "cache_dir:" in out
|
|
458
|
+
assert "socket_path:" in out
|
|
459
|
+
assert "pid_path:" in out
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class TestCmdConfig:
|
|
463
|
+
def test_prints_resolved_defaults_and_path(self, mocker, capsys, tmp_path):
|
|
464
|
+
cfg = tmp_path / "config.toml" # absent → "not present" note
|
|
465
|
+
mocker.patch.object(cli.config, "config_path", return_value=cfg)
|
|
466
|
+
mocker.patch.object(
|
|
467
|
+
cli.config,
|
|
468
|
+
"load_defaults",
|
|
469
|
+
return_value=cli.config.Defaults(voice="bf_emma", speed=1.1, lang="en-gb"),
|
|
470
|
+
)
|
|
471
|
+
rc = cli._cmd_config(_ns())
|
|
472
|
+
assert rc == 0
|
|
473
|
+
out = capsys.readouterr().out
|
|
474
|
+
assert "not present" in out
|
|
475
|
+
assert "bf_emma" in out
|
|
476
|
+
assert "1.1" in out
|
|
477
|
+
assert "en-gb" in out
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class TestNormalizeParsing:
|
|
481
|
+
def test_normalize_subcommand_routes(self, mocker):
|
|
482
|
+
norm = mocker.patch.object(cli, "_cmd_normalize", return_value=0)
|
|
483
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "normalize", "--tables", "csv", "hi"])
|
|
484
|
+
assert cli.main() == 0
|
|
485
|
+
args = norm.call_args.args[0]
|
|
486
|
+
assert args.cmd == "normalize"
|
|
487
|
+
assert args.tables == "csv"
|
|
488
|
+
assert args.text == "hi"
|
|
489
|
+
|
|
490
|
+
def test_speak_normalize_switch_parses(self, mocker):
|
|
491
|
+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
492
|
+
mocker.patch.object(
|
|
493
|
+
cli.sys, "argv", ["stackvox", "speak", "--normalize", "--strip-emoji", "--no-pauses", "hi"]
|
|
494
|
+
)
|
|
495
|
+
assert cli.main() == 0
|
|
496
|
+
args = speak.call_args.args[0]
|
|
497
|
+
assert args.normalize is True
|
|
498
|
+
assert args.strip_emoji is True
|
|
499
|
+
assert args.pauses is False
|
|
500
|
+
|
|
501
|
+
def test_paths_and_config_route(self, mocker):
|
|
502
|
+
paths_handler = mocker.patch.object(cli, "_cmd_paths", return_value=0)
|
|
503
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "paths"])
|
|
504
|
+
assert cli.main() == 0
|
|
505
|
+
assert paths_handler.called
|
|
@@ -35,7 +35,8 @@ def fake_kokoro(mocker, fake_ensure_models):
|
|
|
35
35
|
|
|
36
36
|
@pytest.fixture
|
|
37
37
|
def fake_audio(mocker):
|
|
38
|
-
"""Mock the sounddevice surface used by Stackvox."""
|
|
38
|
+
"""Mock the sounddevice surface used by Stackvox (streaming + speak_sequence)."""
|
|
39
|
+
mocker.patch.object(engine.sd, "OutputStream")
|
|
39
40
|
mocker.patch.object(engine.sd, "play")
|
|
40
41
|
mocker.patch.object(engine.sd, "wait")
|
|
41
42
|
mocker.patch.object(engine.sd, "stop")
|
|
@@ -105,15 +106,35 @@ class TestSpeak:
|
|
|
105
106
|
|
|
106
107
|
engine.Stackvox().speak("One sentence. Two sentence. Three here.")
|
|
107
108
|
|
|
108
|
-
# One synth
|
|
109
|
+
# One synth per sentence (early audio), all streamed through a single
|
|
110
|
+
# output stream: a prime-silence write plus one write per sentence.
|
|
109
111
|
assert fake_kokoro.return_value.create.call_count == 3
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
engine.sd.OutputStream.assert_called_once()
|
|
113
|
+
stream = engine.sd.OutputStream.return_value
|
|
114
|
+
assert stream.write.call_count == 4 # 1 prime + 3 sentences
|
|
115
|
+
stream.close.assert_called_once()
|
|
112
116
|
|
|
113
117
|
def test_single_sentence_plays_once(self, fake_kokoro, fake_audio):
|
|
114
118
|
fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
|
|
115
119
|
engine.Stackvox().speak("Just one sentence with no terminal punctuation")
|
|
116
|
-
|
|
120
|
+
engine.sd.OutputStream.assert_called_once()
|
|
121
|
+
assert engine.sd.OutputStream.return_value.write.call_count == 2 # prime + 1 sentence
|
|
122
|
+
|
|
123
|
+
def test_primes_with_silence_before_first_audio(self, fake_kokoro, fake_audio):
|
|
124
|
+
# The lead-in silence lets the output device (e.g. Bluetooth) warm up
|
|
125
|
+
# before the first word, rather than crackling into it.
|
|
126
|
+
fake_kokoro.return_value.create.return_value = (np.ones(10, dtype=np.float32), 24000)
|
|
127
|
+
engine.Stackvox().speak("hello")
|
|
128
|
+
prime = engine.sd.OutputStream.return_value.write.call_args_list[0].args[0]
|
|
129
|
+
assert not prime.any()
|
|
130
|
+
assert len(prime) == int(24000 * engine._PRIME_SILENCE_SECONDS)
|
|
131
|
+
|
|
132
|
+
def test_first_audio_fades_in_from_zero(self, fake_kokoro, fake_audio):
|
|
133
|
+
fake_kokoro.return_value.create.return_value = (np.ones(1000, dtype=np.float32), 24000)
|
|
134
|
+
engine.Stackvox().speak("hello")
|
|
135
|
+
first_audio = engine.sd.OutputStream.return_value.write.call_args_list[1].args[0]
|
|
136
|
+
assert first_audio[0] == 0.0 # ramp starts at silence
|
|
137
|
+
assert first_audio[-1] == 1.0 # ramp completes, body untouched
|
|
117
138
|
|
|
118
139
|
def test_non_blocking_plays_in_background_thread(self, fake_kokoro, fake_audio):
|
|
119
140
|
fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
|
|
@@ -123,7 +144,7 @@ class TestSpeak:
|
|
|
123
144
|
|
|
124
145
|
assert tts._play_thread is not None
|
|
125
146
|
tts._play_thread.join(timeout=5)
|
|
126
|
-
engine.sd.
|
|
147
|
+
engine.sd.OutputStream.assert_called()
|
|
127
148
|
|
|
128
149
|
def test_passes_engine_defaults_to_synthesis(self, fake_kokoro, fake_audio):
|
|
129
150
|
fake_kokoro.return_value.create.return_value = (np.zeros(4, dtype=np.float32), 24000)
|
|
@@ -177,7 +198,7 @@ class TestSpeak:
|
|
|
177
198
|
tts = engine.Stackvox()
|
|
178
199
|
tts.stop()
|
|
179
200
|
tts.speak("hello world")
|
|
180
|
-
engine.sd.
|
|
201
|
+
engine.sd.OutputStream.assert_called()
|
|
181
202
|
|
|
182
203
|
|
|
183
204
|
class TestSplitSentences:
|
|
@@ -195,3 +195,28 @@ def test_speaklast_style_normalization():
|
|
|
195
195
|
def test_plain_text_mode_splits_paragraphs():
|
|
196
196
|
out = normalize_for_speech("First para.\n\nSecond para.", markdown=False)
|
|
197
197
|
assert out == "First para.\nSecond para."
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_dev_acronyms_are_spelled_out():
|
|
201
|
+
assert normalize_for_speech("Use the CLI.", markdown=False) == "Use the C L I."
|
|
202
|
+
assert "C L I" in normalize_for_speech("the cli tool", markdown=False) # lowercase too
|
|
203
|
+
assert "A W S" in normalize_for_speech("deploy to AWS", markdown=False)
|
|
204
|
+
assert "U R I" in normalize_for_speech("parse the URI", markdown=False)
|
|
205
|
+
assert "C I C D" in normalize_for_speech("the CI/CD pipeline", markdown=False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_dev_terms_leave_correctly_voiced_acronyms_alone():
|
|
209
|
+
out = normalize_for_speech("Send JSON over HTTP to the API.", markdown=False)
|
|
210
|
+
assert "JSON" in out
|
|
211
|
+
assert "HTTP" in out
|
|
212
|
+
assert "API" in out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_dev_terms_can_be_disabled():
|
|
216
|
+
assert normalize_for_speech("Use the CLI.", markdown=False, dev_terms=False) == "Use the CLI."
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_caller_pronunciations_override_dev_terms():
|
|
220
|
+
out = normalize_for_speech("Use the CLI.", markdown=False, pronunciations={"CLI": "command line"})
|
|
221
|
+
assert "command line" in out
|
|
222
|
+
assert "C L I" not in 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|