stackvox 0.10.0__tar.gz → 0.12.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.
- {stackvox-0.10.0/stackvox.egg-info → stackvox-0.12.0}/PKG-INFO +6 -2
- {stackvox-0.10.0 → stackvox-0.12.0}/README.md +5 -1
- {stackvox-0.10.0 → stackvox-0.12.0}/pyproject.toml +9 -1
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/cli.py +40 -5
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/daemon.py +39 -8
- stackvox-0.12.0/stackvox/text.py +738 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/updates.py +39 -1
- {stackvox-0.10.0 → stackvox-0.12.0/stackvox.egg-info}/PKG-INFO +6 -2
- {stackvox-0.10.0 → stackvox-0.12.0}/tests/test_cli.py +44 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/tests/test_daemon.py +72 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/tests/test_text.py +235 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/tests/test_updates.py +45 -0
- stackvox-0.10.0/stackvox/text.py +0 -356
- {stackvox-0.10.0 → stackvox-0.12.0}/LICENSE +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/NOTICE +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/setup.cfg +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/__init__.py +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/__main__.py +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/config.py +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/engine.py +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/paths.py +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox/py.typed +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox.egg-info/SOURCES.txt +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox.egg-info/requires.txt +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/tests/test_config.py +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/tests/test_engine.py +0 -0
- {stackvox-0.10.0 → stackvox-0.12.0}/tests/test_paths.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackvox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.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
|
|
@@ -103,7 +103,11 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
|
|
|
103
103
|
stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
|
|
104
104
|
```
|
|
105
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).
|
|
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-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
|
|
107
|
+
|
|
108
|
+
**Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
|
|
109
|
+
|
|
110
|
+
Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
|
|
107
111
|
|
|
108
112
|
Bash completion:
|
|
109
113
|
|
|
@@ -69,7 +69,11 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
|
|
|
69
69
|
stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
|
|
70
70
|
```
|
|
71
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).
|
|
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-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--code-blocks {drop,placeholder}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
|
|
73
|
+
|
|
74
|
+
**Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `engine.py:42` → "line 42 of engine py", `cli.py:100-118` → "lines 100 to 118 of cli py", `foo.ts:666:10` → "line 666, column 10 of foo ts". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.
|
|
75
|
+
|
|
76
|
+
Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.
|
|
73
77
|
|
|
74
78
|
Bash completion:
|
|
75
79
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.0"
|
|
8
8
|
description = "Offline TTS library using Kokoro-82M"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -61,6 +61,14 @@ stackvox = ["py.typed", "data/stackvox-say"]
|
|
|
61
61
|
line-length = 110
|
|
62
62
|
target-version = "py310"
|
|
63
63
|
|
|
64
|
+
[tool.ruff.format]
|
|
65
|
+
# Ruff 0.16 began formatting Python code blocks inside Markdown. Ours are
|
|
66
|
+
# illustrative rather than real code, and their layout is deliberate: the
|
|
67
|
+
# `normalize_for_speech` signature in docs/speech-normalization.md aligns its
|
|
68
|
+
# trailing comments into a readable column of flags, which the formatter
|
|
69
|
+
# collapses. Prose is a human call, so keep the formatter to the code.
|
|
70
|
+
exclude = ["*.md"]
|
|
71
|
+
|
|
64
72
|
[tool.ruff.lint]
|
|
65
73
|
select = [
|
|
66
74
|
"E", # pycodestyle errors
|
|
@@ -77,13 +77,17 @@ _stackvox_completion() {
|
|
|
77
77
|
COMPREPLY=( $(compgen -W "drop csv" -- "$cur") )
|
|
78
78
|
return 0
|
|
79
79
|
;;
|
|
80
|
+
--code-blocks)
|
|
81
|
+
COMPREPLY=( $(compgen -W "drop placeholder" -- "$cur") )
|
|
82
|
+
return 0
|
|
83
|
+
;;
|
|
80
84
|
--locale)
|
|
81
85
|
COMPREPLY=( $(compgen -W "en-GB" -- "$cur") )
|
|
82
86
|
return 0
|
|
83
87
|
;;
|
|
84
88
|
esac
|
|
85
89
|
|
|
86
|
-
local norm_flags="--no-markdown --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --strip-emoji --no-terminal-stops --locale"
|
|
90
|
+
local norm_flags="--no-markdown --no-dev-terms --no-filenames --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --code-blocks --code-placeholder --strip-emoji --no-terminal-stops --locale"
|
|
87
91
|
|
|
88
92
|
case "$subcommand" in
|
|
89
93
|
speak)
|
|
@@ -216,6 +220,12 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -
|
|
|
216
220
|
action="store_false",
|
|
217
221
|
help="Do not spell out dev acronyms espeak mispronounces (CLI, CI, IDE, AWS, URI, IAM, ...)",
|
|
218
222
|
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--no-filenames",
|
|
225
|
+
dest="filenames",
|
|
226
|
+
action="store_false",
|
|
227
|
+
help="Leave filenames and paths as written (no 'README dot md', no 'line 42 of cli dot py')",
|
|
228
|
+
)
|
|
219
229
|
parser.add_argument(
|
|
220
230
|
"--no-expand-units",
|
|
221
231
|
dest="expand_units",
|
|
@@ -240,6 +250,19 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -
|
|
|
240
250
|
default="drop",
|
|
241
251
|
help="How Markdown tables are voiced (default: drop)",
|
|
242
252
|
)
|
|
253
|
+
parser.add_argument(
|
|
254
|
+
"--code-blocks",
|
|
255
|
+
dest="code_blocks",
|
|
256
|
+
choices=["drop", "placeholder"],
|
|
257
|
+
default="drop",
|
|
258
|
+
help="How fenced code blocks are voiced (default: drop)",
|
|
259
|
+
)
|
|
260
|
+
parser.add_argument(
|
|
261
|
+
"--code-placeholder",
|
|
262
|
+
dest="code_placeholder",
|
|
263
|
+
default="Code block.",
|
|
264
|
+
help='Spoken stand-in for a code block when --code-blocks placeholder (default: "Code block.")',
|
|
265
|
+
)
|
|
243
266
|
parser.add_argument(
|
|
244
267
|
"--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking"
|
|
245
268
|
)
|
|
@@ -277,10 +300,13 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict:
|
|
|
277
300
|
"markdown": args.markdown,
|
|
278
301
|
"pronunciations": _load_pronunciations(args.pronunciations),
|
|
279
302
|
"dev_terms": args.dev_terms,
|
|
303
|
+
"filenames": args.filenames,
|
|
280
304
|
"expand_units": args.expand_units,
|
|
281
305
|
"expand_numbers": args.expand_numbers,
|
|
282
306
|
"pauses": args.pauses,
|
|
283
307
|
"tables": args.tables,
|
|
308
|
+
"code_blocks": args.code_blocks,
|
|
309
|
+
"code_placeholder": args.code_placeholder,
|
|
284
310
|
"strip_emoji": args.strip_emoji,
|
|
285
311
|
"terminal_stops": args.terminal_stops,
|
|
286
312
|
"locale": args.locale,
|
|
@@ -406,12 +432,21 @@ def _cmd_status(_: argparse.Namespace) -> int:
|
|
|
406
432
|
rc = 0
|
|
407
433
|
# The running daemon can lag the installed package if it wasn't restarted
|
|
408
434
|
# after an upgrade — surface that skew instead of trusting it blindly.
|
|
435
|
+
# Only a daemon that is genuinely BEHIND wants restarting; a newer one
|
|
436
|
+
# means the client just run is the older install (a checkout alongside a
|
|
437
|
+
# global one), and restarting would downgrade what's serving.
|
|
409
438
|
got, running_version = daemon.version()
|
|
410
439
|
if got and running_version != installed:
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
440
|
+
if updates._is_newer(installed, running_version):
|
|
441
|
+
print(
|
|
442
|
+
f"daemon running {running_version}, but {installed} is installed "
|
|
443
|
+
f"— restart the daemon (stackvox stop; stackvox serve) to pick it up"
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
print(
|
|
447
|
+
f"daemon running {running_version} is newer than the {installed} client "
|
|
448
|
+
f"you just ran; no restart needed"
|
|
449
|
+
)
|
|
415
450
|
else:
|
|
416
451
|
print("stopped")
|
|
417
452
|
rc = 1
|
|
@@ -26,6 +26,7 @@ import socket
|
|
|
26
26
|
import socketserver
|
|
27
27
|
import sys
|
|
28
28
|
import threading
|
|
29
|
+
import time
|
|
29
30
|
from typing import Any
|
|
30
31
|
|
|
31
32
|
import sounddevice as sd
|
|
@@ -42,6 +43,10 @@ MAX_QUEUE = 2
|
|
|
42
43
|
WORKER_POLL_SECONDS = 0.5
|
|
43
44
|
CLIENT_TIMEOUT_SECONDS = 1.0
|
|
44
45
|
PING_TIMEOUT_SECONDS = 0.5
|
|
46
|
+
# How long `stop` waits for the daemon process to actually exit after it has
|
|
47
|
+
# acknowledged the request, and how often it re-checks.
|
|
48
|
+
STOP_TIMEOUT_SECONDS = 5.0
|
|
49
|
+
STOP_POLL_SECONDS = 0.05
|
|
45
50
|
RECV_BYTES = 1024
|
|
46
51
|
|
|
47
52
|
|
|
@@ -273,14 +278,19 @@ def _pid_alive(pid: int) -> bool:
|
|
|
273
278
|
return False
|
|
274
279
|
|
|
275
280
|
|
|
276
|
-
def
|
|
281
|
+
def _read_pid() -> int | None:
|
|
282
|
+
"""The pid recorded in the pid file, or None if absent or unparseable."""
|
|
277
283
|
if not PID_PATH.exists():
|
|
278
|
-
return
|
|
284
|
+
return None
|
|
279
285
|
try:
|
|
280
|
-
|
|
281
|
-
except ValueError:
|
|
282
|
-
return
|
|
283
|
-
|
|
286
|
+
return int(PID_PATH.read_text().strip())
|
|
287
|
+
except (ValueError, OSError):
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def is_running() -> bool:
|
|
292
|
+
pid = _read_pid()
|
|
293
|
+
return pid is not None and _pid_alive(pid)
|
|
284
294
|
|
|
285
295
|
|
|
286
296
|
def _check_for_update_async() -> None:
|
|
@@ -359,8 +369,29 @@ def say(
|
|
|
359
369
|
return send(req)
|
|
360
370
|
|
|
361
371
|
|
|
362
|
-
def stop() -> tuple[bool, str]:
|
|
363
|
-
|
|
372
|
+
def stop(timeout: float = STOP_TIMEOUT_SECONDS) -> tuple[bool, str]:
|
|
373
|
+
"""Shut the daemon down and wait until the process has actually gone.
|
|
374
|
+
|
|
375
|
+
The daemon acknowledges the request and only then unwinds `serve_forever`
|
|
376
|
+
and removes its pid file, so returning on the ack alone made
|
|
377
|
+
`stackvox stop && stackvox serve` race: `serve` still saw a live pid, refused
|
|
378
|
+
to start, and the shutdown then completed, leaving nothing running at all.
|
|
379
|
+
Reporting success before the process has exited makes that sequence, the one
|
|
380
|
+
`status` recommends, silently useless.
|
|
381
|
+
"""
|
|
382
|
+
pid = _read_pid()
|
|
383
|
+
ok, resp = send({"command": "stop"})
|
|
384
|
+
if not ok or pid is None:
|
|
385
|
+
# Nothing acknowledged, or no pid to watch: the caller's own
|
|
386
|
+
# `is_running` check is as good as it gets.
|
|
387
|
+
return ok, resp
|
|
388
|
+
deadline = time.monotonic() + timeout
|
|
389
|
+
while True:
|
|
390
|
+
if not _pid_alive(pid):
|
|
391
|
+
return True, resp
|
|
392
|
+
if time.monotonic() >= deadline:
|
|
393
|
+
return False, f"daemon acknowledged stop but pid {pid} is still alive after {timeout:g}s"
|
|
394
|
+
time.sleep(STOP_POLL_SECONDS)
|
|
364
395
|
|
|
365
396
|
|
|
366
397
|
def cancel() -> tuple[bool, str]:
|