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.
Files changed (31) hide show
  1. {stackvox-0.7.0/stackvox.egg-info → stackvox-0.8.0}/PKG-INFO +1 -1
  2. {stackvox-0.7.0 → stackvox-0.8.0}/pyproject.toml +1 -1
  3. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/cli.py +7 -0
  4. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/engine.py +54 -2
  5. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/text.py +29 -1
  6. {stackvox-0.7.0 → stackvox-0.8.0/stackvox.egg-info}/PKG-INFO +1 -1
  7. {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_cli.py +1 -0
  8. {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_engine.py +28 -7
  9. {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_text.py +25 -0
  10. {stackvox-0.7.0 → stackvox-0.8.0}/LICENSE +0 -0
  11. {stackvox-0.7.0 → stackvox-0.8.0}/NOTICE +0 -0
  12. {stackvox-0.7.0 → stackvox-0.8.0}/README.md +0 -0
  13. {stackvox-0.7.0 → stackvox-0.8.0}/setup.cfg +0 -0
  14. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/__init__.py +0 -0
  15. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/__main__.py +0 -0
  16. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/config.py +0 -0
  17. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/daemon.py +0 -0
  18. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/data/stackvox-say +0 -0
  19. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/paths.py +0 -0
  20. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/py.typed +0 -0
  21. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox/updates.py +0 -0
  22. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/SOURCES.txt +0 -0
  23. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/dependency_links.txt +0 -0
  24. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/entry_points.txt +0 -0
  25. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/requires.txt +0 -0
  26. {stackvox-0.7.0 → stackvox-0.8.0}/stackvox.egg-info/top_level.txt +0 -0
  27. {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_config.py +0 -0
  28. {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_daemon.py +0 -0
  29. {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_daemon_protocol.py +0 -0
  30. {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_paths.py +0 -0
  31. {stackvox-0.7.0 → stackvox-0.8.0}/tests/test_updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- sd.play(samples, sample_rate)
223
- sd.wait()
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=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,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -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,
@@ -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 + play cycle per sentence — that's what enables early audio.
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
- assert engine.sd.play.call_count == 3
111
- assert engine.sd.wait.call_count == 3
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
- assert engine.sd.play.call_count == 1
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.play.assert_called()
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.play.assert_called()
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