Text-to-speech skill that gives Claude Code a voice. Includes a multi-voice audio daemon with queuing, a web dashboard with animated portraits, and a simple CLI.
macOS users: See docs/SETUP_MAC.md for detailed setup.
# 1. Install dependencies
brew install ffmpeg
curl -LsSf https://astral.sh/uv/install.sh | sh
# 2. Configure API key
cp .env.example .env
# Edit .env and add: ELEVENLABS_API_KEY=sk_your_key
# 3. Start daemon
unset SPEAK_PORT SPEAK_PREROLL_MS SPEAK_RESUME_REWIND_MS SPEAK_COLLECTOR_WORKERS # Shell-exported empties crash the daemon
uv run daemon/server.py
# 4. Test (in new terminal)
./scripts/say.sh "Hello, world!"
open http://127.0.0.1:7865 # DashboardDashboard at http://127.0.0.1:7865
# Clone
git clone <your-repo-url> speak
cd speak
# Configure
cp .env.example .env
# Edit .env β add your ELEVENLABS_API_KEY
# Start the daemon
uv run daemon/server.py
# Speak from any terminal
./scripts/say.sh "Hello, world!"Dashboard at http://127.0.0.1:7865
- macOS (uses
afplayfor file-mode playback) - Python >= 3.12
- uv (runs the daemon with inline deps β no venv needed)
- ffmpeg (
brew install ffmpeg) β envelope extraction, seeking, and live-mode playback. Theffplaybinary ships with most ffmpeg builds and is the preferred live player;ffmpeg -f audiotoolboxis the fallback. - ElevenLabs API key β get one here
ELEVENLABS_API_KEY=your_key_here # Required
ELEVENLABS_VOICE_ID= # Default voice (optional, defaults to Claude)
SPEAK_CACHE_DIR= # Cache dir (default: ./cache)
SPEAK_PORT= # HTTP port (default: 7865)Real environment variables always override .env values.
Playback streams by default: audio starts as soon as enough has arrived, rather than after the whole file downloads. These knobs tune it.
| Variable | Default | What it does |
|---|---|---|
SPEAK_STREAMING |
1 |
Kill switch. Set to 0 to restore the old fetch-whole-file-then-play path wholesale β every entry plays in file mode. |
SPEAK_MODEL |
eleven_v3 |
Boot-default ElevenLabs model id for single-voice synthesis. This is the boot default only: both dashboards flip the model at runtime via POST /config, and a model chosen there is persisted to config.json, which wins over this variable on the next start. An unrecognised value in either source is warned about and ignored β the daemon only ever advertises a model the hop chain can actually route. With SPEAK_STREAMING=0 the conversational model is unreachable (the legacy path always synthesizes with eleven_v3), so POST /config refuses it with 409 and GET /config reports streaming_enabled: false for clients to disable the option. |
SPEAK_PREROLL_MS |
500 |
How much audio must decode before live playback starts. Lower is faster to first sound and more likely to underrun. Integer β a shell-exported empty value crashes the daemon. |
SPEAK_RESUME_REWIND_MS |
1000 |
How far back a resume rewinds from the paused position, so a pause replays rather than skips. Must exceed the feeder's worst-case lead (750 ms: an 8000-byte lead plus a 4000-byte slice at 16 kB/s) or a live resume can SKIP audio instead of replaying it β the daemon warns at startup if it does not. Integer β a shell-exported empty value crashes the daemon. |
SPEAK_LIVE_PLAYER |
auto |
Which live-mode player to use: auto, ffplay, or audiotoolbox. auto probes both at startup and picks the first that works; an unknown name is warned about and skipped. If none work, live mode disables itself and everything plays in file mode. |
SPEAK_COLLECTOR_WORKERS |
8 |
Concurrent streaming fetches. Must be β₯ 1 β the daemon warns at startup if it is lower, and a pool it cannot build fails every entry at its first collection. Integer β a shell-exported empty value crashes the daemon. |
Leave a knob out to get its default. A blank line in .env is treated as absent and is
safe, but a blank value exported in your shell is real and crashes the integer knobs at
import β hence the unset line in the quickstart.
Ships with 9 voices. Add your own ElevenLabs voices:
{
"name": "MyVoice",
"id": "your-elevenlabs-voice-id",
"color": "#ff6600",
"style": "Brief description"
}The daemon also falls back to the ElevenLabs API for voice names not in voices.json.
# Basic
./scripts/say.sh "Hello"
# Choose voice
./scripts/say.sh "Deep thoughts" --voice Adam
# Channel tagging (for multi-agent filtering)
./scripts/say.sh "Status update" --voice Elli --channel researcher
# Session attribution (auto-resolved from the Claude Code environment; override explicitly)
./scripts/say.sh "Build done" --session "My Project Session"
# Priority (jumps queue)
./scripts/say.sh "Alert!" --priority
# Queue control
./scripts/say.sh --status
./scripts/say.sh --skip
./scripts/say.sh --pause
./scripts/say.sh --resume
./scripts/say.sh --clear
./scripts/say.sh --history --limit 10
./scripts/say.sh --replay <id>Install as a skill in ~/.claude/skills/speak/ (or wherever you like), then reference $SPEAK_DIR/scripts/say.sh in your SKILL.md. See the included SKILL.md for the full prompt.
The web dashboard shows:
- Animated portraits with lip-sync during playback
- Speaker attribution on every line: voice, session, and agent channel
- Transport controls (pause/resume, skip, seek)
- Queue panel with per-channel pause toggles
- History panel with replay and voice filtering
Assign each agent a unique voice for audio differentiation:
# Agent 1
./scripts/say.sh "Research complete" --voice Rachel --channel researcher
# Agent 2
./scripts/say.sh "Tests passing" --voice Adam --channel testerspeak/
daemon/server.py Starlette HTTP server β TTS, queue, SSE, dashboard
scripts/say.sh CLI wrapper β talks to daemon, falls back to speak.py
scripts/speak.py Standalone TTS (no daemon needed)
dashboard/index.html Single-file web dashboard
dashboard/portraits/ Voice portrait images (3 frames each for lip-sync)
voices.json Voice name/ID/color mappings
cache/ Cached audio for history replay
.env Local configuration (git-ignored)
SKILL.md Claude Code skill prompt
- No external dependencies in say.sh/speak.py β only stdlib +
curl/afplay/python3. The daemon usesstarlette+uvicornviauv run. - macOS-only playback β
afinfofor duration,ffmpegfor seeking/trimming. Two playback modes: file mode plays a finished file withafplay, and live mode pipes still-arriving audio toffplay(or theaudiotoolboxbackend,ffmpeg -f audiotoolbox). The worker picks per entry β whatever is already downloaded when an entry reaches the head of the queue plays in file mode. - Single shared queue β all agents enqueue to one
AudioQueue. Channel-based filtering and per-channel pause allow multi-agent coordination without overlap. - SSE, not WebSocket β dashboard uses Server-Sent Events for simplicity. Initial state on connect, then incremental events (see below).
- Envelope extraction β
ffmpegdecodes to raw PCM, computes RMS per 50ms chunk, normalizes to 0-1 for lip-sync animation. In live mode the envelope is decoded incrementally alongside playback and shipped in batches.
GET /events opens with a state snapshot, then streams incremental events.
| Event | When | Payload |
|---|---|---|
state |
once, on connect | Queue status + recent_history, plus now_playing when something is current: {id, live, type, phase, epoch, elapsed_estimate, duration, total_duration, envelope_so_far, seq, chunk_ms}. Also carries model and streaming_enabled, so a client renders the model control on connect without a second request. Lets a client that connects mid-playback rebuild the clock and the lip-sync envelope. epoch, elapsed_estimate and envelope_so_far are null while phase is collecting or starting. |
voice_active |
an entry starts, or the queue goes idle | Voice, text, channel, session, duration, envelope. In live mode duration, total_duration and envelope are null, and live: true plus an opaque epoch are present. File-mode payloads are unchanged. |
envelope_append |
~every 300 ms during live playback | {id, epoch, seq, values[], chunk_ms} β seq is the absolute index of the first value, monotonic from 0 per epoch. Extends the lip-sync envelope without disturbing the running clock. |
voice_update |
live collection completes mid-playback | {id, epoch, duration, total_duration, envelope, chunk_ms, segments} β the now-known totals and the calibrated envelope. This is when the dashboard reveals its scrubber. |
pause_state |
pause/resume, global or per-channel | {global_paused, channel_paused} |
history_update |
an entry finishes | The history entry. |
voices_updated |
voices.json changes |
{reason, name} |
config_updated |
the synthesis model is changed via POST /config |
{model} β the new model. Fired only on an actual change, and only after config.json already holds the new value, so a client that re-reads on the event never sees a model the next boot would not restore. |
Every finished entry logs one line:
tts id=cf249713 mode=live model=eleven_v3 gen=0 ttfb_ms=412 first_audio_ms=1160
decoded_ms=3150 total_ms=3420 bytes=54912 framing=chunked
first_audio_ms is the time-to-first-audio metric β enqueue to the moment playback
actually starts, which is what the user waits through. ttfb_ms is only the vendor's
first response byte and says nothing about when sound came out; do not read it as TTFA.
mode tells you which path the entry took (live or file).
Clients must ignore any envelope_append or voice_update whose (id, epoch) does not
match the generation they last saw on a voice_active or state event β a stale event
from a superseded generation must never reset live state. epoch is opaque: it identifies
a generation and is never used for clock arithmetic (the daemon and the client share no
time origin).
| Method | Path | Description |
|---|---|---|
POST |
/speak |
Single-voice TTS |
POST |
/speak/dialogue |
Multi-voice dialogue |
GET |
/queue |
Queue status |
POST |
/queue/skip |
Skip current |
POST |
/queue/pause |
Pause playback |
POST |
/queue/resume |
Resume playback |
POST |
/queue/seek |
Seek within track |
POST |
/queue/clear |
Clear queue |
GET |
/history |
Playback history |
POST |
/history/replay |
Replay cached audio |
GET |
/voices |
Voice configuration |
GET |
/events |
SSE event stream |
GET |
/health |
Health check |
GET |
/ |
Dashboard |
MIT