stackvox 0.9.0__tar.gz → 0.11.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.9.0/stackvox.egg-info → stackvox-0.11.0}/PKG-INFO +6 -2
  2. {stackvox-0.9.0 → stackvox-0.11.0}/README.md +5 -1
  3. {stackvox-0.9.0 → stackvox-0.11.0}/pyproject.toml +1 -1
  4. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/cli.py +33 -2
  5. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/daemon.py +25 -1
  6. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/text.py +92 -6
  7. {stackvox-0.9.0 → stackvox-0.11.0/stackvox.egg-info}/PKG-INFO +6 -2
  8. {stackvox-0.9.0 → stackvox-0.11.0}/tests/test_cli.py +36 -0
  9. {stackvox-0.9.0 → stackvox-0.11.0}/tests/test_daemon_protocol.py +8 -0
  10. {stackvox-0.9.0 → stackvox-0.11.0}/tests/test_text.py +95 -1
  11. {stackvox-0.9.0 → stackvox-0.11.0}/LICENSE +0 -0
  12. {stackvox-0.9.0 → stackvox-0.11.0}/NOTICE +0 -0
  13. {stackvox-0.9.0 → stackvox-0.11.0}/setup.cfg +0 -0
  14. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/__init__.py +0 -0
  15. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/__main__.py +0 -0
  16. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/config.py +0 -0
  17. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/data/stackvox-say +0 -0
  18. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/engine.py +0 -0
  19. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/paths.py +0 -0
  20. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/py.typed +0 -0
  21. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox/updates.py +0 -0
  22. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox.egg-info/SOURCES.txt +0 -0
  23. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox.egg-info/dependency_links.txt +0 -0
  24. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox.egg-info/entry_points.txt +0 -0
  25. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox.egg-info/requires.txt +0 -0
  26. {stackvox-0.9.0 → stackvox-0.11.0}/stackvox.egg-info/top_level.txt +0 -0
  27. {stackvox-0.9.0 → stackvox-0.11.0}/tests/test_config.py +0 -0
  28. {stackvox-0.9.0 → stackvox-0.11.0}/tests/test_daemon.py +0 -0
  29. {stackvox-0.9.0 → stackvox-0.11.0}/tests/test_engine.py +0 -0
  30. {stackvox-0.9.0 → stackvox-0.11.0}/tests/test_paths.py +0 -0
  31. {stackvox-0.9.0 → stackvox-0.11.0}/tests/test_updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.9.0
3
+ Version: 0.11.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
@@ -103,7 +103,11 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
103
103
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
104
104
  ```
105
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).
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-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
107
+
108
+ **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
109
+
110
+ Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
107
111
 
108
112
  Bash completion:
109
113
 
@@ -69,7 +69,11 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
69
69
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
70
70
  ```
71
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).
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-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
73
+
74
+ **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
75
+
76
+ Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
73
77
 
74
78
  Bash completion:
75
79
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.9.0"
7
+ version = "0.11.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -26,6 +26,7 @@ def _configure_logging() -> None:
26
26
  SUBCOMMANDS = {
27
27
  "serve",
28
28
  "stop",
29
+ "cancel",
29
30
  "status",
30
31
  "say",
31
32
  "speak",
@@ -51,7 +52,7 @@ _stackvox_completion() {
51
52
  subcommand="${COMP_WORDS[1]:-}"
52
53
 
53
54
  if [[ ${COMP_CWORD} -eq 1 ]]; then
54
- COMPREPLY=( $(compgen -W "speak say normalize serve stop status voices welcome paths config completion install-helper" -- "$cur") )
55
+ COMPREPLY=( $(compgen -W "speak say normalize serve stop cancel status voices welcome paths config completion install-helper" -- "$cur") )
55
56
  return 0
56
57
  fi
57
58
 
@@ -76,13 +77,17 @@ _stackvox_completion() {
76
77
  COMPREPLY=( $(compgen -W "drop csv" -- "$cur") )
77
78
  return 0
78
79
  ;;
80
+ --code-blocks)
81
+ COMPREPLY=( $(compgen -W "drop placeholder" -- "$cur") )
82
+ return 0
83
+ ;;
79
84
  --locale)
80
85
  COMPREPLY=( $(compgen -W "en-GB" -- "$cur") )
81
86
  return 0
82
87
  ;;
83
88
  esac
84
89
 
85
- local norm_flags="--no-markdown --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --strip-emoji --no-terminal-stops --locale"
90
+ local norm_flags="--no-markdown --no-dev-terms --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --code-blocks --code-placeholder --strip-emoji --no-terminal-stops --locale"
86
91
 
87
92
  case "$subcommand" in
88
93
  speak)
@@ -155,6 +160,7 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
155
160
  _add_voice_args(p_serve, defaults)
156
161
 
157
162
  sub.add_parser("stop", help="Stop the running daemon")
163
+ sub.add_parser("cancel", help="Stop the current utterance without shutting down the daemon")
158
164
  sub.add_parser("status", help="Print daemon status")
159
165
  sub.add_parser("voices", help="List available voices")
160
166
  sub.add_parser("welcome", help="Play a multilingual welcome message")
@@ -238,6 +244,19 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -
238
244
  default="drop",
239
245
  help="How Markdown tables are voiced (default: drop)",
240
246
  )
247
+ parser.add_argument(
248
+ "--code-blocks",
249
+ dest="code_blocks",
250
+ choices=["drop", "placeholder"],
251
+ default="drop",
252
+ help="How fenced code blocks are voiced (default: drop)",
253
+ )
254
+ parser.add_argument(
255
+ "--code-placeholder",
256
+ dest="code_placeholder",
257
+ default="Code block.",
258
+ help='Spoken stand-in for a code block when --code-blocks placeholder (default: "Code block.")',
259
+ )
241
260
  parser.add_argument(
242
261
  "--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking"
243
262
  )
@@ -279,6 +298,8 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict:
279
298
  "expand_numbers": args.expand_numbers,
280
299
  "pauses": args.pauses,
281
300
  "tables": args.tables,
301
+ "code_blocks": args.code_blocks,
302
+ "code_placeholder": args.code_placeholder,
282
303
  "strip_emoji": args.strip_emoji,
283
304
  "terminal_stops": args.terminal_stops,
284
305
  "locale": args.locale,
@@ -388,6 +409,15 @@ def _cmd_stop(_: argparse.Namespace) -> int:
388
409
  return 0 if ok else 1
389
410
 
390
411
 
412
+ def _cmd_cancel(_: argparse.Namespace) -> int:
413
+ # Interrupt the current utterance but leave the daemon running (no model
414
+ # reload). Nothing playing / no daemon is not an error.
415
+ if not daemon.is_running():
416
+ return 0
417
+ ok, _resp = daemon.cancel()
418
+ return 0 if ok else 1
419
+
420
+
391
421
  def _cmd_status(_: argparse.Namespace) -> int:
392
422
  installed = updates._current_version()
393
423
  if daemon.is_running():
@@ -530,6 +560,7 @@ def main() -> int:
530
560
  "normalize": _cmd_normalize,
531
561
  "serve": _cmd_serve,
532
562
  "stop": _cmd_stop,
563
+ "cancel": _cmd_cancel,
533
564
  "status": _cmd_status,
534
565
  "voices": _cmd_voices,
535
566
  "welcome": _cmd_welcome,
@@ -5,8 +5,10 @@ Protocol: one JSON object per connection (line-terminated), reply is a status li
5
5
 
6
6
  Request shapes:
7
7
  {"text": "...", "voice": "af_sarah", "speed": 1.0, "lang": "en-us"}
8
- {"command": "stop"}
8
+ {"command": "stop"} # shut the daemon down
9
+ {"command": "cancel"} # stop the current utterance, keep the daemon up
9
10
  {"command": "ping"}
11
+ {"command": "version"}
10
12
 
11
13
  Replies: "ok", "busy", "err: <msg>".
12
14
  """
@@ -203,6 +205,17 @@ class _DaemonState:
203
205
  def shutdown(self) -> None:
204
206
  self.stop_event.set()
205
207
 
208
+ def cancel(self) -> None:
209
+ """Stop the current utterance and drop anything queued, without shutting
210
+ the daemon down. Drains the queue first so the worker can't immediately
211
+ pick up a pending item, then aborts the in-flight playback."""
212
+ while True:
213
+ try:
214
+ self.queue.get_nowait()
215
+ except queue.Empty:
216
+ break
217
+ self.tts.stop()
218
+
206
219
 
207
220
  class _Handler(socketserver.StreamRequestHandler):
208
221
  def handle(self) -> None:
@@ -231,6 +244,11 @@ class _Handler(socketserver.StreamRequestHandler):
231
244
  threading.Thread(target=self.server.shutdown, daemon=True).start()
232
245
  return
233
246
 
247
+ if command == "cancel":
248
+ state.cancel()
249
+ self.wfile.write(b"ok\n")
250
+ return
251
+
234
252
  text = req.get("text")
235
253
  if not text:
236
254
  self.wfile.write(b"err: missing text\n")
@@ -345,6 +363,12 @@ def stop() -> tuple[bool, str]:
345
363
  return send({"command": "stop"})
346
364
 
347
365
 
366
+ def cancel() -> tuple[bool, str]:
367
+ """Stop the daemon's current utterance (and drop the queue) without shutting
368
+ it down."""
369
+ return send({"command": "cancel"})
370
+
371
+
348
372
  def ping() -> tuple[bool, str]:
349
373
  return send({"command": "ping"}, timeout=PING_TIMEOUT_SECONDS)
350
374
 
@@ -20,6 +20,7 @@ __all__ = [
20
20
  "strip_thousands_separators",
21
21
  "versions_to_words",
22
22
  "decimals_to_words",
23
+ "speak_file_refs",
23
24
  "expand_units",
24
25
  "apply_pronunciations",
25
26
  "shape_pauses",
@@ -64,6 +65,7 @@ _UNIT_RULES: dict[str, list[tuple[str, str]]] = {
64
65
  (r"\s*÷\s*", " divided by "),
65
66
  (r"\s*×\s*", " times "),
66
67
  (r"\s*=\s*", " equals "),
68
+ (r"~\s*(?=\d)", "about "), # ~123 -> "about 123" (else espeak says "tilde 123"); leaves ~/path alone
67
69
  ],
68
70
  }
69
71
  DEFAULT_LOCALE = "en-GB"
@@ -110,6 +112,46 @@ def decimals_to_words(text: str) -> str:
110
112
  )
111
113
 
112
114
 
115
+ # --------------------------------------------------------------------------- #
116
+ # File & line references #
117
+ # --------------------------------------------------------------------------- #
118
+ # `engine.py:42` reads as "…dot py colon forty two" — the dots and colon get
119
+ # voiced literally. Spoken aloud the line number is the signal and the filename
120
+ # is noise, so lead with "line N of <file>" (how a person actually says it) and
121
+ # soften the path: drop directories to the basename, and voice the dotted name
122
+ # word by word.
123
+
124
+ _FILE_REF = re.compile(
125
+ r"(?<!\w)"
126
+ r"(?:[\w.-]*/)*" # optional directory segments (dropped — "src/" reads as "slash")
127
+ r"([A-Za-z0-9_-]+(?:\.[A-Za-z][\w-]*)+)" # filename with >=1 letter-initial extension
128
+ r":(\d+)(?:-(\d+))?(?::(\d+))?" # :line, optional -end (range) or :column
129
+ r"(?!\w)"
130
+ )
131
+
132
+
133
+ def speak_file_refs(text: str) -> str:
134
+ """Turn ``path/file.ext:line`` refs into spoken "line N of file ext".
135
+
136
+ ``engine.py:42`` -> "line 42 of engine py"; ``cli.py:100-118`` -> "lines 100
137
+ to 118 of cli py"; ``foo.ts:666:10`` -> "line 666, column 10 of foo ts". The
138
+ ``:line`` suffix is the trigger, so bare times/ratios/verses (``12:30``,
139
+ ``3:1``, ``John 3:16``) and dotted versions (``1.2.3``) are left untouched —
140
+ none of them carry a dotted-filename before the colon.
141
+ """
142
+
143
+ def repl(match: re.Match[str]) -> str:
144
+ basename = match.group(1).replace(".", " ")
145
+ start, end, column = match.group(2), match.group(3), match.group(4)
146
+ if end:
147
+ location = f"lines {start} to {end}"
148
+ else:
149
+ location = f"line {start}" + (f", column {column}" if column else "")
150
+ return f"{location} of {basename}"
151
+
152
+ return _FILE_REF.sub(repl, text)
153
+
154
+
113
155
  # --------------------------------------------------------------------------- #
114
156
  # Pronunciations #
115
157
  # --------------------------------------------------------------------------- #
@@ -133,14 +175,22 @@ _DEV_PRONUNCIATIONS: dict[str, str] = {
133
175
  "cli": "C L I",
134
176
  "ci": "C I",
135
177
  "ide": "I D E",
136
- "aws": "A W S",
178
+ "aws": "A.W.S.", # dotted: espeak reads a lone "A" as the article "uh"; dots force the letter name "ay"
137
179
  "uri": "U R I",
138
- "iam": "I A M",
180
+ "iam": "I.A.M.", # ditto — the middle "A" needs the dot
139
181
  "saas": "sass",
140
182
  "paas": "pass",
141
183
  "tui": "T U I",
142
184
  "postgresql": "postgres",
143
185
  "kubectl": "kube control",
186
+ "stackone": "stack one", # org name espeak garbles as "stac kone"
187
+ # "dedupe" glued reads as "de-dup" (short u); split + long "dee" gives "dee doop".
188
+ # Inflections listed explicitly — apply_pronunciations matches whole words only.
189
+ "dedupe": "dee dupe",
190
+ "deduped": "dee duped",
191
+ "deduping": "dee duping",
192
+ "dedupes": "dee dupes",
193
+ "dedup": "dee dupe",
144
194
  }
145
195
 
146
196
 
@@ -188,11 +238,24 @@ def _strip_md_inline(line: str) -> str:
188
238
  return line
189
239
 
190
240
 
191
- def markdown_to_paragraphs(text: str, *, tables: str = "drop", strip_emoji_flag: bool = False) -> list[str]:
241
+ def markdown_to_paragraphs(
242
+ text: str,
243
+ *,
244
+ tables: str = "drop",
245
+ strip_emoji_flag: bool = False,
246
+ code_blocks: str = "drop",
247
+ code_placeholder: str = "",
248
+ ) -> list[str]:
192
249
  """Reduce Markdown to a list of speakable paragraphs. Headings and list
193
250
  items become their own paragraphs (so each gets its own pause). Tables are
194
- dropped or rendered comma-separated per ``tables``."""
195
- text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", "\n", text) # fenced code
251
+ dropped or rendered comma-separated per ``tables``. Fenced code blocks are
252
+ dropped, or (``code_blocks="placeholder"``) replaced with a spoken
253
+ ``code_placeholder`` so a silently-skipped block doesn't sound disjointed."""
254
+ speak_code = code_blocks == "placeholder" and bool(code_placeholder)
255
+ # A function replacement keeps the placeholder literal (no backref parsing);
256
+ # blank lines around it make it a standalone paragraph.
257
+ fenced = f"\n\n{code_placeholder}\n\n" if speak_code else "\n"
258
+ text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", lambda _: fenced, text) # fenced code
196
259
  text = re.sub(r"```+|~~~+", " ", text) # stray fences
197
260
  if strip_emoji_flag:
198
261
  text = strip_emoji(text)
@@ -277,6 +340,14 @@ def markdown_to_paragraphs(text: str, *, tables: str = "drop", strip_emoji_flag:
277
340
  flush()
278
341
 
279
342
  flush()
343
+
344
+ if speak_code: # collapse runs of adjacent code blocks into one placeholder
345
+ collapsed: list[str] = []
346
+ for para in paragraphs:
347
+ if para == code_placeholder and collapsed and collapsed[-1] == code_placeholder:
348
+ continue
349
+ collapsed.append(para)
350
+ return collapsed
280
351
  return paragraphs
281
352
 
282
353
 
@@ -289,11 +360,17 @@ def _shape_paragraph(
289
360
  text: str,
290
361
  *,
291
362
  pronunciations: dict[str, str] | None,
363
+ dev_terms_flag: bool,
292
364
  expand_units_flag: bool,
293
365
  expand_numbers_flag: bool,
294
366
  pauses_flag: bool,
295
367
  locale: str,
296
368
  ) -> str:
369
+ # File refs first: it consumes the ":line" digits (so the number stages see a
370
+ # plain "line 42", not a decimal) and forms the spoken basename before the
371
+ # dev-term dict runs over it.
372
+ if dev_terms_flag:
373
+ text = speak_file_refs(text)
297
374
  if expand_numbers_flag:
298
375
  text = strip_thousands_separators(text)
299
376
  if pauses_flag:
@@ -318,6 +395,8 @@ def normalize_for_speech(
318
395
  expand_numbers: bool = True,
319
396
  pauses: bool = True,
320
397
  tables: str = "drop",
398
+ code_blocks: str = "drop",
399
+ code_placeholder: str = "Code block.",
321
400
  strip_emoji: bool = False,
322
401
  terminal_stops: bool = True,
323
402
  locale: str = DEFAULT_LOCALE,
@@ -332,7 +411,13 @@ def normalize_for_speech(
332
411
  effective_pronunciations[written.lower()] = spoken
333
412
 
334
413
  if markdown:
335
- paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji)
414
+ paragraphs = markdown_to_paragraphs(
415
+ text,
416
+ tables=tables,
417
+ strip_emoji_flag=strip_emoji,
418
+ code_blocks=code_blocks,
419
+ code_placeholder=code_placeholder,
420
+ )
336
421
  else:
337
422
  # `strip_emoji` (the bool kwarg) shadows the module function here, so
338
423
  # reach for the underlying pattern directly.
@@ -344,6 +429,7 @@ def normalize_for_speech(
344
429
  shaped = _shape_paragraph(
345
430
  para,
346
431
  pronunciations=effective_pronunciations,
432
+ dev_terms_flag=dev_terms,
347
433
  expand_units_flag=expand_units_flag,
348
434
  expand_numbers_flag=expand_numbers_flag,
349
435
  pauses_flag=pauses,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.9.0
3
+ Version: 0.11.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
@@ -103,7 +103,11 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
103
103
  stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
104
104
  ```
105
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).
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-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
107
+
108
+ **Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
109
+
110
+ Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
107
111
 
108
112
  Bash completion:
109
113
 
@@ -133,6 +133,8 @@ def _norm_ns(text=None, file=None, **overrides):
133
133
  expand_numbers=True,
134
134
  pauses=True,
135
135
  tables="drop",
136
+ code_blocks="drop",
137
+ code_placeholder="Code block.",
136
138
  strip_emoji=False,
137
139
  terminal_stops=True,
138
140
  locale="en-GB",
@@ -523,3 +525,37 @@ class TestNormalizeParsing:
523
525
  mocker.patch.object(cli.sys, "argv", ["stackvox", "paths"])
524
526
  assert cli.main() == 0
525
527
  assert paths_handler.called
528
+
529
+ def test_code_block_flags_parse(self, mocker):
530
+ norm = mocker.patch.object(cli, "_cmd_normalize", return_value=0)
531
+ mocker.patch.object(
532
+ cli.sys,
533
+ "argv",
534
+ [
535
+ "stackvox",
536
+ "normalize",
537
+ "--code-blocks",
538
+ "placeholder",
539
+ "--code-placeholder",
540
+ "see the chat",
541
+ "hi",
542
+ ],
543
+ )
544
+ assert cli.main() == 0
545
+ args = norm.call_args.args[0]
546
+ assert args.code_blocks == "placeholder"
547
+ assert args.code_placeholder == "see the chat"
548
+
549
+
550
+ class TestCmdCancel:
551
+ def test_running_calls_daemon_cancel(self, mocker):
552
+ mocker.patch.object(cli.daemon, "is_running", return_value=True)
553
+ cancel = mocker.patch.object(cli.daemon, "cancel", return_value=(True, "ok"))
554
+ assert cli._cmd_cancel(_ns()) == 0
555
+ cancel.assert_called_once()
556
+
557
+ def test_no_daemon_is_a_noop(self, mocker):
558
+ mocker.patch.object(cli.daemon, "is_running", return_value=False)
559
+ cancel = mocker.patch.object(cli.daemon, "cancel")
560
+ assert cli._cmd_cancel(_ns()) == 0
561
+ cancel.assert_not_called()
@@ -169,3 +169,11 @@ def test_send_helper_when_daemon_not_running(tmp_path, monkeypatch):
169
169
  ok, resp = daemon.send({"command": "ping"})
170
170
  assert not ok
171
171
  assert resp == "daemon not running"
172
+
173
+
174
+ def test_cancel_stops_playback_and_keeps_daemon_up(server: ServerHarness):
175
+ reply = _roundtrip(server.sock, json.dumps({"command": "cancel"}) + "\n")
176
+ assert reply == "ok"
177
+ server.tts.stop.assert_called() # current utterance aborted
178
+ # unlike `stop`, the daemon stays up
179
+ assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok"
@@ -8,6 +8,7 @@ from stackvox.text import (
8
8
  markdown_to_paragraphs,
9
9
  normalize_for_speech,
10
10
  shape_pauses,
11
+ speak_file_refs,
11
12
  strip_emoji,
12
13
  strip_thousands_separators,
13
14
  versions_to_words,
@@ -54,6 +55,44 @@ def test_semver_normalizes_end_to_end():
54
55
  assert "0 point 7.0" not in out
55
56
 
56
57
 
58
+ # --- file & line references ------------------------------------------------
59
+
60
+
61
+ def test_file_ref_leads_with_line_then_file():
62
+ assert speak_file_refs("unifiedAPIv2.service.ts:666") == "line 666 of unifiedAPIv2 service ts"
63
+
64
+
65
+ def test_file_ref_drops_directories_to_basename():
66
+ assert speak_file_refs("Open /abs/path/to/module.py:7.") == "Open line 7 of module py."
67
+
68
+
69
+ def test_file_ref_line_range():
70
+ assert speak_file_refs("cli.py:100-118") == "lines 100 to 118 of cli py"
71
+
72
+
73
+ def test_file_ref_line_and_column():
74
+ assert speak_file_refs("foo.ts:666:10") == "line 666, column 10 of foo ts"
75
+
76
+
77
+ def test_file_ref_leaves_times_ratios_verses_untouched():
78
+ text = "Meet at 12:30, the ratio was 3:1, see John 3:16."
79
+ assert speak_file_refs(text) == text
80
+
81
+
82
+ def test_file_ref_leaves_dotted_versions_untouched():
83
+ assert speak_file_refs("bump to 1.2.3 and 0.7.0") == "bump to 1.2.3 and 0.7.0"
84
+
85
+
86
+ def test_file_ref_normalizes_end_to_end():
87
+ out = normalize_for_speech("See `engine.py:42` for the fix.", markdown=True)
88
+ assert out == "See line 42 of engine py for the fix."
89
+
90
+
91
+ def test_file_ref_disabled_with_dev_terms():
92
+ out = normalize_for_speech("See engine.py:42.", markdown=False, dev_terms=False)
93
+ assert out == "See engine.py:42."
94
+
95
+
57
96
  # --- units -----------------------------------------------------------------
58
97
 
59
98
 
@@ -74,6 +113,20 @@ def test_math_symbols():
74
113
  assert expand_units("770 ÷ 2 × 3 = 5").strip() == "770 divided by 2 times 3 equals 5"
75
114
 
76
115
 
116
+ def test_tilde_before_number_is_approximately():
117
+ assert expand_units("~123") == "about 123"
118
+ assert expand_units("~ 50 items") == "about 50 items"
119
+
120
+
121
+ def test_tilde_not_before_number_is_left_alone():
122
+ # home dir / approx-equal — not an approximated quantity
123
+ assert expand_units("~/projects") == "~/projects"
124
+
125
+
126
+ def test_tilde_approx_end_to_end_with_decimal():
127
+ assert normalize_for_speech("took ~1.5 hours", markdown=False) == "took about 1 point 5 hours."
128
+
129
+
77
130
  def test_unknown_locale_falls_back_to_en_gb():
78
131
  assert expand_units("£5", locale="xx-YY") == "5 pounds"
79
132
 
@@ -136,6 +189,29 @@ def test_fenced_code_dropped():
136
189
  assert markdown_to_paragraphs(md) == ["Before.", "After."]
137
190
 
138
191
 
192
+ def test_fenced_code_replaced_with_placeholder():
193
+ md = "Before.\n\n```py\nprint('hi')\n```\n\nAfter."
194
+ actual = markdown_to_paragraphs(md, code_blocks="placeholder", code_placeholder="see the code")
195
+ assert actual == ["Before.", "see the code", "After."]
196
+
197
+
198
+ def test_consecutive_code_blocks_collapse_to_one_placeholder():
199
+ md = "```py\na\n```\n\n```py\nb\n```"
200
+ actual = markdown_to_paragraphs(md, code_blocks="placeholder", code_placeholder="see the code")
201
+ assert actual == ["see the code"]
202
+
203
+
204
+ def test_code_placeholder_spoken_end_to_end():
205
+ md = "Here is how:\n\n```py\nx = 1\n```\n\nDone."
206
+ out = normalize_for_speech(md, code_blocks="placeholder", code_placeholder="see the chat history")
207
+ assert out == "Here is how:\nsee the chat history.\nDone."
208
+
209
+
210
+ def test_code_blocks_dropped_by_default_end_to_end():
211
+ md = "Here is how:\n\n```py\nx = 1\n```\n\nDone."
212
+ assert normalize_for_speech(md) == "Here is how:\nDone."
213
+
214
+
139
215
  def test_tables_dropped_by_default():
140
216
  md = "| A | B |\n| --- | --- |\n| 1 | 2 |"
141
217
  assert markdown_to_paragraphs(md) == []
@@ -226,11 +302,17 @@ def test_plain_text_mode_splits_paragraphs():
226
302
  def test_dev_acronyms_are_spelled_out():
227
303
  assert normalize_for_speech("Use the CLI.", markdown=False) == "Use the C L I."
228
304
  assert "C L I" in normalize_for_speech("the cli tool", markdown=False) # lowercase too
229
- assert "A W S" in normalize_for_speech("deploy to AWS", markdown=False)
305
+ assert "A.W.S." in normalize_for_speech("deploy to AWS", markdown=False) # dotted → "ay"
306
+ assert "I.A.M." in normalize_for_speech("set up IAM", markdown=False)
230
307
  assert "U R I" in normalize_for_speech("parse the URI", markdown=False)
231
308
  assert "C I C D" in normalize_for_speech("the CI/CD pipeline", markdown=False)
232
309
 
233
310
 
311
+ def test_org_names_are_spoken_word_by_word():
312
+ assert "stack one" in normalize_for_speech("deployed to StackOne today", markdown=False)
313
+ assert "stack one" in normalize_for_speech("the stackone api", markdown=False) # case-insensitive
314
+
315
+
234
316
  def test_dev_terms_leave_correctly_voiced_acronyms_alone():
235
317
  out = normalize_for_speech("Send JSON over HTTP to the API.", markdown=False)
236
318
  assert "JSON" in out
@@ -238,6 +320,18 @@ def test_dev_terms_leave_correctly_voiced_acronyms_alone():
238
320
  assert "API" in out
239
321
 
240
322
 
323
+ def test_dedupe_respelled_for_speech():
324
+ assert normalize_for_speech("dedupe the list", markdown=False) == "dee dupe the list."
325
+ # inflections and case
326
+ assert "dee duped" in normalize_for_speech("we Deduped it", markdown=False)
327
+ assert "dee duping" in normalize_for_speech("deduping now", markdown=False)
328
+
329
+
330
+ def test_dedupe_does_not_touch_other_words():
331
+ # whole-word only — must not rewrite the middle of a longer token
332
+ assert "dee dupe" not in normalize_for_speech("dedupelicated", markdown=False)
333
+
334
+
241
335
  def test_dev_terms_can_be_disabled():
242
336
  assert normalize_for_speech("Use the CLI.", markdown=False, dev_terms=False) == "Use the CLI."
243
337
 
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