stackvox 0.5.0__tar.gz → 0.7.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.5.0/stackvox.egg-info → stackvox-0.7.0}/PKG-INFO +16 -1
  2. {stackvox-0.5.0 → stackvox-0.7.0}/README.md +15 -0
  3. {stackvox-0.5.0 → stackvox-0.7.0}/pyproject.toml +6 -2
  4. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/__init__.py +2 -1
  5. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/cli.py +183 -5
  6. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/engine.py +118 -4
  7. stackvox-0.7.0/stackvox/text.py +310 -0
  8. {stackvox-0.5.0 → stackvox-0.7.0/stackvox.egg-info}/PKG-INFO +16 -1
  9. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/SOURCES.txt +2 -0
  10. {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_cli.py +149 -0
  11. {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_engine.py +95 -13
  12. stackvox-0.7.0/tests/test_text.py +197 -0
  13. {stackvox-0.5.0 → stackvox-0.7.0}/LICENSE +0 -0
  14. {stackvox-0.5.0 → stackvox-0.7.0}/NOTICE +0 -0
  15. {stackvox-0.5.0 → stackvox-0.7.0}/setup.cfg +0 -0
  16. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/__main__.py +0 -0
  17. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/config.py +0 -0
  18. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/daemon.py +0 -0
  19. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/data/stackvox-say +0 -0
  20. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/paths.py +0 -0
  21. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/py.typed +0 -0
  22. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox/updates.py +0 -0
  23. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/dependency_links.txt +0 -0
  24. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/entry_points.txt +0 -0
  25. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/requires.txt +0 -0
  26. {stackvox-0.5.0 → stackvox-0.7.0}/stackvox.egg-info/top_level.txt +0 -0
  27. {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_config.py +0 -0
  28. {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_daemon.py +0 -0
  29. {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_daemon_protocol.py +0 -0
  30. {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_paths.py +0 -0
  31. {stackvox-0.5.0 → stackvox-0.7.0}/tests/test_updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -88,8 +88,23 @@ stackvox welcome # multilingual welcome (6 languages)
88
88
  stackvox voices # list all voice ids
89
89
  echo "from a pipe" | stackvox # piped stdin works for speak/say
90
90
  stackvox speak --file message.txt # read a whole file
91
+ stackvox paths # print resolved cache / socket / pid paths
92
+ stackvox config # print resolved default voice/speed/lang
91
93
  ```
92
94
 
95
+ ### Speech normalization
96
+
97
+ `normalize_for_speech` (Markdown → speakable prose: strip formatting, expand units/numbers, shape pauses, apply a pronunciation dictionary) is available from the shell — no Python import required:
98
+
99
+ ```bash
100
+ stackvox normalize --file post.md # print normalized text (pipe / debug)
101
+ stackvox normalize --tables csv --strip-emoji < response.md
102
+ stackvox speak --normalize --file post.md # normalize, then synthesize
103
+ stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
104
+ ```
105
+
106
+ `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
107
+
93
108
  Bash completion:
94
109
 
95
110
  ```bash
@@ -54,8 +54,23 @@ stackvox welcome # multilingual welcome (6 languages)
54
54
  stackvox voices # list all voice ids
55
55
  echo "from a pipe" | stackvox # piped stdin works for speak/say
56
56
  stackvox speak --file message.txt # read a whole file
57
+ stackvox paths # print resolved cache / socket / pid paths
58
+ stackvox config # print resolved default voice/speed/lang
57
59
  ```
58
60
 
61
+ ### Speech normalization
62
+
63
+ `normalize_for_speech` (Markdown → speakable prose: strip formatting, expand units/numbers, shape pauses, apply a pronunciation dictionary) is available from the shell — no Python import required:
64
+
65
+ ```bash
66
+ stackvox normalize --file post.md # print normalized text (pipe / debug)
67
+ stackvox normalize --tables csv --strip-emoji < response.md
68
+ stackvox speak --normalize --file post.md # normalize, then synthesize
69
+ stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
70
+ ```
71
+
72
+ `--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
73
+
59
74
  Bash completion:
60
75
 
61
76
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.5.0"
7
+ version = "0.7.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
- python_version = "3.10"
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"]
@@ -10,8 +10,9 @@ from pathlib import Path
10
10
 
11
11
  import soundfile as sf
12
12
 
13
- from stackvox import config, daemon, updates
13
+ from stackvox import config, daemon, paths, updates
14
14
  from stackvox.engine import Stackvox
15
+ from stackvox.text import normalize_for_speech
15
16
 
16
17
 
17
18
  def _configure_logging() -> None:
@@ -28,8 +29,11 @@ SUBCOMMANDS = {
28
29
  "status",
29
30
  "say",
30
31
  "speak",
32
+ "normalize",
31
33
  "voices",
32
34
  "welcome",
35
+ "paths",
36
+ "config",
33
37
  "completion",
34
38
  "install-helper",
35
39
  }
@@ -47,12 +51,12 @@ _stackvox_completion() {
47
51
  subcommand="${COMP_WORDS[1]:-}"
48
52
 
49
53
  if [[ ${COMP_CWORD} -eq 1 ]]; then
50
- COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") )
54
+ COMPREPLY=( $(compgen -W "speak say normalize serve stop status voices welcome paths config completion install-helper" -- "$cur") )
51
55
  return 0
52
56
  fi
53
57
 
54
58
  case "$prev" in
55
- --file|--out)
59
+ --file|--out|--pronunciations)
56
60
  COMPREPLY=( $(compgen -f -- "$cur") )
57
61
  return 0
58
62
  ;;
@@ -68,14 +72,27 @@ _stackvox_completion() {
68
72
  COMPREPLY=( $(compgen -W "en-us en-gb fr-fr it hi pt-br es ja zh" -- "$cur") )
69
73
  return 0
70
74
  ;;
75
+ --tables)
76
+ COMPREPLY=( $(compgen -W "drop csv" -- "$cur") )
77
+ return 0
78
+ ;;
79
+ --locale)
80
+ COMPREPLY=( $(compgen -W "en-GB" -- "$cur") )
81
+ return 0
82
+ ;;
71
83
  esac
72
84
 
85
+ local norm_flags="--no-markdown --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --strip-emoji --no-terminal-stops --locale"
86
+
73
87
  case "$subcommand" in
74
88
  speak)
75
- COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --help" -- "$cur") )
89
+ COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --normalize $norm_flags --help" -- "$cur") )
76
90
  ;;
77
91
  say)
78
- COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --help" -- "$cur") )
92
+ COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --normalize $norm_flags --help" -- "$cur") )
93
+ ;;
94
+ normalize)
95
+ COMPREPLY=( $(compgen -W "--file $norm_flags --help" -- "$cur") )
79
96
  ;;
80
97
  serve)
81
98
  COMPREPLY=( $(compgen -W "--voice --speed --lang --help" -- "$cur") )
@@ -115,6 +132,7 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
115
132
  p_speak.add_argument("text", nargs="?")
116
133
  p_speak.add_argument("--file", type=Path)
117
134
  p_speak.add_argument("--out", type=Path, help="Write wav instead of playing")
135
+ _add_normalize_args(p_speak, with_switch=True)
118
136
 
119
137
  p_say = sub.add_parser("say", help="Send text to daemon (fast; fails if daemon not running)")
120
138
  _add_voice_args(p_say, defaults)
@@ -123,6 +141,15 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
123
141
  p_say.add_argument(
124
142
  "--fallback-say", action="store_true", help="Shell out to macOS `say` if daemon unreachable"
125
143
  )
144
+ _add_normalize_args(p_say, with_switch=True)
145
+
146
+ p_normalize = sub.add_parser(
147
+ "normalize",
148
+ help="Print speech-normalized text (Markdown -> speakable prose); no synthesis",
149
+ )
150
+ p_normalize.add_argument("text", nargs="?")
151
+ p_normalize.add_argument("--file", type=Path)
152
+ _add_normalize_args(p_normalize, with_switch=False)
126
153
 
127
154
  p_serve = sub.add_parser("serve", help="Run the daemon in the foreground")
128
155
  _add_voice_args(p_serve, defaults)
@@ -131,6 +158,8 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
131
158
  sub.add_parser("status", help="Print daemon status")
132
159
  sub.add_parser("voices", help="List available voices")
133
160
  sub.add_parser("welcome", help="Play a multilingual welcome message")
161
+ sub.add_parser("paths", help="Print resolved cache / socket / pid file paths")
162
+ sub.add_parser("config", help="Print resolved default voice/speed/lang and config path")
134
163
 
135
164
  p_completion = sub.add_parser("completion", help="Print a shell completion script")
136
165
  p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
@@ -155,6 +184,114 @@ def _add_voice_args(parser: argparse.ArgumentParser, defaults: config.Defaults)
155
184
  parser.add_argument("--lang", default=defaults.lang)
156
185
 
157
186
 
187
+ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -> None:
188
+ """Flags mapping one-to-one onto ``stackvox.text.normalize_for_speech``.
189
+
190
+ Defaults mirror the library: markdown / units / numbers / pauses /
191
+ terminal-stops on, emoji-strip off, tables dropped, ``en-GB`` locale.
192
+
193
+ ``with_switch`` adds the ``--normalize`` master toggle used by ``speak`` and
194
+ ``say`` (off by default, so notification phrases synth exactly as before).
195
+ The ``normalize`` subcommand always normalizes, so it omits the switch.
196
+ """
197
+ if with_switch:
198
+ parser.add_argument(
199
+ "--normalize",
200
+ action="store_true",
201
+ help="Normalize text for speech before synthesizing (see `stackvox normalize`)",
202
+ )
203
+ parser.add_argument(
204
+ "--no-markdown", dest="markdown", action="store_false", help="Treat input as plain text, not Markdown"
205
+ )
206
+ parser.add_argument(
207
+ "--pronunciations",
208
+ type=Path,
209
+ help="JSON file mapping written -> spoken forms (whole-word, case-insensitive)",
210
+ )
211
+ parser.add_argument(
212
+ "--no-expand-units",
213
+ dest="expand_units",
214
+ action="store_false",
215
+ help="Leave £/p/kWh/MPG/kg/km and ÷ × = as written",
216
+ )
217
+ parser.add_argument(
218
+ "--no-expand-numbers",
219
+ dest="expand_numbers",
220
+ action="store_false",
221
+ help="Leave thousands separators and decimals as written",
222
+ )
223
+ parser.add_argument(
224
+ "--no-pauses",
225
+ dest="pauses",
226
+ action="store_false",
227
+ help="Do not reshape dashes / parentheses into pauses",
228
+ )
229
+ parser.add_argument(
230
+ "--tables",
231
+ choices=["drop", "csv"],
232
+ default="drop",
233
+ help="How Markdown tables are voiced (default: drop)",
234
+ )
235
+ parser.add_argument(
236
+ "--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking"
237
+ )
238
+ parser.add_argument(
239
+ "--no-terminal-stops",
240
+ dest="terminal_stops",
241
+ action="store_false",
242
+ help="Do not add terminal punctuation so a pause lands between lines",
243
+ )
244
+ parser.add_argument("--locale", default="en-GB", help="Locale for unit/currency rules (default: en-GB)")
245
+
246
+
247
+ def _load_pronunciations(path: Path | None) -> dict[str, str] | None:
248
+ """Load a ``{written: spoken}`` map from a JSON file.
249
+
250
+ Returns ``None`` when no path is given. Raises ``ValueError`` if the file
251
+ is not a flat table of string -> string entries, so a mistyped dict fails
252
+ loudly rather than silently voicing the wrong thing.
253
+ """
254
+ if path is None:
255
+ return None
256
+ import json
257
+
258
+ data = json.loads(path.expanduser().read_text(encoding="utf-8"))
259
+ if not isinstance(data, dict) or not all(
260
+ isinstance(key, str) and isinstance(value, str) for key, value in data.items()
261
+ ):
262
+ raise ValueError(f"{path}: expected a flat JSON object of string -> string entries")
263
+ return data
264
+
265
+
266
+ def _normalize_kwargs(args: argparse.Namespace) -> dict:
267
+ """Collect the normalize_for_speech keyword arguments from parsed flags."""
268
+ return {
269
+ "markdown": args.markdown,
270
+ "pronunciations": _load_pronunciations(args.pronunciations),
271
+ "expand_units": args.expand_units,
272
+ "expand_numbers": args.expand_numbers,
273
+ "pauses": args.pauses,
274
+ "tables": args.tables,
275
+ "strip_emoji": args.strip_emoji,
276
+ "terminal_stops": args.terminal_stops,
277
+ "locale": args.locale,
278
+ }
279
+
280
+
281
+ def _resolve_normalized(text: str, args: argparse.Namespace) -> str | None:
282
+ """Normalize ``text`` for speech, or print a clean error and return None.
283
+
284
+ A missing or malformed ``--pronunciations`` file surfaces here as an
285
+ ``OSError`` / ``ValueError`` (``json.JSONDecodeError`` is a ``ValueError``);
286
+ turn it into a one-line ``[stackvox] …`` message rather than a traceback.
287
+ """
288
+ try:
289
+ return normalize_for_speech(text, **_normalize_kwargs(args))
290
+ except (OSError, ValueError) as exc:
291
+ print(f"[stackvox] {exc}", file=sys.stderr)
292
+ return None
293
+
294
+
158
295
  def _read_text(args: argparse.Namespace) -> str | None:
159
296
  """Resolve the text to speak from --file, the positional, or piped stdin.
160
297
 
@@ -177,6 +314,10 @@ def _cmd_speak(args: argparse.Namespace) -> int:
177
314
  if not text or not text.strip():
178
315
  print("Error: provide text or --file", file=sys.stderr)
179
316
  return 1
317
+ if getattr(args, "normalize", False):
318
+ text = _resolve_normalized(text, args)
319
+ if text is None:
320
+ return 1
180
321
  tts = Stackvox(voice=args.voice, speed=args.speed, lang=args.lang)
181
322
  if args.out:
182
323
  samples, sr = tts.synthesize(text)
@@ -192,6 +333,10 @@ def _cmd_say(args: argparse.Namespace) -> int:
192
333
  if not text or not text.strip():
193
334
  print("Error: provide text or --file", file=sys.stderr)
194
335
  return 1
336
+ if getattr(args, "normalize", False):
337
+ text = _resolve_normalized(text, args)
338
+ if text is None:
339
+ return 1
195
340
  ok, resp = daemon.say(text, voice=args.voice, speed=args.speed, lang=args.lang)
196
341
  if ok:
197
342
  return 0
@@ -206,6 +351,18 @@ def _cmd_say(args: argparse.Namespace) -> int:
206
351
  return 2
207
352
 
208
353
 
354
+ def _cmd_normalize(args: argparse.Namespace) -> int:
355
+ text = _read_text(args)
356
+ if not text or not text.strip():
357
+ print("Error: provide text or --file", file=sys.stderr)
358
+ return 1
359
+ normalized = _resolve_normalized(text, args)
360
+ if normalized is None:
361
+ return 1
362
+ print(normalized)
363
+ return 0
364
+
365
+
209
366
  def _cmd_serve(args: argparse.Namespace) -> int:
210
367
  try:
211
368
  daemon.serve(voice=args.voice, speed=args.speed, lang=args.lang)
@@ -248,6 +405,24 @@ def _cmd_voices(args: argparse.Namespace) -> int:
248
405
  return 0
249
406
 
250
407
 
408
+ def _cmd_paths(_: argparse.Namespace) -> int:
409
+ print(f"cache_dir: {paths.cache_dir()}")
410
+ print(f"socket_path: {paths.socket_path()}")
411
+ print(f"pid_path: {paths.pid_path()}")
412
+ return 0
413
+
414
+
415
+ def _cmd_config(_: argparse.Namespace) -> int:
416
+ path = config.config_path()
417
+ present = "" if path.is_file() else " (not present; built-in defaults apply)"
418
+ defaults = config.load_defaults()
419
+ print(f"config_path: {path}{present}")
420
+ print(f"voice: {defaults.voice}")
421
+ print(f"speed: {defaults.speed}")
422
+ print(f"lang: {defaults.lang}")
423
+ return 0
424
+
425
+
251
426
  def _cmd_welcome(_: argparse.Namespace) -> int:
252
427
  tts = Stackvox()
253
428
  tts.speak_sequence([{"text": text, "voice": voice, "lang": lang} for voice, lang, text in WELCOME_LINES])
@@ -336,11 +511,14 @@ def main() -> int:
336
511
  handlers = {
337
512
  "speak": _cmd_speak,
338
513
  "say": _cmd_say,
514
+ "normalize": _cmd_normalize,
339
515
  "serve": _cmd_serve,
340
516
  "stop": _cmd_stop,
341
517
  "status": _cmd_status,
342
518
  "voices": _cmd_voices,
343
519
  "welcome": _cmd_welcome,
520
+ "paths": _cmd_paths,
521
+ "config": _cmd_config,
344
522
  "completion": _cmd_completion,
345
523
  "install-helper": _cmd_install_helper,
346
524
  }
@@ -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
- samples, sample_rate = self.synthesize(text, voice=voice, speed=speed, lang=lang)
119
- sd.play(samples, sample_rate)
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
- sd.wait()
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]: