stackvox 0.2.1__tar.gz → 0.3.1__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 (26) hide show
  1. {stackvox-0.2.1/stackvox.egg-info → stackvox-0.3.1}/PKG-INFO +23 -3
  2. {stackvox-0.2.1 → stackvox-0.3.1}/README.md +22 -2
  3. {stackvox-0.2.1 → stackvox-0.3.1}/pyproject.toml +7 -6
  4. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/cli.py +145 -2
  5. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/daemon.py +112 -2
  6. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/engine.py +48 -3
  7. {stackvox-0.2.1 → stackvox-0.3.1/stackvox.egg-info}/PKG-INFO +23 -3
  8. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/SOURCES.txt +3 -1
  9. stackvox-0.3.1/tests/test_cli.py +292 -0
  10. stackvox-0.3.1/tests/test_daemon.py +138 -0
  11. {stackvox-0.2.1 → stackvox-0.3.1}/tests/test_daemon_protocol.py +30 -6
  12. stackvox-0.3.1/tests/test_engine.py +241 -0
  13. stackvox-0.2.1/tests/test_cli.py +0 -41
  14. {stackvox-0.2.1 → stackvox-0.3.1}/LICENSE +0 -0
  15. {stackvox-0.2.1 → stackvox-0.3.1}/NOTICE +0 -0
  16. {stackvox-0.2.1 → stackvox-0.3.1}/setup.cfg +0 -0
  17. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/__init__.py +0 -0
  18. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/__main__.py +0 -0
  19. {stackvox-0.2.1/bin → stackvox-0.3.1/stackvox/data}/stackvox-say +0 -0
  20. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/paths.py +0 -0
  21. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox/py.typed +0 -0
  22. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/dependency_links.txt +0 -0
  23. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/entry_points.txt +0 -0
  24. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/requires.txt +0 -0
  25. {stackvox-0.2.1 → stackvox-0.3.1}/stackvox.egg-info/top_level.txt +0 -0
  26. {stackvox-0.2.1 → stackvox-0.3.1}/tests/test_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.2.1
3
+ Version: 0.3.1
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
@@ -45,11 +45,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
45
45
  From PyPI — recommended for most users:
46
46
 
47
47
  ```bash
48
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
48
+ pipx install stackvox # `stackvox` CLI on PATH
49
49
  # or
50
- pip install stackvox # use as a library
50
+ pip install stackvox # use as a library
51
51
  ```
52
52
 
53
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
54
+
55
+ ```bash
56
+ stackvox install-helper # copies bash helper to ~/.local/bin
57
+ # use --prefix DIR to install elsewhere
58
+ ```
59
+
60
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
61
+
53
62
  From git, if you want an unreleased commit:
54
63
 
55
64
  ```bash
@@ -74,6 +83,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
74
83
  stackvox speak "save" --out a.wav # write wav instead of playing
75
84
  stackvox welcome # multilingual welcome (6 languages)
76
85
  stackvox voices # list all voice ids
86
+ echo "from a pipe" | stackvox # piped stdin works for speak/say
87
+ stackvox speak --file message.txt # read a whole file
88
+ ```
89
+
90
+ Bash completion:
91
+
92
+ ```bash
93
+ eval "$(stackvox completion bash)" # current shell
94
+ # or persist:
95
+ stackvox completion bash > ~/.stackvox-completion.bash
96
+ echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
77
97
  ```
78
98
 
79
99
  Daemon mode (keeps the model resident so each subsequent call is instant):
@@ -12,11 +12,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
12
12
  From PyPI — recommended for most users:
13
13
 
14
14
  ```bash
15
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
15
+ pipx install stackvox # `stackvox` CLI on PATH
16
16
  # or
17
- pip install stackvox # use as a library
17
+ pip install stackvox # use as a library
18
18
  ```
19
19
 
20
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
21
+
22
+ ```bash
23
+ stackvox install-helper # copies bash helper to ~/.local/bin
24
+ # use --prefix DIR to install elsewhere
25
+ ```
26
+
27
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
28
+
20
29
  From git, if you want an unreleased commit:
21
30
 
22
31
  ```bash
@@ -41,6 +50,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
41
50
  stackvox speak "save" --out a.wav # write wav instead of playing
42
51
  stackvox welcome # multilingual welcome (6 languages)
43
52
  stackvox voices # list all voice ids
53
+ echo "from a pipe" | stackvox # piped stdin works for speak/say
54
+ stackvox speak --file message.txt # read a whole file
55
+ ```
56
+
57
+ Bash completion:
58
+
59
+ ```bash
60
+ eval "$(stackvox completion bash)" # current shell
61
+ # or persist:
62
+ stackvox completion bash > ~/.stackvox-completion.bash
63
+ echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
44
64
  ```
45
65
 
46
66
  Daemon mode (keeps the model resident so each subsequent call is instant):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.2.1"
7
+ version = "0.3.1"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -45,15 +45,16 @@ Issues = "https://github.com/StackOneHQ/stackvox/issues"
45
45
  [project.scripts]
46
46
  stackvox = "stackvox.cli:main"
47
47
 
48
- [tool.setuptools]
49
- script-files = ["bin/stackvox-say"]
50
-
51
48
  [tool.setuptools.packages.find]
52
49
  include = ["stackvox*"]
53
50
 
54
51
  [tool.setuptools.package-data]
55
- # Ship the PEP 561 marker so downstream type checkers trust our inline hints.
56
- stackvox = ["py.typed"]
52
+ # Ship the PEP 561 marker so downstream type checkers trust our inline hints,
53
+ # and the bash helper as package data — extracted onto PATH by
54
+ # `stackvox install-helper`. (We deliberately don't use [tool.setuptools]
55
+ # script-files = [...]: it's discouraged, has no PEP 621 equivalent, and
56
+ # doesn't round-trip through hatchling/uv if we ever migrate build backends.)
57
+ stackvox = ["py.typed", "data/stackvox-say"]
57
58
 
58
59
  [tool.ruff]
59
60
  line-length = 110
@@ -21,7 +21,77 @@ def _configure_logging() -> None:
21
21
  )
22
22
 
23
23
 
24
- SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome"}
24
+ SUBCOMMANDS = {
25
+ "serve",
26
+ "stop",
27
+ "status",
28
+ "say",
29
+ "speak",
30
+ "voices",
31
+ "welcome",
32
+ "completion",
33
+ "install-helper",
34
+ }
35
+
36
+ DEFAULT_HELPER_PREFIX = Path.home() / ".local" / "bin"
37
+
38
+ _BASH_COMPLETION = r"""# stackvox bash completion. Install with one of:
39
+ # eval "$(stackvox completion bash)" # current shell
40
+ # stackvox completion bash > ~/.stackvox-completion.bash && \
41
+ # echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
42
+ _stackvox_completion() {
43
+ local cur prev subcommand
44
+ cur="${COMP_WORDS[COMP_CWORD]}"
45
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
46
+ subcommand="${COMP_WORDS[1]:-}"
47
+
48
+ if [[ ${COMP_CWORD} -eq 1 ]]; then
49
+ COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") )
50
+ return 0
51
+ fi
52
+
53
+ case "$prev" in
54
+ --file|--out)
55
+ COMPREPLY=( $(compgen -f -- "$cur") )
56
+ return 0
57
+ ;;
58
+ --prefix)
59
+ COMPREPLY=( $(compgen -d -- "$cur") )
60
+ return 0
61
+ ;;
62
+ --speed)
63
+ COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") )
64
+ return 0
65
+ ;;
66
+ --lang)
67
+ COMPREPLY=( $(compgen -W "en-us en-gb fr-fr it hi pt-br es ja zh" -- "$cur") )
68
+ return 0
69
+ ;;
70
+ esac
71
+
72
+ case "$subcommand" in
73
+ speak)
74
+ COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --help" -- "$cur") )
75
+ ;;
76
+ say)
77
+ COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --help" -- "$cur") )
78
+ ;;
79
+ serve)
80
+ COMPREPLY=( $(compgen -W "--voice --speed --lang --help" -- "$cur") )
81
+ ;;
82
+ completion)
83
+ COMPREPLY=( $(compgen -W "bash" -- "$cur") )
84
+ ;;
85
+ install-helper)
86
+ COMPREPLY=( $(compgen -W "--prefix --help" -- "$cur") )
87
+ ;;
88
+ *)
89
+ COMPREPLY=( $(compgen -W "--help" -- "$cur") )
90
+ ;;
91
+ esac
92
+ }
93
+ complete -F _stackvox_completion stackvox
94
+ """
25
95
 
26
96
  WELCOME_LINES = [
27
97
  ("bf_emma", "en-gb", "Welcome to stackvox."),
@@ -59,6 +129,20 @@ def _build_parser() -> argparse.ArgumentParser:
59
129
  sub.add_parser("voices", help="List available voices")
60
130
  sub.add_parser("welcome", help="Play a multilingual welcome message")
61
131
 
132
+ p_completion = sub.add_parser("completion", help="Print a shell completion script")
133
+ p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
134
+
135
+ p_install_helper = sub.add_parser(
136
+ "install-helper",
137
+ help="Copy the stackvox-say bash helper onto PATH (default: ~/.local/bin)",
138
+ )
139
+ p_install_helper.add_argument(
140
+ "--prefix",
141
+ type=Path,
142
+ default=DEFAULT_HELPER_PREFIX,
143
+ help=f"Install directory (default: {DEFAULT_HELPER_PREFIX})",
144
+ )
145
+
62
146
  return parser
63
147
 
64
148
 
@@ -69,11 +153,20 @@ def _add_voice_args(parser: argparse.ArgumentParser) -> None:
69
153
 
70
154
 
71
155
  def _read_text(args: argparse.Namespace) -> str | None:
156
+ """Resolve the text to speak from --file, the positional, or piped stdin.
157
+
158
+ Precedence: --file > positional text > stdin (when piped, not a TTY).
159
+ """
72
160
  file: Path | None = getattr(args, "file", None)
73
161
  if file is not None:
74
162
  return file.read_text(encoding="utf-8")
75
163
  text: str | None = getattr(args, "text", None)
76
- return text
164
+ if text is not None:
165
+ return text
166
+ if not sys.stdin.isatty():
167
+ piped = sys.stdin.read()
168
+ return piped if piped.strip() else None
169
+ return None
77
170
 
78
171
 
79
172
  def _cmd_speak(args: argparse.Namespace) -> int:
@@ -149,12 +242,60 @@ def _cmd_welcome(_: argparse.Namespace) -> int:
149
242
  return 0
150
243
 
151
244
 
245
+ def _cmd_completion(args: argparse.Namespace) -> int:
246
+ if args.shell == "bash":
247
+ print(_BASH_COMPLETION)
248
+ return 0
249
+ # argparse `choices=` should prevent reaching here; defensive only.
250
+ print(f"unsupported shell: {args.shell}", file=sys.stderr)
251
+ return 1
252
+
253
+
254
+ def _cmd_install_helper(args: argparse.Namespace) -> int:
255
+ """Copy the bundled `stackvox-say` bash helper onto PATH.
256
+
257
+ The helper is shipped as package data (`stackvox/data/stackvox-say`) rather
258
+ than installed automatically by setuptools — `script-files` is discouraged
259
+ and doesn't round-trip through modern build backends. This subcommand is
260
+ the explicit one-time install step.
261
+ """
262
+ import os
263
+ import shutil
264
+ import stat
265
+ from importlib.resources import as_file, files
266
+
267
+ prefix = Path(args.prefix).expanduser()
268
+ prefix.mkdir(parents=True, exist_ok=True)
269
+ dest = prefix / "stackvox-say"
270
+
271
+ src = files("stackvox").joinpath("data/stackvox-say")
272
+ with as_file(src) as src_path:
273
+ shutil.copy2(src_path, dest)
274
+
275
+ # Ensure executable bits regardless of how the wheel preserved them.
276
+ dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
277
+
278
+ print(f"Installed stackvox-say to {dest}", file=sys.stderr)
279
+
280
+ path_dirs = os.environ.get("PATH", "").split(os.pathsep)
281
+ if str(prefix) not in path_dirs:
282
+ print(
283
+ f"Note: {prefix} is not on your PATH. Either add it to PATH or invoke "
284
+ f"the helper via its full path: {dest}",
285
+ file=sys.stderr,
286
+ )
287
+ return 0
288
+
289
+
152
290
  def main() -> int:
153
291
  _configure_logging()
154
292
  argv = sys.argv[1:]
155
293
  # Back-compat: `stackvox "text"` with no subcommand → speak.
294
+ # Same shortcut for piped stdin: `echo hi | stackvox` → speak.
156
295
  if argv and argv[0] not in SUBCOMMANDS and not argv[0].startswith("-"):
157
296
  argv = ["speak", *argv]
297
+ elif not argv and not sys.stdin.isatty():
298
+ argv = ["speak"]
158
299
 
159
300
  parser = _build_parser()
160
301
  args = parser.parse_args(argv)
@@ -171,6 +312,8 @@ def main() -> int:
171
312
  "status": _cmd_status,
172
313
  "voices": _cmd_voices,
173
314
  "welcome": _cmd_welcome,
315
+ "completion": _cmd_completion,
316
+ "install-helper": _cmd_install_helper,
174
317
  }
175
318
  return handlers[args.cmd](args)
176
319
 
@@ -13,6 +13,8 @@ Replies: "ok", "busy", "err: <msg>".
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import ctypes
17
+ import ctypes.util
16
18
  import json
17
19
  import logging
18
20
  import os
@@ -20,7 +22,9 @@ import queue
20
22
  import signal
21
23
  import socket
22
24
  import socketserver
25
+ import sys
23
26
  import threading
27
+ from typing import Any
24
28
 
25
29
  import sounddevice as sd
26
30
 
@@ -38,6 +42,18 @@ PING_TIMEOUT_SECONDS = 0.5
38
42
  RECV_BYTES = 1024
39
43
 
40
44
 
45
+ # Set when PortAudio's cached default-output device is suspected stale and
46
+ # needs Pa_Terminate / Pa_Initialize before the next playback. Initial state
47
+ # is True so the first playback always refreshes; the macOS device watcher
48
+ # re-sets it on real device changes; the worker also re-sets on playback
49
+ # failure as a belt-and-suspenders retry path.
50
+ _audio_dirty = threading.Event()
51
+ _audio_dirty.set()
52
+
53
+ # Holds CoreAudio callback references so they aren't garbage collected.
54
+ _ca_refs: list[Any] = []
55
+
56
+
41
57
  def _refresh_audio_devices() -> None:
42
58
  """Reset PortAudio so the next play picks up the current system default.
43
59
 
@@ -45,7 +61,7 @@ def _refresh_audio_devices() -> None:
45
61
  daemon keeps playing to whatever was default when it started (e.g. the
46
62
  built-in speakers after the user swapped to Bluetooth). Terminating and
47
63
  re-initialising is the only portable way to refresh that cache. Costs
48
- ~10-50ms per call, which is invisible next to synthesis time.
64
+ ~10-50ms per call.
49
65
  """
50
66
  try:
51
67
  sd._terminate()
@@ -54,6 +70,94 @@ def _refresh_audio_devices() -> None:
54
70
  logger.exception("failed to refresh audio devices")
55
71
 
56
72
 
73
+ def _start_device_watcher() -> None:
74
+ """macOS only: mark `_audio_dirty` when the default output device changes.
75
+
76
+ Avoids reinitialising PortAudio on every playback (the simpler approach,
77
+ which adds 10-50ms of latency before each speech). macOS notifies the
78
+ property listener on more than just real device changes — playback start,
79
+ volume changes, and other side effects all fire it — so we compare the
80
+ current default-output device ID against the last seen one and only mark
81
+ dirty on actual changes.
82
+
83
+ No-ops on non-macOS; the dirty flag stays at its initial state (set), so
84
+ the first playback refreshes once and subsequent playbacks reuse the
85
+ PortAudio context. Device changes on those platforms are handled by the
86
+ worker's failure-retry path.
87
+ """
88
+ if sys.platform != "darwin":
89
+ return
90
+ try:
91
+ ca = ctypes.CDLL(ctypes.util.find_library("CoreAudio") or "")
92
+ cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation") or "")
93
+ except Exception:
94
+ logger.debug("CoreAudio unavailable; device watcher disabled")
95
+ return
96
+
97
+ class _PropAddr(ctypes.Structure):
98
+ _fields_ = [
99
+ ("mSelector", ctypes.c_uint32),
100
+ ("mScope", ctypes.c_uint32),
101
+ ("mElement", ctypes.c_uint32),
102
+ ]
103
+
104
+ _ListenerProc = ctypes.CFUNCTYPE(
105
+ ctypes.c_int32,
106
+ ctypes.c_uint32, # inObjectID
107
+ ctypes.c_uint32, # inNumberAddresses
108
+ ctypes.POINTER(_PropAddr),
109
+ ctypes.c_void_p,
110
+ )
111
+
112
+ prop = _PropAddr(
113
+ 0x644F7574, # kAudioHardwarePropertyDefaultOutputDevice 'dOut'
114
+ 0x676C6F62, # kAudioObjectPropertyScopeGlobal 'glob'
115
+ 0, # kAudioObjectPropertyElementMain
116
+ )
117
+
118
+ def _read_default_device() -> int:
119
+ device = ctypes.c_uint32(0)
120
+ size = ctypes.c_uint32(ctypes.sizeof(device))
121
+ status = ca.AudioObjectGetPropertyData(
122
+ 1, # kAudioObjectSystemObject
123
+ ctypes.byref(prop),
124
+ 0,
125
+ None,
126
+ ctypes.byref(size),
127
+ ctypes.byref(device),
128
+ )
129
+ return device.value if status == 0 else 0
130
+
131
+ last_device = [_read_default_device()]
132
+
133
+ def _on_device_change(obj_id: int, n: int, addrs, data) -> int:
134
+ try:
135
+ current = _read_default_device()
136
+ if current and current != last_device[0]:
137
+ last_device[0] = current
138
+ _audio_dirty.set()
139
+ except Exception:
140
+ logger.debug("device-change callback error", exc_info=True)
141
+ return 0
142
+
143
+ cb = _ListenerProc(_on_device_change)
144
+ _ca_refs.append(cb) # prevent GC
145
+ status = ca.AudioObjectAddPropertyListener(1, ctypes.byref(prop), cb, None)
146
+ if status != 0:
147
+ # If registration silently fails the watcher would never fire and
148
+ # device switches would only recover via the worker's failure-retry
149
+ # path. Surface it so the failure mode is observable.
150
+ logger.warning("AudioObjectAddPropertyListener failed (status=%d); device watcher inactive", status)
151
+ return
152
+
153
+ threading.Thread(
154
+ target=cf.CFRunLoopRun,
155
+ daemon=True,
156
+ name="audio-device-watcher",
157
+ ).start()
158
+ logger.debug("audio device watcher started")
159
+
160
+
57
161
  class _DaemonState:
58
162
  def __init__(self, voice: str, speed: float, lang: str) -> None:
59
163
  self.tts = Stackvox(voice=voice, speed=speed, lang=lang)
@@ -61,6 +165,7 @@ class _DaemonState:
61
165
  self.stop_event = threading.Event()
62
166
  self.worker = threading.Thread(target=self._worker, daemon=True)
63
167
  self.worker.start()
168
+ _start_device_watcher()
64
169
 
65
170
  def _worker(self) -> None:
66
171
  while not self.stop_event.is_set():
@@ -68,7 +173,9 @@ class _DaemonState:
68
173
  req = self.queue.get(timeout=WORKER_POLL_SECONDS)
69
174
  except queue.Empty:
70
175
  continue
71
- _refresh_audio_devices()
176
+ if _audio_dirty.is_set():
177
+ _refresh_audio_devices()
178
+ _audio_dirty.clear()
72
179
  try:
73
180
  self.tts.speak(
74
181
  req["text"],
@@ -78,6 +185,9 @@ class _DaemonState:
78
185
  )
79
186
  except Exception:
80
187
  logger.exception("playback error")
188
+ # Failed playback might be a stale audio context; mark dirty
189
+ # so the next request refreshes before trying again.
190
+ _audio_dirty.set()
81
191
 
82
192
  def submit(self, req: dict) -> bool:
83
193
  try:
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import sys
7
+ import threading
6
8
  import urllib.request
7
9
  from pathlib import Path
8
10
 
@@ -22,6 +24,40 @@ _MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/mode
22
24
  _VOICES_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
23
25
 
24
26
 
27
+ def _download_with_progress(url: str, dest: Path) -> None:
28
+ """Stream a URL to a file, printing percentage updates to stderr.
29
+
30
+ The ~340 MB Kokoro model takes long enough on first run that a silent
31
+ download looks like a hang; this gives users feedback without pulling
32
+ in tqdm. Falls back to a single line when the server doesn't report
33
+ Content-Length.
34
+ """
35
+ last_pct = -1
36
+ label = dest.name
37
+
38
+ def hook(blocks: int, blocksize: int, totalsize: int) -> None:
39
+ nonlocal last_pct
40
+ if totalsize <= 0:
41
+ return
42
+ pct = min(100, int(blocks * blocksize * 100 / totalsize))
43
+ if pct != last_pct:
44
+ mb_total = totalsize / 1_000_000
45
+ print(
46
+ f"\r[stackvox] downloading {label} {pct:3d}% ({mb_total:.0f} MB)",
47
+ end="",
48
+ file=sys.stderr,
49
+ flush=True,
50
+ )
51
+ last_pct = pct
52
+
53
+ urllib.request.urlretrieve(url, dest, reporthook=hook)
54
+ if last_pct >= 0 and sys.stderr.isatty():
55
+ # Finish the carriage-returned line so subsequent output starts on
56
+ # its own line. On non-TTY (e.g. CI logs) stderr is line-buffered
57
+ # and the \r writes already land on separate lines.
58
+ print("", file=sys.stderr, flush=True)
59
+
60
+
25
61
  def _ensure_models(cache_dir: Path) -> tuple[Path, Path]:
26
62
  cache_dir.mkdir(parents=True, exist_ok=True)
27
63
  model_path = cache_dir / "kokoro-v1.0.onnx"
@@ -29,8 +65,7 @@ def _ensure_models(cache_dir: Path) -> tuple[Path, Path]:
29
65
  for path, url in [(model_path, _MODEL_URL), (voices_path, _VOICES_URL)]:
30
66
  if path.exists():
31
67
  continue
32
- logger.info("downloading %s...", path.name)
33
- urllib.request.urlretrieve(url, path)
68
+ _download_with_progress(url, path)
34
69
  return model_path, voices_path
35
70
 
36
71
 
@@ -134,12 +169,22 @@ class Stackvox:
134
169
 
135
170
 
136
171
  _default: Stackvox | None = None
172
+ _default_lock = threading.Lock()
137
173
 
138
174
 
139
175
  def _get_default() -> Stackvox:
176
+ """Lazily build a module-level engine, safe under concurrent first calls.
177
+
178
+ Without the lock, two threads racing to call speak() / synthesize() at
179
+ process start could each instantiate Stackvox — meaning two 340 MB model
180
+ loads. Double-checked locking keeps the fast path lock-free once
181
+ initialised.
182
+ """
140
183
  global _default
141
184
  if _default is None:
142
- _default = Stackvox()
185
+ with _default_lock:
186
+ if _default is None:
187
+ _default = Stackvox()
143
188
  return _default
144
189
 
145
190
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.2.1
3
+ Version: 0.3.1
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
@@ -45,11 +45,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
45
45
  From PyPI — recommended for most users:
46
46
 
47
47
  ```bash
48
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
48
+ pipx install stackvox # `stackvox` CLI on PATH
49
49
  # or
50
- pip install stackvox # use as a library
50
+ pip install stackvox # use as a library
51
51
  ```
52
52
 
53
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
54
+
55
+ ```bash
56
+ stackvox install-helper # copies bash helper to ~/.local/bin
57
+ # use --prefix DIR to install elsewhere
58
+ ```
59
+
60
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
61
+
53
62
  From git, if you want an unreleased commit:
54
63
 
55
64
  ```bash
@@ -74,6 +83,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
74
83
  stackvox speak "save" --out a.wav # write wav instead of playing
75
84
  stackvox welcome # multilingual welcome (6 languages)
76
85
  stackvox voices # list all voice ids
86
+ echo "from a pipe" | stackvox # piped stdin works for speak/say
87
+ stackvox speak --file message.txt # read a whole file
88
+ ```
89
+
90
+ Bash completion:
91
+
92
+ ```bash
93
+ eval "$(stackvox completion bash)" # current shell
94
+ # or persist:
95
+ stackvox completion bash > ~/.stackvox-completion.bash
96
+ echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
77
97
  ```
78
98
 
79
99
  Daemon mode (keeps the model resident so each subsequent call is instant):
@@ -2,7 +2,6 @@ LICENSE
2
2
  NOTICE
3
3
  README.md
4
4
  pyproject.toml
5
- bin/stackvox-say
6
5
  stackvox/__init__.py
7
6
  stackvox/__main__.py
8
7
  stackvox/cli.py
@@ -16,6 +15,9 @@ stackvox.egg-info/dependency_links.txt
16
15
  stackvox.egg-info/entry_points.txt
17
16
  stackvox.egg-info/requires.txt
18
17
  stackvox.egg-info/top_level.txt
18
+ stackvox/data/stackvox-say
19
19
  tests/test_cli.py
20
+ tests/test_daemon.py
20
21
  tests/test_daemon_protocol.py
22
+ tests/test_engine.py
21
23
  tests/test_paths.py