A streaming TTS daemon for KittenTTS that keeps the model hot in memory and plays audio chunk-by-chunk over a simple Unix-socket protocol.
KittenTTS is a tiny, open-source English TTS model family that runs on CPU — no GPU required. kitten-say solves the model-load overhead with a persistent daemon that keeps the model resident between calls.
Three model sizes, all sounding surprisingly good:
| Model | Params | Size | Speed (M1 Max) | Speed (Intel i7) | Best for |
|---|---|---|---|---|---|
| nano | 14M | <25MB | 0.08x (12.5x RT) | 0.13x (7.9x RT) | Real-time, edge, IoT |
| micro | 40M | ~40MB | ~0.25x | ~0.45x | Balanced |
| mini | 80M | ~79MB | 0.51x (2x RT) | 0.89x | Highest quality, narration |
All three produce natural-sounding speech. The quality difference between nano and mini is primarily in audio fidelity (compression artifacts), not voice quality (cadence, intonation, naturalness). For most uses, nano is indistinguishable.
┌──────────────┐ Unix Socket ┌──────────────────┐
│ kitten-say │ ──── JSON req ────▶ │ kitten-tts-daemon │
│ (client) │ ◀── PCM frames ─── │ (model in memory) │
└──────┬───────┘ └──────────────────┘
│
▼
┌────────┐
│ play/ │ ← streams each chunk as it's generated
│ afplay │ (chunk N plays while chunk N+1 generates)
└────────┘
- Daemon loads the model once and stays resident
- Client connects, sends text, receives PCM frames, pipes to audio player
- Pipelining: audio plays while the next chunk is still generating — no gaps between sentences
- Auto model switching: requesting a different model gracefully restarts the daemon
# Install dependencies
pip install https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl
brew install espeak-ng sox # macOS — sox provides the 'play' command
# Install kitten-say
sudo bash install.sh
# Use it
kitten-say "Hello world"
# Use the fastest model
kitten-say -m nano "Hello world"First call auto-starts the daemon (a few seconds to load model, cached after first download). Subsequent calls skip model load, though the first spoken chunk still costs real synthesis time.
kitten-say "Hello world" # speak text (default: mini model)
kitten-say -m nano "Hello world" # use nano model (fastest)
kitten-say -m micro "Hello world" # use micro model (balanced)
kitten-say -v Luna "Hello world" # pick a voice
kitten-say -f report.txt # read a file
echo "Hello" | kitten-say # pipe from stdin
kitten-say -o output.wav "text" # save to WAV instead of playing
kitten-say -d 0.5 "Hello" # 500ms delay before playback (BT speakers)
kitten-say -s "Hello" # native KittenTTS streaming (default on)
kitten-say --no-stream "Hello" # force legacy/manual chunking
kitten-say -c paragraph --no-stream -f book.txt
kitten-say --voices # list available voices
kitten-say --info # show daemon status and loaded model
kitten-say --stop # shut down daemon
kitten-say -h # full help
The daemon auto-restarts when you switch models:
kitten-say -m nano "Quick response" # starts daemon with nano
kitten-say -m mini "Quality narration" # restarts daemon with mini
kitten-say --info # shows which model is loadedEight built-in voices: Bella, Jasper (default), Luna, Bruno, Rosie, Hugo, Kiki, Leo
| Flag | Description | Default |
|---|---|---|
-m, --model |
Model: nano, micro, mini, or full HF id |
mini |
-v, --voice |
Voice name | Jasper |
-f, --file |
Read text from file | — |
-o, --output |
Save to WAV file instead of playing | — |
-d, --delay |
Seconds of silence before playback (for BT/USB speaker wake) | 0.2 |
-s, --stream |
Use KittenTTS native generate_stream() path |
on |
--no-stream |
Disable native streaming and use kitten-say manual chunking | off |
-c, --chunks |
Chunking mode when --no-stream: sentence, paragraph, fixed, none |
sentence |
--voices |
List available voices | — |
--info |
Show daemon status and loaded model | — |
--stop |
Shut down the daemon | — |
- Loads KittenTTS model once, keeps it in memory
- Listens on Unix socket (
/tmp/kitten-tts.sock) - Writes
.modelmetadata file so the client knows which model is loaded - Serializes generation requests (model isn't thread-safe)
- Accepts connections from any local user
- Protocol: JSON request line → JSON header line → binary PCM frames (4-byte big-endian length prefix) → zero-length sentinel
- Auto-starts daemon if not running (hybrid lifecycle)
- Auto-restarts daemon if requested model differs from loaded model
- Streams PCM frames to audio player as they arrive
- Can request either native KittenTTS streaming or legacy/manual chunking
- Legacy/manual mode still plays chunk N while generating chunk N+1
- Zero dependencies beyond Python stdlib (daemon needs
kittentts)
| Mode | Splits on | Best for |
|---|---|---|
sentence |
. ! ? |
General use — natural pauses, good pipelining |
paragraph |
Double newline | Long documents with clear paragraph structure |
fixed |
~200 chars | Uniform chunk sizes |
none |
No splitting | Short text, maximum coherence |
These modes apply only to --no-stream. Native KittenTTS streaming currently uses its own internal sentence-style splitting.
| Package | Install | Why |
|---|---|---|
| Python 3.12+ | brew install python@3.12 |
Runtime |
| espeak-ng | brew install espeak-ng |
Phonemizer backend (required by KittenTTS) |
| sox | brew install sox |
Audio playback (play -q command) |
| Package | Install | Why |
|---|---|---|
| kittentts (prefer GitHub main) | see Quick Start | TTS model + inference |
All other imports are Python stdlib. The client script has zero external Python dependencies.
If you are upgrading an older 0.8.1-era environment, pin numpy<2 before importing the old stack. Some older spacy/misaki baggage was compiled against NumPy 1.x and breaks under NumPy 2.x. Current GitHub main removed misaki, but dirty upgraded venvs can still carry the old conflict.
- macOS (Apple Silicon M1 Max) — nano: 12.5x realtime, mini: 2x realtime
- macOS (Intel i7 2015 MBP) — nano: 7.9x realtime, mini: ~1.1x realtime
- Python 3.12 with kittentts 0.8.1
Should work on Linux with espeak-ng and sox installed via your package manager. The client falls back to play (sox) if afplay isn't available. Untested — PRs welcome.
git clone https://github.com/Marvinthebored/kitten-say.git
cd kitten-say
pip install --upgrade "numpy<2"
pip install --upgrade "git+https://github.com/KittenML/KittenTTS.git"
brew install espeak-ng sox # macOS
sudo bash install.shThe install script places three files in /usr/local/bin/:
| File | What |
|---|---|
kitten-say |
Shell wrapper (sets Python interpreter) |
kitten-say.py |
Client script |
kitten-tts-daemon |
Daemon script |
The install script hardcodes a virtualenv Python path in the wrapper. Edit /usr/local/bin/kitten-say if your venv is elsewhere:
#!/bin/bash
exec /path/to/your/venv/bin/python3 /usr/local/bin/kitten-say.py "$@"Hybrid mode — the daemon is not always-on. It starts on first kitten-say invocation and stays running until you explicitly stop it:
kitten-say --stopThis keeps the model warm for rapid successive calls without cluttering your process list when you don't need TTS.
| Model | Load (cached) | Generate (5s audio) | RT Factor | RAM |
|---|---|---|---|---|
| nano | ~4s | 0.42s | 0.08x | ~30MB |
| mini | ~3s | 2.38s | 0.51x | ~80MB |
| Model | Load (cached) | Generate (5s audio) | RT Factor | RAM |
|---|---|---|---|---|
| nano | ~14s | 0.64s | 0.13x | ~30MB |
| mini | ~15s | 4.12s | 0.89x | ~80MB |
Audio: 24kHz, mono, float32 PCM (converted to 16-bit WAV for playback).
For anyone wanting to build alternative clients:
- Connect to Unix socket at
/tmp/kitten-tts.sock - Send JSON line:
{"text": "Hello", "voice": "Jasper", "chunk_mode": "sentence"}\n - Receive JSON header:
{"status": "streaming", "chunks": N, "sample_rate": 24000}\n - Receive N PCM frames: 4-byte big-endian length + float32 PCM data
- End sentinel: 4-byte zero (
\x00\x00\x00\x00)
Commands: {"cmd": "ping"}, {"cmd": "voices"}, {"cmd": "info"}, {"cmd": "shutdown"}
The info command returns: {"model": "KittenML/kitten-tts-nano-0.8", "voices": [...], "pid": 12345}
Today we re-checked this stack against the live scripts and the upgraded KittenTTS build, because memory and reality had diverged in a few annoying ways. The short version:
- Native streaming works: the daemon can now request KittenTTS
generate_stream()and the client exposes that as default behavior. --no-streamkeeps the old path: manual chunking remains available for A/B testing and debugging.- The practical difference is smaller than expected: current KittenTTS
generate_stream()andgenerate()both call the same internalchunk_text()helper. The stream version just yields each chunk instead of concatenating them. - Chunking is still sentence-style: upstream currently splits with
re.split(r'[.!?]+', text)and then word-splits anything over ~400 chars. So cadence differences versus our own sentence chunking may be negligible. - Named voices now work across model tiers on the upgraded GitHub-main build. Earlier assumptions that nano only accepted
expr-voice-*were true for an older package state, not the current one. - Model switching is real: nano/micro/mini do switch correctly, but the most obvious difference can be pacing / pause length rather than a cartoonishly different timbre.
- Old client ↔ new daemon mismatch is confusing. If only one side is updated, the flags may appear to work while silently exercising the wrong code path.
- Stale daemon metadata caused earlier confusion about which model was really loaded. The client relies on the daemon's
.modelfile; old daemons without that metadata muddy the water. /tmp/kitten-tts-daemon.logpermissions matter when different local users start the daemon (marvinvssanae). The client now tries to make the log world-writable before spawning the daemon.--stoponly works if you're talking to the right daemon. If you have multiple versions floating around in different paths, kill the stray process and restart cleanly.
- Added native-stream request plumbing between client and daemon.
- Added
--no-streamto force legacy/manual chunking. - Kept model switching (
nano/micro/mini) and info reporting. - Documented the upstream chunking reality instead of pretending it is magic.
On both MarvinMBP (Intel) and M1 hardware, all three models are still effectively real-time once playback starts. The main latency remains time to first chunk, not total throughput.
- KittenTTS — the model doing the actual work
MIT