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.
Files changed (36) hide show
  1. {stackvox-0.12.0 → stackvox-0.14.0}/NOTICE +25 -0
  2. {stackvox-0.12.0/stackvox.egg-info → stackvox-0.14.0}/PKG-INFO +145 -6
  3. {stackvox-0.12.0 → stackvox-0.14.0}/README.md +141 -5
  4. {stackvox-0.12.0 → stackvox-0.14.0}/pyproject.toml +8 -2
  5. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/__init__.py +2 -2
  6. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/cli.py +135 -16
  7. stackvox-0.14.0/stackvox/config.py +150 -0
  8. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/daemon.py +186 -11
  9. stackvox-0.14.0/stackvox/engine.py +692 -0
  10. stackvox-0.14.0/stackvox/mlx_backend.py +261 -0
  11. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/text.py +38 -3
  12. stackvox-0.14.0/stackvox/voices.py +89 -0
  13. {stackvox-0.12.0 → stackvox-0.14.0/stackvox.egg-info}/PKG-INFO +145 -6
  14. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/SOURCES.txt +3 -0
  15. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/requires.txt +6 -0
  16. {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_cli.py +261 -8
  17. {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_config.py +48 -2
  18. {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_daemon.py +199 -4
  19. {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_daemon_protocol.py +42 -0
  20. {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_engine.py +324 -1
  21. stackvox-0.14.0/tests/test_mlx_backend.py +335 -0
  22. {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_text.py +41 -0
  23. stackvox-0.12.0/stackvox/config.py +0 -80
  24. stackvox-0.12.0/stackvox/engine.py +0 -375
  25. {stackvox-0.12.0 → stackvox-0.14.0}/LICENSE +0 -0
  26. {stackvox-0.12.0 → stackvox-0.14.0}/setup.cfg +0 -0
  27. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/__main__.py +0 -0
  28. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/data/stackvox-say +0 -0
  29. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/paths.py +0 -0
  30. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/py.typed +0 -0
  31. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox/updates.py +0 -0
  32. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/dependency_links.txt +0 -0
  33. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/entry_points.txt +0 -0
  34. {stackvox-0.12.0 → stackvox-0.14.0}/stackvox.egg-info/top_level.txt +0 -0
  35. {stackvox-0.12.0 → stackvox-0.14.0}/tests/test_paths.py +0 -0
  36. {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.12.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.12.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"]