stackvox 0.5.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.5.0/stackvox.egg-info → stackvox-0.6.0}/PKG-INFO +1 -1
  2. {stackvox-0.5.0 → stackvox-0.6.0}/pyproject.toml +6 -2
  3. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/__init__.py +2 -1
  4. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/engine.py +118 -4
  5. stackvox-0.6.0/stackvox/text.py +310 -0
  6. {stackvox-0.5.0 → stackvox-0.6.0/stackvox.egg-info}/PKG-INFO +1 -1
  7. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/SOURCES.txt +2 -0
  8. {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_engine.py +95 -13
  9. stackvox-0.6.0/tests/test_text.py +197 -0
  10. {stackvox-0.5.0 → stackvox-0.6.0}/LICENSE +0 -0
  11. {stackvox-0.5.0 → stackvox-0.6.0}/NOTICE +0 -0
  12. {stackvox-0.5.0 → stackvox-0.6.0}/README.md +0 -0
  13. {stackvox-0.5.0 → stackvox-0.6.0}/setup.cfg +0 -0
  14. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/__main__.py +0 -0
  15. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/cli.py +0 -0
  16. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/config.py +0 -0
  17. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/daemon.py +0 -0
  18. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/data/stackvox-say +0 -0
  19. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/paths.py +0 -0
  20. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/py.typed +0 -0
  21. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/updates.py +0 -0
  22. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/dependency_links.txt +0 -0
  23. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/entry_points.txt +0 -0
  24. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/requires.txt +0 -0
  25. {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/top_level.txt +0 -0
  26. {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_cli.py +0 -0
  27. {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_config.py +0 -0
  28. {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_daemon.py +0 -0
  29. {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_daemon_protocol.py +0 -0
  30. {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_paths.py +0 -0
  31. {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.5.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.5.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"]
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.5.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
@@ -10,6 +10,7 @@ stackvox/daemon.py
10
10
  stackvox/engine.py
11
11
  stackvox/paths.py
12
12
  stackvox/py.typed
13
+ stackvox/text.py
13
14
  stackvox/updates.py
14
15
  stackvox.egg-info/PKG-INFO
15
16
  stackvox.egg-info/SOURCES.txt
@@ -24,4 +25,5 @@ tests/test_daemon.py
24
25
  tests/test_daemon_protocol.py
25
26
  tests/test_engine.py
26
27
  tests/test_paths.py
28
+ tests/test_text.py
27
29
  tests/test_updates.py
@@ -8,6 +8,7 @@ runtime dependencies.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
11
12
  import threading
12
13
 
13
14
  import numpy as np
@@ -99,24 +100,105 @@ class TestSynthesize:
99
100
 
100
101
 
101
102
  class TestSpeak:
102
- def test_blocking_calls_play_and_wait(self, fake_kokoro, fake_audio):
103
- fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
104
- tts = engine.Stackvox()
105
- tts.speak("hi")
106
- engine.sd.play.assert_called_once()
107
- engine.sd.wait.assert_called_once()
103
+ def test_blocking_synthesizes_and_plays_each_sentence(self, fake_kokoro, fake_audio):
104
+ fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
108
105
 
109
- def test_non_blocking_skips_wait(self, fake_kokoro, fake_audio):
110
- fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
106
+ engine.Stackvox().speak("One sentence. Two sentence. Three here.")
107
+
108
+ # One synth + play cycle per sentence — that's what enables early audio.
109
+ assert fake_kokoro.return_value.create.call_count == 3
110
+ assert engine.sd.play.call_count == 3
111
+ assert engine.sd.wait.call_count == 3
112
+
113
+ def test_single_sentence_plays_once(self, fake_kokoro, fake_audio):
114
+ fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
115
+ engine.Stackvox().speak("Just one sentence with no terminal punctuation")
116
+ assert engine.sd.play.call_count == 1
117
+
118
+ def test_non_blocking_plays_in_background_thread(self, fake_kokoro, fake_audio):
119
+ fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
111
120
  tts = engine.Stackvox()
112
- tts.speak("hi", blocking=False)
113
- engine.sd.play.assert_called_once()
114
- engine.sd.wait.assert_not_called()
115
121
 
116
- def test_stop_calls_sounddevice_stop(self, fake_kokoro, fake_audio):
117
- engine.Stackvox().stop()
122
+ tts.speak("hi there", blocking=False)
123
+
124
+ assert tts._play_thread is not None
125
+ tts._play_thread.join(timeout=5)
126
+ engine.sd.play.assert_called()
127
+
128
+ def test_passes_engine_defaults_to_synthesis(self, fake_kokoro, fake_audio):
129
+ fake_kokoro.return_value.create.return_value = (np.zeros(4, dtype=np.float32), 24000)
130
+ engine.Stackvox(voice="af_sarah", speed=1.0, lang="en-us").speak("hello")
131
+ fake_kokoro.return_value.create.assert_called_once_with(
132
+ "hello", voice="af_sarah", speed=1.0, lang="en-us"
133
+ )
134
+
135
+ def test_per_call_overrides_take_priority(self, fake_kokoro, fake_audio):
136
+ fake_kokoro.return_value.create.return_value = (np.zeros(4, dtype=np.float32), 24000)
137
+ engine.Stackvox(voice="af_sarah", speed=1.0, lang="en-us").speak(
138
+ "hi", voice="bf_emma", speed=1.5, lang="en-gb"
139
+ )
140
+ fake_kokoro.return_value.create.assert_called_once_with(
141
+ "hi", voice="bf_emma", speed=1.5, lang="en-gb"
142
+ )
143
+
144
+ def test_stop_sets_event_and_calls_sounddevice_stop(self, fake_kokoro, fake_audio):
145
+ tts = engine.Stackvox()
146
+ tts.stop()
147
+ assert tts._stop_event.is_set()
118
148
  engine.sd.stop.assert_called_once()
119
149
 
150
+ def test_blocking_surfaces_synthesis_errors(self, fake_kokoro, fake_audio):
151
+ # Regression: a Kokoro failure must propagate, not report silent success.
152
+ fake_kokoro.return_value.create.side_effect = RuntimeError("boom")
153
+ tts = engine.Stackvox()
154
+ with pytest.raises(RuntimeError, match="boom"):
155
+ tts.speak("hello there")
156
+
157
+ def test_non_blocking_logs_synthesis_errors_without_crashing(self, fake_kokoro, fake_audio, caplog):
158
+ fake_kokoro.return_value.create.side_effect = RuntimeError("boom")
159
+ tts = engine.Stackvox()
160
+ with caplog.at_level(logging.ERROR):
161
+ tts.speak("hi there", blocking=False)
162
+ assert tts._play_thread is not None
163
+ tts._play_thread.join(timeout=5)
164
+ assert "playback failed" in caplog.text
165
+
166
+ def test_each_speak_uses_a_fresh_stop_event(self, fake_kokoro, fake_audio):
167
+ # A per-stream event means a later speak() can't clear an older stream's
168
+ # cancellation (and stop() only affects the stream it was called on).
169
+ fake_kokoro.return_value.create.return_value = (np.zeros(4, dtype=np.float32), 24000)
170
+ tts = engine.Stackvox()
171
+ original = tts._stop_event
172
+ tts.speak("one sentence")
173
+ assert tts._stop_event is not original
174
+
175
+ def test_speak_after_stop_still_plays(self, fake_kokoro, fake_audio):
176
+ fake_kokoro.return_value.create.return_value = (np.zeros(4, dtype=np.float32), 24000)
177
+ tts = engine.Stackvox()
178
+ tts.stop()
179
+ tts.speak("hello world")
180
+ engine.sd.play.assert_called()
181
+
182
+
183
+ class TestSplitSentences:
184
+ def test_splits_on_sentence_punctuation(self):
185
+ assert engine._split_sentences("One. Two! Three?") == ["One.", "Two!", "Three?"]
186
+
187
+ def test_leaves_decimals_and_mid_token_dots_intact(self):
188
+ assert engine._split_sentences("Set speed 0.95 in config.toml now.") == [
189
+ "Set speed 0.95 in config.toml now."
190
+ ]
191
+
192
+ def test_splits_on_newlines(self):
193
+ assert engine._split_sentences("Line one\nLine two\n\nLine three") == [
194
+ "Line one",
195
+ "Line two",
196
+ "Line three",
197
+ ]
198
+
199
+ def test_blank_text_yields_no_sentences(self):
200
+ assert engine._split_sentences(" \n ") == []
201
+
120
202
 
121
203
  class TestVoices:
122
204
  def test_returns_sorted_voice_ids(self, fake_kokoro):
@@ -0,0 +1,197 @@
1
+ """Tests for stackvox.text — speech-text normalization."""
2
+
3
+ from stackvox.text import (
4
+ apply_pronunciations,
5
+ decimals_to_words,
6
+ ensure_terminal_stop,
7
+ expand_units,
8
+ markdown_to_paragraphs,
9
+ normalize_for_speech,
10
+ shape_pauses,
11
+ strip_emoji,
12
+ strip_thousands_separators,
13
+ )
14
+
15
+ # --- numbers ---------------------------------------------------------------
16
+
17
+
18
+ def test_thousands_separators_removed():
19
+ assert strip_thousands_separators("1,198.9 and 2,294.7") == "1198.9 and 2294.7"
20
+
21
+
22
+ def test_decimals_spoken_digit_by_digit():
23
+ assert decimals_to_words("1198.9") == "1198 point 9"
24
+ assert decimals_to_words("770.72") == "770 point 7 2"
25
+
26
+
27
+ def test_year_is_not_a_decimal():
28
+ assert decimals_to_words("built in 2023") == "built in 2023"
29
+
30
+
31
+ # --- units -----------------------------------------------------------------
32
+
33
+
34
+ def test_currency_and_pence():
35
+ assert expand_units("£1.63") == "1.63 pounds"
36
+ assert expand_units("25p per litre") == "25 pence per litre"
37
+ assert expand_units("167.14p") == "167.14 pence"
38
+
39
+
40
+ def test_kwh_spaced_and_glued_units():
41
+ assert expand_units("13.5 kWh") == "13.5 kilowatt hours"
42
+ assert expand_units("per kWh") == "per kilowatt hours"
43
+ assert expand_units("65kg") == "65 kilograms"
44
+ assert expand_units("a 4km trip") == "a 4 kilometres trip"
45
+
46
+
47
+ def test_math_symbols():
48
+ assert expand_units("770 ÷ 2 × 3 = 5").strip() == "770 divided by 2 times 3 equals 5"
49
+
50
+
51
+ def test_unknown_locale_falls_back_to_en_gb():
52
+ assert expand_units("£5", locale="xx-YY") == "5 pounds"
53
+
54
+
55
+ # --- pronunciations --------------------------------------------------------
56
+
57
+
58
+ def test_pronunciations_whole_word_case_insensitive():
59
+ m = {"Redis": "Reddis", "agy": "antigravity"}
60
+ assert apply_pronunciations("Redis and AGY", m) == "Reddis and antigravity"
61
+
62
+
63
+ def test_pronunciations_do_not_match_substrings():
64
+ # "StackOne" must not partially rewrite "StackOneHQ"
65
+ assert apply_pronunciations("StackOneHQ", {"StackOne": "stack one"}) == "StackOneHQ"
66
+
67
+
68
+ # --- pauses ----------------------------------------------------------------
69
+
70
+
71
+ def test_dash_becomes_ellipsis_pause():
72
+ assert shape_pauses("in control - once I started") == "in control ... once I started"
73
+
74
+
75
+ def test_comma_inserted_before_paren():
76
+ assert shape_pauses("Claude Code (CC)") == "Claude Code, (CC)"
77
+
78
+
79
+ def test_ensure_terminal_stop():
80
+ assert ensure_terminal_stop("About") == "About."
81
+ assert ensure_terminal_stop("Ready?") == "Ready?"
82
+ assert ensure_terminal_stop("A list:") == "A list:"
83
+
84
+
85
+ # --- emoji -----------------------------------------------------------------
86
+
87
+
88
+ def test_strip_emoji():
89
+ assert (
90
+ strip_emoji("done ✅ and 🚀 go").replace(" ", " ").strip()
91
+ == "done and go".replace(" ", " ").strip()
92
+ )
93
+
94
+
95
+ # --- markdown --------------------------------------------------------------
96
+
97
+
98
+ def test_markdown_headings_and_lists_are_own_paragraphs():
99
+ md = "# Title\n\nBody line.\n\n- one\n- two"
100
+ assert markdown_to_paragraphs(md) == ["Title", "Body line.", "one", "two"]
101
+
102
+
103
+ def test_markdown_links_and_code_reduced_to_text():
104
+ md = "See [the docs](https://x.example) and run `make build`."
105
+ assert markdown_to_paragraphs(md) == ["See the docs and run make build."]
106
+
107
+
108
+ def test_fenced_code_dropped():
109
+ md = "Before.\n\n```py\nprint('hi')\n```\n\nAfter."
110
+ assert markdown_to_paragraphs(md) == ["Before.", "After."]
111
+
112
+
113
+ def test_tables_dropped_by_default():
114
+ md = "| A | B |\n| --- | --- |\n| 1 | 2 |"
115
+ assert markdown_to_paragraphs(md) == []
116
+
117
+
118
+ def test_tables_as_csv():
119
+ md = "| A | B |\n| --- | --- |\n| 1 | 2 |"
120
+ assert markdown_to_paragraphs(md, tables="csv") == ["A, B", "1, 2"]
121
+
122
+
123
+ def test_tables_without_outer_pipes_are_recognized():
124
+ md = "A | B\n--- | ---\n1 | 2"
125
+ assert markdown_to_paragraphs(md) == []
126
+ assert markdown_to_paragraphs(md, tables="csv") == ["A, B", "1, 2"]
127
+
128
+
129
+ def test_code_identifiers_are_not_treated_as_emphasis():
130
+ # __init__, **kwargs and *args are not emphasis in CommonMark — keep them.
131
+ md = "The `__init__` method takes **kwargs and *args."
132
+ assert markdown_to_paragraphs(md) == ["The __init__ method takes **kwargs and *args."]
133
+
134
+
135
+ def test_paired_emphasis_markers_are_stripped():
136
+ md = "This is **bold** and *italic* text."
137
+ assert markdown_to_paragraphs(md) == ["This is bold and italic text."]
138
+
139
+
140
+ def test_angle_brackets_in_prose_are_kept():
141
+ # Only real HTML tags are stripped, not comparisons.
142
+ assert markdown_to_paragraphs("Voltage: x < y and y > z.") == ["Voltage: x < y and y > z."]
143
+ assert markdown_to_paragraphs("Wrap <b>x</b> now.") == ["Wrap x now."]
144
+
145
+
146
+ def test_bare_urls_and_reference_definitions_are_dropped():
147
+ md = "See [the docs][docs] here.\n\n[docs]: https://example.com/path"
148
+ assert markdown_to_paragraphs(md) == ["See the docs here."]
149
+ # whitespace left by the stripped URL is collapsed by the full pipeline
150
+ assert normalize_for_speech("Go to https://example.com now.") == "Go to now."
151
+
152
+
153
+ def test_setext_underline_is_dropped():
154
+ assert markdown_to_paragraphs("The Verdict\n===========\n\nBody.") == ["The Verdict", "Body."]
155
+
156
+
157
+ def test_task_list_checkboxes_are_stripped():
158
+ assert markdown_to_paragraphs("- [ ] todo\n- [x] done") == ["todo", "done"]
159
+
160
+
161
+ def test_nested_blockquote_markers_are_stripped():
162
+ assert markdown_to_paragraphs("> > deeply quoted") == ["deeply quoted"]
163
+
164
+
165
+ # --- end to end ------------------------------------------------------------
166
+
167
+
168
+ def test_currency_runs_before_decimal_split():
169
+ # The ordering bug guard: £1.63 -> "1.63 pounds" -> "1 point 6 3 pounds",
170
+ # never "£1 point 63".
171
+ out = normalize_for_speech("It cost £1.63 today.", markdown=False)
172
+ assert "1 point 6 3 pounds" in out
173
+ assert "£" not in out
174
+
175
+
176
+ def test_blog_style_normalization():
177
+ md = "## The Verdict\n\nIt drew 770.72 kWh for £192.68 - a loss."
178
+ out = normalize_for_speech(md, pronunciations={}, tables="drop")
179
+ assert out == ("The Verdict.\nIt drew 770 point 7 2 kilowatt hours for 192 point 6 8 pounds ... a loss.")
180
+
181
+
182
+ def test_speaklast_style_normalization():
183
+ md = "I updated `Redis` ✅ and the CLI (v2)."
184
+ out = normalize_for_speech(
185
+ md,
186
+ pronunciations={"Redis": "Reddis", "CLI": "C L I"},
187
+ tables="csv",
188
+ strip_emoji=True,
189
+ )
190
+ assert "Reddis" in out
191
+ assert "C L I, (v2)" in out
192
+ assert "✅" not in out
193
+
194
+
195
+ def test_plain_text_mode_splits_paragraphs():
196
+ out = normalize_for_speech("First para.\n\nSecond para.", markdown=False)
197
+ assert out == "First para.\nSecond para."
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