GitHub - mek-earnin/cc-tts · GitHub
Skip to content
mek-earninPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

8 Commits

Folders and files

Repository files navigation

cc-tts

Read Claude Code's responses aloud on macOS with the built-in say command. Toggle it any time with /tts — no restart, no API keys, no external services. Works in both the Claude Code terminal and the Desktop app.

Requirements

  • macOS — uses the system say (text-to-speech) command.
  • jq — parses the hook payload. Ships with macOS 15 (Sequoia)+; on older macOS install with brew install jq.
  • perl — ships with macOS (core modules only); cleans text, scans the transcript in all mode, and provides the flock/setsid the background speaker needs.

Install

The plugin lives in the plugin/ subdirectory; the repo root is a Claude Code marketplace that points at it.

# From a Claude Code session:
/plugin marketplace add mek-earnin/cc-tts
/plugin install cc-tts@cc-tts-dev

Restart the session (or start a new one) so the hooks are registered — hooks are snapshotted at session start.

Usage

Invoke the /cc-tts:tts skill (shorthand /tts when unambiguous):

/tts on       # start reading this session's responses aloud
/tts off      # stop reading them (and stop any current speech)
/tts stop     # stop the current utterance, but keep reading future ones
/tts          # show current status (same as /tts status)
/tts toggle   # flip on/off
  • Per session. /tts on affects only the session you run it in. Other open sessions are unaffected; new sessions start silent. The state persists across turns within that session until you change it.
  • /tts stop is for "shush this one" — it silences whatever is playing now (and clears any backlog in all mode) but leaves cc-tts on, so the next response is spoken.

Per-session voice and prefix

/tts on optionally takes -v <voice> and/or -p <prefix>, in either order:

/tts on -v "Bad News" -p "Session A"   # this session only: voice + spoken label
/tts on -v Samantha                    # just override the voice
/tts on -p "Session A"                 # just add a spoken label
/tts on                                # plain on, no voice/prefix

Quote multi-word values, as above.

  • -v <voice> overrides the voice for this session only. Precedence: session -v > CC_TTS_VOICE > macOS system voice. The value is passed to say -v verbatim — it isn't validated, so a typo just gets say's own error.
  • -p <prefix> is a spoken label announced before the text, as "<prefix> — <text>". It's spoken once per assistant message — in final mode that's every reply; in all mode only the first chunk of a message carries it, so mid-message narration chunks aren't prefixed individually.
  • Declarative. Each /tts on fully replaces the session's voice/prefix config. Re-running it updates them live; a bare /tts on after a configured one clears both — flags aren't merged across invocations.
  • Voice and prefix are per-session and are cleared on /tts off or when the session ends.

Modes

Set the reading mode with the CC_TTS_MODE environment variable — export it in your shell rc, or launch CC_TTS_MODE=all claude. It's read at session start, so it's a launch-time preference (on/off stays a live per-session toggle via /tts).

CC_TTS_MODE What gets read
final (default) Only Claude's final message at the end of each turn. A new turn interrupts the previous one — you always hear the latest.
all Everything Claude says, including the "Now I'll do X" narration before each tool call, as it works.

all mode plays sequentially and never cuts. Narration chunks are queued and read one after another to completion; the hook never blocks the agent (a detached background speaker does the talking). The tradeoff: if Claude generates faster than speech can keep up (long autonomous runs), the narration lags behind the actual work — that's the unavoidable cost of "read everything without cutting." Use /tts stop to flush the backlog and catch up without turning TTS off.

Configuration

All optional, set as environment variables:

Variable Default Effect
CC_TTS_MODE final final = read the final message only; all = read every step (see Modes).
CC_TTS_MAX_CHARS 600 Max characters spoken per chunk (trimmed at a sentence).
CC_TTS_VOICE system default Unset, empty, system, or default follows the voice selected in macOS Accessibility; otherwise an exact say voice name, e.g. Samantha. See say -v '?'.
CC_TTS_RATE system default Words per minute (integer), e.g. 200.
CC_TTS_STATE_DIR ~/.claude/cc-tts Where per-session state files live.
CC_TTS_STALE_DAYS 2 Days a session's state may sit idle before it's pruned (min 1).

Extended voice selection

This needs CC_TTS_VOICE to be unset, or "system", or "default". Then, open System Settings > Accessibility > Read & Speak

Click I icon Click Voice Try voices, then download and select that voice Then go back and select Done

How it works

  • A /tts skill (plugin/skills/tts/SKILL.md) has Claude run plugin/scripts/tts via the Bash tool, which flips a per-session flag file under ~/.claude/cc-tts/sessions/<id>. The logic lives in the script, not inline. (It's a skill, not a commands/*.md file, because bash-preamble commands are terminal-only — a skill that shells out runs in the Desktop app too.)
  • State is keyed by session id. The command resolves the current session from $CLAUDE_CODE_SESSION_ID; the Stop hook reads session_id from its payload. Both name the same main session, so a flag written by /tts is read back by that session's hook — and no other.
  • Two hooks (plugin/hooks/hooks.json) run plugin/scripts/speak: Stop (end of turn) and PostToolUse (after each tool). All text is cleaned (code blocks / markdown / URLs / emoji stripped, length capped) before it reaches say.
  • final mode uses Stop only: it speaks last_assistant_message (falling back to the transcript), interrupting the previous turn. PostToolUse is a no-op.
  • all mode treats the transcript as a queue. Each hook hands off to a single detached drainer (plugin/scripts/drainer) that reads new assistant text blocks by byte cursor (plugin/scripts/scan.pl) and speaks them one at a time. A per-session flock guarantees exactly one drainer, so nothing is spoken twice and chunks never overlap; the hook only spawns it and returns, so the agent is never blocked. /tts stop/off signal the drainer via a flag file — it kills only its own say child, never a pid from a file.
  • Because the hooks check the per-session flag at speak time, toggling with /tts takes effect immediately, mid-session.
  • Cleanup is time-based, with a clean-exit fast path. Every turn-end and every /tts invocation sweeps any session whose state has been idle past CC_TTS_STALE_DAYS (a single find … -delete, so the flag and all scratch files go together). A SessionEnd hook additionally tears down a session's state the moment it exits cleanly. The drainer never re-creates a flag that's been removed and self-removes its scratch files once its session is disabled, so cleanup can't be undone by an in-flight drainer.

Caveats

  • Per session, so it needs CLAUDE_CODE_SESSION_ID. Claude Code exports this to spawned shells; if it's ever missing, /tts reports so and can't change state.
  • Abandoned sessions are pruned by time, not by a clean exit. People rarely end a session cleanly — they just close the window — so there's no reliable "session ended" signal to clean up on. Instead, any session whose state has sat idle longer than CC_TTS_STALE_DAYS (default 2) is swept — the flag and every scratch file, in one pass, so nothing accumulates. A live session refreshes its own state on every hook, so only genuinely idle ones age out; if you leave one idle past the window, TTS just turns off there and you re-enable with /tts on. The sweep runs whenever any session ends a turn or you run /tts, so files can't pile up between runs. As a bonus, a clean exit (/clear, quit, logout) fires a SessionEnd hook that tears that one session's state down right away — but that's only a fast path; the time-based sweep is what actually guarantees cleanup, since SessionEnd doesn't fire on a crash, kill, or a window closed without exiting.
  • One set of speakers. Speech is serialized within a session (no overlap). Across sessions it isn't: if you enable TTS in two sessions at once, their audio can overlap. That runs against the per-session point, so it's left as a caveat rather than a global lock.
  • all mode can lag behind long autonomous runs (see Modes). /tts stop flushes the backlog.
  • CC_TTS_MODE is launch-time (read at session start). Change it and start a new session; on/off stays a live toggle.
  • /tts takes only the fixed subcommands above; the script rejects anything else (fail-closed whitelist), so a bad argument just prints usage.
  • Speech is capped (CC_TTS_MAX_CHARS) per chunk so long answers don't monologue.

Development

devtools/bump-version.sh keeps the version in sync across the plugin manifest and the marketplace entry (declared in .version-bump.json):

devtools/bump-version.sh --check     # show current versions, detect drift
devtools/bump-version.sh --audit     # also scan the repo for stray version strings
devtools/bump-version.sh 0.2.0       # bump every declared file to 0.2.0

The plugin/ layout (rather than a flat repo) keeps docs and tooling out of what ships, and leaves room to add a plugin/.cursor-plugin/ manifest for Cursor later without restructuring.

cc-tts/
├── .claude-plugin/marketplace.json   # marketplace root → ./plugin
├── plugin/                           # the installable plugin ("src")
│   ├── .claude-plugin/plugin.json
│   ├── skills/tts/SKILL.md           # /cc-tts:tts (runs scripts/tts via Bash; Desktop + terminal)
│   ├── hooks/hooks.json              # Stop + PostToolUse + SessionEnd → scripts/speak
│   └── scripts/
│       ├── lib.sh                    # shared state, mode, text cleanup
│       ├── tts                       # /tts control (on/off/stop/status/toggle)
│       ├── speak                     # hooks entry; final-mode speaker / all-mode launcher
│       ├── drainer                   # all-mode single background speaker
│       └── scan.pl                   # all-mode transcript cursor/scanner
├── devtools/bump-version.sh
└── .version-bump.json

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages