stackvox 0.12.0__tar.gz → 0.14.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.12.0 → stackvox-0.14.0}/NOTICE +25 -0
- {stackvox-0.12.0/stackvox.egg-info → stackvox-0.14.0}/PKG-INFO +145 -6
- {stackvox-0.12.0 → stackvox-0.14.0}/README.md +141 -5
- {stackvox-0.12.0 → stackvox-0.14.0}/pyproject.toml +8 -2
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/__init__.py +2 -2
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/cli.py +135 -16
- stackvox-0.14.0/stackvox/config.py +150 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/daemon.py +186 -11
- stackvox-0.14.0/stackvox/engine.py +692 -0
- stackvox-0.14.0/stackvox/mlx_backend.py +261 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/text.py +38 -3
- stackvox-0.14.0/stackvox/voices.py +89 -0
- {stackvox-0.12.0 → stackvox-0.14.0/stackvox.egg-info}/PKG-INFO +145 -6
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/SOURCES.txt +3 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/requires.txt +6 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_cli.py +261 -8
- {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_config.py +48 -2
- {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_daemon.py +199 -4
- {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_daemon_protocol.py +42 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_engine.py +324 -1
- stackvox-0.14.0/tests/test_mlx_backend.py +335 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_text.py +41 -0
- stackvox-0.12.0/stackvox/config.py +0 -80
- stackvox-0.12.0/stackvox/engine.py +0 -375
- {stackvox-0.12.0 → stackvox-0.14.0}/LICENSE +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/setup.cfg +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/__main__.py +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/paths.py +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/py.typed +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/updates.py +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_paths.py +0 -0
- {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_updates.py +0 -0
|
@@ -60,3 +60,28 @@ phonemizer-fork
|
|
|
60
60
|
runtime. Downstream users who distribute a combined work that includes
|
|
61
61
|
phonemizer-fork should review GPL-3.0 obligations for their own
|
|
62
62
|
distribution.
|
|
63
|
+
|
|
64
|
+
--------------------------------------------------------------------------------
|
|
65
|
+
Optional MLX backend (`stackvox[mlx]`)
|
|
66
|
+
--------------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
The components below are used only when the optional MLX backend is installed
|
|
69
|
+
and selected. stackvox does not download, bundle, modify, or redistribute any
|
|
70
|
+
MLX model; the user supplies a local model directory, and that model's own
|
|
71
|
+
license governs its use.
|
|
72
|
+
|
|
73
|
+
mlx-audio
|
|
74
|
+
Copyright (c) 2024 Prince Canuma
|
|
75
|
+
Licensed under the MIT License
|
|
76
|
+
https://github.com/Blaizzy/mlx-audio
|
|
77
|
+
|
|
78
|
+
mlx and mlx-lm
|
|
79
|
+
Copyright © 2023 Apple Inc.
|
|
80
|
+
Licensed under the MIT License
|
|
81
|
+
https://github.com/ml-explore/mlx
|
|
82
|
+
https://github.com/ml-explore/mlx-lm
|
|
83
|
+
|
|
84
|
+
transformers
|
|
85
|
+
Copyright 2018- The Hugging Face team. All rights reserved.
|
|
86
|
+
Licensed under the Apache License, Version 2.0
|
|
87
|
+
https://github.com/huggingface/transformers
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackvox
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -18,10 +18,13 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
License-File: NOTICE
|
|
20
20
|
Requires-Dist: kokoro-onnx>=0.4.0
|
|
21
|
+
Requires-Dist: onnxruntime>=1.20.1
|
|
21
22
|
Requires-Dist: soundfile>=0.12.1
|
|
22
23
|
Requires-Dist: sounddevice>=0.4.6
|
|
23
24
|
Requires-Dist: numpy>=1.24
|
|
24
25
|
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
26
|
+
Provides-Extra: mlx
|
|
27
|
+
Requires-Dist: mlx-audio<0.5,>=0.4.5; (platform_system == "Darwin" and platform_machine == "arm64") and extra == "mlx"
|
|
25
28
|
Provides-Extra: dev
|
|
26
29
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
30
|
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
@@ -103,10 +106,12 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
|
|
|
103
106
|
stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
|
|
104
107
|
```
|
|
105
108
|
|
|
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).
|
|
109
|
+
`--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-abbreviations`, `--no-filenames`, `--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
110
|
|
|
108
111
|
**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
112
|
|
|
113
|
+
**Chat shorthand** (on by default; `--no-abbreviations` to disable) is expanded to the words it stands for, so a response that reads "lmk" is spoken as "let me know": `lmk`, `iirc`, `afaik`, `idk`, `tbh`, `imo`, `btw`, `fyi`, `asap`, `wrt`, `tl;dr`/`tldr`. Whole-word and case-insensitive. Shorthand that doubles as a real word or is already voiced correctly (`rn`, `eta`, `aka`) is deliberately left alone; a `--pronunciations` entry overrides any of these.
|
|
114
|
+
|
|
110
115
|
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.
|
|
111
116
|
|
|
112
117
|
Bash completion:
|
|
@@ -129,7 +134,7 @@ speed = 1.1
|
|
|
129
134
|
lang = "en-gb"
|
|
130
135
|
```
|
|
131
136
|
|
|
132
|
-
CLI flags always win over config values, and config values always win over the built-in defaults. A missing file is fine — built-ins apply. A malformed file logs a warning and is ignored.
|
|
137
|
+
CLI flags always win over config values, and config values always win over the built-in defaults. The one thing between a flag and the config is a [voice mix](#voice-mixes)'s own `lang` and `speed`, so `--voice chip` stays transatlantic even when `[defaults]` sets `lang = "en-us"`. A missing file is fine — built-ins apply. A malformed file logs a warning and is ignored.
|
|
133
138
|
|
|
134
139
|
## Daemon mode
|
|
135
140
|
|
|
@@ -139,7 +144,7 @@ Keeps the model resident so each subsequent call is instant:
|
|
|
139
144
|
stackvox serve # foreground; run with `nohup stackvox serve &` to background
|
|
140
145
|
stackvox status # is the daemon up? also shows version + any pending PyPI update
|
|
141
146
|
stackvox say "Hello" # send text to the daemon (fails if not running)
|
|
142
|
-
stackvox stop # graceful shutdown
|
|
147
|
+
stackvox stop # graceful shutdown, force-killed if it acknowledges but is still up after 5s
|
|
143
148
|
```
|
|
144
149
|
|
|
145
150
|
stackvox checks PyPI for newer versions but only at two moments — when you run `stackvox status` and at daemon startup. The script-heavy paths (`say`, `speak`, `stackvox-say`, hooks, CI) never make a network call. To see notices on every invocation set `STACKVOX_UPDATE_NOTICE=1`. To disable the check entirely set `STACKVOX_NO_UPDATE_CHECK=1`. The check is auto-skipped when common CI env vars (`CI`, `GITHUB_ACTIONS`, etc.) are set so build logs stay clean.
|
|
@@ -159,7 +164,7 @@ Exit codes: `0` ok, `2` daemon unreachable (unless `--fallback-say` was given).
|
|
|
159
164
|
## Python library
|
|
160
165
|
|
|
161
166
|
```python
|
|
162
|
-
from stackvox import Stackvox, speak, synthesize
|
|
167
|
+
from stackvox import Stackvox, speak, synthesize, synthesize_stream
|
|
163
168
|
|
|
164
169
|
# One-shot — model loads on first call, reused for subsequent calls.
|
|
165
170
|
speak("Hello world")
|
|
@@ -169,6 +174,10 @@ tts = Stackvox(voice="af_bella")
|
|
|
169
174
|
tts.speak("First line")
|
|
170
175
|
tts.speak("Faster", speed=1.2)
|
|
171
176
|
|
|
177
|
+
# Cap ONNX Runtime's thread pools — it otherwise sizes them from the host's core
|
|
178
|
+
# count, which oversubscribes inside a container with a smaller CPU quota.
|
|
179
|
+
tts = Stackvox(threads=4)
|
|
180
|
+
|
|
172
181
|
# Non-blocking playback.
|
|
173
182
|
tts.speak("async", blocking=False)
|
|
174
183
|
tts.stop()
|
|
@@ -176,6 +185,11 @@ tts.stop()
|
|
|
176
185
|
# Raw samples for custom processing.
|
|
177
186
|
samples, sr = tts.synthesize("give me the array")
|
|
178
187
|
|
|
188
|
+
# Streaming: one chunk per sentence, yielded as soon as it is synthesized, so
|
|
189
|
+
# a caller encoding audio itself waits one sentence rather than the whole text.
|
|
190
|
+
for chunk, sr in tts.synthesize_stream("First sentence. Second sentence."):
|
|
191
|
+
encode_and_send(chunk, sr)
|
|
192
|
+
|
|
179
193
|
# Gapless multi-line playback with concurrent synthesis.
|
|
180
194
|
tts.speak_sequence([
|
|
181
195
|
{"text": "Hello", "voice": "af_heart", "lang": "en-us"},
|
|
@@ -193,6 +207,100 @@ if daemon.is_running():
|
|
|
193
207
|
daemon.stop()
|
|
194
208
|
```
|
|
195
209
|
|
|
210
|
+
## Local MLX models (Apple Silicon)
|
|
211
|
+
|
|
212
|
+
The optional `mlx` backend runs any text-to-speech model that
|
|
213
|
+
[MLX Audio](https://github.com/Blaizzy/mlx-audio) can load from a local
|
|
214
|
+
directory, including ones that support voice cloning and style instructions.
|
|
215
|
+
Kokoro (ONNX) remains the default backend and dependency set.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
pip install 'stackvox[mlx]'
|
|
219
|
+
|
|
220
|
+
stackvox speak \
|
|
221
|
+
--backend mlx \
|
|
222
|
+
--model-dir /path/to/model \
|
|
223
|
+
--reference-audio /path/to/reference.wav \
|
|
224
|
+
--reference-text "The exact transcript of the reference audio." \
|
|
225
|
+
--out cloned.wav \
|
|
226
|
+
"Hello from a local MLX model."
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
> **Model licenses:** stackvox never downloads, bundles, or recommends MLX
|
|
230
|
+
> models. You supply the model directory, and its license (which may restrict
|
|
231
|
+
> commercial use) applies to your use of it. Some models also need their own
|
|
232
|
+
> extra Python packages; MLX Audio names them if they are missing.
|
|
233
|
+
|
|
234
|
+
Keep a large model resident for repeated requests by selecting it when the
|
|
235
|
+
daemon starts. The backend and model cannot be changed without restarting the
|
|
236
|
+
daemon. Generation options given to `serve` become the daemon's defaults, and
|
|
237
|
+
any `say` request can override them:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
stackvox serve --backend mlx --model-dir /path/to/model
|
|
241
|
+
|
|
242
|
+
stackvox say \
|
|
243
|
+
--reference-audio /path/to/reference.wav \
|
|
244
|
+
--reference-text "The exact transcript of the reference audio." \
|
|
245
|
+
"This request uses the resident model."
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Generation options (`--reference-audio`, `--reference-text`, `--instruct`,
|
|
249
|
+
`--temperature`, `--top-p`, `--top-k`) are forwarded only when set, so each
|
|
250
|
+
model's own defaults apply otherwise; which ones a model honours depends on
|
|
251
|
+
the model. They are rejected with an error unless the `mlx` backend is in use,
|
|
252
|
+
both by `speak` and by a daemon running Kokoro. `--voice` and `--lang` apply to
|
|
253
|
+
Kokoro only. Playback streams in chunks of about two sentences (up to ~170
|
|
254
|
+
characters), so the first audio arrives once the first chunk is generated;
|
|
255
|
+
short chunks also stop models drifting off-script on long text. Models slower
|
|
256
|
+
than real time will pause between chunks.
|
|
257
|
+
|
|
258
|
+
### Model adapters
|
|
259
|
+
|
|
260
|
+
Some models need custom loading, such as renamed weights, a codec path, or a
|
|
261
|
+
tokenizer quirk. Rather than carrying model-specific code, stackvox accepts an
|
|
262
|
+
adapter script that you write and keep locally, for example under
|
|
263
|
+
`~/.config/stackvox/adapters/`:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
# my_adapter.py
|
|
267
|
+
from pathlib import Path
|
|
268
|
+
from stackvox.mlx_backend import swapped
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def load(model_dir: Path, load_model):
|
|
272
|
+
# Patch whatever the model needs for the duration of the load, then
|
|
273
|
+
# delegate to MLX Audio's loader (or replace it entirely).
|
|
274
|
+
from some_library import SomeClass
|
|
275
|
+
|
|
276
|
+
with swapped(SomeClass, "some_method", lambda *args, **kwargs: ...):
|
|
277
|
+
return load_model(model_dir)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
stackvox speak --backend mlx --model-dir /path/to/model \
|
|
282
|
+
--model-adapter ~/.config/stackvox/adapters/my_adapter.py "Hello"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`load(model_dir, load_model)` must return the loaded model. `swapped` patches
|
|
286
|
+
an attribute and restores it afterwards, even on error. Adapters run as normal
|
|
287
|
+
Python with your privileges and are only used when passed explicitly; nothing
|
|
288
|
+
is picked up from a model directory.
|
|
289
|
+
|
|
290
|
+
The Python API uses the same waveform contract as Kokoro:
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
from pathlib import Path
|
|
294
|
+
from stackvox import Stackvox
|
|
295
|
+
|
|
296
|
+
tts = Stackvox(backend="mlx", model_dir=Path("/path/to/model"))
|
|
297
|
+
samples, sample_rate = tts.synthesize(
|
|
298
|
+
"A calm and natural delivery.",
|
|
299
|
+
reference_audio=Path("reference.wav"),
|
|
300
|
+
reference_text="The reference transcript.",
|
|
301
|
+
)
|
|
302
|
+
```
|
|
303
|
+
|
|
196
304
|
## Voices
|
|
197
305
|
|
|
198
306
|
Kokoro ships voices across several languages. Voice prefix encodes gender + language:
|
|
@@ -209,7 +317,34 @@ Kokoro ships voices across several languages. Voice prefix encodes gender + lang
|
|
|
209
317
|
| `jf_*`, `jm_*` | Japanese | `jf_alpha` |
|
|
210
318
|
| `zf_*`, `zm_*` | Mandarin Chinese | `zf_xiaoxiao` |
|
|
211
319
|
|
|
212
|
-
Run `stackvox voices` for the authoritative list.
|
|
320
|
+
Run `stackvox voices` for the authoritative list, which includes the voice mixes below.
|
|
321
|
+
|
|
322
|
+
### Voice mixes
|
|
323
|
+
|
|
324
|
+
Each Kokoro voice is a style vector, so a weighted blend of several makes a new voice between them. A mix is named, and the name works anywhere a voice id does: `--voice`, `Stackvox(voice=...)`, and the daemon's socket protocol. It also carries the `lang` and `speed` it was tuned with, which apply unless you pass your own.
|
|
325
|
+
|
|
326
|
+
Two ship built in, a transatlantic newsreader pair (American delivery, British vowels):
|
|
327
|
+
|
|
328
|
+
| Name | Mix | lang | speed |
|
|
329
|
+
| -------- | ----------------------------------------------------- | ------- | ----- |
|
|
330
|
+
| `chip` | `am_liam` 0.6, `bm_daniel` 0.4 | `en-gb` | 1.0 |
|
|
331
|
+
| `ramona` | `af_aoede` 0.5, `af_bella` 0.2, `bf_emma` 0.3 | `en-gb` | 1.0 |
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
stackvox speak --voice chip "Good evening, and welcome to the news."
|
|
335
|
+
stackvox say --voice ramona "Thanks Chip."
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Define your own in the config file. Weights needn't sum to 1, and a table named after a built-in replaces it:
|
|
339
|
+
|
|
340
|
+
```toml
|
|
341
|
+
[voices.narrator]
|
|
342
|
+
mix = { am_michael = 0.6, bm_george = 0.4 }
|
|
343
|
+
lang = "en-gb" # optional
|
|
344
|
+
speed = 1.0 # optional
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The daemon reads mixes when it starts, so restart it (`stackvox stop`, then `stackvox serve`) after editing them.
|
|
213
348
|
|
|
214
349
|
## Architecture
|
|
215
350
|
|
|
@@ -232,6 +367,8 @@ Socket lives at `~/.cache/stackvox/daemon.sock` (override with `STACKVOX_SOCKET`
|
|
|
232
367
|
|
|
233
368
|
Queue depth is 2 — rapid-fire requests beyond that get `busy` rather than piling up.
|
|
234
369
|
|
|
370
|
+
If playback hangs the daemon exits rather than answering `busy` forever. Each utterance gets 60s plus half a second per character, slower than any voice reads it even at speed 0.5. Past that the daemon removes its socket and exits with status 70, so `say` reports it isn't running and anything that starts the daemon on demand gets a fresh one. On macOS this has been PortAudio deadlocked stopping a stream, or stuck writing to an output device that stopped taking audio, and `cancel` can't break either.
|
|
371
|
+
|
|
235
372
|
Before each utterance the daemon resets PortAudio so it picks up the current system default output device. Swap from speakers to Bluetooth headphones mid-session and the next `say` follows you — no daemon restart needed. The refresh costs ~10–50ms per play, which is invisible next to synthesis time.
|
|
236
373
|
|
|
237
374
|
## Requirements
|
|
@@ -275,4 +412,6 @@ stackvox itself is licensed under the **Apache License, Version 2.0** — see [`
|
|
|
275
412
|
|
|
276
413
|
**Runtime dependencies.** [`kokoro-onnx`](https://github.com/thewh1teagle/kokoro-onnx) (MIT, © thewh1teagle), [`onnxruntime`](https://github.com/microsoft/onnxruntime) (MIT, © Microsoft), [`sounddevice`](https://python-sounddevice.readthedocs.io/) (MIT, © Matthias Geier), [`soundfile`](https://github.com/bastibe/python-soundfile) (BSD-3, © Bastian Bechtold), [`numpy`](https://numpy.org/) (BSD-3).
|
|
277
414
|
|
|
415
|
+
**Optional MLX backend.** `stackvox[mlx]` runs on [`mlx-audio`](https://github.com/Blaizzy/mlx-audio) (MIT, © Prince Canuma), [`mlx`](https://github.com/ml-explore/mlx) and [`mlx-lm`](https://github.com/ml-explore/mlx-lm) (MIT, © Apple), and [`transformers`](https://github.com/huggingface/transformers) (Apache 2.0, © Hugging Face). stackvox does not download, bundle, or redistribute any MLX model; the license of whichever model you supply governs your use of it.
|
|
416
|
+
|
|
278
417
|
**GPL note.** `kokoro-onnx` pulls in [`phonemizer-fork`](https://github.com/bootphon/phonemizer) as a transitive runtime dependency; it is licensed under **GPL-3.0**. stackvox does not bundle, modify, or statically link it — pip installs it alongside stackvox and the two communicate through phonemizer's published Python API at runtime. If you redistribute a combined work (e.g. a frozen binary, container image, or vendored wheel set) that includes phonemizer-fork, review GPL-3.0 obligations for that distribution.
|
|
@@ -69,10 +69,12 @@ 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-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).
|
|
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-abbreviations`, `--no-filenames`, `--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
73
|
|
|
74
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
75
|
|
|
76
|
+
**Chat shorthand** (on by default; `--no-abbreviations` to disable) is expanded to the words it stands for, so a response that reads "lmk" is spoken as "let me know": `lmk`, `iirc`, `afaik`, `idk`, `tbh`, `imo`, `btw`, `fyi`, `asap`, `wrt`, `tl;dr`/`tldr`. Whole-word and case-insensitive. Shorthand that doubles as a real word or is already voiced correctly (`rn`, `eta`, `aka`) is deliberately left alone; a `--pronunciations` entry overrides any of these.
|
|
77
|
+
|
|
76
78
|
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.
|
|
77
79
|
|
|
78
80
|
Bash completion:
|
|
@@ -95,7 +97,7 @@ speed = 1.1
|
|
|
95
97
|
lang = "en-gb"
|
|
96
98
|
```
|
|
97
99
|
|
|
98
|
-
CLI flags always win over config values, and config values always win over the built-in defaults. A missing file is fine — built-ins apply. A malformed file logs a warning and is ignored.
|
|
100
|
+
CLI flags always win over config values, and config values always win over the built-in defaults. The one thing between a flag and the config is a [voice mix](#voice-mixes)'s own `lang` and `speed`, so `--voice chip` stays transatlantic even when `[defaults]` sets `lang = "en-us"`. A missing file is fine — built-ins apply. A malformed file logs a warning and is ignored.
|
|
99
101
|
|
|
100
102
|
## Daemon mode
|
|
101
103
|
|
|
@@ -105,7 +107,7 @@ Keeps the model resident so each subsequent call is instant:
|
|
|
105
107
|
stackvox serve # foreground; run with `nohup stackvox serve &` to background
|
|
106
108
|
stackvox status # is the daemon up? also shows version + any pending PyPI update
|
|
107
109
|
stackvox say "Hello" # send text to the daemon (fails if not running)
|
|
108
|
-
stackvox stop # graceful shutdown
|
|
110
|
+
stackvox stop # graceful shutdown, force-killed if it acknowledges but is still up after 5s
|
|
109
111
|
```
|
|
110
112
|
|
|
111
113
|
stackvox checks PyPI for newer versions but only at two moments — when you run `stackvox status` and at daemon startup. The script-heavy paths (`say`, `speak`, `stackvox-say`, hooks, CI) never make a network call. To see notices on every invocation set `STACKVOX_UPDATE_NOTICE=1`. To disable the check entirely set `STACKVOX_NO_UPDATE_CHECK=1`. The check is auto-skipped when common CI env vars (`CI`, `GITHUB_ACTIONS`, etc.) are set so build logs stay clean.
|
|
@@ -125,7 +127,7 @@ Exit codes: `0` ok, `2` daemon unreachable (unless `--fallback-say` was given).
|
|
|
125
127
|
## Python library
|
|
126
128
|
|
|
127
129
|
```python
|
|
128
|
-
from stackvox import Stackvox, speak, synthesize
|
|
130
|
+
from stackvox import Stackvox, speak, synthesize, synthesize_stream
|
|
129
131
|
|
|
130
132
|
# One-shot — model loads on first call, reused for subsequent calls.
|
|
131
133
|
speak("Hello world")
|
|
@@ -135,6 +137,10 @@ tts = Stackvox(voice="af_bella")
|
|
|
135
137
|
tts.speak("First line")
|
|
136
138
|
tts.speak("Faster", speed=1.2)
|
|
137
139
|
|
|
140
|
+
# Cap ONNX Runtime's thread pools — it otherwise sizes them from the host's core
|
|
141
|
+
# count, which oversubscribes inside a container with a smaller CPU quota.
|
|
142
|
+
tts = Stackvox(threads=4)
|
|
143
|
+
|
|
138
144
|
# Non-blocking playback.
|
|
139
145
|
tts.speak("async", blocking=False)
|
|
140
146
|
tts.stop()
|
|
@@ -142,6 +148,11 @@ tts.stop()
|
|
|
142
148
|
# Raw samples for custom processing.
|
|
143
149
|
samples, sr = tts.synthesize("give me the array")
|
|
144
150
|
|
|
151
|
+
# Streaming: one chunk per sentence, yielded as soon as it is synthesized, so
|
|
152
|
+
# a caller encoding audio itself waits one sentence rather than the whole text.
|
|
153
|
+
for chunk, sr in tts.synthesize_stream("First sentence. Second sentence."):
|
|
154
|
+
encode_and_send(chunk, sr)
|
|
155
|
+
|
|
145
156
|
# Gapless multi-line playback with concurrent synthesis.
|
|
146
157
|
tts.speak_sequence([
|
|
147
158
|
{"text": "Hello", "voice": "af_heart", "lang": "en-us"},
|
|
@@ -159,6 +170,100 @@ if daemon.is_running():
|
|
|
159
170
|
daemon.stop()
|
|
160
171
|
```
|
|
161
172
|
|
|
173
|
+
## Local MLX models (Apple Silicon)
|
|
174
|
+
|
|
175
|
+
The optional `mlx` backend runs any text-to-speech model that
|
|
176
|
+
[MLX Audio](https://github.com/Blaizzy/mlx-audio) can load from a local
|
|
177
|
+
directory, including ones that support voice cloning and style instructions.
|
|
178
|
+
Kokoro (ONNX) remains the default backend and dependency set.
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pip install 'stackvox[mlx]'
|
|
182
|
+
|
|
183
|
+
stackvox speak \
|
|
184
|
+
--backend mlx \
|
|
185
|
+
--model-dir /path/to/model \
|
|
186
|
+
--reference-audio /path/to/reference.wav \
|
|
187
|
+
--reference-text "The exact transcript of the reference audio." \
|
|
188
|
+
--out cloned.wav \
|
|
189
|
+
"Hello from a local MLX model."
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
> **Model licenses:** stackvox never downloads, bundles, or recommends MLX
|
|
193
|
+
> models. You supply the model directory, and its license (which may restrict
|
|
194
|
+
> commercial use) applies to your use of it. Some models also need their own
|
|
195
|
+
> extra Python packages; MLX Audio names them if they are missing.
|
|
196
|
+
|
|
197
|
+
Keep a large model resident for repeated requests by selecting it when the
|
|
198
|
+
daemon starts. The backend and model cannot be changed without restarting the
|
|
199
|
+
daemon. Generation options given to `serve` become the daemon's defaults, and
|
|
200
|
+
any `say` request can override them:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
stackvox serve --backend mlx --model-dir /path/to/model
|
|
204
|
+
|
|
205
|
+
stackvox say \
|
|
206
|
+
--reference-audio /path/to/reference.wav \
|
|
207
|
+
--reference-text "The exact transcript of the reference audio." \
|
|
208
|
+
"This request uses the resident model."
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Generation options (`--reference-audio`, `--reference-text`, `--instruct`,
|
|
212
|
+
`--temperature`, `--top-p`, `--top-k`) are forwarded only when set, so each
|
|
213
|
+
model's own defaults apply otherwise; which ones a model honours depends on
|
|
214
|
+
the model. They are rejected with an error unless the `mlx` backend is in use,
|
|
215
|
+
both by `speak` and by a daemon running Kokoro. `--voice` and `--lang` apply to
|
|
216
|
+
Kokoro only. Playback streams in chunks of about two sentences (up to ~170
|
|
217
|
+
characters), so the first audio arrives once the first chunk is generated;
|
|
218
|
+
short chunks also stop models drifting off-script on long text. Models slower
|
|
219
|
+
than real time will pause between chunks.
|
|
220
|
+
|
|
221
|
+
### Model adapters
|
|
222
|
+
|
|
223
|
+
Some models need custom loading, such as renamed weights, a codec path, or a
|
|
224
|
+
tokenizer quirk. Rather than carrying model-specific code, stackvox accepts an
|
|
225
|
+
adapter script that you write and keep locally, for example under
|
|
226
|
+
`~/.config/stackvox/adapters/`:
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
# my_adapter.py
|
|
230
|
+
from pathlib import Path
|
|
231
|
+
from stackvox.mlx_backend import swapped
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load(model_dir: Path, load_model):
|
|
235
|
+
# Patch whatever the model needs for the duration of the load, then
|
|
236
|
+
# delegate to MLX Audio's loader (or replace it entirely).
|
|
237
|
+
from some_library import SomeClass
|
|
238
|
+
|
|
239
|
+
with swapped(SomeClass, "some_method", lambda *args, **kwargs: ...):
|
|
240
|
+
return load_model(model_dir)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
stackvox speak --backend mlx --model-dir /path/to/model \
|
|
245
|
+
--model-adapter ~/.config/stackvox/adapters/my_adapter.py "Hello"
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`load(model_dir, load_model)` must return the loaded model. `swapped` patches
|
|
249
|
+
an attribute and restores it afterwards, even on error. Adapters run as normal
|
|
250
|
+
Python with your privileges and are only used when passed explicitly; nothing
|
|
251
|
+
is picked up from a model directory.
|
|
252
|
+
|
|
253
|
+
The Python API uses the same waveform contract as Kokoro:
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
from pathlib import Path
|
|
257
|
+
from stackvox import Stackvox
|
|
258
|
+
|
|
259
|
+
tts = Stackvox(backend="mlx", model_dir=Path("/path/to/model"))
|
|
260
|
+
samples, sample_rate = tts.synthesize(
|
|
261
|
+
"A calm and natural delivery.",
|
|
262
|
+
reference_audio=Path("reference.wav"),
|
|
263
|
+
reference_text="The reference transcript.",
|
|
264
|
+
)
|
|
265
|
+
```
|
|
266
|
+
|
|
162
267
|
## Voices
|
|
163
268
|
|
|
164
269
|
Kokoro ships voices across several languages. Voice prefix encodes gender + language:
|
|
@@ -175,7 +280,34 @@ Kokoro ships voices across several languages. Voice prefix encodes gender + lang
|
|
|
175
280
|
| `jf_*`, `jm_*` | Japanese | `jf_alpha` |
|
|
176
281
|
| `zf_*`, `zm_*` | Mandarin Chinese | `zf_xiaoxiao` |
|
|
177
282
|
|
|
178
|
-
Run `stackvox voices` for the authoritative list.
|
|
283
|
+
Run `stackvox voices` for the authoritative list, which includes the voice mixes below.
|
|
284
|
+
|
|
285
|
+
### Voice mixes
|
|
286
|
+
|
|
287
|
+
Each Kokoro voice is a style vector, so a weighted blend of several makes a new voice between them. A mix is named, and the name works anywhere a voice id does: `--voice`, `Stackvox(voice=...)`, and the daemon's socket protocol. It also carries the `lang` and `speed` it was tuned with, which apply unless you pass your own.
|
|
288
|
+
|
|
289
|
+
Two ship built in, a transatlantic newsreader pair (American delivery, British vowels):
|
|
290
|
+
|
|
291
|
+
| Name | Mix | lang | speed |
|
|
292
|
+
| -------- | ----------------------------------------------------- | ------- | ----- |
|
|
293
|
+
| `chip` | `am_liam` 0.6, `bm_daniel` 0.4 | `en-gb` | 1.0 |
|
|
294
|
+
| `ramona` | `af_aoede` 0.5, `af_bella` 0.2, `bf_emma` 0.3 | `en-gb` | 1.0 |
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
stackvox speak --voice chip "Good evening, and welcome to the news."
|
|
298
|
+
stackvox say --voice ramona "Thanks Chip."
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Define your own in the config file. Weights needn't sum to 1, and a table named after a built-in replaces it:
|
|
302
|
+
|
|
303
|
+
```toml
|
|
304
|
+
[voices.narrator]
|
|
305
|
+
mix = { am_michael = 0.6, bm_george = 0.4 }
|
|
306
|
+
lang = "en-gb" # optional
|
|
307
|
+
speed = 1.0 # optional
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The daemon reads mixes when it starts, so restart it (`stackvox stop`, then `stackvox serve`) after editing them.
|
|
179
311
|
|
|
180
312
|
## Architecture
|
|
181
313
|
|
|
@@ -198,6 +330,8 @@ Socket lives at `~/.cache/stackvox/daemon.sock` (override with `STACKVOX_SOCKET`
|
|
|
198
330
|
|
|
199
331
|
Queue depth is 2 — rapid-fire requests beyond that get `busy` rather than piling up.
|
|
200
332
|
|
|
333
|
+
If playback hangs the daemon exits rather than answering `busy` forever. Each utterance gets 60s plus half a second per character, slower than any voice reads it even at speed 0.5. Past that the daemon removes its socket and exits with status 70, so `say` reports it isn't running and anything that starts the daemon on demand gets a fresh one. On macOS this has been PortAudio deadlocked stopping a stream, or stuck writing to an output device that stopped taking audio, and `cancel` can't break either.
|
|
334
|
+
|
|
201
335
|
Before each utterance the daemon resets PortAudio so it picks up the current system default output device. Swap from speakers to Bluetooth headphones mid-session and the next `say` follows you — no daemon restart needed. The refresh costs ~10–50ms per play, which is invisible next to synthesis time.
|
|
202
336
|
|
|
203
337
|
## Requirements
|
|
@@ -241,4 +375,6 @@ stackvox itself is licensed under the **Apache License, Version 2.0** — see [`
|
|
|
241
375
|
|
|
242
376
|
**Runtime dependencies.** [`kokoro-onnx`](https://github.com/thewh1teagle/kokoro-onnx) (MIT, © thewh1teagle), [`onnxruntime`](https://github.com/microsoft/onnxruntime) (MIT, © Microsoft), [`sounddevice`](https://python-sounddevice.readthedocs.io/) (MIT, © Matthias Geier), [`soundfile`](https://github.com/bastibe/python-soundfile) (BSD-3, © Bastian Bechtold), [`numpy`](https://numpy.org/) (BSD-3).
|
|
243
377
|
|
|
378
|
+
**Optional MLX backend.** `stackvox[mlx]` runs on [`mlx-audio`](https://github.com/Blaizzy/mlx-audio) (MIT, © Prince Canuma), [`mlx`](https://github.com/ml-explore/mlx) and [`mlx-lm`](https://github.com/ml-explore/mlx-lm) (MIT, © Apple), and [`transformers`](https://github.com/huggingface/transformers) (Apache 2.0, © Hugging Face). stackvox does not download, bundle, or redistribute any MLX model; the license of whichever model you supply governs your use of it.
|
|
379
|
+
|
|
244
380
|
**GPL note.** `kokoro-onnx` pulls in [`phonemizer-fork`](https://github.com/bootphon/phonemizer) as a transitive runtime dependency; it is licensed under **GPL-3.0**. stackvox does not bundle, modify, or statically link it — pip installs it alongside stackvox and the two communicate through phonemizer's published Python API at runtime. If you redistribute a combined work (e.g. a frozen binary, container image, or vendored wheel set) that includes phonemizer-fork, review GPL-3.0 obligations for that distribution.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.14.0"
|
|
8
8
|
description = "Offline TTS library using Kokoro-82M"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -21,6 +21,7 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
dependencies = [
|
|
23
23
|
"kokoro-onnx>=0.4.0",
|
|
24
|
+
"onnxruntime>=1.20.1",
|
|
24
25
|
"soundfile>=0.12.1",
|
|
25
26
|
"sounddevice>=0.4.6",
|
|
26
27
|
"numpy>=1.24",
|
|
@@ -28,6 +29,11 @@ dependencies = [
|
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.optional-dependencies]
|
|
32
|
+
# MLX Audio is pre-1.0, so cap at the next minor; the floor is the version
|
|
33
|
+
# verified end to end with stackvox's loader and generate() usage.
|
|
34
|
+
mlx = [
|
|
35
|
+
"mlx-audio>=0.4.5,<0.5; platform_system=='Darwin' and platform_machine=='arm64'",
|
|
36
|
+
]
|
|
31
37
|
dev = [
|
|
32
38
|
"pytest>=8.0",
|
|
33
39
|
"pytest-mock>=3.12",
|
|
@@ -124,7 +130,7 @@ disallow_untyped_defs = true
|
|
|
124
130
|
[[tool.mypy.overrides]]
|
|
125
131
|
# Third-party deps without inline type stubs (or, for `tomli`, a 3.10-only
|
|
126
132
|
# fallback that's not installed on the typechecker's Python).
|
|
127
|
-
module = ["kokoro_onnx", "sounddevice", "soundfile", "tomli"]
|
|
133
|
+
module = ["kokoro_onnx", "mlx_audio.*", "onnxruntime", "sounddevice", "soundfile", "tomli"]
|
|
128
134
|
ignore_missing_imports = true
|
|
129
135
|
|
|
130
136
|
[[tool.mypy.overrides]]
|
|
@@ -4,7 +4,7 @@ from importlib.metadata import PackageNotFoundError
|
|
|
4
4
|
from importlib.metadata import version as _pkg_version
|
|
5
5
|
|
|
6
6
|
from stackvox import daemon
|
|
7
|
-
from stackvox.engine import Stackvox, speak, synthesize
|
|
7
|
+
from stackvox.engine import Stackvox, speak, synthesize, synthesize_stream
|
|
8
8
|
from stackvox.text import normalize_for_speech
|
|
9
9
|
|
|
10
10
|
try:
|
|
@@ -14,4 +14,4 @@ except PackageNotFoundError:
|
|
|
14
14
|
# bootstrap before `pip install -e .` has completed).
|
|
15
15
|
__version__ = "0.0.0+unknown"
|
|
16
16
|
|
|
17
|
-
__all__ = ["Stackvox", "daemon", "normalize_for_speech", "speak", "synthesize"]
|
|
17
|
+
__all__ = ["Stackvox", "daemon", "normalize_for_speech", "speak", "synthesize", "synthesize_stream"]
|