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

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.
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.
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.
| Command | What it does | |
|---|---|---|
/speak | Turn read-aloud on or off | |
/speak stop | Silence what is playing now | |
/speak replay | Repeat the last thing spoken | |
/speak selected | Read 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.
hooks/register.tsx 324 lines1import 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