stackvox 0.4.0__tar.gz → 0.6.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.4.0/stackvox.egg-info → stackvox-0.6.0}/PKG-INFO +4 -2
  2. {stackvox-0.4.0 → stackvox-0.6.0}/README.md +3 -1
  3. {stackvox-0.4.0 → stackvox-0.6.0}/pyproject.toml +6 -2
  4. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/__init__.py +2 -1
  5. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/cli.py +31 -4
  6. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/daemon.py +18 -0
  7. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/engine.py +118 -4
  8. stackvox-0.6.0/stackvox/text.py +310 -0
  9. stackvox-0.6.0/stackvox/updates.py +184 -0
  10. {stackvox-0.4.0 → stackvox-0.6.0/stackvox.egg-info}/PKG-INFO +4 -2
  11. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/SOURCES.txt +5 -1
  12. {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_cli.py +63 -0
  13. {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_engine.py +95 -13
  14. stackvox-0.6.0/tests/test_text.py +197 -0
  15. stackvox-0.6.0/tests/test_updates.py +220 -0
  16. {stackvox-0.4.0 → stackvox-0.6.0}/LICENSE +0 -0
  17. {stackvox-0.4.0 → stackvox-0.6.0}/NOTICE +0 -0
  18. {stackvox-0.4.0 → stackvox-0.6.0}/setup.cfg +0 -0
  19. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/__main__.py +0 -0
  20. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/config.py +0 -0
  21. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/data/stackvox-say +0 -0
  22. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/paths.py +0 -0
  23. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/py.typed +0 -0
  24. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/dependency_links.txt +0 -0
  25. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/entry_points.txt +0 -0
  26. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/requires.txt +0 -0
  27. {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/top_level.txt +0 -0
  28. {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_config.py +0 -0
  29. {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_daemon.py +0 -0
  30. {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_daemon_protocol.py +0 -0
  31. {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -118,11 +118,13 @@ Keeps the model resident so each subsequent call is instant:
118
118
 
119
119
  ```bash
120
120
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
121
- stackvox status # is the daemon up?
121
+ stackvox status # is the daemon up? also shows version + any pending PyPI update
122
122
  stackvox say "Hello" # send text to the daemon (fails if not running)
123
123
  stackvox stop # graceful shutdown
124
124
  ```
125
125
 
126
+ stackvox checks PyPI for newer versions but only at two moments — when you run `stackvox status` and at daemon startup. The script-heavy paths (`say`, `speak`, `stackvox-say`, hooks, CI) never make a network call. To see notices on every invocation set `STACKVOX_UPDATE_NOTICE=1`. To disable the check entirely set `STACKVOX_NO_UPDATE_CHECK=1`. The check is auto-skipped when common CI env vars (`CI`, `GITHUB_ACTIONS`, etc.) are set so build logs stay clean.
127
+
126
128
  ## `stackvox-say` (bash helper, ~13ms)
127
129
 
128
130
  When you want minimum latency from shell scripts (hooks, CI steps, etc.), skip the Python client and use the bash helper — it talks directly to the daemon's unix socket via `nc`:
@@ -84,11 +84,13 @@ Keeps the model resident so each subsequent call is instant:
84
84
 
85
85
  ```bash
86
86
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
87
- stackvox status # is the daemon up?
87
+ stackvox status # is the daemon up? also shows version + any pending PyPI update
88
88
  stackvox say "Hello" # send text to the daemon (fails if not running)
89
89
  stackvox stop # graceful shutdown
90
90
  ```
91
91
 
92
+ stackvox checks PyPI for newer versions but only at two moments — when you run `stackvox status` and at daemon startup. The script-heavy paths (`say`, `speak`, `stackvox-say`, hooks, CI) never make a network call. To see notices on every invocation set `STACKVOX_UPDATE_NOTICE=1`. To disable the check entirely set `STACKVOX_NO_UPDATE_CHECK=1`. The check is auto-skipped when common CI env vars (`CI`, `GITHUB_ACTIONS`, etc.) are set so build logs stay clean.
93
+
92
94
  ## `stackvox-say` (bash helper, ~13ms)
93
95
 
94
96
  When you want minimum latency from shell scripts (hooks, CI steps, etc.), skip the Python client and use the bash helper — it talks directly to the daemon's unix socket via `nc`:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -100,7 +100,11 @@ show_missing = true
100
100
  skip_covered = false
101
101
 
102
102
  [tool.mypy]
103
- python_version = "3.10"
103
+ # Matches the interpreter CI runs mypy under, and must be >= 3.12: numpy's
104
+ # bundled stubs use PEP 695 `type` aliases, which mypy rejects as a syntax
105
+ # error when told to target an older Python. Runtime support for 3.10+ is
106
+ # still verified by the pytest matrix (py3.10–3.13).
107
+ python_version = "3.12"
104
108
  files = ["stackvox", "tests"]
105
109
  warn_unused_ignores = true
106
110
  warn_redundant_casts = true
@@ -5,6 +5,7 @@ from importlib.metadata import version as _pkg_version
5
5
 
6
6
  from stackvox import daemon
7
7
  from stackvox.engine import Stackvox, speak, synthesize
8
+ from stackvox.text import normalize_for_speech
8
9
 
9
10
  try:
10
11
  __version__ = _pkg_version("stackvox")
@@ -13,4 +14,4 @@ except PackageNotFoundError:
13
14
  # bootstrap before `pip install -e .` has completed).
14
15
  __version__ = "0.0.0+unknown"
15
16
 
16
- __all__ = ["Stackvox", "daemon", "speak", "synthesize"]
17
+ __all__ = ["Stackvox", "daemon", "normalize_for_speech", "speak", "synthesize"]
@@ -4,12 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import logging
7
+ import os
7
8
  import sys
8
9
  from pathlib import Path
9
10
 
10
11
  import soundfile as sf
11
12
 
12
- from stackvox import config, daemon
13
+ from stackvox import config, daemon, updates
13
14
  from stackvox.engine import Stackvox
14
15
 
15
16
 
@@ -226,9 +227,18 @@ def _cmd_stop(_: argparse.Namespace) -> int:
226
227
  def _cmd_status(_: argparse.Namespace) -> int:
227
228
  if daemon.is_running():
228
229
  print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}")
229
- return 0
230
- print("stopped")
231
- return 1
230
+ rc = 0
231
+ else:
232
+ print("stopped")
233
+ rc = 1
234
+ # Status is the canonical "is everything OK?" query — surface update info
235
+ # here regardless of running/stopped. Synchronous fetch with 2s timeout.
236
+ info = updates.check_for_update()
237
+ if info is not None:
238
+ print(f"version: {info.current} ({updates.format_notice(info)})")
239
+ else:
240
+ print(f"version: {updates._current_version()}")
241
+ return rc
232
242
 
233
243
 
234
244
  def _cmd_voices(args: argparse.Namespace) -> int:
@@ -289,6 +299,21 @@ def _cmd_install_helper(args: argparse.Namespace) -> int:
289
299
  return 0
290
300
 
291
301
 
302
+ def _maybe_print_update_notice() -> None:
303
+ """Opt-in stderr notice for the script-friendly default-off case.
304
+
305
+ Off by default (most stackvox invocations are non-interactive — hooks,
306
+ CI, scripts — and pollution there is worse than the missed notice).
307
+ Set `STACKVOX_UPDATE_NOTICE=1` to turn it on. Reads cache only; never
308
+ fetches from PyPI on this path.
309
+ """
310
+ if not os.environ.get("STACKVOX_UPDATE_NOTICE"):
311
+ return
312
+ info = updates.cached_update()
313
+ if info is not None:
314
+ print(f"[stackvox] {updates.format_notice(info)}", file=sys.stderr)
315
+
316
+
292
317
  def main() -> int:
293
318
  _configure_logging()
294
319
  argv = sys.argv[1:]
@@ -299,6 +324,8 @@ def main() -> int:
299
324
  elif not argv and not sys.stdin.isatty():
300
325
  argv = ["speak"]
301
326
 
327
+ _maybe_print_update_notice()
328
+
302
329
  parser = _build_parser(config.load_defaults())
303
330
  args = parser.parse_args(argv)
304
331
 
@@ -28,6 +28,7 @@ from typing import Any
28
28
 
29
29
  import sounddevice as sd
30
30
 
31
+ from stackvox import updates
31
32
  from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE, Stackvox
32
33
  from stackvox.paths import pid_path, socket_path
33
34
 
@@ -260,6 +261,22 @@ def is_running() -> bool:
260
261
  return _pid_alive(pid)
261
262
 
262
263
 
264
+ def _check_for_update_async() -> None:
265
+ """Spawn a background thread that checks PyPI for an update and logs it.
266
+
267
+ Runs at daemon startup, off the critical path. The user is at a terminal
268
+ when they `stackvox serve`, so the daemon's stderr is visible — that's
269
+ the highest-leverage moment to surface "you should upgrade".
270
+ """
271
+
272
+ def _worker() -> None:
273
+ info = updates.check_for_update()
274
+ if info is not None:
275
+ logger.info(updates.format_notice(info))
276
+
277
+ threading.Thread(target=_worker, daemon=True, name="update-check").start()
278
+
279
+
263
280
  def serve(voice: str = DEFAULT_VOICE, speed: float = DEFAULT_SPEED, lang: str = DEFAULT_LANG) -> None:
264
281
  if is_running():
265
282
  raise RuntimeError(f"daemon already running (pid {PID_PATH.read_text().strip()})")
@@ -273,6 +290,7 @@ def serve(voice: str = DEFAULT_VOICE, speed: float = DEFAULT_SPEED, lang: str =
273
290
  server.state = state # type: ignore[attr-defined]
274
291
 
275
292
  PID_PATH.write_text(str(os.getpid()))
293
+ _check_for_update_async()
276
294
 
277
295
  def handle_signal(signum: int, frame: object) -> None:
278
296
  state.shutdown()
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import queue
7
+ import re
6
8
  import sys
7
9
  import threading
8
10
  import urllib.request
@@ -69,6 +71,17 @@ def _ensure_models(cache_dir: Path) -> tuple[Path, Path]:
69
71
  return model_path, voices_path
70
72
 
71
73
 
74
+ # Split after ., !, or ? that is followed by whitespace, and on newlines. The
75
+ # whitespace requirement leaves decimals ("0.95") and mid-token dots
76
+ # ("file.ts") intact, which is good enough for speech chunking.
77
+ _SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+|\n+")
78
+
79
+
80
+ def _split_sentences(text: str) -> list[str]:
81
+ """Break text into sentence-ish chunks for low-latency streaming playback."""
82
+ return [part.strip() for part in _SENTENCE_BOUNDARY.split(text.strip()) if part.strip()]
83
+
84
+
72
85
  class Stackvox:
73
86
  """Reusable TTS engine. Load the model once, speak many times.
74
87
 
@@ -89,6 +102,8 @@ class Stackvox:
89
102
  self.lang = lang
90
103
  model_path, voices_path = _ensure_models(cache_dir or _default_cache_dir())
91
104
  self._kokoro = Kokoro(str(model_path), str(voices_path))
105
+ self._stop_event = threading.Event()
106
+ self._play_thread: threading.Thread | None = None
92
107
 
93
108
  def synthesize(
94
109
  self,
@@ -106,6 +121,21 @@ class Stackvox:
106
121
  )
107
122
  return samples, sample_rate
108
123
 
124
+ def _cancel_active(self) -> None:
125
+ """Supersede any in-progress stream so a new ``speak`` starts clean.
126
+
127
+ Signals the running stream's stop event and joins its thread, so we
128
+ never overlap two streams on the shared output device or orphan the
129
+ previous playback thread.
130
+ """
131
+ thread = self._play_thread
132
+ self._play_thread = None
133
+ if thread is None or thread is threading.current_thread() or not thread.is_alive():
134
+ return
135
+ self._stop_event.set()
136
+ sd.stop()
137
+ thread.join()
138
+
109
139
  def speak(
110
140
  self,
111
141
  text: str,
@@ -114,14 +144,98 @@ class Stackvox:
114
144
  lang: str | None = None,
115
145
  blocking: bool = True,
116
146
  ) -> None:
117
- """Synthesize and play through the system default output device."""
118
- samples, sample_rate = self.synthesize(text, voice=voice, speed=speed, lang=lang)
119
- sd.play(samples, sample_rate)
147
+ """Synthesize and play through the system default output device.
148
+
149
+ Streams sentence by sentence: the first sentence starts playing as soon
150
+ as it is synthesized (~0.2s) while the rest synthesize in the background,
151
+ rather than waiting for the whole text to synthesize first.
152
+ """
153
+ self._cancel_active()
154
+ # Each stream gets its own cancellation token, so a later speak() (or a
155
+ # stop() in between) can never clear an older stream's cancellation.
156
+ stop_event = threading.Event()
157
+ self._stop_event = stop_event
120
158
  if blocking:
121
- sd.wait()
159
+ self._stream_play(text, stop_event, voice=voice, speed=speed, lang=lang)
160
+ return
161
+
162
+ def _run() -> None:
163
+ try:
164
+ self._stream_play(text, stop_event, voice=voice, speed=speed, lang=lang)
165
+ except Exception:
166
+ logger.exception("stackvox playback failed")
167
+
168
+ self._play_thread = threading.Thread(target=_run, daemon=True, name="stackvox-play")
169
+ self._play_thread.start()
170
+
171
+ def _stream_play(
172
+ self,
173
+ text: str,
174
+ stop_event: threading.Event,
175
+ voice: str | None = None,
176
+ speed: float | None = None,
177
+ lang: str | None = None,
178
+ ) -> None:
179
+ """Synthesize sentence by sentence and play each as it is ready.
180
+
181
+ Kokoro batches by phoneme count (~510), so a whole paragraph synthesizes
182
+ as one blob before any audio — the source of the lead-in lag. Splitting
183
+ into sentences ourselves means the first sentence (~0.2s to synthesize)
184
+ plays almost immediately. A producer thread synthesizes ahead into a
185
+ bounded queue while this thread plays, so later sentences are usually
186
+ ready by the time the previous one finishes.
187
+
188
+ A synthesis failure is re-raised rather than swallowed, so a blocking
189
+ caller sees the error instead of silent success; ``stop_event`` cancels
190
+ the stream between sentences.
191
+ """
192
+ sentences = _split_sentences(text)
193
+ if not sentences:
194
+ return
195
+
196
+ chunks: queue.Queue = queue.Queue(maxsize=8)
197
+ sentinel = object()
198
+
199
+ def _produce() -> None:
200
+ try:
201
+ for sentence in sentences:
202
+ if stop_event.is_set():
203
+ break
204
+ chunks.put(self.synthesize(sentence, voice=voice, speed=speed, lang=lang))
205
+ except Exception as exc: # hand the failure to the consumer to re-raise
206
+ chunks.put(exc)
207
+ finally:
208
+ chunks.put(sentinel)
209
+
210
+ producer = threading.Thread(target=_produce, daemon=True, name="stackvox-synth")
211
+ producer.start()
212
+ error: Exception | None = None
213
+ try:
214
+ while not stop_event.is_set():
215
+ item = chunks.get()
216
+ if item is sentinel:
217
+ break
218
+ if isinstance(item, Exception):
219
+ error = item
220
+ break
221
+ samples, sample_rate = item
222
+ sd.play(samples, sample_rate)
223
+ sd.wait()
224
+ finally:
225
+ # Stop the producer and keep draining so it can never stay parked on
226
+ # a full queue — that's what guarantees the synth thread always exits.
227
+ stop_event.set()
228
+ while producer.is_alive():
229
+ try:
230
+ chunks.get_nowait()
231
+ except queue.Empty:
232
+ producer.join(timeout=0.05)
233
+ if error is not None:
234
+ raise error
122
235
 
123
236
  def stop(self) -> None:
124
237
  """Stop any in-progress playback started with blocking=False."""
238
+ self._stop_event.set()
125
239
  sd.stop()
126
240
 
127
241
  def voices(self) -> list[str]:
@@ -0,0 +1,310 @@
1
+ """Turn real-world text — Markdown, Claude responses, prose — into something
2
+ that *sounds* right when synthesized.
3
+
4
+ StackVox speaks whatever string it's given; this module is the pre-step that
5
+ makes that string speakable: strip Markdown structure, expand units and
6
+ numbers, shape pauses, and apply a caller-supplied pronunciation dictionary.
7
+
8
+ The primary entry point is :func:`normalize_for_speech`. Each stage is also
9
+ exposed for composition and testing. See ``docs/speech-normalization.md``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ __all__ = [
17
+ "normalize_for_speech",
18
+ "markdown_to_paragraphs",
19
+ "strip_emoji",
20
+ "strip_thousands_separators",
21
+ "decimals_to_words",
22
+ "expand_units",
23
+ "apply_pronunciations",
24
+ "shape_pauses",
25
+ "ensure_terminal_stop",
26
+ ]
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # Emoji #
30
+ # --------------------------------------------------------------------------- #
31
+
32
+ _EMOJI = re.compile(
33
+ "["
34
+ "\U0001f000-\U0001faff" # symbols, emoticons, pictographs, supplemental
35
+ "\U00002600-\U000027bf" # misc symbols + dingbats
36
+ "\U00002190-\U000021ff" # arrows
37
+ "\U00002b00-\U00002bff" # misc symbols and arrows
38
+ "\U0000fe00-\U0000fe0f" # variation selectors
39
+ "]+",
40
+ flags=re.UNICODE,
41
+ )
42
+
43
+
44
+ def strip_emoji(text: str) -> str:
45
+ return _EMOJI.sub("", text)
46
+
47
+
48
+ # --------------------------------------------------------------------------- #
49
+ # Units & symbols (locale-keyed) #
50
+ # --------------------------------------------------------------------------- #
51
+ # (pattern, replacement). Digit-glued units capture the leading digit and
52
+ # re-emit it, so "65kg" -> "65 kilograms". IMPORTANT: currency/unit expansion
53
+ # must run BEFORE the decimal-point split, or "£1.63" becomes "£1 point 63".
54
+
55
+ _UNIT_RULES: dict[str, list[tuple[str, str]]] = {
56
+ "en-GB": [
57
+ (r"£\s?(\d[\d,]*(?:\.\d+)?)", r"\1 pounds"), # £1.63 -> 1.63 pounds
58
+ (r"\bkWh\b", "kilowatt hours"),
59
+ (r"\bMPG\b", "miles per gallon"),
60
+ (r"(\d)\s?kg\b", r"\1 kilograms"),
61
+ (r"(\d)\s?km\b", r"\1 kilometres"),
62
+ (r"(\d)p\b", r"\1 pence"), # 25p, 167.14p
63
+ (r"\s*÷\s*", " divided by "),
64
+ (r"\s*×\s*", " times "),
65
+ (r"\s*=\s*", " equals "),
66
+ ],
67
+ }
68
+ DEFAULT_LOCALE = "en-GB"
69
+
70
+
71
+ def expand_units(text: str, locale: str = DEFAULT_LOCALE) -> str:
72
+ for pattern, repl in _UNIT_RULES.get(locale, _UNIT_RULES[DEFAULT_LOCALE]):
73
+ text = re.sub(pattern, repl, text)
74
+ return text
75
+
76
+
77
+ # --------------------------------------------------------------------------- #
78
+ # Numbers #
79
+ # --------------------------------------------------------------------------- #
80
+
81
+
82
+ def strip_thousands_separators(text: str) -> str:
83
+ """1,198.9 -> 1198.9 (so the whole number is read as one, not split)."""
84
+ return re.sub(r"(?<=\d),(?=\d)", "", text)
85
+
86
+
87
+ def decimals_to_words(text: str) -> str:
88
+ """1198.9 -> "1198 point 9"; 770.72 -> "770 point 7 2". Removes the bare
89
+ "." between digits, which TTS can otherwise read as a full stop."""
90
+ return re.sub(
91
+ r"(\d+)\.(\d+)",
92
+ lambda m: m.group(1) + " point " + " ".join(m.group(2)),
93
+ text,
94
+ )
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # Pronunciations #
99
+ # --------------------------------------------------------------------------- #
100
+
101
+
102
+ def apply_pronunciations(text: str, mapping: dict[str, str] | None) -> str:
103
+ """Whole-word, case-insensitive spoken-form substitutions."""
104
+ for written, spoken in (mapping or {}).items():
105
+ text = re.sub(rf"\b{re.escape(written)}\b", spoken, text, flags=re.IGNORECASE)
106
+ return text
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # Pauses #
111
+ # --------------------------------------------------------------------------- #
112
+
113
+
114
+ def shape_pauses(text: str) -> str:
115
+ """Give punctuation the beats it deserves: a dash used as punctuation reads
116
+ as a rushed nothing, and a "(" runs onto the previous word."""
117
+ text = text.replace("→", " to ")
118
+ text = re.sub(r"\s*[—–]\s*", " ... ", text) # em / en dash
119
+ text = re.sub(r"\s+--?\s+", " ... ", text) # spaced ASCII hyphen(s)
120
+ text = re.sub(r"(\w)\s*\(", r"\1, (", text) # comma before "("
121
+ return text
122
+
123
+
124
+ def ensure_terminal_stop(text: str) -> str:
125
+ """Guarantee terminal punctuation so a pause lands before the next line —
126
+ StackVox pauses on punctuation, not on line breaks."""
127
+ text = text.rstrip()
128
+ return text if text.endswith((".", "!", "?", ":", "…")) else text + "."
129
+
130
+
131
+ # --------------------------------------------------------------------------- #
132
+ # Markdown -> prose #
133
+ # --------------------------------------------------------------------------- #
134
+
135
+
136
+ def _strip_md_inline(line: str) -> str:
137
+ line = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", line) # images
138
+ line = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", line) # inline links -> text
139
+ line = re.sub(r"\[([^\]]+)\]\[[^\]]*\]", r"\1", line) # reference links -> text
140
+ line = re.sub(r"`([^`]+)`", r"\1", line) # inline code -> text
141
+ line = re.sub(r"<!--.*?-->", "", line) # HTML comments
142
+ line = re.sub(r"</?[a-zA-Z][^>]*>", "", line) # real tags + <url> autolinks; leaves "a < b"
143
+ line = re.sub(r"https?://\S+", "", line) # bare URLs: strip rather than read them aloud
144
+ # Emphasis: only strip *paired, boundary-flanked* markers, so code-ish tokens
145
+ # that aren't emphasis in CommonMark — *args, **kwargs, __init__, snake_case —
146
+ # survive intact. Underscores are left alone entirely (TTS doesn't voice them).
147
+ line = re.sub(r"(?<![*\w])\*\*(?=\S)(.+?)(?<=\S)\*\*(?![*\w])", r"\1", line) # bold
148
+ line = re.sub(r"(?<![*\w])\*(?=\S)(.+?)(?<=\S)\*(?![*\w])", r"\1", line) # italic
149
+ line = re.sub(r"(?<!~)~~(?=\S)(.+?)(?<=\S)~~(?!~)", r"\1", line) # strikethrough
150
+ return line
151
+
152
+
153
+ def markdown_to_paragraphs(text: str, *, tables: str = "drop", strip_emoji_flag: bool = False) -> list[str]:
154
+ """Reduce Markdown to a list of speakable paragraphs. Headings and list
155
+ items become their own paragraphs (so each gets its own pause). Tables are
156
+ dropped or rendered comma-separated per ``tables``."""
157
+ text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", "\n", text) # fenced code
158
+ text = re.sub(r"```+|~~~+", " ", text) # stray fences
159
+ if strip_emoji_flag:
160
+ text = strip_emoji(text)
161
+
162
+ paragraphs: list[str] = []
163
+ current: list[str] = []
164
+ in_table = False
165
+
166
+ def flush() -> None:
167
+ if current:
168
+ paragraphs.append(" ".join(current))
169
+ current.clear()
170
+
171
+ def emit_csv_row(cells_source: str) -> None:
172
+ cells = [c.strip() for c in _strip_md_inline(cells_source.strip("|")).split("|")]
173
+ joined = ", ".join(c for c in cells if c)
174
+ if joined:
175
+ paragraphs.append(joined)
176
+
177
+ for raw in text.splitlines():
178
+ row = raw.strip()
179
+
180
+ if not row: # blank line ends the current paragraph and any table block
181
+ flush()
182
+ in_table = False
183
+ continue
184
+
185
+ # link reference definition ( [id]: https://… ) — invisible when rendered
186
+ if re.match(r"^\[[^\]]+\]:\s*\S", row):
187
+ flush()
188
+ continue
189
+
190
+ # table separator row ( |---|---| or --- | --- )
191
+ if re.fullmatch(r"\|?[\s:|-]*-[\s:|-]*\|?", row):
192
+ # Only a *table* separator if the line above held cells; otherwise it's
193
+ # a `---` horizontal rule / setext underline (no table context).
194
+ if current and "|" in current[-1]:
195
+ header = current.pop()
196
+ flush()
197
+ if tables == "csv":
198
+ emit_csv_row(header)
199
+ in_table = True
200
+ else:
201
+ flush()
202
+ in_table = False
203
+ continue
204
+
205
+ # setext underline ( === ) — drop so it isn't voiced as "equals equals…"
206
+ if re.fullmatch(r"=+", row):
207
+ flush()
208
+ continue
209
+
210
+ # table row: outer-pipe form, or a bare-pipe row within a table block
211
+ outer_pipe = len(row) >= 2 and row.startswith("|") and row.endswith("|")
212
+ if outer_pipe or (in_table and "|" in row):
213
+ flush()
214
+ if tables == "csv":
215
+ emit_csv_row(row)
216
+ continue
217
+
218
+ # horizontal rule ( *** / ___ ; --- is handled by the separator branch )
219
+ if re.fullmatch(r"([-*_])(?:\s*\1){2,}", row):
220
+ flush()
221
+ in_table = False
222
+ continue
223
+
224
+ in_table = False
225
+ is_heading = re.match(r"^\s{0,3}#{1,6}\s+", raw)
226
+ is_item = re.match(r"^\s*([-*+]|\d+[.)])\s+", raw)
227
+ cleaned = _strip_md_inline(re.sub(r"^\s{0,3}(#{1,6}\s*|(?:>\s?)+)", "", raw))
228
+ cleaned = re.sub(r"^\s*([-*+]|\d+[.)])\s+", "", cleaned).strip()
229
+ if is_item: # drop task-list checkboxes ( - [ ] / - [x] )
230
+ cleaned = re.sub(r"^\[[ xX]\]\s+", "", cleaned).strip()
231
+
232
+ if is_heading or is_item: # each stands alone -> its own pause
233
+ flush()
234
+ if cleaned:
235
+ paragraphs.append(cleaned)
236
+ elif cleaned:
237
+ current.append(cleaned)
238
+ else:
239
+ flush()
240
+
241
+ flush()
242
+ return paragraphs
243
+
244
+
245
+ # --------------------------------------------------------------------------- #
246
+ # Orchestrator #
247
+ # --------------------------------------------------------------------------- #
248
+
249
+
250
+ def _shape_paragraph(
251
+ text: str,
252
+ *,
253
+ pronunciations: dict[str, str] | None,
254
+ expand_units_flag: bool,
255
+ expand_numbers_flag: bool,
256
+ pauses_flag: bool,
257
+ locale: str,
258
+ ) -> str:
259
+ if expand_numbers_flag:
260
+ text = strip_thousands_separators(text)
261
+ if pauses_flag:
262
+ text = shape_pauses(text)
263
+ if pronunciations:
264
+ text = apply_pronunciations(text, pronunciations)
265
+ if expand_units_flag: # units BEFORE decimals (see note above)
266
+ text = expand_units(text, locale)
267
+ if expand_numbers_flag:
268
+ text = decimals_to_words(text)
269
+ return re.sub(r"[ \t]{2,}", " ", text).strip()
270
+
271
+
272
+ def normalize_for_speech(
273
+ text: str,
274
+ *,
275
+ markdown: bool = True,
276
+ pronunciations: dict[str, str] | None = None,
277
+ expand_units: bool = True,
278
+ expand_numbers: bool = True,
279
+ pauses: bool = True,
280
+ tables: str = "drop",
281
+ strip_emoji: bool = False,
282
+ terminal_stops: bool = True,
283
+ locale: str = DEFAULT_LOCALE,
284
+ ) -> str:
285
+ """Normalize ``text`` into speakable prose. Returns paragraphs joined by
286
+ newlines. See ``docs/speech-normalization.md`` for the full contract."""
287
+ expand_units_flag, expand_numbers_flag = expand_units, expand_numbers
288
+
289
+ if markdown:
290
+ paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji)
291
+ else:
292
+ # `strip_emoji` (the bool kwarg) shadows the module function here, so
293
+ # reach for the underlying pattern directly.
294
+ body = _EMOJI.sub("", text) if strip_emoji else text
295
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]
296
+
297
+ out = []
298
+ for para in paragraphs:
299
+ shaped = _shape_paragraph(
300
+ para,
301
+ pronunciations=pronunciations,
302
+ expand_units_flag=expand_units_flag,
303
+ expand_numbers_flag=expand_numbers_flag,
304
+ pauses_flag=pauses,
305
+ locale=locale,
306
+ )
307
+ if not shaped:
308
+ continue
309
+ out.append(ensure_terminal_stop(shaped) if terminal_stops else shaped)
310
+ return "\n".join(out)