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.
Files changed (31) hide show
  1. {stackvox-0.6.0/stackvox.egg-info → stackvox-0.7.0}/PKG-INFO +16 -1
  2. {stackvox-0.6.0 → stackvox-0.7.0}/README.md +15 -0
  3. {stackvox-0.6.0 → stackvox-0.7.0}/pyproject.toml +1 -1
  4. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/cli.py +183 -5
  5. {stackvox-0.6.0 → stackvox-0.7.0/stackvox.egg-info}/PKG-INFO +16 -1
  6. {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_cli.py +149 -0
  7. {stackvox-0.6.0 → stackvox-0.7.0}/LICENSE +0 -0
  8. {stackvox-0.6.0 → stackvox-0.7.0}/NOTICE +0 -0
  9. {stackvox-0.6.0 → stackvox-0.7.0}/setup.cfg +0 -0
  10. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/__init__.py +0 -0
  11. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/__main__.py +0 -0
  12. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/config.py +0 -0
  13. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/daemon.py +0 -0
  14. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/data/stackvox-say +0 -0
  15. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/engine.py +0 -0
  16. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/paths.py +0 -0
  17. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/py.typed +0 -0
  18. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/text.py +0 -0
  19. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox/updates.py +0 -0
  20. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/SOURCES.txt +0 -0
  21. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/dependency_links.txt +0 -0
  22. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/entry_points.txt +0 -0
  23. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/requires.txt +0 -0
  24. {stackvox-0.6.0 → stackvox-0.7.0}/stackvox.egg-info/top_level.txt +0 -0
  25. {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_config.py +0 -0
  26. {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_daemon.py +0 -0
  27. {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_daemon_protocol.py +0 -0
  28. {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_engine.py +0 -0
  29. {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_paths.py +0 -0
  30. {stackvox-0.6.0 → stackvox-0.7.0}/tests/test_text.py +0 -0
  31. {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.6.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.6.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"
@@ -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.6.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