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.
- {stackvox-0.4.0/stackvox.egg-info → stackvox-0.6.0}/PKG-INFO +4 -2
- {stackvox-0.4.0 → stackvox-0.6.0}/README.md +3 -1
- {stackvox-0.4.0 → stackvox-0.6.0}/pyproject.toml +6 -2
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/__init__.py +2 -1
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/cli.py +31 -4
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/daemon.py +18 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/engine.py +118 -4
- stackvox-0.6.0/stackvox/text.py +310 -0
- stackvox-0.6.0/stackvox/updates.py +184 -0
- {stackvox-0.4.0 → stackvox-0.6.0/stackvox.egg-info}/PKG-INFO +4 -2
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/SOURCES.txt +5 -1
- {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_cli.py +63 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_engine.py +95 -13
- stackvox-0.6.0/tests/test_text.py +197 -0
- stackvox-0.6.0/tests/test_updates.py +220 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/LICENSE +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/NOTICE +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/setup.cfg +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/__main__.py +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/config.py +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/paths.py +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox/py.typed +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_config.py +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_daemon.py +0 -0
- {stackvox-0.4.0 → stackvox-0.6.0}/tests/test_daemon_protocol.py +0 -0
- {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.
|
|
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.
|
|
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"]
|
|
@@ -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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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)
|