stackvox 0.13.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.13.0 → stackvox-0.14.0}/NOTICE +25 -0
  2. {stackvox-0.13.0/stackvox.egg-info → stackvox-0.14.0}/PKG-INFO +142 -5
  3. {stackvox-0.13.0 → stackvox-0.14.0}/README.md +138 -4
  4. {stackvox-0.13.0 → stackvox-0.14.0}/pyproject.toml +8 -2
  5. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox/__init__.py +2 -2
  6. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox/cli.py +127 -15
  7. stackvox-0.14.0/stackvox/config.py +150 -0
  8. {stackvox-0.13.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.14.0/stackvox/voices.py +89 -0
  12. {stackvox-0.13.0 → stackvox-0.14.0/stackvox.egg-info}/PKG-INFO +142 -5
  13. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox.egg-info/SOURCES.txt +3 -0
  14. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox.egg-info/requires.txt +6 -0
  15. {stackvox-0.13.0 → stackvox-0.14.0}/tests/test_cli.py +260 -8
  16. {stackvox-0.13.0 → stackvox-0.14.0}/tests/test_config.py +48 -2
  17. {stackvox-0.13.0 → stackvox-0.14.0}/tests/test_daemon.py +199 -4
  18. {stackvox-0.13.0 → stackvox-0.14.0}/tests/test_daemon_protocol.py +42 -0
  19. {stackvox-0.13.0 → stackvox-0.14.0}/tests/test_engine.py +324 -1
  20. stackvox-0.14.0/tests/test_mlx_backend.py +335 -0
  21. stackvox-0.13.0/stackvox/config.py +0 -80
  22. stackvox-0.13.0/stackvox/engine.py +0 -375
  23. {stackvox-0.13.0 → stackvox-0.14.0}/LICENSE +0 -0
  24. {stackvox-0.13.0 → stackvox-0.14.0}/setup.cfg +0 -0
  25. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox/__main__.py +0 -0
  26. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox/data/stackvox-say +0 -0
  27. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox/paths.py +0 -0
  28. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox/py.typed +0 -0
  29. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox/text.py +0 -0
  30. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox/updates.py +0 -0
  31. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox.egg-info/dependency_links.txt +0 -0
  32. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox.egg-info/entry_points.txt +0 -0
  33. {stackvox-0.13.0 → stackvox-0.14.0}/stackvox.egg-info/top_level.txt +0 -0
  34. {stackvox-0.13.0 → stackvox-0.14.0}/tests/test_paths.py +0 -0
  35. {stackvox-0.13.0 → stackvox-0.14.0}/tests/test_text.py +0 -0
  36. {stackvox-0.13.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.13.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"
@@ -131,7 +134,7 @@ speed = 1.1
131
134
  lang = "en-gb"
132
135
  ```
133
136
 
134
- 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.
135
138
 
136
139
  ## Daemon mode
137
140
 
@@ -141,7 +144,7 @@ Keeps the model resident so each subsequent call is instant:
141
144
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
142
145
  stackvox status # is the daemon up? also shows version + any pending PyPI update
143
146
  stackvox say "Hello" # send text to the daemon (fails if not running)
144
- stackvox stop # graceful shutdown
147
+ stackvox stop # graceful shutdown, force-killed if it acknowledges but is still up after 5s
145
148
  ```
146
149
 
147
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.
@@ -161,7 +164,7 @@ Exit codes: `0` ok, `2` daemon unreachable (unless `--fallback-say` was given).
161
164
  ## Python library
162
165
 
163
166
  ```python
164
- from stackvox import Stackvox, speak, synthesize
167
+ from stackvox import Stackvox, speak, synthesize, synthesize_stream
165
168
 
166
169
  # One-shot — model loads on first call, reused for subsequent calls.
167
170
  speak("Hello world")
@@ -171,6 +174,10 @@ tts = Stackvox(voice="af_bella")
171
174
  tts.speak("First line")
172
175
  tts.speak("Faster", speed=1.2)
173
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
+
174
181
  # Non-blocking playback.
175
182
  tts.speak("async", blocking=False)
176
183
  tts.stop()
@@ -178,6 +185,11 @@ tts.stop()
178
185
  # Raw samples for custom processing.
179
186
  samples, sr = tts.synthesize("give me the array")
180
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
+
181
193
  # Gapless multi-line playback with concurrent synthesis.
182
194
  tts.speak_sequence([
183
195
  {"text": "Hello", "voice": "af_heart", "lang": "en-us"},
@@ -195,6 +207,100 @@ if daemon.is_running():
195
207
  daemon.stop()
196
208
  ```
197
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
+
198
304
  ## Voices
199
305
 
200
306
  Kokoro ships voices across several languages. Voice prefix encodes gender + language:
@@ -211,7 +317,34 @@ Kokoro ships voices across several languages. Voice prefix encodes gender + lang
211
317
  | `jf_*`, `jm_*` | Japanese | `jf_alpha` |
212
318
  | `zf_*`, `zm_*` | Mandarin Chinese | `zf_xiaoxiao` |
213
319
 
214
- 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.
215
348
 
216
349
  ## Architecture
217
350
 
@@ -234,6 +367,8 @@ Socket lives at `~/.cache/stackvox/daemon.sock` (override with `STACKVOX_SOCKET`
234
367
 
235
368
  Queue depth is 2 — rapid-fire requests beyond that get `busy` rather than piling up.
236
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
+
237
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.
238
373
 
239
374
  ## Requirements
@@ -277,4 +412,6 @@ stackvox itself is licensed under the **Apache License, Version 2.0** — see [`
277
412
 
278
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).
279
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
+
280
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.
@@ -97,7 +97,7 @@ speed = 1.1
97
97
  lang = "en-gb"
98
98
  ```
99
99
 
100
- 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.
101
101
 
102
102
  ## Daemon mode
103
103
 
@@ -107,7 +107,7 @@ Keeps the model resident so each subsequent call is instant:
107
107
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
108
108
  stackvox status # is the daemon up? also shows version + any pending PyPI update
109
109
  stackvox say "Hello" # send text to the daemon (fails if not running)
110
- stackvox stop # graceful shutdown
110
+ stackvox stop # graceful shutdown, force-killed if it acknowledges but is still up after 5s
111
111
  ```
112
112
 
113
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.
@@ -127,7 +127,7 @@ Exit codes: `0` ok, `2` daemon unreachable (unless `--fallback-say` was given).
127
127
  ## Python library
128
128
 
129
129
  ```python
130
- from stackvox import Stackvox, speak, synthesize
130
+ from stackvox import Stackvox, speak, synthesize, synthesize_stream
131
131
 
132
132
  # One-shot — model loads on first call, reused for subsequent calls.
133
133
  speak("Hello world")
@@ -137,6 +137,10 @@ tts = Stackvox(voice="af_bella")
137
137
  tts.speak("First line")
138
138
  tts.speak("Faster", speed=1.2)
139
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
+
140
144
  # Non-blocking playback.
141
145
  tts.speak("async", blocking=False)
142
146
  tts.stop()
@@ -144,6 +148,11 @@ tts.stop()
144
148
  # Raw samples for custom processing.
145
149
  samples, sr = tts.synthesize("give me the array")
146
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
+
147
156
  # Gapless multi-line playback with concurrent synthesis.
148
157
  tts.speak_sequence([
149
158
  {"text": "Hello", "voice": "af_heart", "lang": "en-us"},
@@ -161,6 +170,100 @@ if daemon.is_running():
161
170
  daemon.stop()
162
171
  ```
163
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
+
164
267
  ## Voices
165
268
 
166
269
  Kokoro ships voices across several languages. Voice prefix encodes gender + language:
@@ -177,7 +280,34 @@ Kokoro ships voices across several languages. Voice prefix encodes gender + lang
177
280
  | `jf_*`, `jm_*` | Japanese | `jf_alpha` |
178
281
  | `zf_*`, `zm_*` | Mandarin Chinese | `zf_xiaoxiao` |
179
282
 
180
- 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.
181
311
 
182
312
  ## Architecture
183
313
 
@@ -200,6 +330,8 @@ Socket lives at `~/.cache/stackvox/daemon.sock` (override with `STACKVOX_SOCKET`
200
330
 
201
331
  Queue depth is 2 — rapid-fire requests beyond that get `busy` rather than piling up.
202
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
+
203
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.
204
336
 
205
337
  ## Requirements
@@ -243,4 +375,6 @@ stackvox itself is licensed under the **Apache License, Version 2.0** — see [`
243
375
 
244
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).
245
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
+
246
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.13.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"]