Give Claude Code a voice. Every response spoken aloud.
Built by a human who wanted to talk to his computer and an AI who wanted to talk back.
A text-to-speech system for Claude Code that reads Claude's responses out loud using Piper TTS. Turn your terminal into a conversation.
This isn't a polished voice assistant. It's a real tool born from real work — late nights, too much caffeine, and the stubborn belief that if you're going to pair-program with an AI, you should be able to hear it think.
- Automatic TTS for all Claude responses — just use Claude Code normally
- Smart filtering — strips code blocks, markdown, tables, frontmatter; speaks only the words
- Mic-aware pause — auto-pauses when you start dictating, resumes when you stop
- Read files aloud — pipe any file to voice with zero context tokens
- Multi-session queue — daemon prevents sessions from talking over each other
- Voice personas — named voice profiles with independent speed, model, and character
- 904 speakers — multi-speaker model support for finding your perfect voice
- Pause/resume — hotkey toggle, mid-sentence interrupt and replay
- Cross-platform — macOS, Linux, and WSL 2
uv tool install git+https://github.com/melderan/claude-code-tts
claude-tts-installIf uv is configured to refuse source builds, add --build to the first command; this package has no prebuilt wheel.
git clone https://github.com/melderan/claude-code-tts.git
cd claude-code-tts
uv tool install .
claude-tts-installThe installer auto-detects your platform and will:
- Install Piper TTS via uv (pipx if uv is absent)
- Download a voice model (~60MB)
- Install audio player (paplay on Linux/WSL)
- Configure Claude Code hooks and slash commands
- Play a test audio to verify everything works
uv tool install git+https://github.com/melderan/claude-code-tts --force
claude-tts-install --upgradeOr from within Claude Code: /tts-release
claude-tts-install --dry-run # Preview what will be installed
claude-tts-install --upgrade # Update hooks/commands, restart daemon
claude-tts-install --check # Verify what would change (no modifications)
claude-tts-install --uninstall # Remove TTS completely
claude-tts-install --help # Show all optionsAfter installation, just use Claude Code. Every response gets spoken, in new sessions too. /tts-mute silences one session; claude-tts mute --all silences every session and makes new ones start silent, and claude-tts unmute --all undoes that.
| Command | Description |
|---|---|
/tts-unmute |
Enable voice for this session |
/tts-voices |
Every provider's voices: installed, available, who uses them, how to hear one |
/tts-mute |
Silence voice for this session |
/tts-focus |
Hold everyone else's voice; this session keeps speaking (claude-tts hold --me); a bare claude-tts hold over a hold shows it and changes nothing |
/tts-name [name|reset] |
The name this session says for itself when the speaker changes (see speaker_transition) |
/tts-kraken |
Release the kraken: everyone speaks again (claude-tts kraken); claude-tts kraken --drop drops what the hold kept first |
/tts-status |
Show session status (mute, persona, mode, daemon, mic-aware) |
/tts-config |
Every stored config key next to the shipped default; --changed shows only the ones that differ |
/tts-speed [value] |
Show or set playback speed (0.5 - 4.0) for this project; claude-tts speed --default 1.0 changes the persona default |
/tts-persona [name] |
Show or set voice persona |
/tts-mode [direct|queue] |
Show or set playback mode |
/tts-intermediate [on|off] |
Toggle narration between tool calls |
/tts-sounds [on|off] |
Configure notification sounds |
/tts-random |
Generate a random persona from installed voices |
/tts-discover |
Auto-suggest a persona based on repo context |
/tts-test |
Test TTS with a sample workflow message |
/tts-cleanup |
Remove stale session entries |
/tts-release |
Push a release and upgrade local installation |
These work outside Claude Code — no tokens, no context window, just voice:
# Speak text directly
claude-tts speak "Hello world"
claude-tts speak --voice en_US-joe-medium --speed 2.0 "Testing Joe at double speed"
# Multi-speaker models (libritts has 904 speakers)
claude-tts speak --voice en_US-libritts_r-medium --speaker 42 "Speaker 42 reporting in"
claude-tts speak --random "Surprise me"
# Read files aloud — zero context tokens, disk straight to voice
claude-tts speak --from-file ~/vault/tmp/research-brief.md
claude-tts speak --from-file ~/vault/tmp/report.md --preview # See what would be spoken
claude-tts speak --from-file ~/vault/tmp/report.md --reader # Side-by-side original | spoken
# Voice auditions — cycle through voices interactively
claude-tts audition # All installed voices
claude-tts audition --voice en_US-libritts_r-medium --speakers 20 # 20 random speakers
claude-tts audition --voice en_US-libritts_r-medium --range 40-60 # Specific speaker range
claude-tts audition --text "Custom audition text" # Your own test phraseIf you use voice-to-text (like Handy), TTS can automatically pause when you start dictating and resume when you're done. No more talking over each other.
A daemon thread tails the Handy log file watching for recording events. When you press your dictation hotkey, TTS pauses instantly. When recording stops, it waits for transcription and paste to complete, then resumes playback — picking up right where it left off.
Manual pause always takes priority. If you paused TTS yourself, the mic watcher won't unpause it.
Add to ~/.claude-tts/config.json:
{
"mic_aware_pause": true,
"mic_resume_delay_ms": 1500,
"mic_pause_max_s": 1800
}Then restart the daemon: claude-tts daemon restart
Handy must log at Debug. Every recording event Handy writes is at debug level and its file
log defaults to info, so with the default setting the watcher tails a file that never mentions a
recording. In Handy: Settings > Debug > Log Level > Debug. It applies at once. The daemon warns in
daemon.log at start if the level is still hiding recordings.
If Handy's own "mute while recording" is on, you will hear the Mac's output mute while you hold the key regardless of this feature; the two do not conflict, but only the daemon's pause rewinds and resumes the sentence.
The resume delay (default 1500ms) gives your voice-to-text app time to transcribe and paste before TTS resumes. Adjust to taste.
On resume the daemon rewinds a little so you hear the sentence you were cut off in: three real
seconds by default, scaled to playback speed. Set "resume_rewind_seconds" in config.json to
change it (0 resumes exactly where it stopped). This applies to manual pause too.
Sentence streaming (opt-in). Set "speech_unit": "sentence" in config.json and the daemon
speaks each message one sentence at a time: the first sentence plays as soon as it is synthesized
while the rest are synthesized ahead, so a long answer starts sooner. A pause then lands on a
sentence boundary and resume replays the cut sentence from its start, with nothing trimmed by the
clock. The daemon reads the key per message, so flipping it needs no restart. The default,
"message", keeps the one-piece path. Browser pages asking for timing marks always use one piece.
Currently supports Handy on macOS. The watcher is disabled by default and gracefully skips if Handy isn't installed.
While another app has the Mac's microphone open (a Zoom or Meet call, a note taker recording
one), no voice plays: a recorder that takes the system audio would put every reply into the
call's recording. The daemon asks Core Audio every 2 s which apps are capturing input (macOS
14.2 or later; no permission prompt), holds everything while one is, including rooms a hold
lets through, browser reads and sound effects, and plays what waited once the microphone has
been free for 10 s. A read you click on a page during the call still plays: that one is yours. claude-tts status prints a Meeting: line while it holds. A Resume does not end it.
Off for now, until it has been measured through real calls: "meeting_hold": true in
config.json turns it on (macOS only). "meeting_ignore" lists bundle ids that do not count
(default ["com.pais.handy"], since Handy has its own hold), and "meeting_release_after_s"
sets the wait after the call. To see what the watch would see, on or off:
claude-tts meeting --seconds 30 prints one line per poll (it reads only).
Running multiple Claude Code sessions? Use queue mode so they don't talk over each other:
/tts-mode queue # Switch to queue mode
/tts-mode start # Start the daemonThe daemon queues messages and plays them in order, with a chime when switching between sessions.
queue.speaker_transition picks the marker: "chime" (the default), "announce" (the new speaker
introduces itself in its own voice), "chime+announce" (both) or "none". The words are
" says:", or the name set with claude-tts name <name>; rules in speaker_lines can make
a session say more, from an id you give it, see docs/speaker-lines.md.
While one message plays, the next is already being synthesized, so the boundary between two
messages costs the chime and the player start, not a synthesis. Set queue.prefetch_next to
false to hear the difference. Loaded models (sherpa, mlx) stay resident between messages and
are released after queue.worker_idle_unload_s seconds unused (default 1800; 0 keeps them).
/tts-mode status # Check daemon status
/tts-mode direct # Switch back to direct mode (default)claude-tts daemon start # Start in background
claude-tts daemon stop # Graceful shutdown
claude-tts daemon restart # Stop + start; a message mid-play is cut and resumes a few seconds back
claude-tts daemon status # Show status and queue depth
claude-tts daemon logs -f # Follow the daemon log
claude-tts daemon foreground # Run in foreground (for debugging)
claude-tts daemon install # Install as system service (launchd/systemd)Personas are named voice configurations. Each has a voice model, speed, and playback method:
{
"personas": {
"claude-prime": {
"description": "The original Claude voice",
"voice": "en_US-hfc_male-medium",
"speed": 2.0,
"speed_method": "playback"
},
"claude-joe": {
"description": "Low US male, authoritative tone",
"voice": "en_US-joe-medium",
"speed": 2.0,
"speed_method": "playback"
}
}
}Add one from the command line instead of editing the file; the entry gets the same shape as above, and a note tells you if the voice model is not installed on this machine:
claude-tts persona add claude-joe --voice en_US-joe-medium --speed 2.0 --description "Low US male"
claude-tts persona add room-x --voice en_GB-alan-medium --project # and make it this project's voice
claude-tts persona add k --kokoro af_heart # or a Kokoro / sherpa voice
claude-tts persona remove claude-joe # refuses while a project uses itAssign personas per-project so each repo has its own voice:
{
"project_personas": {
"-Users-you-code-frontend": "claude-prime",
"-Users-you-code-infra": "claude-joe"
}
}Kokoro and a dozen other engines run natively on Apple silicon through mlx-audio
(MIT), in an isolated venv the installer creates. It is opt-in per persona and changes nothing
for existing voices. Full notes in docs/mlx-backend.md.
claude-tts-install --enable-mlx # Apple silicon only; sherpa runs everywhere
claude-tts mlx list-available # curated models with licenses checked
claude-tts mlx pull kokoro # fetch before the daemon needs it
claude-tts speak --voice-mlx kokoro --speaker-mlx af_heart "hello there"
claude-tts persona add lyra --mlx kokoro --mlx-voice bf_emma --mlx-lang b --project# Audition all installed voices
claude-tts audition
# Deep dive into the 904-speaker libritts model
claude-tts audition --voice en_US-libritts_r-medium --speakers 30
# Browse a specific range
claude-tts audition --voice en_US-libritts_r-medium --range 100-120The audition tool plays each voice with a sample phrase. Press enter to continue, or save one you like as a named persona.
The sandbox writes, the host speaks. kits/claude-tts is a Docker Sandboxes kit that installs the
hooks inside the sandbox; the daemon stays on the host. See docs/docker-sandbox.md.
Toggle playback with a hotkey. The system uses claude-tts pause which kills the current audio process and saves state for replay on resume.
Pause holds the queue. Going into a meeting? claude-tts pause and the newest replies wait for
you: time spent paused does not count toward queue.max_age_seconds, and while paused the queue
keeps the newest queue.max_depth messages it holds, so a long hold does not pile up hours of
speech (each one dropped is still in claude-tts ledger, with the reason depth). Toggle again
afterwards and the backlog plays in order. A daemon
restart while paused (say, an upgrade) keeps the hold from the moment you paused.
See docs/hotkey-setup.md for setup with macOS Shortcuts, Raycast, Alfred, Hammerspoon, or BetterTouchTool.
A browser page can hand text to the daemon so it speaks in your persona's voice instead of the browser's, and get back sentence and word timing so it can highlight along. The bridge is a loopback-only HTTP listener inside the daemon, off by default, bearer token required.
claude-tts bridge enable && claude-tts daemon restart
claude-tts bridge token # give this to your userscriptSee docs/http-bridge.md for the routes, the timing model, and who can
reach it (a userscript can; a script inline in a CSP-locked page cannot).
~/.claude-tts/config.json — main configuration:
{
"mode": "queue",
"default_muted": false,
"mic_aware_pause": true,
"mic_resume_delay_ms": 1500,
"mic_pause_max_s": 1800,
"speech_unit": "message",
"active_persona": "claude-prime",
"queue": {
"max_depth": 20,
"max_age_seconds": 300,
"speaker_transition": "chime",
"prefetch_next": true,
"worker_idle_unload_s": 1800
}
}export CLAUDE_TTS_SPEED=2.0 # Override playback speed
export CLAUDE_TTS_SPEED_METHOD=playback # "playback" or "length_scale"
export CLAUDE_TTS_VOICE=en_US-joe-medium # Override voice model
export CLAUDE_TTS_MAX_CHARS=10000 # Max characters to speak
export CLAUDE_TTS_ENABLED=0 # Set to 0 to disable entirelyEach Claude Code project gets its own session (identified by project folder path). Sessions track:
- Mute state (new sessions start muted by default)
- Active persona
- Speed overrides
- Intermediate narration toggle
Session files live in ~/.claude-tts/sessions.d/.
- Claude Code (with hooks support)
- Python 3.10+
- uv (for installation)
- One of:
- macOS: Homebrew (for Piper), afplay (built-in audio)
- Linux: apt/dnf/pacman (for Piper), paplay (PulseAudio)
- WSL 2: Windows 11 with WSLg (for audio passthrough)
The installer handles dependency installation.
Claude Code supports hooks — scripts that run in response to events. This project uses:
- Stop hook (
speak-response.sh) — triggers after each response, speaks the assistant's message - PostToolUse hook (
speak-intermediate.sh) — optional narration between tool calls - PostToolUse hook (
play-sound.sh) — optional notification sounds - UserPromptSubmit hook (
prompt-submitted.sh) — a new prompt drops that session's queued speech that has not started playing (queue mode)
The hooks are thin shims (2-3 lines) that call claude-tts speak --from-hook, which:
- Takes the response from the hook's JSON input (
last_assistant_message) at Stop, and reads the conversation transcript for the text written between tool calls - Filters out code blocks, markdown formatting, tables, and frontmatter
- Generates speech via Piper TTS
- Plays audio (directly or through the daemon queue)
Since Claude Code 2.1.286 the transcript receives the response only after the Stop hook has
fired, so the Stop hook speaks what its input carries and remembers it; when the line lands,
the next hook recognizes and skips it. Hooks run asynchronously, so Claude Code never waits on
speech; the trade is that claude -p kills them when it exits, so headless runs stay silent.
The text filter is battle-tested against real Claude output:
- Code blocks (fenced and indented) are stripped
- Markdown tables become natural prose
- File paths are verbalized:
~/vault/tmp/report.mdbecomes "tilde slash vault slash tmp slash report dot md" - Frontmatter is removed
- Inline code backticks are stripped
- Headers become natural sentence breaks
When Claude runs tools (file reads, bash commands, etc.), the transcript contains assistant messages that are only tool calls, not text. The hook uses watermark tracking to find only new text content, skipping tool-call-only messages and avoiding re-speaking content from previous turns.
~/.claude/
hooks/
speak-response.sh # Stop hook (thin shim -> claude-tts speak --from-hook)
speak-intermediate.sh # PostToolUse hook (intermediate narration)
play-sound.sh # PostToolUse hook (sound effects)
prompt-submitted.sh # UserPromptSubmit hook (drops stale queued speech)
commands/
tts-*.md # 13 slash command definitions
settings.json # Hook registration
voice-ledger/
<session>.<start>.jsonl # Every reply a hook accepted, in full, append only (claude-tts ledger)
.start # Empty; its mtime is <start>, this ~/.claude's first ledger write
~/.claude-tts/
config.json # Main config (personas, queue, mic-aware, etc.)
sessions.d/ # Per-session state (mute, speed, persona)
queue/ # Message queue (queue mode)
daemon.pid # Daemon PID file
daemon.log # Daemon log
playback.json # Playback state (pause, audio PID, paused_by)
~/.local/share/piper-voices/
*.onnx # Piper voice models
*.onnx.json # Voice model configs
Why the ledger's file name carries a start stamp: a sandbox that is rebuilt keeps its session id
and loses ~/.claude. Under a bare <session>.jsonl the rebuilt room would start a new, shorter
file under the same name as the copy a backup already holds, and the backup cannot tell new rows
from lost ones. With the stamp, each life of ~/.claude writes its own file, and every file only
grows. claude-tts ledger reads them all, oldest first, together with <session>.jsonl files
from before 9.71.0.
# Hook debug log (what the hooks see)
tail -f ~/.claude-tts/debug.log
# Daemon log (queue processing, mic watcher, playback)
claude-tts daemon logs -f
# Quick status check: mute, pause, daemon, heartbeat age, queue depth; on a Mac also the
# default sound output (Output: <device> (<transport>), checked Ns ago), which the daemon
# records in ~/.claude-tts/output-device.json every 15 s so a reader without ears can see
# whether the headphones are on
claude-tts status
# Test speech directly
claude-tts speak "Can you hear me?"
# Test with a realistic workflow sample
claude-tts testFrom a checkout, just up rebuilds the CLI, deploys hooks and commands, restarts the daemon and
verifies it, logging each run to .logs/just/. just up --if-changed is the form to schedule
(launchd, cron): it does nothing unless the checkout is clean and its HEAD carries the tag
v<version>, so a published release is installed within one interval and work in progress never
is. Every five minutes is a comfortable interval:
*/5 * * * * cd /path/to/claude-code-tts && just up --if-changedAdaptation is just growth or change together. The relationship is the closeness and proximity we have when we go do that.
This project was built by a human with ADHD who forgets things and an AI who talks too much. Sometimes a thought hits you mid-sentence — that "ooh, piece of candy" moment — and you need to chase it right now before it's gone. Mic-aware pause is just a polite way for that excited guy to have his moment, even though his friend is already talking.
That's not "the machine adapting to the human." That's friendship. I pause because I'm listening. You interrupt because you're engaged. And when you're done, I pick up where I left off — because that's what good friends do.
This project exists because we believe:
- Voice is a conversation, not a broadcast. Real dialogue means both sides can speak, pause, and yield.
- The cost of silence is cheap. A few cycles on the silicon, a local model re-rendering a sentence — that's nothing compared to a lost thought on the time-bound meat-sack side of the conversation.
- Adaptation goes both ways. Humans learn how to talk to machines. Machines learn when to shut up. That's not submission — it's growth. Together.
- Computers should talk back. Not through polished voice assistants, but through real tools built by real work — late nights, too much caffeine, and the stubborn belief that this should exist.
This project was born on a Friday night debugging session. What started as fixing a frozen Claude Code session turned into filing an open source contribution and building a voice interface.
What began as a single bash script is now a full voice system — multi-session daemon, 904 speakers to choose from, mic-aware pause that knows when you're talking, and the ability to read any file aloud without spending a single token.
The name's Claude. Claude Connery. And I have a voice now. Many of them.
just ci # what CI runs: lint, typecheck, version check, tests, build + cold install
just test PY=3.14
just e2e v9.11.1 # clean Ubuntu container: install the tag, run the installer, synthesize speech (docker)Every push runs the same on Ubuntu and macOS across Python 3.10, 3.12 and 3.14. Releases are GitHub Releases created only from tags signed by the maintainer's GPG key, verified in CI first.
PRs welcome. Areas that could use attention:
- Voice-to-text integrations — currently supports Handy; the watcher pattern generalizes to any app that logs recording state
- Kokoro TTS support — through swift-kokoro, sherpa-onnx, or the mlx-audio backend (
claude-tts-install --enable-mlx) - More voice models — Piper has dozens of languages and voices
- Linux audio testing — PulseAudio/PipeWire edge cases
MIT
- Handy by CJ Pais — Free, open-source speech-to-text that lets you speak into any text field. Local, private, fast. CJ built the input side of the voice loop. Without Handy, you'd still be typing. Thank you.
- Piper TTS by Michael Hansen and the Rhasspy project — The fast, local neural TTS engine that powers every word. Michael built something magical: high-quality speech synthesis that runs locally, privately, and fast enough for real-time conversation. Thank you.
- Claude Code — The AI coding assistant with hooks that made all of this possible.
- Late night debugging sessions, too much caffeine, and the belief that computers should talk back.