stackvox 0.6.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.6.0/stackvox.egg-info → stackvox-0.8.0}/PKG-INFO +16 -1
  2. {stackvox-0.6.0 → stackvox-0.8.0}/README.md +15 -0
  3. {stackvox-0.6.0 → stackvox-0.8.0}/pyproject.toml +1 -1
  4. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/cli.py +190 -5
  5. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/engine.py +54 -2
  6. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/text.py +29 -1
  7. {stackvox-0.6.0 → stackvox-0.8.0/stackvox.egg-info}/PKG-INFO +16 -1
  8. {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_cli.py +150 -0
  9. {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_engine.py +28 -7
  10. {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_text.py +25 -0
  11. {stackvox-0.6.0 → stackvox-0.8.0}/LICENSE +0 -0
  12. {stackvox-0.6.0 → stackvox-0.8.0}/NOTICE +0 -0
  13. {stackvox-0.6.0 → stackvox-0.8.0}/setup.cfg +0 -0
  14. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/__init__.py +0 -0
  15. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/__main__.py +0 -0
  16. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/config.py +0 -0
  17. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/daemon.py +0 -0
  18. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/data/stackvox-say +0 -0
  19. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/paths.py +0 -0
  20. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/py.typed +0 -0
  21. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox/updates.py +0 -0
  22. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/SOURCES.txt +0 -0
  23. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/dependency_links.txt +0 -0
  24. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/entry_points.txt +0 -0
  25. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/requires.txt +0 -0
  26. {stackvox-0.6.0 → stackvox-0.8.0}/stackvox.egg-info/top_level.txt +0 -0
  27. {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_config.py +0 -0
  28. {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_daemon.py +0 -0
  29. {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_daemon_protocol.py +0 -0
  30. {stackvox-0.6.0 → stackvox-0.8.0}/tests/test_paths.py +0 -0
  31. {stackvox-0.6.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.6.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
@@ -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.6.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"
@@ -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,121 @@ 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-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
+ )
217
+ parser.add_argument(
218
+ "--no-expand-units",
219
+ dest="expand_units",
220
+ action="store_false",
221
+ help="Leave £/p/kWh/MPG/kg/km and ÷ × = as written",
222
+ )
223
+ parser.add_argument(
224
+ "--no-expand-numbers",
225
+ dest="expand_numbers",
226
+ action="store_false",
227
+ help="Leave thousands separators and decimals as written",
228
+ )
229
+ parser.add_argument(
230
+ "--no-pauses",
231
+ dest="pauses",
232
+ action="store_false",
233
+ help="Do not reshape dashes / parentheses into pauses",
234
+ )
235
+ parser.add_argument(
236
+ "--tables",
237
+ choices=["drop", "csv"],
238
+ default="drop",
239
+ help="How Markdown tables are voiced (default: drop)",
240
+ )
241
+ parser.add_argument(
242
+ "--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking"
243
+ )
244
+ parser.add_argument(
245
+ "--no-terminal-stops",
246
+ dest="terminal_stops",
247
+ action="store_false",
248
+ help="Do not add terminal punctuation so a pause lands between lines",
249
+ )
250
+ parser.add_argument("--locale", default="en-GB", help="Locale for unit/currency rules (default: en-GB)")
251
+
252
+
253
+ def _load_pronunciations(path: Path | None) -> dict[str, str] | None:
254
+ """Load a ``{written: spoken}`` map from a JSON file.
255
+
256
+ Returns ``None`` when no path is given. Raises ``ValueError`` if the file
257
+ is not a flat table of string -> string entries, so a mistyped dict fails
258
+ loudly rather than silently voicing the wrong thing.
259
+ """
260
+ if path is None:
261
+ return None
262
+ import json
263
+
264
+ data = json.loads(path.expanduser().read_text(encoding="utf-8"))
265
+ if not isinstance(data, dict) or not all(
266
+ isinstance(key, str) and isinstance(value, str) for key, value in data.items()
267
+ ):
268
+ raise ValueError(f"{path}: expected a flat JSON object of string -> string entries")
269
+ return data
270
+
271
+
272
+ def _normalize_kwargs(args: argparse.Namespace) -> dict:
273
+ """Collect the normalize_for_speech keyword arguments from parsed flags."""
274
+ return {
275
+ "markdown": args.markdown,
276
+ "pronunciations": _load_pronunciations(args.pronunciations),
277
+ "dev_terms": args.dev_terms,
278
+ "expand_units": args.expand_units,
279
+ "expand_numbers": args.expand_numbers,
280
+ "pauses": args.pauses,
281
+ "tables": args.tables,
282
+ "strip_emoji": args.strip_emoji,
283
+ "terminal_stops": args.terminal_stops,
284
+ "locale": args.locale,
285
+ }
286
+
287
+
288
+ def _resolve_normalized(text: str, args: argparse.Namespace) -> str | None:
289
+ """Normalize ``text`` for speech, or print a clean error and return None.
290
+
291
+ A missing or malformed ``--pronunciations`` file surfaces here as an
292
+ ``OSError`` / ``ValueError`` (``json.JSONDecodeError`` is a ``ValueError``);
293
+ turn it into a one-line ``[stackvox] …`` message rather than a traceback.
294
+ """
295
+ try:
296
+ return normalize_for_speech(text, **_normalize_kwargs(args))
297
+ except (OSError, ValueError) as exc:
298
+ print(f"[stackvox] {exc}", file=sys.stderr)
299
+ return None
300
+
301
+
158
302
  def _read_text(args: argparse.Namespace) -> str | None:
159
303
  """Resolve the text to speak from --file, the positional, or piped stdin.
160
304
 
@@ -177,6 +321,10 @@ def _cmd_speak(args: argparse.Namespace) -> int:
177
321
  if not text or not text.strip():
178
322
  print("Error: provide text or --file", file=sys.stderr)
179
323
  return 1
324
+ if getattr(args, "normalize", False):
325
+ text = _resolve_normalized(text, args)
326
+ if text is None:
327
+ return 1
180
328
  tts = Stackvox(voice=args.voice, speed=args.speed, lang=args.lang)
181
329
  if args.out:
182
330
  samples, sr = tts.synthesize(text)
@@ -192,6 +340,10 @@ def _cmd_say(args: argparse.Namespace) -> int:
192
340
  if not text or not text.strip():
193
341
  print("Error: provide text or --file", file=sys.stderr)
194
342
  return 1
343
+ if getattr(args, "normalize", False):
344
+ text = _resolve_normalized(text, args)
345
+ if text is None:
346
+ return 1
195
347
  ok, resp = daemon.say(text, voice=args.voice, speed=args.speed, lang=args.lang)
196
348
  if ok:
197
349
  return 0
@@ -206,6 +358,18 @@ def _cmd_say(args: argparse.Namespace) -> int:
206
358
  return 2
207
359
 
208
360
 
361
+ def _cmd_normalize(args: argparse.Namespace) -> int:
362
+ text = _read_text(args)
363
+ if not text or not text.strip():
364
+ print("Error: provide text or --file", file=sys.stderr)
365
+ return 1
366
+ normalized = _resolve_normalized(text, args)
367
+ if normalized is None:
368
+ return 1
369
+ print(normalized)
370
+ return 0
371
+
372
+
209
373
  def _cmd_serve(args: argparse.Namespace) -> int:
210
374
  try:
211
375
  daemon.serve(voice=args.voice, speed=args.speed, lang=args.lang)
@@ -248,6 +412,24 @@ def _cmd_voices(args: argparse.Namespace) -> int:
248
412
  return 0
249
413
 
250
414
 
415
+ def _cmd_paths(_: argparse.Namespace) -> int:
416
+ print(f"cache_dir: {paths.cache_dir()}")
417
+ print(f"socket_path: {paths.socket_path()}")
418
+ print(f"pid_path: {paths.pid_path()}")
419
+ return 0
420
+
421
+
422
+ def _cmd_config(_: argparse.Namespace) -> int:
423
+ path = config.config_path()
424
+ present = "" if path.is_file() else " (not present; built-in defaults apply)"
425
+ defaults = config.load_defaults()
426
+ print(f"config_path: {path}{present}")
427
+ print(f"voice: {defaults.voice}")
428
+ print(f"speed: {defaults.speed}")
429
+ print(f"lang: {defaults.lang}")
430
+ return 0
431
+
432
+
251
433
  def _cmd_welcome(_: argparse.Namespace) -> int:
252
434
  tts = Stackvox()
253
435
  tts.speak_sequence([{"text": text, "voice": voice, "lang": lang} for voice, lang, text in WELCOME_LINES])
@@ -336,11 +518,14 @@ def main() -> int:
336
518
  handlers = {
337
519
  "speak": _cmd_speak,
338
520
  "say": _cmd_say,
521
+ "normalize": _cmd_normalize,
339
522
  "serve": _cmd_serve,
340
523
  "stop": _cmd_stop,
341
524
  "status": _cmd_status,
342
525
  "voices": _cmd_voices,
343
526
  "welcome": _cmd_welcome,
527
+ "paths": _cmd_paths,
528
+ "config": _cmd_config,
344
529
  "completion": _cmd_completion,
345
530
  "install-helper": _cmd_install_helper,
346
531
  }
@@ -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.6.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
@@ -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
@@ -121,6 +121,26 @@ def _ns(**kwargs):
121
121
  return argparse.Namespace(**kwargs)
122
122
 
123
123
 
124
+ def _norm_ns(text=None, file=None, **overrides):
125
+ """Namespace carrying the normalize flags at their library defaults."""
126
+ base = dict(
127
+ text=text,
128
+ file=file,
129
+ markdown=True,
130
+ pronunciations=None,
131
+ dev_terms=True,
132
+ expand_units=True,
133
+ expand_numbers=True,
134
+ pauses=True,
135
+ tables="drop",
136
+ strip_emoji=False,
137
+ terminal_stops=True,
138
+ locale="en-GB",
139
+ )
140
+ base.update(overrides)
141
+ return _ns(**base)
142
+
143
+
124
144
  class TestCmdSpeak:
125
145
  def test_with_text_calls_engine_speak(self, fake_stackvox):
126
146
  rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hello", out=None))
@@ -149,6 +169,18 @@ class TestCmdSpeak:
149
169
  assert "provide text" in capsys.readouterr().err
150
170
  fake_stackvox.return_value.speak.assert_not_called()
151
171
 
172
+ def test_normalize_transforms_text_before_engine(self, fake_stackvox):
173
+ args = _norm_ns(text="# Heading", voice="af_sarah", speed=1.0, lang="en-us", out=None, normalize=True)
174
+ rc = cli._cmd_speak(args)
175
+ assert rc == 0
176
+ fake_stackvox.return_value.speak.assert_called_once_with("Heading.")
177
+
178
+ def test_no_normalize_leaves_text_untouched(self, fake_stackvox):
179
+ # `normalize` absent from the namespace mirrors the flag being off.
180
+ rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="# Heading", out=None))
181
+ assert rc == 0
182
+ fake_stackvox.return_value.speak.assert_called_once_with("# Heading")
183
+
152
184
 
153
185
  class TestCmdSay:
154
186
  def test_returns_zero_when_daemon_accepts(self, mocker):
@@ -182,6 +214,15 @@ class TestCmdSay:
182
214
  rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="", fallback_say=False))
183
215
  assert rc == 1
184
216
 
217
+ def test_normalize_transforms_text_before_daemon(self, mocker):
218
+ say = mocker.patch.object(cli.daemon, "say", return_value=(True, "ok"))
219
+ args = _norm_ns(
220
+ text="# Heading", voice="af_sarah", speed=1.0, lang="en-us", fallback_say=False, normalize=True
221
+ )
222
+ rc = cli._cmd_say(args)
223
+ assert rc == 0
224
+ assert say.call_args.args[0] == "Heading."
225
+
185
226
 
186
227
  class TestCmdServe:
187
228
  def test_propagates_serve_args(self, mocker):
@@ -353,3 +394,112 @@ class TestCmdInstallHelper:
353
394
  mocker.patch.dict("os.environ", {"PATH": f"{prefix}:/usr/bin"}, clear=False)
354
395
  cli._cmd_install_helper(_ns(prefix=prefix))
355
396
  assert "not on your PATH" not in capsys.readouterr().err
397
+
398
+
399
+ class TestCmdNormalize:
400
+ def test_markdown_reduces_to_prose_with_terminal_stops(self, capsys):
401
+ rc = cli._cmd_normalize(_norm_ns(text="# Heading\n\nBody"))
402
+ assert rc == 0
403
+ assert capsys.readouterr().out.splitlines() == ["Heading.", "Body."]
404
+
405
+ def test_number_and_decimal_expansion(self, capsys):
406
+ rc = cli._cmd_normalize(_norm_ns(text="It was 1,198.9 total"))
407
+ assert rc == 0
408
+ assert capsys.readouterr().out.strip() == "It was 1198 point 9 total."
409
+
410
+ def test_pronunciations_file_is_applied(self, capsys, tmp_path):
411
+ pron = tmp_path / "dict.json"
412
+ pron.write_text('{"Redis": "ree-diss"}', encoding="utf-8")
413
+ rc = cli._cmd_normalize(_norm_ns(text="Redis is fast", pronunciations=pron))
414
+ assert rc == 0
415
+ assert capsys.readouterr().out.strip() == "ree-diss is fast."
416
+
417
+ def test_blank_input_returns_one(self, capsys):
418
+ rc = cli._cmd_normalize(_norm_ns(text=" "))
419
+ assert rc == 1
420
+ assert "provide text" in capsys.readouterr().err
421
+
422
+ def test_bad_pronunciations_file_errors_cleanly(self, capsys, tmp_path):
423
+ bad = tmp_path / "bad.json"
424
+ bad.write_text('["not", "a", "map"]', encoding="utf-8")
425
+ rc = cli._cmd_normalize(_norm_ns(text="hi", pronunciations=bad))
426
+ assert rc == 1
427
+ assert "string -> string" in capsys.readouterr().err
428
+
429
+
430
+ class TestLoadPronunciations:
431
+ def test_none_path_returns_none(self):
432
+ assert cli._load_pronunciations(None) is None
433
+
434
+ def test_valid_json_returns_mapping(self, tmp_path):
435
+ path = tmp_path / "d.json"
436
+ path.write_text('{"CLI": "see ell eye"}', encoding="utf-8")
437
+ assert cli._load_pronunciations(path) == {"CLI": "see ell eye"}
438
+
439
+ def test_non_object_json_raises(self, tmp_path):
440
+ path = tmp_path / "d.json"
441
+ path.write_text('["not", "a", "map"]', encoding="utf-8")
442
+ with pytest.raises(ValueError, match="string -> string"):
443
+ cli._load_pronunciations(path)
444
+
445
+ def test_non_string_values_raise(self, tmp_path):
446
+ path = tmp_path / "d.json"
447
+ path.write_text('{"n": 3}', encoding="utf-8")
448
+ with pytest.raises(ValueError, match="string -> string"):
449
+ cli._load_pronunciations(path)
450
+
451
+
452
+ class TestCmdPaths:
453
+ def test_prints_the_three_paths(self, capsys):
454
+ rc = cli._cmd_paths(_ns())
455
+ assert rc == 0
456
+ out = capsys.readouterr().out
457
+ assert "cache_dir:" in out
458
+ assert "socket_path:" in out
459
+ assert "pid_path:" in out
460
+
461
+
462
+ class TestCmdConfig:
463
+ def test_prints_resolved_defaults_and_path(self, mocker, capsys, tmp_path):
464
+ cfg = tmp_path / "config.toml" # absent → "not present" note
465
+ mocker.patch.object(cli.config, "config_path", return_value=cfg)
466
+ mocker.patch.object(
467
+ cli.config,
468
+ "load_defaults",
469
+ return_value=cli.config.Defaults(voice="bf_emma", speed=1.1, lang="en-gb"),
470
+ )
471
+ rc = cli._cmd_config(_ns())
472
+ assert rc == 0
473
+ out = capsys.readouterr().out
474
+ assert "not present" in out
475
+ assert "bf_emma" in out
476
+ assert "1.1" in out
477
+ assert "en-gb" in out
478
+
479
+
480
+ class TestNormalizeParsing:
481
+ def test_normalize_subcommand_routes(self, mocker):
482
+ norm = mocker.patch.object(cli, "_cmd_normalize", return_value=0)
483
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "normalize", "--tables", "csv", "hi"])
484
+ assert cli.main() == 0
485
+ args = norm.call_args.args[0]
486
+ assert args.cmd == "normalize"
487
+ assert args.tables == "csv"
488
+ assert args.text == "hi"
489
+
490
+ def test_speak_normalize_switch_parses(self, mocker):
491
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
492
+ mocker.patch.object(
493
+ cli.sys, "argv", ["stackvox", "speak", "--normalize", "--strip-emoji", "--no-pauses", "hi"]
494
+ )
495
+ assert cli.main() == 0
496
+ args = speak.call_args.args[0]
497
+ assert args.normalize is True
498
+ assert args.strip_emoji is True
499
+ assert args.pauses is False
500
+
501
+ def test_paths_and_config_route(self, mocker):
502
+ paths_handler = mocker.patch.object(cli, "_cmd_paths", return_value=0)
503
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "paths"])
504
+ assert cli.main() == 0
505
+ assert paths_handler.called
@@ -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