stackvox 0.6.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.6.0/stackvox.egg-info → stackvox-0.7.0}/PKG-INFO +16 -1
- {stackvox-0.6.0 → stackvox-0.7.0}/README.md +15 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/pyproject.toml +1 -1
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/cli.py +183 -5
- {stackvox-0.6.0 → stackvox-0.7.0/stackvox.egg-info}/PKG-INFO +16 -1
- {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_cli.py +149 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/LICENSE +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/NOTICE +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/setup.cfg +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/__init__.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/__main__.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/config.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/daemon.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/engine.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/paths.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/py.typed +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/text.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/updates.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/SOURCES.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_config.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_daemon.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_engine.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_paths.py +0 -0
- {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_text.py +0 -0
- {stackvox-0.6.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
|
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -121,6 +121,25 @@ 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
|
+
expand_units=True,
|
|
132
|
+
expand_numbers=True,
|
|
133
|
+
pauses=True,
|
|
134
|
+
tables="drop",
|
|
135
|
+
strip_emoji=False,
|
|
136
|
+
terminal_stops=True,
|
|
137
|
+
locale="en-GB",
|
|
138
|
+
)
|
|
139
|
+
base.update(overrides)
|
|
140
|
+
return _ns(**base)
|
|
141
|
+
|
|
142
|
+
|
|
124
143
|
class TestCmdSpeak:
|
|
125
144
|
def test_with_text_calls_engine_speak(self, fake_stackvox):
|
|
126
145
|
rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hello", out=None))
|
|
@@ -149,6 +168,18 @@ class TestCmdSpeak:
|
|
|
149
168
|
assert "provide text" in capsys.readouterr().err
|
|
150
169
|
fake_stackvox.return_value.speak.assert_not_called()
|
|
151
170
|
|
|
171
|
+
def test_normalize_transforms_text_before_engine(self, fake_stackvox):
|
|
172
|
+
args = _norm_ns(text="# Heading", voice="af_sarah", speed=1.0, lang="en-us", out=None, normalize=True)
|
|
173
|
+
rc = cli._cmd_speak(args)
|
|
174
|
+
assert rc == 0
|
|
175
|
+
fake_stackvox.return_value.speak.assert_called_once_with("Heading.")
|
|
176
|
+
|
|
177
|
+
def test_no_normalize_leaves_text_untouched(self, fake_stackvox):
|
|
178
|
+
# `normalize` absent from the namespace mirrors the flag being off.
|
|
179
|
+
rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="# Heading", out=None))
|
|
180
|
+
assert rc == 0
|
|
181
|
+
fake_stackvox.return_value.speak.assert_called_once_with("# Heading")
|
|
182
|
+
|
|
152
183
|
|
|
153
184
|
class TestCmdSay:
|
|
154
185
|
def test_returns_zero_when_daemon_accepts(self, mocker):
|
|
@@ -182,6 +213,15 @@ class TestCmdSay:
|
|
|
182
213
|
rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="", fallback_say=False))
|
|
183
214
|
assert rc == 1
|
|
184
215
|
|
|
216
|
+
def test_normalize_transforms_text_before_daemon(self, mocker):
|
|
217
|
+
say = mocker.patch.object(cli.daemon, "say", return_value=(True, "ok"))
|
|
218
|
+
args = _norm_ns(
|
|
219
|
+
text="# Heading", voice="af_sarah", speed=1.0, lang="en-us", fallback_say=False, normalize=True
|
|
220
|
+
)
|
|
221
|
+
rc = cli._cmd_say(args)
|
|
222
|
+
assert rc == 0
|
|
223
|
+
assert say.call_args.args[0] == "Heading."
|
|
224
|
+
|
|
185
225
|
|
|
186
226
|
class TestCmdServe:
|
|
187
227
|
def test_propagates_serve_args(self, mocker):
|
|
@@ -353,3 +393,112 @@ class TestCmdInstallHelper:
|
|
|
353
393
|
mocker.patch.dict("os.environ", {"PATH": f"{prefix}:/usr/bin"}, clear=False)
|
|
354
394
|
cli._cmd_install_helper(_ns(prefix=prefix))
|
|
355
395
|
assert "not on your PATH" not in capsys.readouterr().err
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class TestCmdNormalize:
|
|
399
|
+
def test_markdown_reduces_to_prose_with_terminal_stops(self, capsys):
|
|
400
|
+
rc = cli._cmd_normalize(_norm_ns(text="# Heading\n\nBody"))
|
|
401
|
+
assert rc == 0
|
|
402
|
+
assert capsys.readouterr().out.splitlines() == ["Heading.", "Body."]
|
|
403
|
+
|
|
404
|
+
def test_number_and_decimal_expansion(self, capsys):
|
|
405
|
+
rc = cli._cmd_normalize(_norm_ns(text="It was 1,198.9 total"))
|
|
406
|
+
assert rc == 0
|
|
407
|
+
assert capsys.readouterr().out.strip() == "It was 1198 point 9 total."
|
|
408
|
+
|
|
409
|
+
def test_pronunciations_file_is_applied(self, capsys, tmp_path):
|
|
410
|
+
pron = tmp_path / "dict.json"
|
|
411
|
+
pron.write_text('{"Redis": "ree-diss"}', encoding="utf-8")
|
|
412
|
+
rc = cli._cmd_normalize(_norm_ns(text="Redis is fast", pronunciations=pron))
|
|
413
|
+
assert rc == 0
|
|
414
|
+
assert capsys.readouterr().out.strip() == "ree-diss is fast."
|
|
415
|
+
|
|
416
|
+
def test_blank_input_returns_one(self, capsys):
|
|
417
|
+
rc = cli._cmd_normalize(_norm_ns(text=" "))
|
|
418
|
+
assert rc == 1
|
|
419
|
+
assert "provide text" in capsys.readouterr().err
|
|
420
|
+
|
|
421
|
+
def test_bad_pronunciations_file_errors_cleanly(self, capsys, tmp_path):
|
|
422
|
+
bad = tmp_path / "bad.json"
|
|
423
|
+
bad.write_text('["not", "a", "map"]', encoding="utf-8")
|
|
424
|
+
rc = cli._cmd_normalize(_norm_ns(text="hi", pronunciations=bad))
|
|
425
|
+
assert rc == 1
|
|
426
|
+
assert "string -> string" in capsys.readouterr().err
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class TestLoadPronunciations:
|
|
430
|
+
def test_none_path_returns_none(self):
|
|
431
|
+
assert cli._load_pronunciations(None) is None
|
|
432
|
+
|
|
433
|
+
def test_valid_json_returns_mapping(self, tmp_path):
|
|
434
|
+
path = tmp_path / "d.json"
|
|
435
|
+
path.write_text('{"CLI": "see ell eye"}', encoding="utf-8")
|
|
436
|
+
assert cli._load_pronunciations(path) == {"CLI": "see ell eye"}
|
|
437
|
+
|
|
438
|
+
def test_non_object_json_raises(self, tmp_path):
|
|
439
|
+
path = tmp_path / "d.json"
|
|
440
|
+
path.write_text('["not", "a", "map"]', encoding="utf-8")
|
|
441
|
+
with pytest.raises(ValueError, match="string -> string"):
|
|
442
|
+
cli._load_pronunciations(path)
|
|
443
|
+
|
|
444
|
+
def test_non_string_values_raise(self, tmp_path):
|
|
445
|
+
path = tmp_path / "d.json"
|
|
446
|
+
path.write_text('{"n": 3}', encoding="utf-8")
|
|
447
|
+
with pytest.raises(ValueError, match="string -> string"):
|
|
448
|
+
cli._load_pronunciations(path)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class TestCmdPaths:
|
|
452
|
+
def test_prints_the_three_paths(self, capsys):
|
|
453
|
+
rc = cli._cmd_paths(_ns())
|
|
454
|
+
assert rc == 0
|
|
455
|
+
out = capsys.readouterr().out
|
|
456
|
+
assert "cache_dir:" in out
|
|
457
|
+
assert "socket_path:" in out
|
|
458
|
+
assert "pid_path:" in out
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class TestCmdConfig:
|
|
462
|
+
def test_prints_resolved_defaults_and_path(self, mocker, capsys, tmp_path):
|
|
463
|
+
cfg = tmp_path / "config.toml" # absent → "not present" note
|
|
464
|
+
mocker.patch.object(cli.config, "config_path", return_value=cfg)
|
|
465
|
+
mocker.patch.object(
|
|
466
|
+
cli.config,
|
|
467
|
+
"load_defaults",
|
|
468
|
+
return_value=cli.config.Defaults(voice="bf_emma", speed=1.1, lang="en-gb"),
|
|
469
|
+
)
|
|
470
|
+
rc = cli._cmd_config(_ns())
|
|
471
|
+
assert rc == 0
|
|
472
|
+
out = capsys.readouterr().out
|
|
473
|
+
assert "not present" in out
|
|
474
|
+
assert "bf_emma" in out
|
|
475
|
+
assert "1.1" in out
|
|
476
|
+
assert "en-gb" in out
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class TestNormalizeParsing:
|
|
480
|
+
def test_normalize_subcommand_routes(self, mocker):
|
|
481
|
+
norm = mocker.patch.object(cli, "_cmd_normalize", return_value=0)
|
|
482
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "normalize", "--tables", "csv", "hi"])
|
|
483
|
+
assert cli.main() == 0
|
|
484
|
+
args = norm.call_args.args[0]
|
|
485
|
+
assert args.cmd == "normalize"
|
|
486
|
+
assert args.tables == "csv"
|
|
487
|
+
assert args.text == "hi"
|
|
488
|
+
|
|
489
|
+
def test_speak_normalize_switch_parses(self, mocker):
|
|
490
|
+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
491
|
+
mocker.patch.object(
|
|
492
|
+
cli.sys, "argv", ["stackvox", "speak", "--normalize", "--strip-emoji", "--no-pauses", "hi"]
|
|
493
|
+
)
|
|
494
|
+
assert cli.main() == 0
|
|
495
|
+
args = speak.call_args.args[0]
|
|
496
|
+
assert args.normalize is True
|
|
497
|
+
assert args.strip_emoji is True
|
|
498
|
+
assert args.pauses is False
|
|
499
|
+
|
|
500
|
+
def test_paths_and_config_route(self, mocker):
|
|
501
|
+
paths_handler = mocker.patch.object(cli, "_cmd_paths", return_value=0)
|
|
502
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "paths"])
|
|
503
|
+
assert cli.main() == 0
|
|
504
|
+
assert paths_handler.called
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|