SLOPSHOPPER

talk

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

newbandcommandpromptprocess
v0.3.3MITupdated 2026-10-20DevMits/claude-talk
A shopper browsing a rack in a slop shop
Preview could not run: ReferenceError: process is not defined at anonymous (file:///inner.js:50:28) at run (inner.js:982:9) at /home/runner/work/slopshopper/slopshopper/scripts/harness/run-one.ts:116:36
README

talk

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.

Install

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).

Voice setup (Kokoro, Linux)

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).

Use

  • /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.

Source 2 files
hooks/register.tsx 279 lines
1import { 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}
279
types/index.d.ts 9 lines
1export 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