Each project gets a distinct voice from a curated English pool, assigned via round-robin with gender/accent interleaving for maximum perceptual contrast between consecutive projects. - New voice_identity.py: pool filtering, interleaving, persistence - Round-robin replaces SHA-256 hashing (no collisions until pool exhaustion at 22 voices) - Assignments persist to /data/voice-assignments.json across restarts - speak() and generate_audio() accept optional project= parameter - MCP roots fallback with 2s timeout for future bidirectional clients - English-only pool (af_/am_/bf_/bm_/ef_/em_ prefixes) - af_nicole excluded from auto-assign (whispery), still explicit-ok - Fix voice blacklist to use full identifiers (am_adam, af_jessica)
4.3 KiB
TTS MCP Server
Multi-engine text-to-speech server exposed via FastMCP 3.0 Streamable HTTP. Engines: Kokoro (ONNX), Piper (Wyoming/Docker), Orpheus (llama-server + SNAC).
Build & Run
make up # build + start (docker compose)
make logs # follow logs
make restart # restart containers
make status # show running containers + health
Entry & Exit Tones (Beep System)
Queued speech playback (speak()) is bookended by short alert tones. generate_audio() is unaffected (file-only, no playback).
Tone Positions
| Position | When | Purpose |
|---|---|---|
| Entry tone | Before speech starts | "Incoming transmission" alert |
| Exit tone | After speech, queue empty | "Over and out" — channel clear |
| Standby tone | After speech, more queued | "Standby" — more messages coming |
Available Tones
| Name | Frequency | Duration | Inspired by |
|---|---|---|---|
chirp |
1800 Hz | ~144 ms | Nextel iDEN Talk Permit Tone (TPT) — the 24/24/24/24/48 ms on/off pattern |
apollo |
2525 Hz | 250 ms | NASA quindar intro (key-up) tone used during Apollo missions |
roger |
1400-1000 Hz | ~100 ms | Classic CB radio descending two-tone roger beep |
quindar-out |
2475 Hz | 250 ms | NASA quindar unkey tone (distinct frequency from intro) |
standby |
1000-1400 Hz | ~60 ms | Ascending blip — inverse of roger, signals "more coming" |
Configuration
TTS_ENTRY_TONE=chirp # before speech (chirp, apollo, none, or /path/to/custom.wav)
TTS_EXIT_TONE=roger # after speech, queue empty (roger, quindar-out, none, or path)
The standby tone is always the built-in ascending blip. It plays instead of the exit tone when more items are queued.
Tones are generated programmatically at startup (48kHz, 16-bit PCM, -3 dB headroom) in tones.py using numpy. No bundled audio assets.
Voice Identity (Project-Aware Voices)
When multiple Claude Code sessions connect simultaneously, voice identity gives each project a distinct voice via round-robin assignment from a curated English voice pool.
How It Works
- Client calls
speak()orgenerate_audio()without specifyingvoice= - Project is identified via the
projecttool parameter, or falls back to MCP Roots (list_roots()with 2s timeout) - The next unused voice is assigned from the interleaved pool (alternating gender and accent for maximum contrast)
- Assignment is persisted to
/data/voice-assignments.json— survives server restarts
Explicit voice= parameter always overrides auto-assignment. Voice pools are cached for 5 minutes (picks up blacklist/engine changes).
Note: MCP Roots require stateful Streamable HTTP. With stateless_http=True (current default), roots will timeout — the project parameter is the primary identification method.
Configuration
TTS_VOICE_IDENTITY=true # Enable project-aware voice assignment
TTS_VOICE_IDENTITY_PREFIXES=af_,am_,bf_,bm_,ef_,em_ # English voice prefixes
TTS_VOICE_IDENTITY_EXCLUDE=af_nicole # Available explicitly, excluded from auto-assign (whispery)
TTS_VOICE_IDENTITY_FILE=/data/voice-assignments.json # Persist across restarts
TTS_ANNOUNCE_PROJECT=false # Prefix speech with project name
Pool Interleave Order
Voices are interleaved for perceptual diversity: American female → British male → European female → American male → British female → European male. First 6 projects get maximally distinct voices.
Files
voice_identity.py— Pool filtering, interleaving, round-robin assignment, JSON persistence
Architecture
server.py— FastMCP lifespan, tool definitions, engine setupqueue.py— Producer-consumer speech queue with priority tierstones.py— Tone WAV generator (entry/exit/standby)audio.py— WAV writing andpw-playasync wrappersettings.py— Pydantic settings from env vars (prefix:TTS_)engines/— TTSEngine implementations (kokoro, piper, orpheus)
Key Design Decisions
- Speech queue is serialized (one playback at a time) but synthesis is parallel
- Tones are non-fatal: if
pw-playfails on a tone, speech still plays - Orpheus uses llama-server (not Ollama) for 15x throughput via continuous batching
- SNAC decoder is lazy-loaded on first Orpheus call to reduce idle memory