Reads Claude's whole reply aloud with Kokoro; band above the prompt has Pause, Replay and a speed bar

Easier replies for a busy brain: the mod reads Claude's whole reply out loud, in a natural voice. A band above the prompt has Pause/Resume, Replay and a speed bar (0.5x to 3x). Code blocks, links and markdown marks are skipped, not read.
claude plugin marketplace add DevMits/claude-talk
claude plugin install talk@claude-talk
To try it for one session: claude --plugin-dir ~/claude-talk (add -r to resume an old conversation).
Kokoro needs Python 3.12, so it gets its own private environment. uv makes it:
mise x uv@latest -- uv venv --python 3.12 ~/.local/share/kokoro/.venv
mise x uv@latest -- uv pip install --python ~/.local/share/kokoro/.venv/bin/python kokoro-onnx soundfile
cd ~/.local/share/kokoro
B=https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0
curl -LO $B/kokoro-v1.0.onnx && curl -LO $B/voices-v1.0.bin
cp <this folder>/kokoro/say.py ~/.local/share/kokoro/say.py
The mod prepares the next sentences while one plays. If Kokoro fails, it falls back to espeak-ng (robotic). Change the voice with VOICE at the top of hooks/register.tsx (default af_heart).
/talk shows whether reading aloud is on./talk off stops reading and silences future replies. /talk on turns it back on.The setting is saved, so it carries across sessions.
hooks/register.tsx 279 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pos, Status } from '../types'
5
6// Kokoro runs in its own Python 3.12 venv; say.py (see kokoro/say.py) writes one wav per chunk.
7const KOKORO_DIR = `${process.env.HOME}/.local/share/kokoro`
8const KOKORO_PY = `${KOKORO_DIR}/.venv/bin/python`
9const KOKORO_SAY = `${KOKORO_DIR}/say.py`
10const VOICE = 'af_heart'
11const WAIT_MS = 90_000
12
13type Proc = { run: (argv: string[], opts?: any) => Promise<any>; spawn: (opts: any) => any }
14export const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]
15const MAX_CHUNK = 240
16
17const statusAtom = atom({ plugin: 'talk', key: 'status' } as const, 'idle' as Status)
18const speedAtom = atom({ plugin: 'talk', key: 'speed' } as const, 1)
19const posAtom = atom({ plugin: 'talk', key: 'pos' } as const, { i: 0, n: 0 } as Pos)
20
21// Reply text to speakable chunks: no code blocks, links or markdown marks.
22export const toChunks = (answer: string): string[] => {
23 const text = answer
24 .replace(/```[\s\S]*?```/g, '\n')
25 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
26 .replace(/https?:\/\/\S+/g, '')
27 .replace(/^\s*\|.*\|\s*$/gm, '')
28 .replace(/^\s*[-*+]\s+/gm, '')
29 .replace(/^\s*#+\s*/gm, '')
30 .replace(/[`*_>~]/g, '')
31 const sentences = text
32 .split(/\n+|(?<=[.!?:])\s+/)
33 .map(s => s.replace(/\s+/g, ' ').trim())
34 .filter(s => /[A-Za-z0-9]/.test(s))
35 const chunks: string[] = []
36 for (const s of sentences) {
37 const last = chunks[chunks.length - 1]
38 if (last !== undefined && last.length + s.length < MAX_CHUNK) chunks[chunks.length - 1] = `${last} ${s}`
39 else chunks.push(s)
40 }
41 return chunks
42}
43
44// On/off lives in $.store, so it carries across sessions. Default: on.
45const isOn = async ($: EngineInterface): Promise<boolean> => (await $.store.get('muted')) !== true
46
47export const register: Register = on => {
48 let chunks: string[] = []
49 let idx = 0
50 let gen = 0
51 let speed = 1
52 let isPlaying = false
53 let kills = 0
54 let wake: (() => void) | null = null
55 let current: AsyncGenerator<unknown> | null = null
56 let worker: AsyncGenerator<unknown> | null = null
57 let workerDone = true
58 let wGen = -1
59 let wSpeed = 0
60 let wFrom = 0
61 let dir = ''
62
63 const poke = () => {
64 const w = wake
65 wake = null
66 w?.()
67 }
68 const killCurrent = async () => {
69 kills += 1
70 const c = current
71 current = null
72 await c?.return(undefined).catch(() => {})
73 }
74 const killWorker = async () => {
75 const w = worker
76 worker = null
77 await w?.return(undefined).catch(() => {})
78 }
79 // The mod sandbox has no file access of its own, so files go through small shell commands.
80 const exists = async (proc: Proc, f: string) => (await proc.run(['test', '-f', f]).catch(() => null))?.exitCode === 0
81 // Makes sure a Kokoro worker is preparing chunks from `from` at `at` speed; restarts it if not.
82 const ensureWorker = async (proc: Proc, my: number, from: number, at: number) => {
83 const reuse = worker && !workerDone && wGen === my && wSpeed === at && wFrom <= from
84 if (reuse) return
85 if (wGen === my && wSpeed === at && wFrom <= from && (await exists(proc, `${dir}/${from}.wav`))) return
86 await killWorker()
87 wGen = my
88 wSpeed = at
89 wFrom = from
90 dir = `/tmp/talk-${my}-${at}-${from}`
91 await proc.run(['sh', '-c', 'mkdir -p "$1" && cat > "$1/chunks.json"', 'sh', dir], { stdin: JSON.stringify(chunks) })
92 workerDone = false
93 const w = proc.spawn({ argv: [KOKORO_PY, KOKORO_SAY, `${dir}/chunks.json`, dir, String(at), VOICE, String(from)] }) as AsyncGenerator<unknown>
94 worker = w
95 void (async () => {
96 try {
97 for await (const _ of w) void _
98 } catch {}
99 if (worker === w) workerDone = true
100 })()
101 }
102 // Controls only flip flags; the reading loop (below) obeys them.
103 const ctl = {
104 pause: async () => {
105 isPlaying = false
106 await killCurrent()
107 },
108 resume: () => {
109 isPlaying = true
110 poke()
111 },
112 replay: async () => {
113 idx = 0
114 isPlaying = true
115 await killCurrent()
116 poke()
117 },
118 speed: async (v: number) => {
119 speed = v
120 await killWorker()
121 if (isPlaying) await killCurrent()
122 },
123 cancel: async () => {
124 gen += 1
125 isPlaying = false
126 await killCurrent()
127 await killWorker()
128 poke()
129 },
130 }
131
132 on('session.start', async ($, e, next) => {
133 await $.command.register({
134 name: 'talk',
135 description: 'talk: turn reading aloud on or off',
136 argumentHint: '[on|off]',
137 })
138 return next(e)
139 })
140
141 on('command.run', { command: 'talk' }, async ($, e) => {
142 const cmd = e.args.trim().toLowerCase()
143 if (cmd === 'on' || cmd === 'off') {
144 await $.store.set('muted', cmd === 'off')
145 if (cmd === 'off') {
146 await ctl.cancel()
147 await update($, statusAtom, () => 'idle')
148 }
149 return { text: `talk is now: ${cmd}` }
150 }
151 return { text: `talk is ${(await isOn($)) ? 'on' : 'off'}. Use /talk on or /talk off.` }
152 })
153
154 on('turn.complete', async ($, e, next) => {
155 const r = await next(e)
156 if (e.agentId !== undefined || e.isAborted || !(await isOn($))) return r
157 const found = toChunks(e.answer)
158 if (found.length === 0) return r
159
160 await ctl.cancel()
161 chunks = found
162 idx = 0
163 speed = await read($, speedAtom)
164 isPlaying = true
165 const my = gen
166 // The mod loader only allows $ to be used as $.noun.event(...), so helpers get this wrapper instead.
167 const proc: Proc = { run: (a, o) => $.process.run(a, o), spawn: o => $.process.spawn(o) }
168
169 void (async () => {
170 while (my === gen) {
171 if (idx >= chunks.length || !isPlaying) {
172 isPlaying = false
173 await update($, posAtom, () => ({ i: Math.min(idx, chunks.length - 1), n: chunks.length }))
174 await update($, statusAtom, () => (idx >= chunks.length ? 'done' : 'paused'))
175 await new Promise<void>(res => {
176 wake = res
177 })
178 continue
179 }
180 await update($, statusAtom, () => 'playing')
181 await update($, posAtom, () => ({ i: idx, n: chunks.length }))
182 const text = chunks[idx]!
183 const k = kills
184 let wav = ''
185 try {
186 await ensureWorker(proc, my, idx, speed)
187 const want = `${dir}/${idx}.wav`
188 const until = Date.now() + WAIT_MS
189 while (my === gen && k === kills && !(await exists(proc, want))) {
190 if (workerDone || Date.now() > until) break
191 await new Promise(res => setTimeout(res, 100))
192 }
193 if (await exists(proc, want)) wav = want
194 } catch {}
195 if (my !== gen || k !== kills) continue
196 const ok = wav !== ''
197 const player = $.process.spawn({
198 argv: ok ? ['pw-play', wav] : ['espeak-ng', '-s', String(Math.round(165 * speed)), text],
199 })
200 current = player
201 try {
202 for await (const _ of player) void _
203 } catch {}
204 if (current === player) current = null
205 if (my === gen && k === kills) idx += 1
206 }
207 })()
208
209 return r
210 })
211
212 on('prompt.submit', async ($, e, next) => {
213 if (isPlaying) {
214 await ctl.cancel()
215 await update($, statusAtom, () => 'done')
216 }
217 return next(e)
218 }).catch(($, e, next) => next(e))
219
220 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
221 const status = await read($, statusAtom)
222 if (status === 'idle' || e.props.hasSurvey) return next(e)
223 const pos = await read($, posAtom)
224 const now = await read($, speedAtom)
225 const { Box, Text, Button } = $.ui.resolve(e)
226 const label =
227 status === 'playing' ? `Reading ${pos.i + 1}/${pos.n}` : status === 'paused' ? `Paused ${pos.i + 1}/${pos.n}` : 'Finished'
228
229 return (
230 <Box>
231 <Text dimColor>🔊 {label} </Text>
232 {status === 'paused' ? (
233 <Button
234 key="resume"
235 label="Resume"
236 hotkey="p"
237 onPress={async () => {
238 ctl.resume()
239 await update($, statusAtom, () => 'playing')
240 }}
241 />
242 ) : status === 'playing' ? (
243 <Button
244 key="pause"
245 label="Pause"
246 hotkey="p"
247 onPress={async () => {
248 await ctl.pause()
249 await update($, statusAtom, () => 'paused')
250 }}
251 />
252 ) : null}
253 <Button
254 key="replay"
255 label="Replay"
256 hotkey="r"
257 onPress={async () => {
258 await ctl.replay()
259 await update($, statusAtom, () => 'playing')
260 }}
261 />
262 <Text dimColor> Speed </Text>
263 {SPEEDS.map(v => (
264 <Button
265 key={`speed-${v}`}
266 plain
267 label={v === now ? '●' : '○'}
268 onPress={async () => {
269 await ctl.speed(v)
270 await update($, speedAtom, () => v)
271 }}
272 />
273 ))}
274 <Text> {now}x</Text>
275 </Box>
276 )
277 })
278}
279types/index.d.ts 9 lines1export type Status = 'idle' | 'playing' | 'paused' | 'done'
2export type Pos = { i: number; n: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'talk': { status: Status; speed: number; pos: Pos }
7 }
8}
9