Reads Claude's replies and your prompts aloud on request, with the open-source Kokoro TTS model.

A Claude Code mod (read-aloud) that gives Claude a voice. It puts a small 🔊 read trigger under each of Claude's replies and each of your prompts: press it and that one is read aloud, press it again to stop. Nothing is read unless you ask. The voice is Kokoro-82M, an open-source (Apache-2.0) text-to-speech model that runs locally on the CPU, so nothing you or Claude write leaves the machine to be spoken.
Built on Claude Code's function hooks, which are early access and may change between releases; written against Claude Code 2.1.295. Developed and used on Windows 11. The speech process is plain Python and is written to run on macOS and Linux too, but it has not been run there.
At the prompt of a Claude Code terminal session:
/plugin install read-aloud --marketplace tanujarun/it-speaks
Then install the voice (next section).
The voice model and its Python runtime live outside the mod, in ~/.claude/read-aloud (about 340 MB of downloads). Install them once, either from a session:
/read-aloud setup
or from a shell, with Python 3.10 or newer:
python tts/setup.py
python tts/setup.py check says what is in place. Deleting ~/.claude/read-aloud removes all of it.
/read-aloud update
does three things and says what each came to:
<package> <old> -> <new>.tts/models.json names, downloads one that is missing or different, and removes a file an earlier version of the mod installed and this one no longer uses.claude plugin update, then /reload-plugins.The speech process is stopped for the update and started again after it. If packages fail to install with "access is denied", another Claude Code session is speaking with them: /hush there, or close it, and run the update again. From a shell the same is python tts/setup.py update.
A new model release reaches people as a new version of the mod: models.json names the packages, each file's address and its checksum, so changing the model is an edit to that one file, and /read-aloud update after the mod updates brings an install in line with it.
🔊 read under a reply or a prompt. It turns into ■ stop while that row plays. The trigger is drawn where a click can reach a transcript row: the fullscreen terminal ("tui": "fullscreen") and the desktop app. In a terminal that prints into scrollback it is left out./read-aloud last reads Claude's last reply, on any surface./read-aloud selection reads the text you selected with the mouse./hush stops the speech. So does submitting a prompt or interrupting a turn.reading aloud · /hush stops it, beside the mode labels Claude Code shows there, and loading the voice while the model loads for the first utterance. Nothing is pinned to the status line under the prompt.Code blocks and tables are named ("Code block.") rather than read out.
Where skins is installed and a skin is on, the trigger wears it: ► read with the glyph in the skin's accent and the word in its muted colour, ■ stop in its error colour, and > / x under the skin's ASCII icons. It follows a change of skin as it happens. The built-in skins' colours are kept in hooks/skin-paint.ts; a built-in added to skins after this was written leaves the trigger in its plain look.
The trigger wraps whatever draws the row beneath it. A mod that draws a prompt or a reply itself (a skin) and is listed before this one in enabledPlugins (~/.claude/settings.json) never hands the row down, so no trigger shows there. List read-aloud@... first in enabledPlugins: plugins nest in that order, first outermost. Then start a new session.
| Command | What it does |
|---|---|
/hush | Stops the speech now |
/read-aloud | What is on, and the voices in use |
/read-aloud on / off | Everything on or off |
/read-aloud triggers on / off | The read trigger under each reply and prompt |
/read-aloud last | Claude's last reply |
/read-aloud selection | The text selected with the mouse |
/read-aloud say <text> | Says the text |
/read-aloud voice <name> | The voice for Claude (/read-aloud voices lists them) |
/read-aloud prompt-voice <name> | The voice for your prompts |
/read-aloud speed <0.5-2> | Speaking rate |
/read-aloud volume <0-200> | Percent |
/read-aloud update | Upgrades the voice packages and model; says if the mod is behind |
To have it read without being asked:
| Command | What it does |
|---|---|
/read-aloud replies all / final / off | Everything Claude says as it lands, the final answer alone, or nothing (the default) |
/read-aloud prompts on / off | Read your prompt back as you submit it (off by default) |
/read-aloud limit <characters> | Longest reply read unasked; 0 reads all of it |
Settings are kept across sessions.
hooks/register.tsx starts one small Python process per session (tts/daemon.py) and hands it text through a spool folder of JSON files; the process loads the model at the first utterance and lets it go after ten quiet minutes. Subagents' output and non-interactive runs (claude -p) are never read.
claude plugin test . runs the mod's tests; tts/selftest.py, run with the runtime's Python, says a sentence through the real model.
hooks/register.tsx 809 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import { paintOf } from './skin-paint'
5import type { Paint } from './skin-paint'
6
7import { toSpeech } from './speech-text'
8import type { Saying } from '../types'
9
10// Speech is made by a process of the mod's own (tts/daemon.py: Kokoro-82M, an
11// open-source model, through kokoro-onnx), started once per session and told
12// what to say through a spool folder of JSON files. Its runtime lives outside
13// the mod, under HOME_FOLDER, where tts/setup.py installs it.
14
15type Replies = 'all' | 'final' | 'off'
16
17type Settings = {
18 isOn: boolean
19 /** `all`: each thing Claude says as it lands; `final`: the turn's answer. */
20 replies: Replies
21 /** Whether a prompt is read back as it is submitted. */
22 prompts: boolean
23 /** Whether each reply and prompt in the transcript carries a read trigger. */
24 triggers: boolean
25 voice: string
26 promptVoice: string
27 speed: number
28 /** Percent, 100 the model's own level. */
29 volume: number
30 /** The most characters one reply is read for; 0 for the whole of it. */
31 limit: number
32}
33
34type Daemon = {
35 spool: string
36 sequence: number
37 voices: readonly string[]
38 /** Settles when the speech process has exited. */
39 ended: Promise<void>
40}
41
42type DaemonEvent = { event?: string; id?: unknown; voices?: unknown; message?: unknown }
43
44// Nothing is read unasked: each row carries a trigger, and the person picks.
45const DEFAULTS: Settings = {
46 isOn: true,
47 replies: 'off',
48 prompts: false,
49 triggers: true,
50 voice: 'af_heart',
51 promptVoice: 'am_michael',
52 speed: 1,
53 volume: 100,
54 limit: 2000,
55}
56
57const PROMPT_LIMIT = 600
58const READ = '\u{1F50A} read'
59const STOP = '■ stop'
60
61// What the footer's indicator says while the speech process works.
62const INDICATOR: Readonly<Record<Saying, string | undefined>> = {
63 idle: undefined,
64 loading: 'loading the voice',
65 speaking: 'reading aloud · /hush stops it',
66}
67
68// What the speech process is doing now; the footer's indicator draws from it.
69const saying = atom({ plugin: 'read-aloud', key: 'saying' } as const, 'idle' as Saying)
70
71// One member per transcript row: true on the row whose text is being read.
72const playing = atom({ plugin: 'read-aloud', key: 'playing' } as const, false)
73const SETUP_TIMEOUT_MS = 600_000
74
75const HELP = [
76 '/read-aloud what is on, and the voices in use',
77 '/read-aloud on | off everything on or off',
78 '/read-aloud triggers on | off the read trigger under each reply and prompt',
79 '/read-aloud last the last reply (again: the same)',
80 '/read-aloud selection the text selected with the mouse',
81 '/read-aloud replies all | final | off',
82 ' read unasked: each thing Claude says, or the answer alone',
83 '/read-aloud prompts on | off read your prompt back as you submit it',
84 '/read-aloud voice <name> the voice for Claude (voices: list them)',
85 '/read-aloud prompt-voice <name> the voice for your prompts',
86 '/read-aloud speed <0.5-2> speaking rate',
87 '/read-aloud volume <0-200> percent',
88 '/read-aloud limit <characters> longest reply read unasked; 0 reads all of it',
89 '/read-aloud say <text> says the text',
90 '/read-aloud setup installs the voice model (about 340 MB)',
91 '/read-aloud update upgrades the voice packages and model, and says if the mod is behind',
92 '/hush stops the speech now',
93].join('\n')
94
95const clamp = (value: number, low: number, high: number): number =>
96 Math.min(high, Math.max(low, value))
97
98const readSettings = (stored: unknown): Settings => {
99 const held = (typeof stored === 'object' && stored !== null ? stored : {}) as Partial<Settings>
100 const text = (value: unknown, fallback: string): string =>
101 typeof value === 'string' && value !== '' ? value : fallback
102 const number = (value: unknown, fallback: number): number =>
103 typeof value === 'number' && Number.isFinite(value) ? value : fallback
104
105 return {
106 isOn: typeof held.isOn === 'boolean' ? held.isOn : DEFAULTS.isOn,
107 replies:
108 held.replies === 'all' || held.replies === 'final' || held.replies === 'off'
109 ? held.replies
110 : DEFAULTS.replies,
111 prompts: typeof held.prompts === 'boolean' ? held.prompts : DEFAULTS.prompts,
112 triggers: typeof held.triggers === 'boolean' ? held.triggers : DEFAULTS.triggers,
113 voice: text(held.voice, DEFAULTS.voice),
114 promptVoice: text(held.promptVoice, DEFAULTS.promptVoice),
115 speed: clamp(number(held.speed, DEFAULTS.speed), 0.5, 2),
116 volume: clamp(number(held.volume, DEFAULTS.volume), 0, 200),
117 limit: Math.max(0, Math.round(number(held.limit, DEFAULTS.limit))),
118 }
119}
120
121const squeeze = (text: string): string => text.replace(/\s+/g, ' ').trim()
122
123let settings = DEFAULTS
124let isInteractive = false
125let daemon: Daemon | undefined
126let starting: Promise<Daemon | undefined> | undefined
127let hasToldMissing = false
128let spokenThisTurn: string[] = []
129let lastReply = ''
130let utterance = 0
131// The row a trigger asked for, and the utterance that reads it: `awaited`
132// until the speech process says it speaks, so the idle a stop reports just
133// before it is not taken for this one's end.
134let playingRow: string | undefined
135let awaited: string | undefined
136
137const setSaying = ($: EngineInterface, now: Saying): void => {
138 void update($, saying, () => now).catch(() => {})
139}
140
141const setPlaying = async ($: EngineInterface, row: string | undefined): Promise<void> => {
142 const before = playingRow
143 playingRow = row
144 if (before !== undefined && before !== row) {
145 await update($, memberOf(playing, { requestId: before }), () => false)
146 }
147 if (row !== undefined) {
148 await update($, memberOf(playing, { requestId: row }), () => true)
149 }
150}
151
152const loadSettings = async ($: EngineInterface): Promise<Settings> => {
153 settings = readSettings(await $.store.get('settings'))
154
155 return settings
156}
157
158const saveSettings = async ($: EngineInterface, change: Partial<Settings>): Promise<void> => {
159 settings = readSettings({ ...(await loadSettings($)), ...change })
160 await $.store.set('settings', settings)
161}
162
163const homeFolder = async ($: EngineInterface): Promise<string> => {
164 const own = await $.env.get('READ_ALOUD_HOME')
165 const user = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
166
167 return (own ?? `${user}/.claude/read-aloud`).replace(/\\/g, '/')
168}
169
170/** The runtime's Python, or undefined while tts/setup.py has not run. */
171const findPython = async ($: EngineInterface, home: string): Promise<string | undefined> => {
172 // setup.py records a finished install; one from before it did has the model.
173 const isInstalled =
174 (await $.fs.exists(`${home}/installed.json`)) || (await $.fs.exists(`${home}/models/kokoro-v1.0.onnx`))
175 if (!isInstalled) {
176 return undefined
177 }
178 for (const python of [`${home}/venv/Scripts/python.exe`, `${home}/venv/bin/python`]) {
179 if (await $.fs.exists(python)) {
180 return python
181 }
182 }
183
184 return undefined
185}
186
187const onDaemonEvent = ($: EngineInterface, mine: Daemon, line: string): void => {
188 let said: DaemonEvent
189 try {
190 said = JSON.parse(line) as DaemonEvent
191 } catch {
192 return
193 }
194
195 if (said.event === 'ready' && Array.isArray(said.voices)) {
196 mine.voices = said.voices.filter((voice): voice is string => typeof voice === 'string')
197 } else if (said.event === 'loading') {
198 setSaying($, 'loading')
199 } else if (said.event === 'speaking') {
200 setSaying($, 'speaking')
201 if (said.id === awaited) {
202 awaited = undefined
203 }
204 } else if (said.event === 'idle') {
205 setSaying($, 'idle')
206 if (awaited === undefined) {
207 void setPlaying($, undefined)
208 }
209 } else if (said.event === 'error') {
210 $.ui.log(`read-aloud: ${String(said.message)}`, { to: 'debug' })
211 if (said.id === awaited) {
212 awaited = undefined
213 }
214 }
215}
216
217/** Reads the daemon's lines for as long as it lives; the loop is its life. */
218const follow = async ($: EngineInterface, mine: Daemon, argv: readonly string[]): Promise<void> => {
219 let held = ''
220 try {
221 for await (const { stream, text } of $.process.spawn({ argv })) {
222 if (stream === 'stderr') {
223 $.ui.log(`read-aloud: ${text.trim()}`, { to: 'debug' })
224 continue
225 }
226 held += text
227 const lines = held.split('\n')
228 held = lines.pop() ?? ''
229 for (const line of lines) {
230 onDaemonEvent($, mine, line)
231 }
232 }
233 } catch (error) {
234 $.ui.log(`read-aloud: the speech process did not run: ${String(error)}`, { to: 'debug' })
235 } finally {
236 if (daemon === mine) {
237 daemon = undefined
238 setSaying($, 'idle')
239 }
240 }
241}
242
243const start = async ($: EngineInterface): Promise<Daemon | undefined> => {
244 const home = await homeFolder($)
245 const python = await findPython($, home)
246
247 if (python === undefined) {
248 if (!hasToldMissing) {
249 hasToldMissing = true
250 $.ui.toast('read-aloud: no voice model yet. Run /read-aloud setup', { timeoutMs: 10_000 })
251 }
252
253 return undefined
254 }
255
256 const name = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
257 const mine: Daemon = { spool: `${home}/spool/${name}`, sequence: 0, voices: [], ended: Promise.resolve() }
258 const script = `${$.plugin.root.replace(/\\/g, '/')}/tts/daemon.py`
259
260 daemon = mine
261 mine.ended = follow($, mine, [python, '-B', script, '--spool', mine.spool, '--models', `${home}/models`])
262
263 return mine
264}
265
266const running = async ($: EngineInterface): Promise<Daemon | undefined> => {
267 if (daemon !== undefined) {
268 return daemon
269 }
270 starting ??= start($).finally(() => {
271 starting = undefined
272 })
273
274 return starting
275}
276
277const send = async ($: EngineInterface, command: Record<string, unknown>): Promise<boolean> => {
278 const to = await running($)
279 if (to === undefined) {
280 return false
281 }
282 to.sequence += 1
283 const file = `${to.spool}/${String(to.sequence).padStart(6, '0')}.json`
284 await $.fs.write(file, JSON.stringify(command))
285
286 return true
287}
288
289/** Queues `text` to be spoken; answers the utterance's id, or undefined. */
290const say = async ($: EngineInterface, text: string, voice: string): Promise<string | undefined> => {
291 if (text === '') {
292 return undefined
293 }
294 utterance += 1
295 const id = `u${utterance}`
296 const isSent = await send($, {
297 op: 'speak',
298 id,
299 text,
300 voice,
301 speed: settings.speed,
302 gain: settings.volume / 100,
303 })
304
305 return isSent ? id : undefined
306}
307
308/** Silences the speech, when a process is there to silence. */
309const hush = async ($: EngineInterface): Promise<void> => {
310 awaited = undefined
311 if (daemon !== undefined) {
312 await send($, { op: 'stop' })
313 }
314 await setPlaying($, undefined)
315}
316
317type Who = 'voice' | 'promptVoice'
318
319/** A press on a row's trigger: reads that row whole, or stops it if it plays. */
320const toggle = async ($: EngineInterface, row: string, markdown: string, who: Who): Promise<void> => {
321 try {
322 const wasPlaying = playingRow === row
323 await loadSettings($)
324 await hush($)
325 if (wasPlaying) {
326 return
327 }
328 const id = await say($, toSpeech(markdown), settings[who])
329 if (id === undefined) {
330 return
331 }
332 awaited = id
333 await setPlaying($, row)
334 } catch (error) {
335 $.ui.log(`read-aloud: ${String(error)}`, { to: 'debug' })
336 }
337}
338
339// The skins mod's state, read as any plugin may read another's. Read while a
340// row is drawn, it draws the row again when the person changes their skin.
341// Its contract is not this mod's to import, so each value is read as unknown.
342type Held = { value: unknown }
343
344const skinPaint = async ($: EngineInterface): Promise<Paint | undefined> => {
345 try {
346 const prefs: Held = await $.state.get({ plugin: 'skins', key: 'prefs' } as never)
347 const custom: Held = await $.state.get({ plugin: 'skins', key: 'custom' } as never)
348 const isLight: Held = await $.state.get({ plugin: 'skins', key: 'isLight' } as never)
349
350 return paintOf(prefs.value, custom.value, isLight.value)
351 } catch {
352 return undefined
353 }
354}
355
356type Ui = Pick<ElementTable, 'Box' | 'Button' | 'Text'>
357
358/** The row as drawn beneath, and under it the trigger, in the skin's colours. */
359const withTrigger = (
360 { Box, Button, Text }: Ui,
361 drawn: RenderElement,
362 isPlaying: boolean,
363 paint: Paint | undefined,
364 onPress: () => void,
365): RenderElement => (
366 <Box flexDirection="column">
367 {drawn}
368 <Box paddingLeft={2}>
369 {paint === undefined ? (
370 <Button key="read-aloud" plain dimColor label={isPlaying ? STOP : READ} onPress={onPress} />
371 ) : (
372 <Button key="read-aloud" plain label={isPlaying ? 'stop' : 'read'} onPress={onPress}>
373 <Text color={isPlaying ? paint.stop : paint.accent}>
374 {isPlaying ? (paint.isAscii ? 'x' : '■') : paint.isAscii ? '>' : '►'}
375 </Text>
376 <Text color={paint.muted}>{isPlaying ? ' stop' : ' read'}</Text>
377 </Button>
378 )}
379 </Box>
380 </Box>
381)
382
383/** Whether a press can reach a transcript row: not in a terminal's scrollback. */
384const canPress = (surface: string, isFullscreen: boolean | undefined): boolean =>
385 surface !== 'terminal' || isFullscreen !== false
386
387const sayReply = async ($: EngineInterface, markdown: string): Promise<void> => {
388 spokenThisTurn.push(squeeze(markdown))
389 await say($, toSpeech(markdown, settings.limit), settings.voice)
390}
391
392// A hook here never fails the event it rides on: speech is an extra.
393const quietly = async ($: EngineInterface, work: () => Promise<void>): Promise<void> => {
394 try {
395 await work()
396 } catch (error) {
397 $.ui.log(`read-aloud: ${String(error)}`, { to: 'debug' })
398 }
399}
400
401const describe = async ($: EngineInterface): Promise<string> => {
402 const home = await homeFolder($)
403 const isInstalled = (await findPython($, home)) !== undefined
404 const replies = { all: 'everything Claude says', final: 'the final answer', off: 'off' }
405
406 return [
407 `read-aloud is ${settings.isOn ? 'on' : 'off'}.`,
408 ` triggers: ${settings.triggers ? 'a read trigger under each reply and prompt' : 'off'}`,
409 ` read unasked, replies: ${replies[settings.replies]} (voice ${settings.voice})`,
410 ` read unasked, prompts: ${settings.prompts ? 'as you submit them' : 'off'} (voice ${settings.promptVoice})`,
411 ` speed ${settings.speed}, volume ${settings.volume}%, limit ${settings.limit || 'none'}`,
412 isInstalled
413 ? ` voice model: Kokoro-82M in ${home}`
414 : ' voice model: not installed. Run /read-aloud setup',
415 ' /read-aloud help lists the commands; /hush stops the speech.',
416 ].join('\n')
417}
418
419/** Ends the speech process and waits for it, so no file of its is in use. */
420const stopDaemon = async ($: EngineInterface): Promise<void> => {
421 const mine = daemon
422 if (mine === undefined) {
423 return
424 }
425 awaited = undefined
426 await send($, { op: 'quit' })
427 // Three seconds at most; where no clock answers, its exit alone ends the wait.
428 const patience = $.clock.sleep(3000).catch(() => new Promise<void>(() => {}))
429 await Promise.race([mine.ended, patience])
430 await setPlaying($, undefined)
431}
432
433type SetupRun = { isDone: boolean; report: string }
434
435/** Runs tts/setup.py with the machine's Python: `all` installs, `update` the same on an install. */
436const runSetup = async ($: EngineInterface, step: 'all' | 'update'): Promise<SetupRun> => {
437 const script = `${$.plugin.root.replace(/\\/g, '/')}/tts/setup.py`
438 const home = await homeFolder($)
439
440 await stopDaemon($)
441 for (const python of ['python', 'py', 'python3']) {
442 const ran = await $.process
443 .run([python, '-B', script, step], {
444 timeoutMs: SETUP_TIMEOUT_MS,
445 env: { READ_ALOUD_HOME: home },
446 })
447 .catch(() => undefined)
448 if (ran === undefined) {
449 continue
450 }
451 // Every line but a download's progress, which only its last one is worth.
452 const lines = `${ran.stdout}\n${ran.stderr}`.split('\n').map(line => line.trim())
453 const report = lines.filter(line => line !== '' && !/ \d+ MB( of \d+)?$/.test(line)).slice(-10).join('\n')
454
455 hasToldMissing = false
456 await running($)
457
458 return { isDone: ran.exitCode === 0, report }
459 }
460
461 return { isDone: false, report: 'No Python found. Install Python 3.10 or newer and run it again.' }
462}
463
464const setUp = async ($: EngineInterface): Promise<string> => {
465 const { isDone, report } = await runSetup($, 'all')
466
467 return `read-aloud: ${isDone ? 'the voice is installed.' : 'setup failed.'}\n${report}`
468}
469
470const isNewer = (theirs: string, ours: string): boolean => {
471 const [a, b] = [theirs, ours].map(version => version.split('.').map(part => Number.parseInt(part, 10) || 0))
472 for (let at = 0; at < 3; at += 1) {
473 const difference = (a?.[at] ?? 0) - (b?.[at] ?? 0)
474 if (difference !== 0) {
475 return difference > 0
476 }
477 }
478
479 return false
480}
481
482/**
483 * One line on the mod itself: its version here against the one its GitHub
484 * repository (the manifest's `homepage`) publishes. Empty when it cannot tell.
485 */
486const modNews = async ($: EngineInterface): Promise<string> => {
487 try {
488 const own = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as Record<string, unknown>
489 const home = typeof own.homepage === 'string' ? own.homepage : ''
490 if (typeof own.version !== 'string' || !home.startsWith('https://github.com/')) {
491 return ''
492 }
493 const raw = home.replace('https://github.com/', 'https://raw.githubusercontent.com/').replace(/\/$/, '')
494 const published = await $.http.fetch(`${raw}/HEAD/.claude-plugin/plugin.json`)
495 const theirs = published.ok ? (JSON.parse(published.text) as Record<string, unknown>).version : undefined
496 if (typeof theirs !== 'string') {
497 return ''
498 }
499
500 return isNewer(theirs, own.version)
501 ? `mod: version ${theirs} is out and this is ${own.version}. Update it with: claude plugin update, then /reload-plugins`
502 : `mod: version ${own.version} is the newest published.`
503 } catch {
504 return ''
505 }
506}
507
508const updateRuntime = async ($: EngineInterface): Promise<string> => {
509 const home = await homeFolder($)
510 if ((await findPython($, home)) === undefined) {
511 return 'read-aloud: the voice is not installed yet. Run /read-aloud setup'
512 }
513 const { isDone, report } = await runSetup($, 'update')
514 const news = await modNews($)
515
516 return [`read-aloud: ${isDone ? 'the voice is up to date.' : 'the update failed.'}`, report, news]
517 .filter(part => part !== '')
518 .join('\n')
519}
520
521const setVoice = async ($: EngineInterface, key: 'voice' | 'promptVoice', name: string): Promise<string> => {
522 const voices = (await running($))?.voices ?? []
523
524 if (name === '') {
525 return `Name a voice. ${voices.length > 0 ? `Voices: ${voices.join(', ')}` : ''}`.trim()
526 }
527 if (voices.length > 0 && !voices.includes(name)) {
528 return `No voice named ${name}. Voices: ${voices.join(', ')}`
529 }
530 await saveSettings($, { [key]: name })
531 await hush($)
532 await say($, `This is the voice ${name.replace(/^[a-z]{2}_/, '')}.`, name)
533
534 return `${key === 'voice' ? "Claude's" : 'Your prompt'} voice is now ${name}.`
535}
536
537const runCommand = async ($: EngineInterface, args: string): Promise<string> => {
538 const [verb = '', ...rest] = args.trim().split(/\s+/)
539 const value = rest.join(' ')
540 const isYes = value === 'on' || value === ''
541
542 await loadSettings($)
543
544 switch (verb) {
545 case '':
546 case 'status':
547 return describe($)
548 case 'help':
549 return HELP
550 case 'on':
551 await saveSettings($, { isOn: true })
552 await running($)
553 $.ui.invalidate('ui.render')
554
555 return describe($)
556 case 'off':
557 await saveSettings($, { isOn: false })
558 await hush($)
559 $.ui.invalidate('ui.render')
560
561 return 'read-aloud is off.'
562 case 'replies': {
563 if (value !== 'all' && value !== 'final' && value !== 'off') {
564 return 'Say which: /read-aloud replies all | final | off'
565 }
566 await saveSettings($, { replies: value })
567
568 return describe($)
569 }
570 case 'prompts':
571 if (value !== 'on' && value !== 'off' && value !== '') {
572 return 'Say which: /read-aloud prompts on | off'
573 }
574 await saveSettings($, { prompts: isYes })
575
576 return isYes ? 'Your prompts are read back to you.' : 'Your prompts are no longer read back.'
577 case 'triggers':
578 if (value !== 'on' && value !== 'off' && value !== '') {
579 return 'Say which: /read-aloud triggers on | off'
580 }
581 await saveSettings($, { triggers: isYes })
582 $.ui.invalidate('ui.render')
583
584 return isYes ? 'Each reply and prompt carries a read trigger.' : 'The read triggers are hidden.'
585 case 'voice':
586 return setVoice($, 'voice', value)
587 case 'prompt-voice':
588 return setVoice($, 'promptVoice', value)
589 case 'voices': {
590 const voices = (await running($))?.voices ?? []
591
592 return voices.length > 0
593 ? `Voices (a: American, b: British; f: female, m: male):\n${voices.join(', ')}`
594 : 'The voices are listed once the speech process has started. Try again in a moment.'
595 }
596 case 'speed': {
597 const speed = Number(value)
598 if (!Number.isFinite(speed) || speed < 0.5 || speed > 2) {
599 return 'Give a rate from 0.5 to 2: /read-aloud speed 1.2'
600 }
601 await saveSettings($, { speed })
602
603 return `Speed is ${speed}.`
604 }
605 case 'volume': {
606 const volume = Number(value.replace('%', ''))
607 if (!Number.isFinite(volume) || volume < 0 || volume > 200) {
608 return 'Give a percent from 0 to 200: /read-aloud volume 80'
609 }
610 await saveSettings($, { volume })
611
612 return `Volume is ${volume}%.`
613 }
614 case 'limit': {
615 const limit = Number(value)
616 if (!Number.isInteger(limit) || limit < 0) {
617 return 'Give a number of characters, 0 for no limit: /read-aloud limit 2000'
618 }
619 await saveSettings($, { limit })
620
621 return limit === 0 ? 'Replies are read whole.' : `Replies are read up to ${limit} characters.`
622 }
623 case 'last':
624 case 'again':
625 if (lastReply === '') {
626 return 'Claude has said nothing yet in this session.'
627 }
628 await hush($)
629
630 return (await say($, toSpeech(lastReply), settings.voice)) !== undefined
631 ? 'Reading the last reply.'
632 : 'The voice is not installed. Run /read-aloud setup'
633 case 'selection': {
634 const selected = await $.ui.selection()
635 if (selected === undefined || selected.text.trim() === '') {
636 return 'Nothing is selected. Select text with the mouse, then run /read-aloud selection'
637 }
638 await hush($)
639
640 return (await say($, toSpeech(selected.text), settings.voice)) !== undefined
641 ? 'Reading the selection.'
642 : 'The voice is not installed. Run /read-aloud setup'
643 }
644 case 'say':
645 return (await say($, toSpeech(value), settings.voice)) !== undefined
646 ? 'Saying it.'
647 : 'Nothing to say, or the voice is not installed (/read-aloud setup).'
648 case 'setup':
649 return setUp($)
650 case 'update':
651 return updateRuntime($)
652 default:
653 return `No such setting: ${verb}\n${HELP}`
654 }
655}
656
657export const register: Register = on => {
658 on('session.start', async ($, e, next) => {
659 isInteractive = e.isInteractive
660 await $.command.register({
661 name: 'read-aloud',
662 description: 'Read Claude aloud: triggers, last, selection, voice, speed (bare: status)',
663 argumentHint: '[on|off|triggers|last|selection|replies|prompts|voice|speed|volume|say|setup|update|help]',
664 immediate: true,
665 })
666 await $.command.register({
667 name: 'hush',
668 description: 'Stop the speech that is playing',
669 immediate: true,
670 })
671 await quietly($, async () => {
672 await loadSettings($)
673 if (isInteractive && settings.isOn) {
674 await running($)
675 }
676 })
677
678 return next(e)
679 })
680
681 on('prompt.submit', async ($, e, next) => {
682 const isTyped = e.origin.kind === 'composer' && !e.text.trimStart().startsWith('/')
683
684 if (isInteractive && isTyped) {
685 await quietly($, async () => {
686 await loadSettings($)
687 // A new prompt makes whatever was being read stale.
688 await hush($)
689 if (settings.isOn && settings.prompts) {
690 await say($, toSpeech(e.text, PROMPT_LIMIT), settings.promptVoice)
691 }
692 })
693 }
694
695 return next(e)
696 })
697
698 on('turn.start', ($, e, next) => {
699 spokenThisTurn = []
700
701 return next(e)
702 })
703
704 on('session.append', { door: 'response' }, async ($, e, next) => {
705 const stored = await next(e)
706 const isMainReply = e.agentId === undefined && e.message.type === 'assistant'
707
708 if (isInteractive && isMainReply) {
709 await quietly($, async () => {
710 for (const block of e.message.content) {
711 if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
712 lastReply = block.text
713 if (settings.isOn && settings.replies === 'all') {
714 await sayReply($, block.text)
715 }
716 }
717 }
718 })
719 }
720
721 return stored
722 })
723
724 on('turn.complete', async ($, e, next) => {
725 if (isInteractive && e.agentId === undefined) {
726 await quietly($, async () => {
727 const answer = squeeze(e.answer)
728 const isSaid = spokenThisTurn.some(said => said.includes(answer) || answer.includes(said))
729
730 if (answer !== '') {
731 lastReply = e.answer
732 }
733 if (e.isAborted) {
734 await hush($)
735 } else if (settings.isOn && settings.replies !== 'off' && answer !== '' && !isSaid) {
736 await sayReply($, e.answer)
737 }
738 })
739 }
740
741 return next(e)
742 })
743
744 // The trigger: the row as it is drawn beneath, and a small button under it
745 // that reads that row, or stops it while it is the one being read.
746 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
747 const drawn = await next(e)
748 const isShown = settings.isOn && settings.triggers && canPress(e.surface, e.viewport?.isFullscreen)
749
750 if (!isShown || toSpeech(e.props.text) === '') {
751 return drawn
752 }
753
754 const isPlaying = await read($, memberOf(playing, e))
755 const row = e.requestId
756 const text = e.props.text
757
758 return withTrigger($.ui.resolve(e), drawn, isPlaying, await skinPaint($), () => {
759 void toggle($, row, text, 'voice')
760 })
761 })
762
763 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
764 const drawn = await next(e)
765 const isTyped = e.props.origin.kind === 'composer' && !e.props.text.trimStart().startsWith('/')
766 const isShown = settings.isOn && settings.triggers && canPress(e.surface, e.viewport?.isFullscreen)
767
768 if (!isShown || !isTyped || toSpeech(e.props.text) === '') {
769 return drawn
770 }
771
772 const isPlaying = await read($, memberOf(playing, e))
773 const row = e.requestId
774 const text = e.props.text
775
776 return withTrigger($.ui.resolve(e), drawn, isPlaying, await skinPaint($), () => {
777 void toggle($, row, text, 'promptVoice')
778 })
779 })
780
781 // The indicator: one more of the dim labels at the right of the prompt's
782 // footer, the bottom right corner, for as long as a row is being read.
783 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
784 const label = INDICATOR[await read($, saying)]
785
786 return label === undefined
787 ? next(e)
788 : next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
789 })
790
791 on('command.run', { command: 'read-aloud' }, async ($, e) => {
792 try {
793 return { text: await runCommand($, e.args) }
794 } catch (error) {
795 return { text: `read-aloud: ${String(error)}` }
796 }
797 })
798
799 on('command.run', { command: 'hush' }, async $ => {
800 try {
801 await hush($)
802 } catch (error) {
803 return { text: `read-aloud: ${String(error)}` }
804 }
805
806 return { text: 'Hushed.' }
807 })
808}
809hooks/skin-paint.ts 98 lines1// The trigger wears the active skin of the skins mod
2// (github.com/hellosverre/claude-skins) when that mod is installed: its accent
3// on the glyph, its muted colour on the word, its error colour on stop. The
4// skin is read from that mod's own state (`prefs`, `custom`, `isLight`), which
5// names a skin but holds only the colours of the ones a person made, so the
6// three slots of its built-in skins are kept here. A skin this file does not
7// know leaves the trigger as it is without a skin.
8
9export type Paint = {
10 accent: string
11 muted: string
12 stop: string
13 /** The skin's icon set: glyphs every font has, when it is `ascii`. */
14 isAscii: boolean
15}
16
17type Slots = { user: string; muted: string; err: string }
18
19const BUILT_IN: Readonly<Record<string, Slots>> = {
20 noir: { user: '#f5f5f5', muted: '#8c8c8c', err: '#f87171' },
21 'tokyo-night': { user: '#7aa2f7', muted: '#737aa2', err: '#f7768e' },
22 dracula: { user: '#bd93f9', muted: '#6272a4', err: '#ff5555' },
23 nord: { user: '#88c0d0', muted: '#7b88a1', err: '#bf616a' },
24 gruvbox: { user: '#fabd2f', muted: '#928374', err: '#fb4934' },
25 catppuccin: { user: '#cba6f7', muted: '#7f849c', err: '#f38ba8' },
26 mono: { user: '#e0e0e0', muted: '#7a7a7a', err: '#ffffff' },
27}
28
29// The one built-in that carries its own palette for a light background.
30const LIGHT: Readonly<Record<string, Slots>> = {
31 noir: { user: '#111111', muted: '#6b6b6b', err: '#c42b2b' },
32}
33
34const HEX = /^#[0-9a-f]{6}$/i
35
36const isRecord = (value: unknown): value is Record<string, unknown> =>
37 typeof value === 'object' && value !== null
38
39/** `amount` of the way from `hex` to black, as the skins mod deepens a colour. */
40const deepen = (hex: string, amount: number): string =>
41 `#${[1, 3, 5]
42 .map(at => Math.round(parseInt(hex.slice(at, at + 2), 16) * (1 - amount)))
43 .map(value => Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0'))
44 .join('')}`
45
46// For a light background the skins mod derives a palette: colours deepened
47// until they read on white, secondary text a fixed grey.
48const toLight = (slots: Slots): Slots => ({
49 user: deepen(slots.user, 0.45),
50 muted: '#6b6b6b',
51 err: deepen(slots.err, 0.25),
52})
53
54const slotsOf = (name: string, custom: unknown): Slots | undefined => {
55 const own = BUILT_IN[name]
56 if (own !== undefined) {
57 return own
58 }
59
60 const made = isRecord(custom) ? custom[name] : undefined
61 if (!isRecord(made)) {
62 return undefined
63 }
64
65 // A made skin is its base with the slots it changed laid over it.
66 const base = (typeof made.base === 'string' ? BUILT_IN[made.base] : undefined) ?? BUILT_IN.noir
67 const palette = isRecord(made.palette) ? made.palette : {}
68 const slot = (key: keyof Slots, fallback: string): string => {
69 const value = palette[key]
70
71 return typeof value === 'string' && HEX.test(value) ? value : fallback
72 }
73
74 return base === undefined
75 ? undefined
76 : { user: slot('user', base.user), muted: slot('muted', base.muted), err: slot('err', base.err) }
77}
78
79/**
80 * What the trigger wears, from the skins mod's `prefs`, `custom` and `isLight`
81 * as its state holds them; undefined with no skins mod, its skin off, or a
82 * skin unknown here.
83 */
84export const paintOf = (prefs: unknown, custom: unknown, isLight: unknown): Paint | undefined => {
85 if (!isRecord(prefs) || typeof prefs.skin !== 'string' || prefs.skin === 'off') {
86 return undefined
87 }
88
89 const dark = slotsOf(prefs.skin, custom)
90 if (dark === undefined) {
91 return undefined
92 }
93
94 const slots = isLight === true ? (LIGHT[prefs.skin] ?? toLight(dark)) : dark
95
96 return { accent: slots.user, muted: slots.muted, stop: slots.err, isAscii: prefs.icons === 'ascii' }
97}
98hooks/speech-text.ts 111 lines1// Markdown as Claude writes it, turned into what is worth hearing: the prose
2// kept, the marks dropped, and what cannot be read aloud (code, tables, links)
3// named in a word.
4
5const TERMINAL = /[.!?:;,…]$/
6
7const basename = (path: string): string => {
8 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
9
10 return cut < 0 ? path : path.slice(cut + 1)
11}
12
13// A path of two folders or more is its file's name; `name.ts:42` is a line.
14const sayPaths = (text: string): string =>
15 text
16 .replace(/(?:[A-Za-z]:)?(?:[\\/][\w.@~-]+){2,}[\\/]?|(?:[\w.@~-]+[\\/]){2,}[\w.@~-]*/g, path =>
17 basename(path.replace(/[\\/]$/, '')),
18 )
19 .replace(/(\.[A-Za-z]{1,5}):(\d+)(?::\d+)?\b/g, '$1 line $2')
20
21// A heading or a list item is a sentence of its own: its mark goes, and
22// `closed` gives it the stop that makes the voice pause.
23const isOwnSentence = (line: string): boolean =>
24 /^\s{0,3}#{1,6}\s/.test(line) || /^\s*(?:[-*+]|\d+[.)])\s+/.test(line)
25
26const unmarked = (line: string): string =>
27 line
28 .replace(/^\s{0,3}#{1,6}\s+/, '')
29 .replace(/^\s*>+\s?/, '')
30 .replace(/^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/, '')
31
32const closed = (said: string): string => (said !== '' && !TERMINAL.test(said) ? `${said}.` : said)
33
34const sayInline = (text: string): string =>
35 sayPaths(
36 text
37 .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
38 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
39 .replace(/<https?:\/\/[^>\s]+>|https?:\/\/[^\s)>\]]*[^\s)>\].,;:!?]/g, 'link')
40 .replace(/<\/?[A-Za-z][^>]*>/g, ' ')
41 .replace(/`([^`]*)`/g, '$1'),
42 )
43 .replace(/\*+|~~/g, '')
44 .replace(/(^|[^\w])_{1,2}([^_\n]+?)_{1,2}(?=[^\w]|$)/g, '$1$2')
45 .replace(/\s*(?:->|=>|→|⇒)\s*/g, ' to ')
46 .replace(/[\p{Extended_Pictographic}️]/gu, '')
47
48/** Cuts at the last sentence that fits, and says that more is on screen. */
49const hold = (text: string, limit: number): string => {
50 if (limit <= 0 || text.length <= limit) {
51 return text
52 }
53
54 const head = text.slice(0, limit)
55 const end = Math.max(
56 head.lastIndexOf('. '),
57 head.lastIndexOf('.\n'),
58 head.lastIndexOf('? '),
59 head.lastIndexOf('! '),
60 head.lastIndexOf('\n'),
61 )
62 const kept = end > limit / 2 ? head.slice(0, end + 1) : head.slice(0, head.lastIndexOf(' '))
63
64 return `${kept.trim()}\nThe rest is on screen.`
65}
66
67/**
68 * What to say for `markdown`; `""` when nothing of it is prose. `limit` holds
69 * the spoken text to that many characters (0: no limit).
70 */
71export const toSpeech = (markdown: string, limit = 0): string => {
72 const lines: string[] = []
73 let isInFence = false
74 let isInTable = false
75
76 for (const line of markdown.replace(/\r\n?/g, '\n').split('\n')) {
77 if (/^\s*(?:```|~~~)/.test(line)) {
78 if (!isInFence) {
79 lines.push('Code block.')
80 }
81 isInFence = !isInFence
82 continue
83 }
84 if (isInFence) {
85 continue
86 }
87
88 const isTableRow = /^\s*\|.*\|\s*$/.test(line)
89 if (isTableRow) {
90 if (!isInTable) {
91 lines.push('Table.')
92 }
93 isInTable = true
94 continue
95 }
96 isInTable = false
97
98 if (/^\s*(?:[-*_]\s*){3,}$/.test(line)) {
99 continue
100 }
101
102 const prose = sayInline(unmarked(line)).replace(/[ \t]+/g, ' ').trim()
103 const said = isOwnSentence(line) ? closed(prose) : prose
104 if (said !== '' && /[\p{L}\p{N}]/u.test(said)) {
105 lines.push(said)
106 }
107 }
108
109 return hold(lines.join('\n'), limit)
110}
111types/index.d.ts 12 lines1/** Whether a transcript row's text is the one being read aloud now. */
2export type IsPlaying = boolean
3
4/** What the speech process is doing, as the footer's indicator says it. */
5export type Saying = 'idle' | 'loading' | 'speaking'
6
7declare module 'claude-code' {
8 interface PluginState {
9 'read-aloud': { playing: StateFamily<IsPlaying>; saying: Saying }
10 }
11}
12