Metadata-Version: 2.4
Name: stackvox
Version: 0.16.0
Summary: Offline TTS library using Kokoro-82M
Author-email: "StackOne Technologies Ltd." <support@stackone.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/StackOneHQ/stackvox
Project-URL: Repository, https://github.com/StackOneHQ/stackvox
Project-URL: Issues, https://github.com/StackOneHQ/stackvox/issues
Keywords: tts,kokoro,onnx,offline,speech
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: kokoro-onnx>=0.4.0
Requires-Dist: onnxruntime>=1.20.1
Requires-Dist: soundfile>=0.12.1
Requires-Dist: sounddevice>=0.4.6
Requires-Dist: numpy>=1.24
Requires-Dist: tomli>=2.0; python_version < "3.11"
Provides-Extra: mlx
Requires-Dist: mlx-audio<0.5,>=0.4.5; (platform_system == "Darwin" and platform_machine == "arm64") and extra == "mlx"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-mock>=3.12; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Requires-Dist: pre-commit>=3.7; extra == "dev"
Requires-Dist: commitizen>=3.27; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Dynamic: license-file

# stackvox

[![ci](https://github.com/StackOneHQ/stackvox/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/StackOneHQ/stackvox/actions/workflows/ci.yml)
[![coverage](https://codecov.io/gh/StackOneHQ/stackvox/branch/main/graph/badge.svg)](https://codecov.io/gh/StackOneHQ/stackvox)
[![python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
[![license](https://img.shields.io/badge/license-Apache%202.0-blue)](./LICENSE)

Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/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](./docs/demo.wav) — five seconds of two voices speaking the tagline (`af_heart` then `bf_emma`).

## Install

From PyPI — recommended for most users:

```bash
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:

```bash
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:

```bash
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:

```bash
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

```bash
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:

```bash
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:

```bash
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):

```toml
[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](#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.

A `[serve]` table picks the backend the daemon starts with, for when something else starts it and you can't add flags. See [Local MLX models](#local-mlx-models-apple-silicon).

## Daemon mode

Keeps the model resident so each subsequent call is instant:

```bash
stackvox serve         # foreground; run with `nohup stackvox serve &` to background
stackvox status        # is the daemon up? also shows version, backend + 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
```

Only one daemon starts at a time per cache directory, which is also where its socket lives. A `serve` started while another is running there, or still loading its model, exits straight away rather than loading a second copy. One with a different `STACKVOX_CACHE_DIR` is a separate daemon and runs alongside.

The daemon logs a line to stderr for every request it plays, so you can see how long speech takes to start on your machine (the text itself never goes in the log):

```text
[stackvox] spoke 84 chars: first audio after 1.2s (0.0s queued), finished after 6.4s, 5.1s of audio
```

Times count from when the daemon accepted the request, so waiting behind another request is included.

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`:

```bash
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

```python
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

```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](https://github.com/Blaizzy/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.

```bash
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:

```bash
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."
```

To make a model the daemon's default, put it in the [config file](#configuration) instead. A plain `stackvox serve` then loads it, whatever starts the daemon (a login item, an app):

```toml
[serve]
backend = "mlx"
model_dir = "~/models/my-model"
model_adapter = "~/.config/stackvox/adapters/my_model.py"  # optional
# Optional generation defaults, the same names as the flags:
reference_audio = "~/voices/reference.wav"
reference_text = "The exact transcript of the reference audio."
```

Passing `--backend` or `--model-dir` ignores the table altogether, since the flags then describe the model. Any other flag beats its matching key, and the reference clip and its transcript count as one setting, so give both flags or neither. Relative paths are relative to the folder the config file is in (the link's folder, if it's a symlink), and an unknown key (`top-p` for `top_p`, say) gets a warning. Only `serve` acts on `[serve]`, so `speak` from an install without the mlx extra isn't affected. If the configured model fails to load, `serve` logs why and starts Kokoro instead, so a daemon that gets restarted automatically doesn't crash loop into silence, and `stackvox status` shows which backend is actually running. A model named on the command line still fails outright.

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/`:

```python
# 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)
```

```bash
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:

```python
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   |

```bash
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:

```toml
[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](https://github.com/thewh1teagle/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`](./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](https://github.com/rhasspy/piper) | ✅ | High | ~100ms | MIT | Similar use-case to stackvox; ONNX-based, more voices in some languages |
| [Coqui TTS](https://github.com/coqui-ai/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`](./LICENSE). Third-party attributions are collected in [`NOTICE`](./NOTICE); the summary below is informational.

**Model.** Speech is generated by [Kokoro-82M](https://huggingface.co/hexgrad/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](https://github.com/thewh1teagle/kokoro-onnx) release assets on first use and cached under `~/.cache/stackvox/`. stackvox does not modify or redistribute them.

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

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

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