Skip to main content

stackvox

ci coverage python license

Offline TTS using Kokoro-82M via kokoro-onnx. Apache 2.0 model, ~340MB, CPU real-time, plays straight to system audio. Designed to be importable as a Python library, drivable as a CLI, or poked via a unix socket for ~13ms speech requests from shell scripts.

🔊 Hear it: docs/demo.wav — five seconds of two voices speaking the tagline (af_heart then bf_emma).

Install

From PyPI — recommended for most users:

pipx install stackvox       # `stackvox` CLI on PATH
# or
pip install stackvox        # use as a library

If you want the low-latency bash helper (stackvox-say) for shell scripts and hooks, install it on PATH after installing the package:

stackvox install-helper     # copies bash helper to ~/.local/bin
                            # use --prefix DIR to install elsewhere

This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python stackvox say client.

From git, if you want an unreleased commit:

pipx install git+https://github.com/StackOneHQ/stackvox.git
# upgrade later with: pipx install --force git+https://github.com/StackOneHQ/stackvox.git

Dev install from a clone:

python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

Model + voice files auto-download to ~/.cache/stackvox/ on first use. Override with STACKVOX_CACHE_DIR.

CLI

stackvox "Hello world"              # synthesize and play in-process
stackvox speak "Hi" --voice bf_emma # same, explicit subcommand
stackvox speak "save" --out a.wav   # write wav instead of playing
stackvox welcome                    # multilingual welcome (6 languages)
stackvox voices                     # list all voice ids
echo "from a pipe" | stackvox       # piped stdin works for speak/say
stackvox speak --file message.txt   # read a whole file
stackvox paths                      # print resolved cache / socket / pid paths
stackvox config                     # print resolved default voice/speed/lang

Speech normalization

normalize_for_speech (Markdown → speakable prose: strip formatting, expand units/numbers, shape pauses, apply a pronunciation dictionary) is available from the shell — no Python import required:

stackvox normalize --file post.md               # print normalized text (pipe / debug)
stackvox normalize --tables csv --strip-emoji < response.md
stackvox speak --normalize --file post.md       # normalize, then synthesize
stackvox say   --normalize "**Shipped** 1,198.9 MPG"   # normalize, then send to daemon

--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).

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.

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.

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.

Bash completion:

eval "$(stackvox completion bash)"  # current shell
# or persist:
stackvox completion bash > ~/.stackvox-completion.bash
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc

Configuration

stackvox reads per-user defaults from a TOML file, so you don't need to repeat --voice bf_emma --speed 1.1 on every invocation. Set values in ~/.config/stackvox/config.toml (or $XDG_CONFIG_HOME/stackvox/config.toml, or wherever STACKVOX_CONFIG points):

[defaults]
voice = "bf_emma"
speed = 1.1
lang = "en-gb"

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'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.

Daemon mode

Keeps the model resident so each subsequent call is instant:

stackvox serve         # foreground; run with `nohup stackvox serve &` to background
stackvox status        # is the daemon up? also shows version + any pending PyPI update
stackvox say "Hello"   # send text to the daemon (fails if not running)
stackvox stop          # graceful shutdown, force-killed if it acknowledges but is still up after 5s

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.

stackvox-say (bash helper, ~13ms)

When you want minimum latency from shell scripts (hooks, CI steps, etc.), skip the Python client and use the bash helper — it talks directly to the daemon's unix socket via nc:

stackvox-say "back to you in 5"
stackvox-say --voice bf_emma --speed 1.1 "hello"
stackvox-say --fallback-say "text"     # shell out to macOS `say` if daemon is down

Exit codes: 0 ok, 2 daemon unreachable (unless --fallback-say was given).

Python library

from stackvox import Stackvox, speak, synthesize, synthesize_stream

# One-shot — model loads on first call, reused for subsequent calls.
speak("Hello world")

# Reusable engine.
tts = Stackvox(voice="af_bella")
tts.speak("First line")
tts.speak("Faster", speed=1.2)

# Cap ONNX Runtime's thread pools — it otherwise sizes them from the host's core
# count, which oversubscribes inside a container with a smaller CPU quota.
tts = Stackvox(threads=4)

# Non-blocking playback.
tts.speak("async", blocking=False)
tts.stop()

# Raw samples for custom processing.
samples, sr = tts.synthesize("give me the array")

# Streaming: one chunk per sentence, yielded as soon as it is synthesized, so
# a caller encoding audio itself waits one sentence rather than the whole text.
for chunk, sr in tts.synthesize_stream("First sentence. Second sentence."):
    encode_and_send(chunk, sr)

# Gapless multi-line playback with concurrent synthesis.
tts.speak_sequence([
    {"text": "Hello", "voice": "af_heart", "lang": "en-us"},
    {"text": "Bonjour", "voice": "ff_siwis", "lang": "fr-fr"},
])

Daemon client from Python

from stackvox import daemon

ok, resp = daemon.say("queue this via the running daemon")
if daemon.is_running():
    daemon.stop()

Local MLX models (Apple Silicon)

The optional mlx backend runs any text-to-speech model that MLX Audio can load from a local directory, including ones that support voice cloning and style instructions. Kokoro (ONNX) remains the default backend and dependency set.

pip install 'stackvox[mlx]'

stackvox speak \
  --backend mlx \
  --model-dir /path/to/model \
  --reference-audio /path/to/reference.wav \
  --reference-text "The exact transcript of the reference audio." \
  --out cloned.wav \
  "Hello from a local MLX model."

Model licenses: stackvox never downloads, bundles, or recommends MLX models. You supply the model directory, and its license (which may restrict commercial use) applies to your use of it. Some models also need their own extra Python packages; MLX Audio names them if they are missing.

Keep a large model resident for repeated requests by selecting it when the daemon starts. The backend and model cannot be changed without restarting the daemon. Generation options given to serve become the daemon's defaults, and any say request can override them:

stackvox serve --backend mlx --model-dir /path/to/model

stackvox say \
  --reference-audio /path/to/reference.wav \
  --reference-text "The exact transcript of the reference audio." \
  "This request uses the resident model."

Generation options (--reference-audio, --reference-text, --instruct, --temperature, --top-p, --top-k) are forwarded only when set, so each model's own defaults apply otherwise; which ones a model honours depends on the model. They are rejected with an error unless the mlx backend is in use, both by speak and by a daemon running Kokoro. --voice and --lang apply to Kokoro only. Playback streams in chunks of about two sentences (up to ~170 characters), so the first audio arrives once the first chunk is generated; short chunks also stop models drifting off-script on long text. Models slower than real time will pause between chunks.

Model adapters

Some models need custom loading, such as renamed weights, a codec path, or a tokenizer quirk. Rather than carrying model-specific code, stackvox accepts an adapter script that you write and keep locally, for example under ~/.config/stackvox/adapters/:

# my_adapter.py
from pathlib import Path
from stackvox.mlx_backend import swapped


def load(model_dir: Path, load_model):
    # Patch whatever the model needs for the duration of the load, then
    # delegate to MLX Audio's loader (or replace it entirely).
    from some_library import SomeClass

    with swapped(SomeClass, "some_method", lambda *args, **kwargs: ...):
        return load_model(model_dir)
stackvox speak --backend mlx --model-dir /path/to/model \
  --model-adapter ~/.config/stackvox/adapters/my_adapter.py "Hello"

load(model_dir, load_model) must return the loaded model. swapped patches an attribute and restores it afterwards, even on error. Adapters run as normal Python with your privileges and are only used when passed explicitly; nothing is picked up from a model directory.

The Python API uses the same waveform contract as Kokoro:

from pathlib import Path
from stackvox import Stackvox

tts = Stackvox(backend="mlx", model_dir=Path("/path/to/model"))
samples, sample_rate = tts.synthesize(
    "A calm and natural delivery.",
    reference_audio=Path("reference.wav"),
    reference_text="The reference transcript.",
)

Voices

Kokoro ships voices across several languages. Voice prefix encodes gender + language:

Prefix Language Example
af_*, am_* American English af_heart, am_michael
bf_*, bm_* British English bf_emma, bm_fable
ff_* French ff_siwis
hf_*, hm_* Hindi hf_alpha, hm_omega
if_*, im_* Italian if_sara, im_nicola
pf_*, pm_* Portuguese pf_dora, pm_alex
ef_*, em_* Spanish ef_dora, em_alex
jf_*, jm_* Japanese jf_alpha
zf_*, zm_* Mandarin Chinese zf_xiaoxiao

Run stackvox voices for the authoritative list, which includes the voice mixes below.

Voice mixes

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.

Two ship built in, a transatlantic newsreader pair (American delivery, British vowels):

Name Mix lang speed
chip am_liam 0.6, bm_daniel 0.4 en-gb 1.0
ramona af_aoede 0.5, af_bella 0.2, bf_emma 0.3 en-gb 1.0
stackvox speak --voice chip "Good evening, and welcome to the news."
stackvox say --voice ramona "Thanks Chip."

Define your own in the config file. Weights needn't sum to 1, and a table named after a built-in replaces it:

[voices.narrator]
mix = { am_michael = 0.6, bm_george = 0.4 }
lang = "en-gb"   # optional
speed = 1.0      # optional

The daemon reads mixes when it starts, so restart it (stackvox stop, then stackvox serve) after editing them.

Architecture

┌────────────────────┐      unix socket           ┌─────────────────────────┐
│  stackvox-say      │ ───────────────────────▶   │  stackvox daemon        │
│  (bash, ~13ms)     │   JSON line per request    │  (Python, long-lived)   │
└────────────────────┘                            │                         │
┌────────────────────┐      ~500ms (Py startup)   │  preloaded Kokoro ONNX  │
│  stackvox say      │ ───────────────────────▶   │  worker thread playback │
│  (Python client)   │                            │  → sounddevice → audio  │
└────────────────────┘                            └─────────────────────────┘
┌────────────────────┐
│  stackvox speak    │   loads model in-process, plays, exits
│  (one-shot CLI)    │
└────────────────────┘

Socket lives at ~/.cache/stackvox/daemon.sock (override with STACKVOX_SOCKET for the client, STACKVOX_CACHE_DIR for the daemon). Protocol is one line of JSON per connection: {"text":"...", "voice":"...", "speed":1.0, "lang":"en-us"}; reply is ok / busy / err: <msg>. Plain text (no JSON) is accepted as a fallback and treated as {"text": line}.

Queue depth is 2 — rapid-fire requests beyond that get busy rather than piling up.

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.

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.

Requirements

  • Python 3.10+
  • macOS or Linux
  • nc (BSD netcat — default on macOS, netcat-openbsd on Linux) for the bash helper

Security considerations

stackvox doesn't open any network port. The daemon binds a unix socket under ~/.cache/stackvox/ (default file-mode 0600, i.e. user-only per the OS defaults for files in $HOME). Any process running as the same local user can send text to the daemon — there's no per-message authentication on the socket itself. That's the trust boundary: stackvox assumes anything running as your UID is allowed to speak on your behalf.

If you're exposing stackvox through a different surface (HTTP server, shared system service, container), authentication and rate-limiting are your responsibility at that layer.

Model weights (kokoro-v1.0.onnx, ~340 MB) and voices are downloaded from the kokoro-onnx GitHub release assets on first use and cached under ~/.cache/stackvox/. If you operate in a restricted environment, pre-seed that directory offline.

Security issues themselves should not be filed as public GitHub issues — see SECURITY.md for the disclosure process.

How does this compare to other TTS?

stackvox is a fairly opinionated narrow slice of the TTS space. Here's where it sits next to the obvious neighbours:

Tool Offline? Quality Latency (typical) License Best for
stackvox (Kokoro-82M) ✅ High (24kHz, 50+ voices, 9 languages) ~300ms in-process · ~13ms via daemon helper Apache 2.0 Local apps, shell hooks, anything that wants natural voice without the cloud
macOS say ✅ OK ~50ms macOS only macOS-only scripts, "good enough" voice
espeak-ng ✅ Robotic ~10ms GPL-3.0 Accessibility, screen readers, embedded
Piper ✅ High ~100ms MIT Similar use-case to stackvox; ONNX-based, more voices in some languages
Coqui TTS ✅ Very high (research models) seconds MPL-2.0 Research, fine-tuning, voice cloning
OpenAI / ElevenLabs / etc. ❌ Highest network-bound Proprietary Production apps that can pay per-call and accept network dependency

Where stackvox tries to be different from Piper specifically: a resident daemon + bash helper path that gets you sub-15ms speech requests from shell scripts (CI hooks, terminal notifications, status announcements) without paying Python's startup cost on every call. That's basically the point — voice quality alone wouldn't be enough to switch off Piper, but the IPC story makes a difference for shell-driven workflows.

Pick stackvox if you want good voices, fully offline, with a fast shell-friendly API.

License & attributions

stackvox itself is licensed under the Apache License, Version 2.0 — see LICENSE. Third-party attributions are collected in NOTICE; the summary below is informational.

Model. Speech is generated by Kokoro-82M (© hexgrad, Apache 2.0). The ONNX-converted weights (kokoro-v1.0.onnx) and voice pack (voices-v1.0.bin) are downloaded from the kokoro-onnx release assets on first use and cached under ~/.cache/stackvox/. stackvox does not modify or redistribute them.

Runtime dependencies. kokoro-onnx (MIT, © thewh1teagle), onnxruntime (MIT, © Microsoft), sounddevice (MIT, © Matthias Geier), soundfile (BSD-3, © Bastian Bechtold), numpy (BSD-3).

Optional MLX backend. stackvox[mlx] runs on mlx-audio (MIT, © Prince Canuma), mlx and mlx-lm (MIT, © Apple), and 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.

GPL note. kokoro-onnx pulls in phonemizer-fork 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.

Metadata

Release files for stackvox 0.14.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for stackvox 0.14.0
File Size Uploaded Provenance
stackvox-0.14.0.tar.gz 96.2 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for stackvox 0.14.0
File Interpreter ABI Platform
stackvox-0.14.0-py3-none-any.whl Python 3 none any Details

Total release size: 159.0 kB

Release files / stackvox-0.14.0.tar.gz

Download URL stackvox-0.14.0.tar.gz
Size 96.2 kB
Tags Source
SHA-256 checksum
How to use checksums
38c36f25e17e9410e43f260258426fe2f11593acda8c0063f02c880bfcd6b842
BLAKE2b-256 checksum
How to use checksums
72609206a398ea27c931c8c6e47a68045ffc4f2b6de5b57acd5e1c284a78b395
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.

Transparency log

Release files / stackvox-0.14.0-py3-none-any.whl

Download URL stackvox-0.14.0-py3-none-any.whl
Size 62.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
50035b47364bd08d080c891bc65038e58c176641770abf579f1823caad12f2e9
BLAKE2b-256 checksum
How to use checksums
02fb2ed9c1bf405e4b5be37a012eadb737d107fe1d496d92a130adc415efaf2b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.

Transparency log

Security for stackvox 0.14.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for stackvox-0.14.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 23, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page