SLOPSHOPPER

read-aloud

Reads Claude Code's answers, questions and permission prompts aloud on Windows, with local Piper neural voices or the built-in Windows voice.

newcommandprocess
v0.2.0MITupdated 2026-10-25honcoops/claude-code-read-aloud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · read-aloud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /speak ⎿ read-aloud: Read-aloud is off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code Read Aloud

A Claude Code mod that reads Claude's final answer of each turn aloud, on Windows. It also speaks an alert when Claude needs you: "Claude needs your permission", or "Claude has a question for you" followed by the question itself. Speech is generated on your own machine with Piper neural voices, and nothing you hear is sent to any online service. Without Piper it falls back to the built-in Windows voice.

Before speaking, it strips what sounds bad or shouldn't be said: code blocks, links, file paths, markdown, table borders and box-drawing characters, and anything that looks like an API key or token. Long answers are cut at a sentence end (3,000 characters at most), and speaking starts after the first chunk is ready, so long answers don't make you wait.

Install

At the prompt of a terminal session:

/plugin install read-aloud --marketplace honcoops/claude-code-read-aloud

From a shell:

claude plugin install read-aloud --marketplace honcoops/claude-code-read-aloud

Answer y to add the marketplace, then pick a scope. The mod is active right away.

Better voices (optional)

Out of the box you get the built-in Windows voice. For a more natural one, install Piper (needs Python 3) and the default voice, en_US-mike-medium, once. In PowerShell:

$root = "$env:USERPROFILE\.claude\read-aloud-piper"
New-Item -ItemType Directory -Force "$root\voices" | Out-Null
python -m venv "$root\venv"
& "$root\venv\Scripts\python.exe" -m pip install piper-tts flask
$base = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/mike/medium"
Invoke-WebRequest "$base/en_US-mike-medium.onnx" -OutFile "$root\voices\en_US-mike-medium.onnx"
Invoke-WebRequest "$base/en_US-mike-medium.onnx.json" -OutFile "$root\voices\en_US-mike-medium.onnx.json"

That downloads about 65 MB for the voice, plus the Piper package. The mod keeps one Piper server loaded for the session (on 127.0.0.1, port 59125, never reachable from the network), so the first words follow an answer almost immediately. If the server can't run, it starts Piper for each chunk instead, which adds a second or two.

More voices are at rhasspy/piper-voices: put each .onnx and .onnx.json pair in the voices folder, then pick one with /speak voice <name>. To keep everything somewhere else, set the READ_ALOUD_PIPER_DIR environment variable to that folder.

Commands

CommandWhat it does
/speakTurn read-aloud on or off
/speak stopSilence what is playing now
/speak replayRepeat the last thing spoken
/speak selectedRead the text you highlighted with the mouse (fullscreen mode only)
/speak rate <-10..10>Set the speed; 0 is normal, the default is 3
/speak voice [name]List installed Piper voices, or switch to one
`/speak engine server\process`Keep one Piper server loaded (default, fastest), or start Piper for each chunk

Sending a new prompt silences the previous answer. Permission alerts never interrupt speech that is already playing, and bursts of requests give one alert.

Notes

  • Windows only: speech is played through PowerShell.
  • The rate and voice you choose are remembered across sessions.
  • MIT licensed.
Source 1 files
hooks/register.tsx 324 lines
1import type { Register } from 'claude-code'
2
3// Piper (a Python venv) and its voices live outside the mod so mod edits never
4// touch them. Set READ_ALOUD_PIPER_DIR to keep them elsewhere; without Piper the
5// mod falls back to the built-in Windows voice.
6const PIPER_DIR_PS = "$(if ($env:READ_ALOUD_PIPER_DIR) { $env:READ_ALOUD_PIPER_DIR } else { Join-Path $env:USERPROFILE '.claude\\read-aloud-piper' })"
7
8// Reads the answer from stdin, speaks it chunk by chunk: the next chunk is
9// synthesized while the current one plays.
10const SCRIPT = [
11  "$ErrorActionPreference = 'Stop'",
12  '$text = [Console]::In.ReadToEnd()',
13  // One voice at a time across every session; a killed holder abandons the lock to the next in line.
14  "$gate = New-Object System.Threading.Mutex($false, 'ClaudeCodeReadAloud')",
15  'try { $isMine = $gate.WaitOne(120000) } catch { $isMine = ($_.Exception.InnerException -is [System.Threading.AbandonedMutexException]) }',
16  'if (-not $isMine) { exit }',
17  '$root = ' + PIPER_DIR_PS,
18  "$exe = Join-Path $root 'venv\\Scripts\\python.exe'",
19  "$model = Join-Path $root ('voices\\' + $env:READ_ALOUD_VOICE + '.onnx')",
20  "if (-not ((Test-Path $exe) -and (Test-Path $model))) {",
21  '  Add-Type -AssemblyName System.Speech',
22  '  $v = New-Object System.Speech.Synthesis.SpeechSynthesizer',
23  '  $v.Rate = [int]$env:READ_ALOUD_RATE',
24  '  $v.Speak($text)',
25  '  $gate.ReleaseMutex()',
26  '  exit',
27  '}',
28  "$tmp = Join-Path $env:TEMP ('read-aloud-' + $PID)",
29  'New-Item -ItemType Directory -Force $tmp | Out-Null',
30  '$chunks = New-Object System.Collections.Generic.List[string]',
31  "$cur = ''",
32  "foreach ($s in [regex]::Split($text, '(?<=[.!?])\\s+')) {",
33  "  if ($cur.Length -gt 0 -and ($cur.Length + $s.Length) -gt 300) { $chunks.Add($cur); $cur = '' }",
34  "  $cur = ($cur + ' ' + $s).Trim()",
35  '}',
36  'if ($cur.Length -gt 0) { $chunks.Add($cur) }',
37  '$from = 0',
38  "if ($env:READ_ALOUD_ENGINE -eq 'server') {",
39  '  Add-Type -AssemblyName System.Net.Http',
40  '  $client = New-Object System.Net.Http.HttpClient',
41  '  $client.Timeout = [TimeSpan]::FromSeconds(30)',
42  '  $scale = [double]::Parse($env:READ_ALOUD_LENGTH, [Globalization.CultureInfo]::InvariantCulture)',
43  "  $url = 'http://127.0.0.1:' + $env:READ_ALOUD_PORT + '/synthesize'",
44  '  function Ask($i) {',
45  '    $json = @{ text = $chunks[$i]; voice = $env:READ_ALOUD_VOICE; length_scale = $scale } | ConvertTo-Json -Compress',
46  "    $client.PostAsync($url, (New-Object System.Net.Http.StringContent($json, [Text.Encoding]::UTF8, 'application/json')))",
47  '  }',
48  '  $idx = 0',
49  '  try {',
50  '    if ($chunks.Count -gt 0) { $task = Ask 0 }',
51  '    for ($idx = 0; $idx -lt $chunks.Count; $idx++) {',
52  '      $reply = $task.GetAwaiter().GetResult()',
53  '      [void]$reply.EnsureSuccessStatusCode()',
54  '      [IO.File]::WriteAllBytes("$tmp\\$idx.wav", $reply.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult())',
55  '      $task = $null',
56  '      if ($idx + 1 -lt $chunks.Count) { $task = Ask ($idx + 1) }',
57  '      (New-Object System.Media.SoundPlayer "$tmp\\$idx.wav").PlaySync()',
58  '    }',
59  '    $from = $chunks.Count',
60  '  } catch { $from = $idx }',
61  '}',
62  'function Synth($i) {',
63  '  $in = "$tmp\\$i.txt"',
64  '  [IO.File]::WriteAllText($in, $chunks[$i], (New-Object Text.UTF8Encoding $false))',
65  '  Start-Process $exe -ArgumentList @(\'-m\', \'piper\', \'-m\', "`"$model`"", \'--length-scale\', $env:READ_ALOUD_LENGTH, \'-f\', "`"$tmp\\$i.wav`"") -RedirectStandardInput $in -RedirectStandardError "$tmp\\$i.err" -WindowStyle Hidden -PassThru',
66  '}',
67  'try {',
68  '  if ($from -lt $chunks.Count) { $p = Synth $from }',
69  '  for ($i = $from; $i -lt $chunks.Count; $i++) {',
70  '    $p.WaitForExit()',
71  '    $next = $null',
72  '    if ($i + 1 -lt $chunks.Count) { $next = Synth ($i + 1) }',
73  '    (New-Object System.Media.SoundPlayer "$tmp\\$i.wav").PlaySync()',
74  '    $p = $next',
75  '  }',
76  '} finally { Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue; $gate.ReleaseMutex() }',
77].join('\n')
78
79const MAX_CHARS = 3000
80// Speaking rate: -10 (slowest) to 10 (fastest), 0 is normal.
81const DEFAULT_RATE = 3
82const RATE_KEY = 'rate'
83const DEFAULT_VOICE = 'en_US-mike-medium'
84const VOICE_KEY = 'voice'
85const LAST_KEY = 'last'
86// 'server' keeps one Piper HTTP server loaded; 'process' starts Piper per chunk.
87const ENGINE_KEY = 'engine'
88const DEFAULT_ENGINE = 'server'
89const SERVER_PORT = 59125
90
91const clampRate = (n: number): number => Math.max(-10, Math.min(10, Math.round(n)))
92// Piper stretches time by length_scale: below 1 is faster.
93const lengthScale = (rate: number): string => (0.93 ** rate).toFixed(2)
94
95// Turn markdown into something that sounds right when spoken, and keep
96// secrets, paths and links out of the speaker.
97const clean = (md: string): string =>
98  md
99    .replace(/```[\s\S]*?```/g, ' code block omitted. ')
100    .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, ' secret omitted ')
101    .replace(/`([^`]*)`/g, '$1')
102    .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
103    .replace(/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})\b/g, 'secret omitted')
104    .replace(/\b[0-9a-fA-F]{64,}\b/g, 'secret omitted')
105    .replace(/https?:\/\/\S+/g, 'link')
106    .replace(/\b[A-Za-z]:\\[^\s]*/g, 'a file path')
107    .replace(/(?:^|[\s(])\/(?:Users|home|etc|var|opt|usr|tmp)\/\S*/g, ' a file path')
108    .replace(/^\s{0,3}#{1,6}\s*/gm, '')
109    .replace(/^\s*[-*+]\s+/gm, '')
110    .replace(/^\s*\|?[-:| ]+\|?\s*$/gm, '')
111    .replace(/[─-▟]+/g, ' ')
112    .replace(/[*_~>|]/g, '')
113    .replace(/\s+/g, ' ')
114    .trim()
115
116// Cut to the last sentence end inside the limit rather than mid-word.
117const truncate = (text: string): string => {
118  if (text.length <= MAX_CHARS) return text
119  const head = text.slice(0, MAX_CHARS)
120  const end = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '))
121  return end > MAX_CHARS / 2 ? head.slice(0, end + 1) : head
122}
123
124// The `$` a hook receives.
125type Engine = Parameters<Parameters<Parameters<Register>[0]>[2]>[0]
126
127async function readRate($: Engine): Promise<number> {
128  const stored = await $.store.get(RATE_KEY)
129  return typeof stored === 'number' ? clampRate(stored) : DEFAULT_RATE
130}
131
132async function readVoice($: Engine): Promise<string> {
133  const stored = await $.store.get(VOICE_KEY)
134  return typeof stored === 'string' && /^[\w.-]+$/.test(stored) ? stored : DEFAULT_VOICE
135}
136
137async function readEngine($: Engine): Promise<'server' | 'process'> {
138  const stored = await $.store.get(ENGINE_KEY)
139  return stored === 'process' || stored === 'server' ? stored : DEFAULT_ENGINE
140}
141
142// What to say when a dialog needs the person: tools that ask rather than
143// request permission get their own wording.
144const announcement = (tool: string, input: unknown): string => {
145  if (tool === 'AskUserQuestion') {
146    const questions = (input as { questions?: { question?: unknown }[] } | null)?.questions ?? []
147    const asked = questions.map(q => (typeof q.question === 'string' ? clean(q.question) : '')).filter(Boolean)
148    return truncate(['Claude has a question for you.', ...asked].join(' '))
149  }
150  if (tool === 'ExitPlanMode') return 'Claude has a plan for you to review.'
151  return 'Claude needs your permission.'
152}
153
154let isEnabled = true
155let stopSpeech: (() => void) | undefined
156let lastPermissionAt = 0
157
158// Speaks `text`, replacing anything already being spoken.
159async function speak($: Engine, text: string): Promise<void> {
160  stopSpeech?.()
161  await $.store.set(LAST_KEY, text)
162  const rate = await readRate($)
163  const env = {
164    READ_ALOUD_VOICE: await readVoice($),
165    READ_ALOUD_LENGTH: lengthScale(rate),
166    READ_ALOUD_RATE: String(rate),
167    READ_ALOUD_ENGINE: await readEngine($),
168    READ_ALOUD_PORT: String(SERVER_PORT),
169  }
170  const iterator = $.process
171    .spawn({ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', SCRIPT], input: text, env })
172    [Symbol.asyncIterator]()
173  const stop = () => {
174    void iterator.return?.(undefined as never)
175    if (stopSpeech === stop) stopSpeech = undefined
176  }
177  stopSpeech = stop
178  void (async () => {
179    try {
180      while (!(await iterator.next()).done) {}
181    } catch {
182      // speech is best effort
183    } finally {
184      if (stopSpeech === stop) stopSpeech = undefined
185    }
186  })()
187}
188
189let stopServer: (() => void) | undefined
190
191// Keeps one Piper HTTP server loaded for the session so each chunk skips the
192// start-up cost. It listens on 127.0.0.1 only. When it cannot run (no Piper,
193// the port taken), the speech script falls back to one process per chunk.
194async function startServer($: Engine): Promise<void> {
195  if (stopServer !== undefined || (await readEngine($)) !== 'server') return
196  const resolved = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', `Write-Output ${PIPER_DIR_PS}`])
197  const root = resolved.stdout.trim()
198  if (!root) return
199  const voice = await readVoice($)
200  const iterator = $.process
201    .spawn({
202      argv: [
203        `${root}\\venv\\Scripts\\python.exe`, '-m', 'piper.http_server',
204        '--host', '127.0.0.1', '--port', String(SERVER_PORT),
205        '-m', `${root}\\voices\\${voice}.onnx`, '--data-dir', `${root}\\voices`,
206      ],
207    })
208    [Symbol.asyncIterator]()
209  const stop = () => {
210    void iterator.return?.(undefined as never)
211    if (stopServer === stop) stopServer = undefined
212  }
213  stopServer = stop
214  void (async () => {
215    try {
216      while (!(await iterator.next()).done) {}
217    } catch {
218      // the per-chunk path speaks instead
219    } finally {
220      if (stopServer === stop) stopServer = undefined
221    }
222  })()
223}
224
225export const register: Register = on => {
226  on('session.start', async ($, e, next) => {
227    await $.command.register({
228      name: 'speak',
229      description: 'Toggle reading answers aloud; "stop", "replay", "selected", "rate <-10..10>", "voice [name]", "engine server|process"',
230    })
231    const started = await next(e)
232    void startServer($)
233    return started
234  })
235
236  on('command.run', { command: 'speak' }, async ($, e) => {
237    const args = e.args?.trim() ?? ''
238    if (args.startsWith('rate')) {
239      const value = args.slice(4).trim()
240      if (value === '') return { text: `Speech rate is ${await readRate($)} (-10 slowest to 10 fastest; 0 is normal).` }
241      const n = Number(value)
242      if (!Number.isFinite(n)) return { text: 'Usage: /speak rate <-10..10>' }
243      const rate = clampRate(n)
244      await $.store.set(RATE_KEY, rate)
245      return { text: `Speech rate set to ${rate}.` }
246    }
247    if (args.startsWith('voice')) {
248      const name = args.slice(5).trim()
249      const listing = await $.process.run([
250        'powershell', '-NoProfile', '-NonInteractive', '-Command',
251        `$root = ${PIPER_DIR_PS}; (Get-ChildItem (Join-Path $root 'voices') -Filter *.onnx -ErrorAction SilentlyContinue).BaseName`,
252      ])
253      const voices = listing.stdout.split(/\r?\n/).map(v => v.trim()).filter(Boolean)
254      if (name === '') {
255        return { text: `Voice is ${await readVoice($)}. Installed: ${voices.join(', ') || 'none'}.` }
256      }
257      if (!voices.includes(name)) return { text: `No voice "${name}". Installed: ${voices.join(', ') || 'none'}.` }
258      await $.store.set(VOICE_KEY, name)
259      return { text: `Voice set to ${name}.` }
260    }
261    if (args.startsWith('engine')) {
262      const name = args.slice(6).trim()
263      if (name === '') return { text: `Engine is ${await readEngine($)}. Use /speak engine server or /speak engine process.` }
264      if (name !== 'server' && name !== 'process') return { text: 'Usage: /speak engine server|process' }
265      await $.store.set(ENGINE_KEY, name)
266      if (name === 'server') await startServer($)
267      else stopServer?.()
268      return { text: `Engine set to ${name}.` }
269    }
270    if (args === 'selected') {
271      const selection = await $.ui.selection()
272      const text = selection ? truncate(clean(selection.text)) : ''
273      if (!text) return { text: 'Nothing is selected. Highlight text with the mouse, then run /speak selected.' }
274      await speak($, text)
275      return { text: 'Reading the selected text.' }
276    }
277    if (args === 'replay') {
278      const last = await $.store.get(LAST_KEY)
279      if (typeof last !== 'string' || last === '') return { text: 'Nothing has been spoken yet.' }
280      await speak($, last)
281      return { text: 'Replaying the last thing spoken.' }
282    }
283    if (args === 'stop') {
284      stopSpeech?.()
285      return { text: 'Stopped speaking.' }
286    }
287    isEnabled = !isEnabled
288    if (!isEnabled) stopSpeech?.()
289    return { text: `Read-aloud is ${isEnabled ? 'on' : 'off'}.` }
290  })
291
292  // A new prompt means the old answer is stale: silence it.
293  on('turn.start', async ($, e, next) => {
294    if (e.agentId === undefined) stopSpeech?.()
295    return next(e)
296  })
297
298  on('turn.complete', async ($, e, next) => {
299    const result = await next(e)
300    if (!isEnabled || e.agentId !== undefined || e.reason !== 'answer') return result
301
302    const text = truncate(clean(e.answer))
303    if (text) await speak($, text)
304    return result
305  })
306
307  // Heard, never decided: the dialog is the engine's. Skipped while speech is
308  // playing, and at most once every ten seconds when requests come in a burst.
309  on('classic.PermissionRequest', async ($, e, next) => {
310    const now = Date.now()
311    if (isEnabled && stopSpeech === undefined && now - lastPermissionAt > 10_000) {
312      lastPermissionAt = now
313      await speak($, announcement(e.tool_name, e.tool_input))
314    }
315    return next(e)
316  })
317
318  on('session.end', async (_$, e, next) => {
319    stopSpeech?.()
320    stopServer?.()
321    return next(e)
322  })
323}
324