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.
- {stackvox-0.5.0/stackvox.egg-info → stackvox-0.6.0}/PKG-INFO +1 -1
- {stackvox-0.5.0 → stackvox-0.6.0}/pyproject.toml +6 -2
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/__init__.py +2 -1
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/engine.py +118 -4
- stackvox-0.6.0/stackvox/text.py +310 -0
- {stackvox-0.5.0 → stackvox-0.6.0/stackvox.egg-info}/PKG-INFO +1 -1
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/SOURCES.txt +2 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_engine.py +95 -13
- stackvox-0.6.0/tests/test_text.py +197 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/LICENSE +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/NOTICE +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/README.md +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/setup.cfg +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/__main__.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/cli.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/config.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/daemon.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/paths.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/py.typed +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox/updates.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_cli.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_config.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_daemon.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_paths.py +0 -0
- {stackvox-0.5.0 → stackvox-0.6.0}/tests/test_updates.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|