stackvox 0.8.0__tar.gz → 0.10.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.8.0/stackvox.egg-info → stackvox-0.10.0}/PKG-INFO +1 -1
  2. {stackvox-0.8.0 → stackvox-0.10.0}/pyproject.toml +1 -1
  3. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/cli.py +22 -1
  4. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/daemon.py +40 -1
  5. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/text.py +20 -2
  6. {stackvox-0.8.0 → stackvox-0.10.0/stackvox.egg-info}/PKG-INFO +1 -1
  7. {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_cli.py +34 -0
  8. {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_daemon_protocol.py +24 -0
  9. {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_text.py +33 -1
  10. {stackvox-0.8.0 → stackvox-0.10.0}/LICENSE +0 -0
  11. {stackvox-0.8.0 → stackvox-0.10.0}/NOTICE +0 -0
  12. {stackvox-0.8.0 → stackvox-0.10.0}/README.md +0 -0
  13. {stackvox-0.8.0 → stackvox-0.10.0}/setup.cfg +0 -0
  14. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/__init__.py +0 -0
  15. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/__main__.py +0 -0
  16. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/config.py +0 -0
  17. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/data/stackvox-say +0 -0
  18. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/engine.py +0 -0
  19. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/paths.py +0 -0
  20. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/py.typed +0 -0
  21. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox/updates.py +0 -0
  22. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/SOURCES.txt +0 -0
  23. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/dependency_links.txt +0 -0
  24. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/entry_points.txt +0 -0
  25. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/requires.txt +0 -0
  26. {stackvox-0.8.0 → stackvox-0.10.0}/stackvox.egg-info/top_level.txt +0 -0
  27. {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_config.py +0 -0
  28. {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_daemon.py +0 -0
  29. {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_engine.py +0 -0
  30. {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_paths.py +0 -0
  31. {stackvox-0.8.0 → stackvox-0.10.0}/tests/test_updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.8.0"
7
+ version = "0.10.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -26,6 +26,7 @@ def _configure_logging() -> None:
26
26
  SUBCOMMANDS = {
27
27
  "serve",
28
28
  "stop",
29
+ "cancel",
29
30
  "status",
30
31
  "say",
31
32
  "speak",
@@ -51,7 +52,7 @@ _stackvox_completion() {
51
52
  subcommand="${COMP_WORDS[1]:-}"
52
53
 
53
54
  if [[ ${COMP_CWORD} -eq 1 ]]; then
54
- COMPREPLY=( $(compgen -W "speak say normalize serve stop status voices welcome paths config completion install-helper" -- "$cur") )
55
+ COMPREPLY=( $(compgen -W "speak say normalize serve stop cancel status voices welcome paths config completion install-helper" -- "$cur") )
55
56
  return 0
56
57
  fi
57
58
 
@@ -155,6 +156,7 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP
155
156
  _add_voice_args(p_serve, defaults)
156
157
 
157
158
  sub.add_parser("stop", help="Stop the running daemon")
159
+ sub.add_parser("cancel", help="Stop the current utterance without shutting down the daemon")
158
160
  sub.add_parser("status", help="Print daemon status")
159
161
  sub.add_parser("voices", help="List available voices")
160
162
  sub.add_parser("welcome", help="Play a multilingual welcome message")
@@ -388,10 +390,28 @@ def _cmd_stop(_: argparse.Namespace) -> int:
388
390
  return 0 if ok else 1
389
391
 
390
392
 
393
+ def _cmd_cancel(_: argparse.Namespace) -> int:
394
+ # Interrupt the current utterance but leave the daemon running (no model
395
+ # reload). Nothing playing / no daemon is not an error.
396
+ if not daemon.is_running():
397
+ return 0
398
+ ok, _resp = daemon.cancel()
399
+ return 0 if ok else 1
400
+
401
+
391
402
  def _cmd_status(_: argparse.Namespace) -> int:
403
+ installed = updates._current_version()
392
404
  if daemon.is_running():
393
405
  print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}")
394
406
  rc = 0
407
+ # The running daemon can lag the installed package if it wasn't restarted
408
+ # after an upgrade — surface that skew instead of trusting it blindly.
409
+ got, running_version = daemon.version()
410
+ if got and running_version != installed:
411
+ print(
412
+ f"daemon running {running_version}, but {installed} is installed "
413
+ f"— restart the daemon (stackvox stop; stackvox serve) to pick it up"
414
+ )
395
415
  else:
396
416
  print("stopped")
397
417
  rc = 1
@@ -521,6 +541,7 @@ def main() -> int:
521
541
  "normalize": _cmd_normalize,
522
542
  "serve": _cmd_serve,
523
543
  "stop": _cmd_stop,
544
+ "cancel": _cmd_cancel,
524
545
  "status": _cmd_status,
525
546
  "voices": _cmd_voices,
526
547
  "welcome": _cmd_welcome,
@@ -5,8 +5,10 @@ Protocol: one JSON object per connection (line-terminated), reply is a status li
5
5
 
6
6
  Request shapes:
7
7
  {"text": "...", "voice": "af_sarah", "speed": 1.0, "lang": "en-us"}
8
- {"command": "stop"}
8
+ {"command": "stop"} # shut the daemon down
9
+ {"command": "cancel"} # stop the current utterance, keep the daemon up
9
10
  {"command": "ping"}
11
+ {"command": "version"}
10
12
 
11
13
  Replies: "ok", "busy", "err: <msg>".
12
14
  """
@@ -203,6 +205,17 @@ class _DaemonState:
203
205
  def shutdown(self) -> None:
204
206
  self.stop_event.set()
205
207
 
208
+ def cancel(self) -> None:
209
+ """Stop the current utterance and drop anything queued, without shutting
210
+ the daemon down. Drains the queue first so the worker can't immediately
211
+ pick up a pending item, then aborts the in-flight playback."""
212
+ while True:
213
+ try:
214
+ self.queue.get_nowait()
215
+ except queue.Empty:
216
+ break
217
+ self.tts.stop()
218
+
206
219
 
207
220
  class _Handler(socketserver.StreamRequestHandler):
208
221
  def handle(self) -> None:
@@ -222,11 +235,20 @@ class _Handler(socketserver.StreamRequestHandler):
222
235
  self.wfile.write(b"ok\n")
223
236
  return
224
237
 
238
+ if command == "version":
239
+ self.wfile.write(f"{updates._current_version()}\n".encode())
240
+ return
241
+
225
242
  if command == "stop":
226
243
  self.wfile.write(b"ok\n")
227
244
  threading.Thread(target=self.server.shutdown, daemon=True).start()
228
245
  return
229
246
 
247
+ if command == "cancel":
248
+ state.cancel()
249
+ self.wfile.write(b"ok\n")
250
+ return
251
+
230
252
  text = req.get("text")
231
253
  if not text:
232
254
  self.wfile.write(b"err: missing text\n")
@@ -341,5 +363,22 @@ def stop() -> tuple[bool, str]:
341
363
  return send({"command": "stop"})
342
364
 
343
365
 
366
+ def cancel() -> tuple[bool, str]:
367
+ """Stop the daemon's current utterance (and drop the queue) without shutting
368
+ it down."""
369
+ return send({"command": "cancel"})
370
+
371
+
344
372
  def ping() -> tuple[bool, str]:
345
373
  return send({"command": "ping"}, timeout=PING_TIMEOUT_SECONDS)
374
+
375
+
376
+ def version(timeout: float = PING_TIMEOUT_SECONDS) -> tuple[bool, str]:
377
+ """(got_version, version_or_error) reported by the running daemon.
378
+
379
+ The running daemon can differ from the installed package if it wasn't
380
+ restarted after an upgrade, so this reports what is actually serving — not
381
+ what's on disk. A version reply starts with a digit; error strings don't.
382
+ """
383
+ _, resp = send({"command": "version"}, timeout=timeout)
384
+ return resp[:1].isdigit(), resp
@@ -18,6 +18,7 @@ __all__ = [
18
18
  "markdown_to_paragraphs",
19
19
  "strip_emoji",
20
20
  "strip_thousands_separators",
21
+ "versions_to_words",
21
22
  "decimals_to_words",
22
23
  "expand_units",
23
24
  "apply_pronunciations",
@@ -84,6 +85,21 @@ def strip_thousands_separators(text: str) -> str:
84
85
  return re.sub(r"(?<=\d),(?=\d)", "", text)
85
86
 
86
87
 
88
+ _VERSION = re.compile(r"(?<!\d)\d+(?:\.\d+){2,}(?!\d)")
89
+
90
+
91
+ def versions_to_words(text: str) -> str:
92
+ """1.2.3 -> "1 point 2 point 3"; 0.7.0 -> "0 point 7 point 0".
93
+
94
+ A dotted version string has more than one decimal point, which
95
+ `decimals_to_words` can't handle — it splits only the first, leaving a stray
96
+ full stop mid-number ("0.7.0" -> "0 point 7.0"). Run this before it. Two-part
97
+ numbers are left untouched so ordinary decimals still read digit-by-digit,
98
+ and a trailing sentence stop (``upgrade to 1.2.3.``) is preserved.
99
+ """
100
+ return _VERSION.sub(lambda m: m.group(0).replace(".", " point "), text)
101
+
102
+
87
103
  def decimals_to_words(text: str) -> str:
88
104
  """1198.9 -> "1198 point 9"; 770.72 -> "770 point 7 2". Removes the bare
89
105
  "." between digits, which TTS can otherwise read as a full stop."""
@@ -117,14 +133,15 @@ _DEV_PRONUNCIATIONS: dict[str, str] = {
117
133
  "cli": "C L I",
118
134
  "ci": "C I",
119
135
  "ide": "I D E",
120
- "aws": "A W S",
136
+ "aws": "A.W.S.", # dotted: espeak reads a lone "A" as the article "uh"; dots force the letter name "ay"
121
137
  "uri": "U R I",
122
- "iam": "I A M",
138
+ "iam": "I.A.M.", # ditto — the middle "A" needs the dot
123
139
  "saas": "sass",
124
140
  "paas": "pass",
125
141
  "tui": "T U I",
126
142
  "postgresql": "postgres",
127
143
  "kubectl": "kube control",
144
+ "stackone": "stack one", # org name espeak garbles as "stac kone"
128
145
  }
129
146
 
130
147
 
@@ -287,6 +304,7 @@ def _shape_paragraph(
287
304
  if expand_units_flag: # units BEFORE decimals (see note above)
288
305
  text = expand_units(text, locale)
289
306
  if expand_numbers_flag:
307
+ text = versions_to_words(text) # multi-dot versions BEFORE the decimal split
290
308
  text = decimals_to_words(text)
291
309
  return re.sub(r"[ \t]{2,}", " ", text).strip()
292
310
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.8.0
3
+ Version: 0.10.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
@@ -261,6 +261,9 @@ class TestCmdStatus:
261
261
  pid_path.read_text.return_value = "12345\n"
262
262
  mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
263
263
  mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
264
+ # status now queries the running daemon's version; stub it so the test
265
+ # doesn't reach a real socket.
266
+ mocker.patch.object(cli.daemon, "version", return_value=(False, "n/a"))
264
267
 
265
268
  rc = cli._cmd_status(_ns())
266
269
 
@@ -269,6 +272,23 @@ class TestCmdStatus:
269
272
  assert "running" in out
270
273
  assert "12345" in out
271
274
 
275
+ def test_running_flags_daemon_version_skew(self, mocker, capsys):
276
+ mocker.patch.object(cli.daemon, "is_running", return_value=True)
277
+ pid_path = mocker.MagicMock()
278
+ pid_path.read_text.return_value = "1\n"
279
+ mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
280
+ mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
281
+ mocker.patch.object(cli.daemon, "version", return_value=(True, "0.5.0"))
282
+ mocker.patch.object(cli.updates, "_current_version", return_value="0.8.0")
283
+ mocker.patch.object(cli.updates, "check_for_update", return_value=None)
284
+
285
+ rc = cli._cmd_status(_ns())
286
+
287
+ assert rc == 0
288
+ out = capsys.readouterr().out
289
+ assert "0.5.0" in out and "0.8.0" in out # the skew is surfaced
290
+ assert "restart" in out.lower()
291
+
272
292
  def test_stopped_returns_one(self, mocker, capsys):
273
293
  mocker.patch.object(cli.daemon, "is_running", return_value=False)
274
294
  rc = cli._cmd_status(_ns())
@@ -503,3 +523,17 @@ class TestNormalizeParsing:
503
523
  mocker.patch.object(cli.sys, "argv", ["stackvox", "paths"])
504
524
  assert cli.main() == 0
505
525
  assert paths_handler.called
526
+
527
+
528
+ class TestCmdCancel:
529
+ def test_running_calls_daemon_cancel(self, mocker):
530
+ mocker.patch.object(cli.daemon, "is_running", return_value=True)
531
+ cancel = mocker.patch.object(cli.daemon, "cancel", return_value=(True, "ok"))
532
+ assert cli._cmd_cancel(_ns()) == 0
533
+ cancel.assert_called_once()
534
+
535
+ def test_no_daemon_is_a_noop(self, mocker):
536
+ mocker.patch.object(cli.daemon, "is_running", return_value=False)
537
+ cancel = mocker.patch.object(cli.daemon, "cancel")
538
+ assert cli._cmd_cancel(_ns()) == 0
539
+ cancel.assert_not_called()
@@ -76,6 +76,22 @@ def test_ping_returns_ok(server: ServerHarness):
76
76
  assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok"
77
77
 
78
78
 
79
+ def test_version_returns_installed_version(server: ServerHarness):
80
+ from stackvox import updates
81
+
82
+ reply = _roundtrip(server.sock, json.dumps({"command": "version"}) + "\n")
83
+ assert reply == updates._current_version()
84
+
85
+
86
+ def test_version_client_helper_reports_running_version(server: ServerHarness, monkeypatch):
87
+ from stackvox import daemon, updates
88
+
89
+ monkeypatch.setattr(daemon, "SOCKET_PATH", server.sock)
90
+ got, resp = daemon.version()
91
+ assert got is True
92
+ assert resp == updates._current_version()
93
+
94
+
79
95
  def test_plain_text_is_treated_as_text_field(server: ServerHarness):
80
96
  """Non-JSON payloads are accepted and wrapped as `{"text": line}`."""
81
97
  import time
@@ -153,3 +169,11 @@ def test_send_helper_when_daemon_not_running(tmp_path, monkeypatch):
153
169
  ok, resp = daemon.send({"command": "ping"})
154
170
  assert not ok
155
171
  assert resp == "daemon not running"
172
+
173
+
174
+ def test_cancel_stops_playback_and_keeps_daemon_up(server: ServerHarness):
175
+ reply = _roundtrip(server.sock, json.dumps({"command": "cancel"}) + "\n")
176
+ assert reply == "ok"
177
+ server.tts.stop.assert_called() # current utterance aborted
178
+ # unlike `stop`, the daemon stays up
179
+ assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok"
@@ -10,6 +10,7 @@ from stackvox.text import (
10
10
  shape_pauses,
11
11
  strip_emoji,
12
12
  strip_thousands_separators,
13
+ versions_to_words,
13
14
  )
14
15
 
15
16
  # --- numbers ---------------------------------------------------------------
@@ -28,6 +29,31 @@ def test_year_is_not_a_decimal():
28
29
  assert decimals_to_words("built in 2023") == "built in 2023"
29
30
 
30
31
 
32
+ def test_semver_all_parts_spoken():
33
+ assert versions_to_words("0.7.0") == "0 point 7 point 0"
34
+ assert versions_to_words("1.2.3") == "1 point 2 point 3"
35
+
36
+
37
+ def test_semver_multi_digit_component():
38
+ assert versions_to_words("1.20.3") == "1 point 20 point 3"
39
+
40
+
41
+ def test_version_preserves_trailing_sentence_stop():
42
+ assert versions_to_words("upgrade to 1.2.3.") == "upgrade to 1 point 2 point 3."
43
+
44
+
45
+ def test_two_part_number_is_left_for_the_decimal_pass():
46
+ # Only two components — an ordinary decimal, not a version.
47
+ assert versions_to_words("3.14") == "3.14"
48
+ assert decimals_to_words(versions_to_words("3.14")) == "3 point 1 4"
49
+
50
+
51
+ def test_semver_normalizes_end_to_end():
52
+ out = normalize_for_speech("Upgraded to 0.7.0 today.", markdown=False)
53
+ assert "0 point 7 point 0" in out
54
+ assert "0 point 7.0" not in out
55
+
56
+
31
57
  # --- units -----------------------------------------------------------------
32
58
 
33
59
 
@@ -200,11 +226,17 @@ def test_plain_text_mode_splits_paragraphs():
200
226
  def test_dev_acronyms_are_spelled_out():
201
227
  assert normalize_for_speech("Use the CLI.", markdown=False) == "Use the C L I."
202
228
  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)
229
+ assert "A.W.S." in normalize_for_speech("deploy to AWS", markdown=False) # dotted → "ay"
230
+ assert "I.A.M." in normalize_for_speech("set up IAM", markdown=False)
204
231
  assert "U R I" in normalize_for_speech("parse the URI", markdown=False)
205
232
  assert "C I C D" in normalize_for_speech("the CI/CD pipeline", markdown=False)
206
233
 
207
234
 
235
+ def test_org_names_are_spoken_word_by_word():
236
+ assert "stack one" in normalize_for_speech("deployed to StackOne today", markdown=False)
237
+ assert "stack one" in normalize_for_speech("the stackone api", markdown=False) # case-insensitive
238
+
239
+
208
240
  def test_dev_terms_leave_correctly_voiced_acronyms_alone():
209
241
  out = normalize_for_speech("Send JSON over HTTP to the API.", markdown=False)
210
242
  assert "JSON" in out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes