stackvox 0.2.1__tar.gz → 0.3.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 (24) hide show
  1. {stackvox-0.2.1/stackvox.egg-info → stackvox-0.3.0}/PKG-INFO +12 -1
  2. {stackvox-0.2.1 → stackvox-0.3.0}/README.md +11 -0
  3. {stackvox-0.2.1 → stackvox-0.3.0}/pyproject.toml +1 -1
  4. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox/cli.py +78 -2
  5. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox/daemon.py +112 -2
  6. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox/engine.py +48 -3
  7. {stackvox-0.2.1 → stackvox-0.3.0/stackvox.egg-info}/PKG-INFO +12 -1
  8. stackvox-0.3.0/tests/test_cli.py +91 -0
  9. {stackvox-0.2.1 → stackvox-0.3.0}/tests/test_daemon_protocol.py +30 -6
  10. stackvox-0.2.1/tests/test_cli.py +0 -41
  11. {stackvox-0.2.1 → stackvox-0.3.0}/LICENSE +0 -0
  12. {stackvox-0.2.1 → stackvox-0.3.0}/NOTICE +0 -0
  13. {stackvox-0.2.1 → stackvox-0.3.0}/bin/stackvox-say +0 -0
  14. {stackvox-0.2.1 → stackvox-0.3.0}/setup.cfg +0 -0
  15. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox/__init__.py +0 -0
  16. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox/__main__.py +0 -0
  17. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox/paths.py +0 -0
  18. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox/py.typed +0 -0
  19. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox.egg-info/SOURCES.txt +0 -0
  20. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox.egg-info/dependency_links.txt +0 -0
  21. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox.egg-info/entry_points.txt +0 -0
  22. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox.egg-info/requires.txt +0 -0
  23. {stackvox-0.2.1 → stackvox-0.3.0}/stackvox.egg-info/top_level.txt +0 -0
  24. {stackvox-0.2.1 → stackvox-0.3.0}/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.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
@@ -74,6 +74,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
74
74
  stackvox speak "save" --out a.wav # write wav instead of playing
75
75
  stackvox welcome # multilingual welcome (6 languages)
76
76
  stackvox voices # list all voice ids
77
+ echo "from a pipe" | stackvox # piped stdin works for speak/say
78
+ stackvox speak --file message.txt # read a whole file
79
+ ```
80
+
81
+ Bash completion:
82
+
83
+ ```bash
84
+ eval "$(stackvox completion bash)" # current shell
85
+ # or persist:
86
+ stackvox completion bash > ~/.stackvox-completion.bash
87
+ echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
77
88
  ```
78
89
 
79
90
  Daemon mode (keeps the model resident so each subsequent call is instant):
@@ -41,6 +41,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
41
41
  stackvox speak "save" --out a.wav # write wav instead of playing
42
42
  stackvox welcome # multilingual welcome (6 languages)
43
43
  stackvox voices # list all voice ids
44
+ echo "from a pipe" | stackvox # piped stdin works for speak/say
45
+ stackvox speak --file message.txt # read a whole file
46
+ ```
47
+
48
+ Bash completion:
49
+
50
+ ```bash
51
+ eval "$(stackvox completion bash)" # current shell
52
+ # or persist:
53
+ stackvox completion bash > ~/.stackvox-completion.bash
54
+ echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
44
55
  ```
45
56
 
46
57
  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.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,7 +21,58 @@ def _configure_logging() -> None:
21
21
  )
22
22
 
23
23
 
24
- SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome"}
24
+ SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome", "completion"}
25
+
26
+ _BASH_COMPLETION = r"""# stackvox bash completion. Install with one of:
27
+ # eval "$(stackvox completion bash)" # current shell
28
+ # stackvox completion bash > ~/.stackvox-completion.bash && \
29
+ # echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
30
+ _stackvox_completion() {
31
+ local cur prev subcommand
32
+ cur="${COMP_WORDS[COMP_CWORD]}"
33
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
34
+ subcommand="${COMP_WORDS[1]:-}"
35
+
36
+ if [[ ${COMP_CWORD} -eq 1 ]]; then
37
+ COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion" -- "$cur") )
38
+ return 0
39
+ fi
40
+
41
+ case "$prev" in
42
+ --file|--out)
43
+ COMPREPLY=( $(compgen -f -- "$cur") )
44
+ return 0
45
+ ;;
46
+ --speed)
47
+ COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") )
48
+ return 0
49
+ ;;
50
+ --lang)
51
+ COMPREPLY=( $(compgen -W "en-us en-gb fr-fr it hi pt-br es ja zh" -- "$cur") )
52
+ return 0
53
+ ;;
54
+ esac
55
+
56
+ case "$subcommand" in
57
+ speak)
58
+ COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --help" -- "$cur") )
59
+ ;;
60
+ say)
61
+ COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --help" -- "$cur") )
62
+ ;;
63
+ serve)
64
+ COMPREPLY=( $(compgen -W "--voice --speed --lang --help" -- "$cur") )
65
+ ;;
66
+ completion)
67
+ COMPREPLY=( $(compgen -W "bash" -- "$cur") )
68
+ ;;
69
+ *)
70
+ COMPREPLY=( $(compgen -W "--help" -- "$cur") )
71
+ ;;
72
+ esac
73
+ }
74
+ complete -F _stackvox_completion stackvox
75
+ """
25
76
 
26
77
  WELCOME_LINES = [
27
78
  ("bf_emma", "en-gb", "Welcome to stackvox."),
@@ -59,6 +110,9 @@ def _build_parser() -> argparse.ArgumentParser:
59
110
  sub.add_parser("voices", help="List available voices")
60
111
  sub.add_parser("welcome", help="Play a multilingual welcome message")
61
112
 
113
+ p_completion = sub.add_parser("completion", help="Print a shell completion script")
114
+ p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
115
+
62
116
  return parser
63
117
 
64
118
 
@@ -69,11 +123,20 @@ def _add_voice_args(parser: argparse.ArgumentParser) -> None:
69
123
 
70
124
 
71
125
  def _read_text(args: argparse.Namespace) -> str | None:
126
+ """Resolve the text to speak from --file, the positional, or piped stdin.
127
+
128
+ Precedence: --file > positional text > stdin (when piped, not a TTY).
129
+ """
72
130
  file: Path | None = getattr(args, "file", None)
73
131
  if file is not None:
74
132
  return file.read_text(encoding="utf-8")
75
133
  text: str | None = getattr(args, "text", None)
76
- return text
134
+ if text is not None:
135
+ return text
136
+ if not sys.stdin.isatty():
137
+ piped = sys.stdin.read()
138
+ return piped if piped.strip() else None
139
+ return None
77
140
 
78
141
 
79
142
  def _cmd_speak(args: argparse.Namespace) -> int:
@@ -149,12 +212,24 @@ def _cmd_welcome(_: argparse.Namespace) -> int:
149
212
  return 0
150
213
 
151
214
 
215
+ def _cmd_completion(args: argparse.Namespace) -> int:
216
+ if args.shell == "bash":
217
+ print(_BASH_COMPLETION)
218
+ return 0
219
+ # argparse `choices=` should prevent reaching here; defensive only.
220
+ print(f"unsupported shell: {args.shell}", file=sys.stderr)
221
+ return 1
222
+
223
+
152
224
  def main() -> int:
153
225
  _configure_logging()
154
226
  argv = sys.argv[1:]
155
227
  # Back-compat: `stackvox "text"` with no subcommand → speak.
228
+ # Same shortcut for piped stdin: `echo hi | stackvox` → speak.
156
229
  if argv and argv[0] not in SUBCOMMANDS and not argv[0].startswith("-"):
157
230
  argv = ["speak", *argv]
231
+ elif not argv and not sys.stdin.isatty():
232
+ argv = ["speak"]
158
233
 
159
234
  parser = _build_parser()
160
235
  args = parser.parse_args(argv)
@@ -171,6 +246,7 @@ def main() -> int:
171
246
  "status": _cmd_status,
172
247
  "voices": _cmd_voices,
173
248
  "welcome": _cmd_welcome,
249
+ "completion": _cmd_completion,
174
250
  }
175
251
  return handlers[args.cmd](args)
176
252
 
@@ -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.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
@@ -74,6 +74,17 @@ stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
74
74
  stackvox speak "save" --out a.wav # write wav instead of playing
75
75
  stackvox welcome # multilingual welcome (6 languages)
76
76
  stackvox voices # list all voice ids
77
+ echo "from a pipe" | stackvox # piped stdin works for speak/say
78
+ stackvox speak --file message.txt # read a whole file
79
+ ```
80
+
81
+ Bash completion:
82
+
83
+ ```bash
84
+ eval "$(stackvox completion bash)" # current shell
85
+ # or persist:
86
+ stackvox completion bash > ~/.stackvox-completion.bash
87
+ echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
77
88
  ```
78
89
 
79
90
  Daemon mode (keeps the model resident so each subsequent call is instant):
@@ -0,0 +1,91 @@
1
+ """CLI argument parsing — no engine loaded, no audio played."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ import pytest
8
+
9
+ from stackvox import cli
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def _stdin_is_a_tty(mocker):
14
+ """Default tests to a TTY stdin so the new piped-stdin shortcut is off.
15
+
16
+ Tests that exercise the piped path opt in by patching isatty to False
17
+ and providing a real stdin StringIO.
18
+ """
19
+ mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
20
+
21
+
22
+ def test_bare_text_routes_to_speak(mocker):
23
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
24
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "hello world"])
25
+ assert cli.main() == 0
26
+ args = speak.call_args.args[0]
27
+ assert args.cmd == "speak"
28
+ assert args.text == "hello world"
29
+
30
+
31
+ def test_speak_subcommand_parses_voice_and_speed(mocker):
32
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
33
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "--voice", "bf_emma", "--speed", "1.2", "hi"])
34
+ assert cli.main() == 0
35
+ args = speak.call_args.args[0]
36
+ assert args.voice == "bf_emma"
37
+ assert args.speed == pytest.approx(1.2)
38
+ assert args.text == "hi"
39
+
40
+
41
+ def test_no_subcommand_prints_help(mocker, capsys):
42
+ mocker.patch.object(cli.sys, "argv", ["stackvox"])
43
+ assert cli.main() == 1
44
+ captured = capsys.readouterr()
45
+ assert "usage: stackvox" in captured.out
46
+
47
+
48
+ def test_unknown_subcommand_treated_as_speak_text(mocker):
49
+ """`stackvox foo` is treated as `stackvox speak foo`, not an error."""
50
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
51
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "hello"])
52
+ cli.main()
53
+ assert speak.call_args.args[0].text == "hello"
54
+
55
+
56
+ def test_piped_stdin_with_no_args_routes_to_speak(mocker):
57
+ """`echo hi | stackvox` should default to speak."""
58
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
59
+ mocker.patch.object(cli.sys, "argv", ["stackvox"])
60
+ mocker.patch.object(cli.sys.stdin, "isatty", return_value=False)
61
+ mocker.patch.object(cli.sys, "stdin", io.StringIO("hello from stdin\n"))
62
+ assert cli.main() == 0
63
+ assert speak.call_args.args[0].cmd == "speak"
64
+
65
+
66
+ def test_read_text_prefers_file_then_positional_then_stdin(mocker, tmp_path):
67
+ """_read_text precedence: --file > positional > piped stdin."""
68
+ file_arg = tmp_path / "src.txt"
69
+ file_arg.write_text("from-file", encoding="utf-8")
70
+
71
+ # Both --file and positional present → file wins.
72
+ args = mocker.MagicMock(file=file_arg, text="from-positional")
73
+ assert cli._read_text(args) == "from-file"
74
+
75
+ # No --file, positional present → positional wins.
76
+ args = mocker.MagicMock(file=None, text="from-positional")
77
+ assert cli._read_text(args) == "from-positional"
78
+
79
+ # No --file, no positional, stdin piped → stdin wins.
80
+ mocker.patch.object(cli.sys.stdin, "isatty", return_value=False)
81
+ mocker.patch.object(cli.sys, "stdin", io.StringIO("from-stdin"))
82
+ args = mocker.MagicMock(file=None, text=None)
83
+ assert cli._read_text(args) == "from-stdin"
84
+
85
+
86
+ def test_completion_bash_emits_complete_script(mocker, capsys):
87
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "completion", "bash"])
88
+ assert cli.main() == 0
89
+ captured = capsys.readouterr()
90
+ assert "_stackvox_completion()" in captured.out
91
+ assert "complete -F _stackvox_completion stackvox" in captured.out
@@ -107,18 +107,42 @@ def test_full_queue_returns_busy(server: ServerHarness):
107
107
  assert reply in {"ok", "busy"}
108
108
 
109
109
 
110
- def test_worker_refreshes_audio_devices_before_each_play(server: ServerHarness):
111
- """PortAudio is reset before every play so device switches are picked up."""
110
+ def test_worker_refreshes_audio_only_when_dirty(server: ServerHarness):
111
+ """PortAudio is refreshed once per dirty cycle, not before every play.
112
+
113
+ Initial dirty state at startup → first playback refreshes. A second
114
+ playback with no device change → no extra refresh. Re-marking dirty
115
+ (simulating a default-output-device change) → next playback refreshes
116
+ again.
117
+ """
112
118
  import time
113
119
 
120
+ from stackvox import daemon
121
+
122
+ # Ensure a known starting state regardless of prior-test ordering: dirty
123
+ # so the first playback in this test refreshes once.
124
+ daemon._audio_dirty.set()
125
+
126
+ def wait_for_speak_count(target: int) -> None:
127
+ deadline = time.monotonic() + 1.0
128
+ while server.tts.speak.call_count < target and time.monotonic() < deadline:
129
+ time.sleep(0.01)
130
+
131
+ # First playback consumes the dirty flag.
114
132
  assert _roundtrip(server.sock, json.dumps({"text": "a"}) + "\n") == "ok"
133
+ wait_for_speak_count(1)
134
+ assert server.refresh.call_count == 1
135
+
136
+ # Second playback with no device change → no additional refresh.
115
137
  assert _roundtrip(server.sock, json.dumps({"text": "b"}) + "\n") == "ok"
138
+ wait_for_speak_count(2)
139
+ assert server.refresh.call_count == 1
116
140
 
117
- deadline = time.monotonic() + 1.0
118
- while server.tts.speak.call_count < 2 and time.monotonic() < deadline:
119
- time.sleep(0.01)
141
+ # Simulate a real device change.
142
+ daemon._audio_dirty.set()
120
143
 
121
- assert server.tts.speak.call_count == 2
144
+ assert _roundtrip(server.sock, json.dumps({"text": "c"}) + "\n") == "ok"
145
+ wait_for_speak_count(3)
122
146
  assert server.refresh.call_count == 2
123
147
 
124
148
 
@@ -1,41 +0,0 @@
1
- """CLI argument parsing — no engine loaded, no audio played."""
2
-
3
- from __future__ import annotations
4
-
5
- import pytest
6
-
7
- from stackvox import cli
8
-
9
-
10
- def test_bare_text_routes_to_speak(mocker):
11
- speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
12
- mocker.patch.object(cli.sys, "argv", ["stackvox", "hello world"])
13
- assert cli.main() == 0
14
- args = speak.call_args.args[0]
15
- assert args.cmd == "speak"
16
- assert args.text == "hello world"
17
-
18
-
19
- def test_speak_subcommand_parses_voice_and_speed(mocker):
20
- speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
21
- mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "--voice", "bf_emma", "--speed", "1.2", "hi"])
22
- assert cli.main() == 0
23
- args = speak.call_args.args[0]
24
- assert args.voice == "bf_emma"
25
- assert args.speed == pytest.approx(1.2)
26
- assert args.text == "hi"
27
-
28
-
29
- def test_no_subcommand_prints_help(mocker, capsys):
30
- mocker.patch.object(cli.sys, "argv", ["stackvox"])
31
- assert cli.main() == 1
32
- captured = capsys.readouterr()
33
- assert "usage: stackvox" in captured.out
34
-
35
-
36
- def test_unknown_subcommand_treated_as_speak_text(mocker):
37
- """`stackvox foo` is treated as `stackvox speak foo`, not an error."""
38
- speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
39
- mocker.patch.object(cli.sys, "argv", ["stackvox", "hello"])
40
- cli.main()
41
- assert speak.call_args.args[0].text == "hello"
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