stackvox 0.7.0__tar.gz → 0.9.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.7.0/stackvox.egg-info → stackvox-0.9.0}/PKG-INFO +1 -1
- {stackvox-0.7.0 → stackvox-0.9.0}/pyproject.toml +1 -1
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/cli.py +16 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/daemon.py +15 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/engine.py +54 -2
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/text.py +46 -1
- {stackvox-0.7.0 → stackvox-0.9.0/stackvox.egg-info}/PKG-INFO +1 -1
- {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_cli.py +21 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_daemon_protocol.py +16 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_engine.py +28 -7
- {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_text.py +51 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/LICENSE +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/NOTICE +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/README.md +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/setup.cfg +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/__init__.py +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/__main__.py +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/config.py +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/paths.py +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/py.typed +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/updates.py +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/SOURCES.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_config.py +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_daemon.py +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_paths.py +0 -0
- {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_updates.py +0 -0
|
@@ -208,6 +208,12 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -
|
|
|
208
208
|
type=Path,
|
|
209
209
|
help="JSON file mapping written -> spoken forms (whole-word, case-insensitive)",
|
|
210
210
|
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--no-dev-terms",
|
|
213
|
+
dest="dev_terms",
|
|
214
|
+
action="store_false",
|
|
215
|
+
help="Do not spell out dev acronyms espeak mispronounces (CLI, CI, IDE, AWS, URI, IAM, ...)",
|
|
216
|
+
)
|
|
211
217
|
parser.add_argument(
|
|
212
218
|
"--no-expand-units",
|
|
213
219
|
dest="expand_units",
|
|
@@ -268,6 +274,7 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict:
|
|
|
268
274
|
return {
|
|
269
275
|
"markdown": args.markdown,
|
|
270
276
|
"pronunciations": _load_pronunciations(args.pronunciations),
|
|
277
|
+
"dev_terms": args.dev_terms,
|
|
271
278
|
"expand_units": args.expand_units,
|
|
272
279
|
"expand_numbers": args.expand_numbers,
|
|
273
280
|
"pauses": args.pauses,
|
|
@@ -382,9 +389,18 @@ def _cmd_stop(_: argparse.Namespace) -> int:
|
|
|
382
389
|
|
|
383
390
|
|
|
384
391
|
def _cmd_status(_: argparse.Namespace) -> int:
|
|
392
|
+
installed = updates._current_version()
|
|
385
393
|
if daemon.is_running():
|
|
386
394
|
print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}")
|
|
387
395
|
rc = 0
|
|
396
|
+
# The running daemon can lag the installed package if it wasn't restarted
|
|
397
|
+
# after an upgrade — surface that skew instead of trusting it blindly.
|
|
398
|
+
got, running_version = daemon.version()
|
|
399
|
+
if got and running_version != installed:
|
|
400
|
+
print(
|
|
401
|
+
f"daemon running {running_version}, but {installed} is installed "
|
|
402
|
+
f"— restart the daemon (stackvox stop; stackvox serve) to pick it up"
|
|
403
|
+
)
|
|
388
404
|
else:
|
|
389
405
|
print("stopped")
|
|
390
406
|
rc = 1
|
|
@@ -222,6 +222,10 @@ class _Handler(socketserver.StreamRequestHandler):
|
|
|
222
222
|
self.wfile.write(b"ok\n")
|
|
223
223
|
return
|
|
224
224
|
|
|
225
|
+
if command == "version":
|
|
226
|
+
self.wfile.write(f"{updates._current_version()}\n".encode())
|
|
227
|
+
return
|
|
228
|
+
|
|
225
229
|
if command == "stop":
|
|
226
230
|
self.wfile.write(b"ok\n")
|
|
227
231
|
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
@@ -343,3 +347,14 @@ def stop() -> tuple[bool, str]:
|
|
|
343
347
|
|
|
344
348
|
def ping() -> tuple[bool, str]:
|
|
345
349
|
return send({"command": "ping"}, timeout=PING_TIMEOUT_SECONDS)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def version(timeout: float = PING_TIMEOUT_SECONDS) -> tuple[bool, str]:
|
|
353
|
+
"""(got_version, version_or_error) reported by the running daemon.
|
|
354
|
+
|
|
355
|
+
The running daemon can differ from the installed package if it wasn't
|
|
356
|
+
restarted after an upgrade, so this reports what is actually serving — not
|
|
357
|
+
what's on disk. A version reply starts with a digit; error strings don't.
|
|
358
|
+
"""
|
|
359
|
+
_, resp = send({"command": "version"}, timeout=timeout)
|
|
360
|
+
return resp[:1].isdigit(), resp
|
|
@@ -22,6 +22,12 @@ DEFAULT_VOICE = "af_sarah"
|
|
|
22
22
|
DEFAULT_SPEED = 1.0
|
|
23
23
|
DEFAULT_LANG = "en-us"
|
|
24
24
|
|
|
25
|
+
# Streaming playback smoothing: play a touch of silence before the first word so
|
|
26
|
+
# output-device warm-up (notably a Bluetooth codec/profile switch) lands during
|
|
27
|
+
# silence, and ramp the opening samples up from zero to avoid an onset click.
|
|
28
|
+
_PRIME_SILENCE_SECONDS = 0.12
|
|
29
|
+
_FADE_IN_SECONDS = 0.008
|
|
30
|
+
|
|
25
31
|
_MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx"
|
|
26
32
|
_VOICES_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
|
|
27
33
|
|
|
@@ -82,6 +88,16 @@ def _split_sentences(text: str) -> list[str]:
|
|
|
82
88
|
return [part.strip() for part in _SENTENCE_BOUNDARY.split(text.strip()) if part.strip()]
|
|
83
89
|
|
|
84
90
|
|
|
91
|
+
def _fade_in(samples: np.ndarray, sample_rate: int) -> np.ndarray:
|
|
92
|
+
"""Ramp the opening samples up from zero so playback doesn't begin with a click."""
|
|
93
|
+
ramp_len = min(len(samples), int(sample_rate * _FADE_IN_SECONDS))
|
|
94
|
+
if ramp_len <= 0:
|
|
95
|
+
return samples
|
|
96
|
+
faded = np.array(samples, dtype="float32", copy=True)
|
|
97
|
+
faded[:ramp_len] *= np.linspace(0.0, 1.0, ramp_len, dtype="float32")
|
|
98
|
+
return faded
|
|
99
|
+
|
|
100
|
+
|
|
85
101
|
class Stackvox:
|
|
86
102
|
"""Reusable TTS engine. Load the model once, speak many times.
|
|
87
103
|
|
|
@@ -104,6 +120,8 @@ class Stackvox:
|
|
|
104
120
|
self._kokoro = Kokoro(str(model_path), str(voices_path))
|
|
105
121
|
self._stop_event = threading.Event()
|
|
106
122
|
self._play_thread: threading.Thread | None = None
|
|
123
|
+
self._stream: sd.OutputStream | None = None
|
|
124
|
+
self._stream_lock = threading.Lock()
|
|
107
125
|
|
|
108
126
|
def synthesize(
|
|
109
127
|
self,
|
|
@@ -210,6 +228,7 @@ class Stackvox:
|
|
|
210
228
|
producer = threading.Thread(target=_produce, daemon=True, name="stackvox-synth")
|
|
211
229
|
producer.start()
|
|
212
230
|
error: Exception | None = None
|
|
231
|
+
stream: sd.OutputStream | None = None
|
|
213
232
|
try:
|
|
214
233
|
while not stop_event.is_set():
|
|
215
234
|
item = chunks.get()
|
|
@@ -219,9 +238,36 @@ class Stackvox:
|
|
|
219
238
|
error = item
|
|
220
239
|
break
|
|
221
240
|
samples, sample_rate = item
|
|
222
|
-
|
|
223
|
-
|
|
241
|
+
samples = np.ascontiguousarray(samples, dtype="float32")
|
|
242
|
+
if stream is None:
|
|
243
|
+
# One stream for the whole utterance: writing chunks into it is
|
|
244
|
+
# gapless and avoids the per-sentence open/close that crackles at
|
|
245
|
+
# the start. Prime with silence so device warm-up (e.g. a
|
|
246
|
+
# Bluetooth codec switch) lands before the first word, and fade
|
|
247
|
+
# the opening samples in to avoid an onset click.
|
|
248
|
+
stream = sd.OutputStream(samplerate=sample_rate, channels=1, dtype="float32")
|
|
249
|
+
stream.start()
|
|
250
|
+
with self._stream_lock:
|
|
251
|
+
self._stream = stream
|
|
252
|
+
prime = int(sample_rate * _PRIME_SILENCE_SECONDS)
|
|
253
|
+
if prime > 0:
|
|
254
|
+
stream.write(np.zeros(prime, dtype="float32"))
|
|
255
|
+
samples = _fade_in(samples, sample_rate)
|
|
256
|
+
try:
|
|
257
|
+
stream.write(samples)
|
|
258
|
+
except Exception:
|
|
259
|
+
if stop_event.is_set():
|
|
260
|
+
break # aborted by stop(); expected
|
|
261
|
+
raise
|
|
224
262
|
finally:
|
|
263
|
+
with self._stream_lock:
|
|
264
|
+
self._stream = None
|
|
265
|
+
if stream is not None:
|
|
266
|
+
try:
|
|
267
|
+
stream.stop()
|
|
268
|
+
stream.close()
|
|
269
|
+
except Exception:
|
|
270
|
+
logger.debug("stream teardown failed", exc_info=True)
|
|
225
271
|
# Stop the producer and keep draining so it can never stay parked on
|
|
226
272
|
# a full queue — that's what guarantees the synth thread always exits.
|
|
227
273
|
stop_event.set()
|
|
@@ -236,6 +282,12 @@ class Stackvox:
|
|
|
236
282
|
def stop(self) -> None:
|
|
237
283
|
"""Stop any in-progress playback started with blocking=False."""
|
|
238
284
|
self._stop_event.set()
|
|
285
|
+
with self._stream_lock:
|
|
286
|
+
if self._stream is not None:
|
|
287
|
+
try:
|
|
288
|
+
self._stream.abort()
|
|
289
|
+
except Exception:
|
|
290
|
+
logger.debug("stream abort failed", exc_info=True)
|
|
239
291
|
sd.stop()
|
|
240
292
|
|
|
241
293
|
def voices(self) -> list[str]:
|
|
@@ -18,6 +18,7 @@ __all__ = [
|
|
|
18
18
|
"markdown_to_paragraphs",
|
|
19
19
|
"strip_emoji",
|
|
20
20
|
"strip_thousands_separators",
|
|
21
|
+
"versions_to_words",
|
|
21
22
|
"decimals_to_words",
|
|
22
23
|
"expand_units",
|
|
23
24
|
"apply_pronunciations",
|
|
@@ -84,6 +85,21 @@ def strip_thousands_separators(text: str) -> str:
|
|
|
84
85
|
return re.sub(r"(?<=\d),(?=\d)", "", text)
|
|
85
86
|
|
|
86
87
|
|
|
88
|
+
_VERSION = re.compile(r"(?<!\d)\d+(?:\.\d+){2,}(?!\d)")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def versions_to_words(text: str) -> str:
|
|
92
|
+
"""1.2.3 -> "1 point 2 point 3"; 0.7.0 -> "0 point 7 point 0".
|
|
93
|
+
|
|
94
|
+
A dotted version string has more than one decimal point, which
|
|
95
|
+
`decimals_to_words` can't handle — it splits only the first, leaving a stray
|
|
96
|
+
full stop mid-number ("0.7.0" -> "0 point 7.0"). Run this before it. Two-part
|
|
97
|
+
numbers are left untouched so ordinary decimals still read digit-by-digit,
|
|
98
|
+
and a trailing sentence stop (``upgrade to 1.2.3.``) is preserved.
|
|
99
|
+
"""
|
|
100
|
+
return _VERSION.sub(lambda m: m.group(0).replace(".", " point "), text)
|
|
101
|
+
|
|
102
|
+
|
|
87
103
|
def decimals_to_words(text: str) -> str:
|
|
88
104
|
"""1198.9 -> "1198 point 9"; 770.72 -> "770 point 7 2". Removes the bare
|
|
89
105
|
"." between digits, which TTS can otherwise read as a full stop."""
|
|
@@ -106,6 +122,28 @@ def apply_pronunciations(text: str, mapping: dict[str, str] | None) -> str:
|
|
|
106
122
|
return text
|
|
107
123
|
|
|
108
124
|
|
|
125
|
+
# Dev acronyms/terms espeak mispronounces — it reads them as a word ("CLI" ->
|
|
126
|
+
# "kligh", "AWS" -> "awz", "URI" -> "yuri") instead of spelling them out.
|
|
127
|
+
# Applied by default (dev_terms=True), whole-word and case-insensitive, so
|
|
128
|
+
# lowercase "cli" is fixed too. Compound keys (ci/cd) precede their parts (ci)
|
|
129
|
+
# so the specific form wins. Only terms espeak gets WRONG are here — API, URL,
|
|
130
|
+
# JSON, YAML, HTTP, CRUD, nginx, etc. already voice correctly and are left alone.
|
|
131
|
+
_DEV_PRONUNCIATIONS: dict[str, str] = {
|
|
132
|
+
"ci/cd": "C I C D",
|
|
133
|
+
"cli": "C L I",
|
|
134
|
+
"ci": "C I",
|
|
135
|
+
"ide": "I D E",
|
|
136
|
+
"aws": "A W S",
|
|
137
|
+
"uri": "U R I",
|
|
138
|
+
"iam": "I A M",
|
|
139
|
+
"saas": "sass",
|
|
140
|
+
"paas": "pass",
|
|
141
|
+
"tui": "T U I",
|
|
142
|
+
"postgresql": "postgres",
|
|
143
|
+
"kubectl": "kube control",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
109
147
|
# --------------------------------------------------------------------------- #
|
|
110
148
|
# Pauses #
|
|
111
149
|
# --------------------------------------------------------------------------- #
|
|
@@ -265,6 +303,7 @@ def _shape_paragraph(
|
|
|
265
303
|
if expand_units_flag: # units BEFORE decimals (see note above)
|
|
266
304
|
text = expand_units(text, locale)
|
|
267
305
|
if expand_numbers_flag:
|
|
306
|
+
text = versions_to_words(text) # multi-dot versions BEFORE the decimal split
|
|
268
307
|
text = decimals_to_words(text)
|
|
269
308
|
return re.sub(r"[ \t]{2,}", " ", text).strip()
|
|
270
309
|
|
|
@@ -274,6 +313,7 @@ def normalize_for_speech(
|
|
|
274
313
|
*,
|
|
275
314
|
markdown: bool = True,
|
|
276
315
|
pronunciations: dict[str, str] | None = None,
|
|
316
|
+
dev_terms: bool = True,
|
|
277
317
|
expand_units: bool = True,
|
|
278
318
|
expand_numbers: bool = True,
|
|
279
319
|
pauses: bool = True,
|
|
@@ -285,6 +325,11 @@ def normalize_for_speech(
|
|
|
285
325
|
"""Normalize ``text`` into speakable prose. Returns paragraphs joined by
|
|
286
326
|
newlines. See ``docs/speech-normalization.md`` for the full contract."""
|
|
287
327
|
expand_units_flag, expand_numbers_flag = expand_units, expand_numbers
|
|
328
|
+
# Built-in dev-term fixes first; caller-supplied pronunciations override them
|
|
329
|
+
# (keyed case-insensitively, so a caller's "CLI" beats the default "cli").
|
|
330
|
+
effective_pronunciations: dict[str, str] = dict(_DEV_PRONUNCIATIONS) if dev_terms else {}
|
|
331
|
+
for written, spoken in (pronunciations or {}).items():
|
|
332
|
+
effective_pronunciations[written.lower()] = spoken
|
|
288
333
|
|
|
289
334
|
if markdown:
|
|
290
335
|
paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji)
|
|
@@ -298,7 +343,7 @@ def normalize_for_speech(
|
|
|
298
343
|
for para in paragraphs:
|
|
299
344
|
shaped = _shape_paragraph(
|
|
300
345
|
para,
|
|
301
|
-
pronunciations=
|
|
346
|
+
pronunciations=effective_pronunciations,
|
|
302
347
|
expand_units_flag=expand_units_flag,
|
|
303
348
|
expand_numbers_flag=expand_numbers_flag,
|
|
304
349
|
pauses_flag=pauses,
|
|
@@ -128,6 +128,7 @@ def _norm_ns(text=None, file=None, **overrides):
|
|
|
128
128
|
file=file,
|
|
129
129
|
markdown=True,
|
|
130
130
|
pronunciations=None,
|
|
131
|
+
dev_terms=True,
|
|
131
132
|
expand_units=True,
|
|
132
133
|
expand_numbers=True,
|
|
133
134
|
pauses=True,
|
|
@@ -260,6 +261,9 @@ class TestCmdStatus:
|
|
|
260
261
|
pid_path.read_text.return_value = "12345\n"
|
|
261
262
|
mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
|
|
262
263
|
mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
|
|
264
|
+
# status now queries the running daemon's version; stub it so the test
|
|
265
|
+
# doesn't reach a real socket.
|
|
266
|
+
mocker.patch.object(cli.daemon, "version", return_value=(False, "n/a"))
|
|
263
267
|
|
|
264
268
|
rc = cli._cmd_status(_ns())
|
|
265
269
|
|
|
@@ -268,6 +272,23 @@ class TestCmdStatus:
|
|
|
268
272
|
assert "running" in out
|
|
269
273
|
assert "12345" in out
|
|
270
274
|
|
|
275
|
+
def test_running_flags_daemon_version_skew(self, mocker, capsys):
|
|
276
|
+
mocker.patch.object(cli.daemon, "is_running", return_value=True)
|
|
277
|
+
pid_path = mocker.MagicMock()
|
|
278
|
+
pid_path.read_text.return_value = "1\n"
|
|
279
|
+
mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
|
|
280
|
+
mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
|
|
281
|
+
mocker.patch.object(cli.daemon, "version", return_value=(True, "0.5.0"))
|
|
282
|
+
mocker.patch.object(cli.updates, "_current_version", return_value="0.8.0")
|
|
283
|
+
mocker.patch.object(cli.updates, "check_for_update", return_value=None)
|
|
284
|
+
|
|
285
|
+
rc = cli._cmd_status(_ns())
|
|
286
|
+
|
|
287
|
+
assert rc == 0
|
|
288
|
+
out = capsys.readouterr().out
|
|
289
|
+
assert "0.5.0" in out and "0.8.0" in out # the skew is surfaced
|
|
290
|
+
assert "restart" in out.lower()
|
|
291
|
+
|
|
271
292
|
def test_stopped_returns_one(self, mocker, capsys):
|
|
272
293
|
mocker.patch.object(cli.daemon, "is_running", return_value=False)
|
|
273
294
|
rc = cli._cmd_status(_ns())
|
|
@@ -76,6 +76,22 @@ def test_ping_returns_ok(server: ServerHarness):
|
|
|
76
76
|
assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok"
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def test_version_returns_installed_version(server: ServerHarness):
|
|
80
|
+
from stackvox import updates
|
|
81
|
+
|
|
82
|
+
reply = _roundtrip(server.sock, json.dumps({"command": "version"}) + "\n")
|
|
83
|
+
assert reply == updates._current_version()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_version_client_helper_reports_running_version(server: ServerHarness, monkeypatch):
|
|
87
|
+
from stackvox import daemon, updates
|
|
88
|
+
|
|
89
|
+
monkeypatch.setattr(daemon, "SOCKET_PATH", server.sock)
|
|
90
|
+
got, resp = daemon.version()
|
|
91
|
+
assert got is True
|
|
92
|
+
assert resp == updates._current_version()
|
|
93
|
+
|
|
94
|
+
|
|
79
95
|
def test_plain_text_is_treated_as_text_field(server: ServerHarness):
|
|
80
96
|
"""Non-JSON payloads are accepted and wrapped as `{"text": line}`."""
|
|
81
97
|
import time
|
|
@@ -35,7 +35,8 @@ def fake_kokoro(mocker, fake_ensure_models):
|
|
|
35
35
|
|
|
36
36
|
@pytest.fixture
|
|
37
37
|
def fake_audio(mocker):
|
|
38
|
-
"""Mock the sounddevice surface used by Stackvox."""
|
|
38
|
+
"""Mock the sounddevice surface used by Stackvox (streaming + speak_sequence)."""
|
|
39
|
+
mocker.patch.object(engine.sd, "OutputStream")
|
|
39
40
|
mocker.patch.object(engine.sd, "play")
|
|
40
41
|
mocker.patch.object(engine.sd, "wait")
|
|
41
42
|
mocker.patch.object(engine.sd, "stop")
|
|
@@ -105,15 +106,35 @@ class TestSpeak:
|
|
|
105
106
|
|
|
106
107
|
engine.Stackvox().speak("One sentence. Two sentence. Three here.")
|
|
107
108
|
|
|
108
|
-
# One synth
|
|
109
|
+
# One synth per sentence (early audio), all streamed through a single
|
|
110
|
+
# output stream: a prime-silence write plus one write per sentence.
|
|
109
111
|
assert fake_kokoro.return_value.create.call_count == 3
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
engine.sd.OutputStream.assert_called_once()
|
|
113
|
+
stream = engine.sd.OutputStream.return_value
|
|
114
|
+
assert stream.write.call_count == 4 # 1 prime + 3 sentences
|
|
115
|
+
stream.close.assert_called_once()
|
|
112
116
|
|
|
113
117
|
def test_single_sentence_plays_once(self, fake_kokoro, fake_audio):
|
|
114
118
|
fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
|
|
115
119
|
engine.Stackvox().speak("Just one sentence with no terminal punctuation")
|
|
116
|
-
|
|
120
|
+
engine.sd.OutputStream.assert_called_once()
|
|
121
|
+
assert engine.sd.OutputStream.return_value.write.call_count == 2 # prime + 1 sentence
|
|
122
|
+
|
|
123
|
+
def test_primes_with_silence_before_first_audio(self, fake_kokoro, fake_audio):
|
|
124
|
+
# The lead-in silence lets the output device (e.g. Bluetooth) warm up
|
|
125
|
+
# before the first word, rather than crackling into it.
|
|
126
|
+
fake_kokoro.return_value.create.return_value = (np.ones(10, dtype=np.float32), 24000)
|
|
127
|
+
engine.Stackvox().speak("hello")
|
|
128
|
+
prime = engine.sd.OutputStream.return_value.write.call_args_list[0].args[0]
|
|
129
|
+
assert not prime.any()
|
|
130
|
+
assert len(prime) == int(24000 * engine._PRIME_SILENCE_SECONDS)
|
|
131
|
+
|
|
132
|
+
def test_first_audio_fades_in_from_zero(self, fake_kokoro, fake_audio):
|
|
133
|
+
fake_kokoro.return_value.create.return_value = (np.ones(1000, dtype=np.float32), 24000)
|
|
134
|
+
engine.Stackvox().speak("hello")
|
|
135
|
+
first_audio = engine.sd.OutputStream.return_value.write.call_args_list[1].args[0]
|
|
136
|
+
assert first_audio[0] == 0.0 # ramp starts at silence
|
|
137
|
+
assert first_audio[-1] == 1.0 # ramp completes, body untouched
|
|
117
138
|
|
|
118
139
|
def test_non_blocking_plays_in_background_thread(self, fake_kokoro, fake_audio):
|
|
119
140
|
fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
|
|
@@ -123,7 +144,7 @@ class TestSpeak:
|
|
|
123
144
|
|
|
124
145
|
assert tts._play_thread is not None
|
|
125
146
|
tts._play_thread.join(timeout=5)
|
|
126
|
-
engine.sd.
|
|
147
|
+
engine.sd.OutputStream.assert_called()
|
|
127
148
|
|
|
128
149
|
def test_passes_engine_defaults_to_synthesis(self, fake_kokoro, fake_audio):
|
|
129
150
|
fake_kokoro.return_value.create.return_value = (np.zeros(4, dtype=np.float32), 24000)
|
|
@@ -177,7 +198,7 @@ class TestSpeak:
|
|
|
177
198
|
tts = engine.Stackvox()
|
|
178
199
|
tts.stop()
|
|
179
200
|
tts.speak("hello world")
|
|
180
|
-
engine.sd.
|
|
201
|
+
engine.sd.OutputStream.assert_called()
|
|
181
202
|
|
|
182
203
|
|
|
183
204
|
class TestSplitSentences:
|
|
@@ -10,6 +10,7 @@ from stackvox.text import (
|
|
|
10
10
|
shape_pauses,
|
|
11
11
|
strip_emoji,
|
|
12
12
|
strip_thousands_separators,
|
|
13
|
+
versions_to_words,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
# --- numbers ---------------------------------------------------------------
|
|
@@ -28,6 +29,31 @@ def test_year_is_not_a_decimal():
|
|
|
28
29
|
assert decimals_to_words("built in 2023") == "built in 2023"
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def test_semver_all_parts_spoken():
|
|
33
|
+
assert versions_to_words("0.7.0") == "0 point 7 point 0"
|
|
34
|
+
assert versions_to_words("1.2.3") == "1 point 2 point 3"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_semver_multi_digit_component():
|
|
38
|
+
assert versions_to_words("1.20.3") == "1 point 20 point 3"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_version_preserves_trailing_sentence_stop():
|
|
42
|
+
assert versions_to_words("upgrade to 1.2.3.") == "upgrade to 1 point 2 point 3."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_two_part_number_is_left_for_the_decimal_pass():
|
|
46
|
+
# Only two components — an ordinary decimal, not a version.
|
|
47
|
+
assert versions_to_words("3.14") == "3.14"
|
|
48
|
+
assert decimals_to_words(versions_to_words("3.14")) == "3 point 1 4"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_semver_normalizes_end_to_end():
|
|
52
|
+
out = normalize_for_speech("Upgraded to 0.7.0 today.", markdown=False)
|
|
53
|
+
assert "0 point 7 point 0" in out
|
|
54
|
+
assert "0 point 7.0" not in out
|
|
55
|
+
|
|
56
|
+
|
|
31
57
|
# --- units -----------------------------------------------------------------
|
|
32
58
|
|
|
33
59
|
|
|
@@ -195,3 +221,28 @@ def test_speaklast_style_normalization():
|
|
|
195
221
|
def test_plain_text_mode_splits_paragraphs():
|
|
196
222
|
out = normalize_for_speech("First para.\n\nSecond para.", markdown=False)
|
|
197
223
|
assert out == "First para.\nSecond para."
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_dev_acronyms_are_spelled_out():
|
|
227
|
+
assert normalize_for_speech("Use the CLI.", markdown=False) == "Use the C L I."
|
|
228
|
+
assert "C L I" in normalize_for_speech("the cli tool", markdown=False) # lowercase too
|
|
229
|
+
assert "A W S" in normalize_for_speech("deploy to AWS", markdown=False)
|
|
230
|
+
assert "U R I" in normalize_for_speech("parse the URI", markdown=False)
|
|
231
|
+
assert "C I C D" in normalize_for_speech("the CI/CD pipeline", markdown=False)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_dev_terms_leave_correctly_voiced_acronyms_alone():
|
|
235
|
+
out = normalize_for_speech("Send JSON over HTTP to the API.", markdown=False)
|
|
236
|
+
assert "JSON" in out
|
|
237
|
+
assert "HTTP" in out
|
|
238
|
+
assert "API" in out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_dev_terms_can_be_disabled():
|
|
242
|
+
assert normalize_for_speech("Use the CLI.", markdown=False, dev_terms=False) == "Use the CLI."
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_caller_pronunciations_override_dev_terms():
|
|
246
|
+
out = normalize_for_speech("Use the CLI.", markdown=False, pronunciations={"CLI": "command line"})
|
|
247
|
+
assert "command line" in out
|
|
248
|
+
assert "C L I" not in out
|
|
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
|