SLOPSHOPPER

read-aloud

Reads Claude's replies out loud. Off by default; /read-aloud toggles it per session.

newbandspinnertimer
v1.6.1MITupdated 2026-10-09ericcecchi/claude-voice-plugin
A shopper browsing a rack in a slop shop
README

read-aloud

A Claude Code plugin that reads Claude's replies out loud, so you can look away from the screen while it works.

  • Off by default. /read-aloud toggles it for the current session; /read-aloud on and /read-aloud off set it. A Voice on / Voice off button does the same: just above the prompt in the desktop app, in the prompt footer in the terminal (experimental, see below).
  • Replies: when Claude finishes, Claude Haiku sums up the final message for the ear: the outcome, anything you need to do or decide, and any question for you, at a length that fits. Short replies are read as they are. If Haiku is slow or missing, it reads the message minus code, tables and pleasantries.
  • Acknowledgment: a beat after you send a prompt, it reacts out loud in a sentence or two, so there's no dead air. The line comes from Claude Haiku (through claude -p, on your Claude login); if Haiku is slow or missing, from a local Ollama model; otherwise it's a canned one.
  • Updates (off by default): on long tasks, it reads what Claude last wrote between tool calls (each line once), but only after 20 seconds of quiet, so short turns stay quiet.
  • Questions: when an AskUserQuestion prompt opens, it gives a short heads-up.
  • Voice: a warm Kokoro server if one is running, otherwise the system voice: say on macOS, spd-say or espeak on Linux. New speech cuts off the old.

Scheduled tasks and subagents are never read aloud.

Requirements

  • Claude Code with plugin support.
  • python3 on your PATH. A fresh Mac doesn't have it until the Xcode command line tools are installed (xcode-select --install). Without it the hooks fail silently.
  • A speech engine. macOS has say built in. On Linux, install speech-dispatcher (spd-say) or espeak-ng.
  • Optional: a local Ollama with gemma4:e2b as the fallback for acknowledgments, and the Kokoro server below for a better voice (macOS only, since it plays through afplay).

Install

In Claude Code, add this repo as a marketplace, then install the plugin:

/plugin marketplace add ericcecchi/claude-voice-plugin
/plugin install read-aloud@claude-voice

A local checkout works too: /plugin marketplace add /path/to/claude-voice-plugin. Restart Claude Code, then type /read-aloud in any session to turn the voice on.

Privacy and cost

Speech is synthesized on your machine. Two things go to Claude Haiku through your own claude login: your prompt (its first 1,500 characters) for the spoken reaction, and Claude's final message (when it's longer than a couple of sentences) for the summary. That's up to two small calls per turn. Set READ_ALOUD_ACK_MODEL and READ_ALOUD_SUMMARY_MODEL to empty to keep everything local.

Settings

These are saved for every session (in ~/.claude/read-aloud/config.json):

CommandDefaultWhat it does
`/read-aloud updates on\off`offMid-task updates on long turns.
`/read-aloud reactions on\off`onThe short spoken reaction when you send a prompt.
/read-aloud speed 1.31.2Speaking speed, 0.5 to 2.0, for Kokoro and the system voice.
/read-aloud voice heartaf_heartThe Kokoro voice, by full or short name (heart, emma, am_fenrir).
/read-aloud voicesLists the voices.
/read-aloud settingsShows what's set now.

/read-aloud, /read-aloud on and /read-aloud off turn the voice on or off for the current session only.

For finer control, set these in the env block of ~/.claude/settings.json:

VariableDefaultWhat it does
READ_ALOUD_DIRSunsetColon-separated folders. If set, speak only when the session's cwd is inside one.
READ_ALOUD_ACK_MODELhaikuClaude model for the acknowledgment, via claude -p. Empty to skip it.
READ_ALOUD_ACK_WAIT8Seconds to wait for that model before falling back.
READ_ALOUD_SUMMARY_MODELhaikuClaude model that sums up the final message. Empty for the rule-based reading only.
READ_ALOUD_SUMMARY_WAIT60Seconds to wait for it before the rule-based reading.
READ_ALOUD_EFFORThighEffort level for every Haiku call.
READ_ALOUD_OLLAMA_MODELgemma4:e2bLocal Ollama model, the fallback. Empty to skip it.
READ_ALOUD_SAY_RATE175 × speedSystem voice words per minute, overriding the speed setting.
READ_ALOUD_PROGRESS_GAP20Seconds of quiet before a mid-task update. 0 speaks every one.

Each utterance is logged to ~/.claude/read-aloud.log with the hook that spoke it and the engine (kokoro or say).

touch ~/.claude/read-aloud.off silences it everywhere; delete the file to undo.

State lives in ~/.claude/read-aloud/on/<session id> (on or off), so other tools (for example a status-line toggle) can read or flip it.

The voice button (experimental)

The Voice on / Voice off button is drawn with Claude Code's early-access plugin UI API, which changes between releases, so it may not appear on some versions or surfaces. The /read-aloud command and everything spoken are ordinary hooks and don't depend on it.

Optional: Kokoro voice

scripts/kokoro-speak-server.py keeps Kokoro loaded and listens on ~/.claude/kokoro.sock.

brew install espeak-ng
python3 -m venv ~/.kokoro-venv && ~/.kokoro-venv/bin/pip install kokoro soundfile numpy torch

Copy the server somewhere stable (the plugin's install path changes between versions), then keep it running with launchd. Save this as ~/Library/LaunchAgents/local.kokoro-speak.plist, replacing YOU:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>local.kokoro-speak</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/YOU/.kokoro-venv/bin/python</string>
    <string>/Users/YOU/.kokoro-venv/kokoro-speak-server.py</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string>
  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>ThrottleInterval</key><integer>30</integer>
  <key>StandardOutPath</key><string>/Users/YOU/.claude/kokoro-speak.log</string>
  <key>StandardErrorPath</key><string>/Users/YOU/.claude/kokoro-speak.log</string>
</dict>
</plist>
launchctl load ~/Library/LaunchAgents/local.kokoro-speak.plist

KOKORO_VOICE (the default voice, af_heart, which /read-aloud voice overrides) and KOKORO_DEVICE (cpu by default; mps uses the GPU, but it's slower for this small model and roughens the voice) go in the plist's EnvironmentVariables. Voices differ in quality: Kokoro grades the US voices af_heart (A) and af_bella (A-) highest; the British ones, bf_emma (B-) and bm_fable (C), lower. See Kokoro's VOICES.md for the full list. The server reads /read-aloud speed and /read-aloud voice on every request, so changes need no restart.

Development

python3 -m unittest discover tests
claude plugin validate .
claude plugin test .

Installed copies run from Claude Code's plugin cache, which refreshes only when the version changes: bump version in .claude-plugin/plugin.json and .claude-plugin/marketplace.json with every change, then run claude plugin update read-aloud@claude-voice.

License

MIT

Source 2 files
hooks/register.tsx 72 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4// The read-aloud hook (scripts/read-aloud.py) speaks only when
5// ~/.claude/read-aloud/on/<session id> holds "on". `/read-aloud` and this toggle both write it.
6const isOn = atom({ plugin: 'read-aloud', key: 'isOn' } as const, false)
7
8// Lucide's volume-2 / volume-x, the stroke icon set the Claude UI draws with.
9const SPEAKER =
10  '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"/>'
11const WAVES = '<path d="M16 9a5 5 0 0 1 0 6"/><path d="M19.364 18.364a9 9 0 0 0 0-12.728"/>'
12const CROSS = '<path d="M22 9l-6 6"/><path d="M16 9l6 6"/>'
13const icon = (on: boolean) =>
14  `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8b8b86" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${SPEAKER}${on ? WAVES : CROSS}</svg>`
15
16// The button itself, the same wherever it's drawn.
17async function control($: EngineInterface, e: RenderInput<'SessionMode' | 'AbovePrompt'>) {
18  const { Box, Button, Svg } = $.ui.resolve(e)
19  const voice = await read($, isOn)
20  const toggle = async () => {
21    const flag = `${await $.env.get('HOME')}/.claude/read-aloud/on/${await $.session.id()}`
22    await $.fs.write(flag, voice ? 'off' : 'on')
23    await update($, isOn, () => !voice)
24  }
25  const label = voice ? 'Voice on' : 'Voice off'
26  return (
27    <Box key="voice-row" flexDirection="row" alignItems="center">
28      {e.surface !== 'terminal' && Svg && <Svg source={icon(voice)} alt={label} width={14} height={14} />}
29      <Button key="voice" label={label} plain dimColor onPress={() => void toggle()} />
30    </Box>
31  )
32}
33
34export const register: Register = on => {
35  on('session.start', async ($, e, next) => {
36    const result = await next(e)
37    const flag = `${await $.env.get('HOME')}/.claude/read-aloud/on/${await $.session.id()}`
38    const sync = async () => {
39      const now = (await $.fs.exists(flag)) && (await $.fs.read(flag)).trim() !== 'off'
40      if (now !== (await read($, isOn))) await update($, isOn, () => now)
41    }
42    await sync()
43    $.clock.every(1000, () => void sync())
44    return result
45  })
46
47  // Terminal: in the prompt footer's right-hand mode area, after any mode labels (`focus`).
48  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
49    const base = await next(e)
50    if (e.surface !== 'terminal') return base
51    const { Box } = $.ui.resolve(e)
52    return (
53      <Box flexDirection="row" alignItems="center">
54        {base}
55        <Box marginLeft={1}>{await control($, e)}</Box>
56      </Box>
57    )
58  })
59
60  // Desktop and other surfaces: the footer slots aren't on screen there, so the button sits in
61  // the band just above the prompt, at its right edge. It gives way to a survey.
62  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
63    if (e.surface === 'terminal' || e.props.hasSurvey) return next(e)
64    const { Box } = $.ui.resolve(e)
65    return (
66      <Box flexDirection="row" justifyContent="flex-end">
67        {await control($, e)}
68      </Box>
69    )
70  })
71}
72
types/index.d.ts 8 lines
1export type ReadAloud = { isOn: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'read-aloud': { isOn: boolean }
6  }
7}
8