stackvox 0.7.0__tar.gz → 0.8.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.8.0}/PKG-INFO +1 -1
- {stackvox-0.7.0 → stackvox-0.8.0}/pyproject.toml +1 -1
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/cli.py +7 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/engine.py +54 -2
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/text.py +29 -1
- {stackvox-0.7.0 → stackvox-0.8.0/stackvox.egg-info}/PKG-INFO +1 -1
- {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_cli.py +1 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_engine.py +28 -7
- {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_text.py +25 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/LICENSE +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/NOTICE +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/README.md +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/setup.cfg +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/__init__.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/__main__.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/config.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/daemon.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/paths.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/py.typed +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/updates.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/SOURCES.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_config.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_daemon.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_paths.py +0 -0
- {stackvox-0.7.0 → stackvox-0.8.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,
|
|
@@ -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]:
|
|
@@ -106,6 +106,28 @@ def apply_pronunciations(text: str, mapping: dict[str, str] | None) -> str:
|
|
|
106
106
|
return text
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
# Dev acronyms/terms espeak mispronounces — it reads them as a word ("CLI" ->
|
|
110
|
+
# "kligh", "AWS" -> "awz", "URI" -> "yuri") instead of spelling them out.
|
|
111
|
+
# Applied by default (dev_terms=True), whole-word and case-insensitive, so
|
|
112
|
+
# lowercase "cli" is fixed too. Compound keys (ci/cd) precede their parts (ci)
|
|
113
|
+
# so the specific form wins. Only terms espeak gets WRONG are here — API, URL,
|
|
114
|
+
# JSON, YAML, HTTP, CRUD, nginx, etc. already voice correctly and are left alone.
|
|
115
|
+
_DEV_PRONUNCIATIONS: dict[str, str] = {
|
|
116
|
+
"ci/cd": "C I C D",
|
|
117
|
+
"cli": "C L I",
|
|
118
|
+
"ci": "C I",
|
|
119
|
+
"ide": "I D E",
|
|
120
|
+
"aws": "A W S",
|
|
121
|
+
"uri": "U R I",
|
|
122
|
+
"iam": "I A M",
|
|
123
|
+
"saas": "sass",
|
|
124
|
+
"paas": "pass",
|
|
125
|
+
"tui": "T U I",
|
|
126
|
+
"postgresql": "postgres",
|
|
127
|
+
"kubectl": "kube control",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
109
131
|
# --------------------------------------------------------------------------- #
|
|
110
132
|
# Pauses #
|
|
111
133
|
# --------------------------------------------------------------------------- #
|
|
@@ -274,6 +296,7 @@ def normalize_for_speech(
|
|
|
274
296
|
*,
|
|
275
297
|
markdown: bool = True,
|
|
276
298
|
pronunciations: dict[str, str] | None = None,
|
|
299
|
+
dev_terms: bool = True,
|
|
277
300
|
expand_units: bool = True,
|
|
278
301
|
expand_numbers: bool = True,
|
|
279
302
|
pauses: bool = True,
|
|
@@ -285,6 +308,11 @@ def normalize_for_speech(
|
|
|
285
308
|
"""Normalize ``text`` into speakable prose. Returns paragraphs joined by
|
|
286
309
|
newlines. See ``docs/speech-normalization.md`` for the full contract."""
|
|
287
310
|
expand_units_flag, expand_numbers_flag = expand_units, expand_numbers
|
|
311
|
+
# Built-in dev-term fixes first; caller-supplied pronunciations override them
|
|
312
|
+
# (keyed case-insensitively, so a caller's "CLI" beats the default "cli").
|
|
313
|
+
effective_pronunciations: dict[str, str] = dict(_DEV_PRONUNCIATIONS) if dev_terms else {}
|
|
314
|
+
for written, spoken in (pronunciations or {}).items():
|
|
315
|
+
effective_pronunciations[written.lower()] = spoken
|
|
288
316
|
|
|
289
317
|
if markdown:
|
|
290
318
|
paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji)
|
|
@@ -298,7 +326,7 @@ def normalize_for_speech(
|
|
|
298
326
|
for para in paragraphs:
|
|
299
327
|
shaped = _shape_paragraph(
|
|
300
328
|
para,
|
|
301
|
-
pronunciations=
|
|
329
|
+
pronunciations=effective_pronunciations,
|
|
302
330
|
expand_units_flag=expand_units_flag,
|
|
303
331
|
expand_numbers_flag=expand_numbers_flag,
|
|
304
332
|
pauses_flag=pauses,
|
|
@@ -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:
|
|
@@ -195,3 +195,28 @@ def test_speaklast_style_normalization():
|
|
|
195
195
|
def test_plain_text_mode_splits_paragraphs():
|
|
196
196
|
out = normalize_for_speech("First para.\n\nSecond para.", markdown=False)
|
|
197
197
|
assert out == "First para.\nSecond para."
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_dev_acronyms_are_spelled_out():
|
|
201
|
+
assert normalize_for_speech("Use the CLI.", markdown=False) == "Use the C L I."
|
|
202
|
+
assert "C L I" in normalize_for_speech("the cli tool", markdown=False) # lowercase too
|
|
203
|
+
assert "A W S" in normalize_for_speech("deploy to AWS", markdown=False)
|
|
204
|
+
assert "U R I" in normalize_for_speech("parse the URI", markdown=False)
|
|
205
|
+
assert "C I C D" in normalize_for_speech("the CI/CD pipeline", markdown=False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_dev_terms_leave_correctly_voiced_acronyms_alone():
|
|
209
|
+
out = normalize_for_speech("Send JSON over HTTP to the API.", markdown=False)
|
|
210
|
+
assert "JSON" in out
|
|
211
|
+
assert "HTTP" in out
|
|
212
|
+
assert "API" in out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_dev_terms_can_be_disabled():
|
|
216
|
+
assert normalize_for_speech("Use the CLI.", markdown=False, dev_terms=False) == "Use the CLI."
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_caller_pronunciations_override_dev_terms():
|
|
220
|
+
out = normalize_for_speech("Use the CLI.", markdown=False, pronunciations={"CLI": "command line"})
|
|
221
|
+
assert "command line" in out
|
|
222
|
+
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
|
|
File without changes
|
|
File without changes
|