Hands-free voice for Claude Code: listens through the microphone, sends each spoken request, and reads the reply aloud. OpenAI hears and speaks by default…

Claude Code mods: tweaked copies of community mods, as a plugin marketplace.
A copy of cache-tax 2.2.1 by Karan Bansal. The original drops a prompt sent on a cold prompt cache and asks you to send it again to pay. This copy asks first, in Claude Code's own dialog:
The question says what each answer saves. Closing the dialog without an answer falls back to the original behaviour: refused once, the resend pays.
/cache-tax guard test asks the question on your next prompt without a cold cache, priced at the session's context, so you can see it. Nothing is scored.
Everything else (/keepwarm, /cache-tax, the band above the prompt) works as in the original README.
Needs Claude Code 2.1.287 or later.
claude plugin marketplace add grvermeulen/claude-mods
claude plugin install cache-tax@guido-mods
If the original is installed, turn it off, or a cold send is guarded twice:
claude plugin disable cache-tax@claude-code-mods
Restart Claude Code afterwards.
Claude Code's own voice mode is hold-to-talk or tap-to-talk. live-voice keeps listening instead: say what you want, and after a pause of 1.5 seconds it is sent as your prompt. When Claude answers, the reply is read aloud (code and tables left out, up to 700 characters), then it listens again.
gpt-4o-transcribe, and the reply is spoken by gpt-4o-mini-tts with the voice marin, streamed so it starts at once. Claude stays the one answering./listen engine local keeps everything on your machine instead: faster-whisper hears, a Windows voice speaks (Microsoft Frank for Dutch).Windows only for now. Needs Python 3.10 or later, and for OpenAI an API key in the environment variable OPENAI_API_KEY (or CHATGPT_API_KEY), set before Claude starts. OpenAI bills transcription and speech per minute of audio.
claude plugin install live-voice@guido-mods
Then, in Claude Code:
| Command | What it does |
|---|---|
/listen setup | One time: a Python environment in ~/.claude-live-voice (with engine local, also faster-whisper and the Whisper model, about 600 MB) |
/listen | Start or stop listening |
/listen engine local | Whisper and a Windows voice instead of OpenAI; engine openai switches back |
/listen language en | The language you speak and hear (nl by default, auto to detect) |
/listen voice cedar | Another voice: an OpenAI voice (marin, cedar, coral, sage, …), or a Windows voice with engine local |
/listen speak off | Stop reading replies aloud |
/listen device jabra | Use another microphone, by part of its name or its number |
/listen model medium | With engine local: a larger, slower, more accurate Whisper model; run /listen setup again to download it |
Anything near the microphone that sounds like speech becomes a prompt, with the permissions your session has. Turn it off when you are not talking to Claude.
Signs in with your ChatGPT account through OpenAI's Sign in with ChatGPT for open-source and local apps, then lets an OpenAI model answer the turns of a Claude Code session in Claude's place. Usage counts against your ChatGPT plan, not an API key; ChatGPT's settings show it per app and can cap it.
claude plugin install openai-models@guido-mods
| Command | What it does |
|---|---|
/gpt login | Opens your browser: sign in and allow plan usage. The tokens stay in ~/.config/claude-openai-models/credentials.json |
/gpt models | The models your account can use |
/gpt picker | Adds them to the /model picker, after Claude's, through the modelPicker setting in your user settings; pick one and it answers the session, subagents included. /gpt picker off takes them out again |
/gpt use <model> | Without the picker: that model answers this session's main loop. A part of the ID will do (astra, 6.1-sol) when it names one model. It stays picked for the session across a plugin reload |
/gpt off | Claude answers again (/gpt on brings back the last model) |
/gpt agents follow · /gpt agents claude | Subagents answer with whoever answers the loop that starts them (the default), or always run on Claude |
/gpt usage | GPT's requests and tokens in this session, and the last error from OpenAI |
/gpt schemas | Reads Claude Code's tool schemas again (it also does at every session start) |
/gpt status · /gpt logout | Who is signed in and who answers · forget the tokens |
How it works:
invoke_tool function by its name; Claude Code checks its arguments against the real schema. Names OpenAI does not accept (over 64 characters) are shortened and mapped back./gpt agents claude keeps them on Claude; a model the Agent call names stands). A subagent on GPT gets its own conversation and a role naming its type and task; for a plugin's, project's or your own agent, its definition file's instructions too.$.model.complete naming a ChatGPT model, and its $.model.fork while GPT answers, go to OpenAI too./gpt usage count GPT's requests and tokens; Claude Code's own cost line does not price GPT. Cache Tax leaves GPT's turns out of Claude's cache clock, size and cost.Where it stops:
/gpt python <path> for another); scripts/gpt.py uses only its standard library.MIT. cache-tax is © 2026 Karan Bansal; see plugins/cache-tax/LICENSE. live-voice is © 2026 Guido Vermeulen.
hooks/register.tsx 398 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Phase } from '../types'
5import { LineReader, forSpeech, parseEvent } from './speech'
6import type { ListenerEvent } from './speech'
7
8const phase = atom({ plugin: 'live-voice', key: 'phase' } as const, 'off')
9const heard = atom({ plugin: 'live-voice', key: 'heard' } as const, '')
10
11// A pause this long after the last words sends them; talking again inside it adds to them.
12export const SEND_AFTER_MS = 1500
13const SPOKEN_CHARS = 700
14const MODELS = ['tiny', 'base', 'small', 'medium']
15const HINT = 'on | off | status | setup | engine openai|local | language nl | speak on|off | voice <name> | device <name> | model small'
16
17const LABELS: Record<Phase, string> = {
18 off: 'off',
19 loading: 'starting…',
20 listening: 'listening',
21 hearing: 'hearing you…',
22 transcribing: 'writing down what you said…',
23 thinking: 'Claude is working',
24 speaking: 'speaking',
25}
26
27/** openai: OpenAI hears and speaks (gpt-4o-transcribe, gpt-4o-mini-tts); local: Whisper and a Windows voice. */
28export type Engine = 'openai' | 'local'
29export type Settings = { engine: Engine; language: string; speak: boolean; device: string; model: string; voice: string }
30const DEFAULTS: Settings = { engine: 'openai', language: 'nl', speak: true, device: '', model: 'small', voice: '' }
31const OPENAI_VOICE = 'marin'
32
33type Stream = ReturnType<EngineInterface['process']['spawn']>
34
35export type State = {
36 settings: Settings
37 isWindows: boolean
38 /** ~/.claude-live-voice: the venv, the models and the control files. */
39 dir: string
40 /** The file listen.py and speak.ps1 poll: listen, pause, hush or stop. */
41 control: string
42 listener: Stream | null
43 ended: Promise<void>
44 speaking: boolean
45 isStopping: boolean
46 /** What was said since the last send, waiting out SEND_AFTER_MS. */
47 words: string[]
48 sendTimer: { cancel: () => void } | null
49}
50
51export function freshState(): State {
52 return {
53 settings: { ...DEFAULTS }, isWindows: false, dir: '', control: '', listener: null, ended: Promise.resolve(),
54 speaking: false, isStopping: false, words: [], sendTimer: null,
55 }
56}
57
58export function settingsFrom(raw: unknown): Settings {
59 const v = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
60 const text = (key: 'language' | 'device' | 'model' | 'voice') => (typeof v[key] === 'string' ? (v[key] as string) : DEFAULTS[key])
61 return {
62 engine: v.engine === 'local' || v.engine === 'openai' ? v.engine : DEFAULTS.engine,
63 language: text('language'),
64 speak: typeof v.speak === 'boolean' ? v.speak : DEFAULTS.speak,
65 device: text('device'),
66 model: text('model'),
67 voice: text('voice'),
68 }
69}
70
71function clip(text: string, max: number): string {
72 return text.length <= max ? text : '…' + text.slice(text.length - max + 1)
73}
74
75function python(s: State): string {
76 return s.isWindows ? `${s.dir}/venv/Scripts/python.exe` : `${s.dir}/venv/bin/python`
77}
78
79function describe(s: State): string {
80 const { engine, language, model, speak, device, voice } = s.settings
81 const ears = engine === 'openai' ? 'OpenAI' : `Whisper ${model}`
82 const mouth = engine === 'openai' ? voice || OPENAI_VOICE : voice || 'Windows'
83 return `language ${language} · ${ears} · read aloud ${speak ? `on (${mouth})` : 'off'} · microphone ${device || 'default'}`
84}
85
86/** The API key the scripts will read, by either name; never stored or shown. */
87async function hasKey($: EngineInterface): Promise<boolean> {
88 return Boolean((await $.env.get('OPENAI_API_KEY')) || (await $.env.get('CHATGPT_API_KEY')))
89}
90
91function cancelSend(s: State) {
92 s.sendTimer?.cancel()
93 s.sendTimer = null
94}
95
96async function setPhase($: EngineInterface, p: Phase) {
97 await update($, phase, () => p)
98}
99
100async function setControl($: EngineInterface, s: State, word: 'listen' | 'pause' | 'hush' | 'stop') {
101 await $.fs.write(s.control, word)
102}
103
104async function start($: EngineInterface, s: State): Promise<string> {
105 if (s.listener) return 'live voice is already on'
106 const isOpenai = s.settings.engine === 'openai'
107 if (!(await $.fs.exists(python(s)))) {
108 return isOpenai
109 ? 'live voice needs its setup first: /listen setup (a minute; a Python environment with sounddevice)'
110 : 'live voice needs its speech setup first: /listen setup (a few minutes; Python packages and the Whisper model, about 600 MB)'
111 }
112 if (isOpenai && !(await hasKey($))) {
113 return 'live voice with OpenAI needs an API key in the environment variable OPENAI_API_KEY (or CHATGPT_API_KEY). ' +
114 'Set it, restart Claude, and /listen again; or /listen engine local for Whisper and a Windows voice.'
115 }
116 s.isStopping = false
117 await setControl($, s, 'listen')
118 await setPhase($, 'loading')
119 const argv = [python(s), '-I', `${$.plugin.root}/scripts/listen.py`, '--engine', s.settings.engine,
120 '--language', s.settings.language, '--control', s.control]
121 if (!isOpenai) argv.push('--models', `${s.dir}/models`, '--model', s.settings.model)
122 if (s.settings.device) argv.push('--device', s.settings.device)
123 const stream = $.process.spawn({ argv, env: { HF_HUB_OFFLINE: '1', HF_HUB_DISABLE_SYMLINKS_WARNING: '1' } })
124 s.listener = stream
125 s.ended = follow($, s, stream)
126 return `live voice starting: ${describe(s)}. Talk once the band says listening; /listen off stops it.`
127}
128
129/** Reads listen.py for as long as it runs; its end, for whatever reason, turns voice off. */
130async function follow($: EngineInterface, s: State, stream: Stream) {
131 const lines = new LineReader()
132 const errors: string[] = []
133 try {
134 for await (const { stream: pipe, text } of stream) {
135 if (pipe === 'stderr') {
136 errors.push(text)
137 continue
138 }
139 for (const line of lines.push(text)) {
140 const event = parseEvent(line)
141 if (event) await onEvent($, s, event)
142 }
143 }
144 } catch (err) {
145 errors.push(err instanceof Error ? err.message : String(err))
146 } finally {
147 if (s.listener === stream) s.listener = null
148 cancelSend(s)
149 s.words = []
150 try {
151 await update($, heard, () => '')
152 await setPhase($, 'off')
153 if (!s.isStopping) {
154 const why = errors.join('').trim().split(/\r?\n/).slice(-2).join(' ')
155 $.ui.log(`live voice stopped${why ? `: ${why}` : ''}`)
156 }
157 } catch {
158 // The module is unloading; nothing is left to draw on.
159 }
160 }
161}
162
163async function stop($: EngineInterface, s: State): Promise<string> {
164 const stream = s.listener
165 if (!stream) return 'live voice is off'
166 s.isStopping = true
167 cancelSend(s)
168 await setControl($, s, 'stop')
169 await Promise.race([s.ended, $.clock.sleep(3000)])
170 if (s.listener === stream) void stream.return(undefined)
171 return 'live voice off'
172}
173
174async function onEvent($: EngineInterface, s: State, event: ListenerEvent) {
175 switch (event.type) {
176 case 'ready':
177 await setPhase($, 'listening')
178 $.ui.toast('live voice: listening')
179 return
180 case 'speech':
181 cancelSend(s)
182 await setPhase($, 'hearing')
183 return
184 case 'heard':
185 await setPhase($, 'transcribing')
186 return
187 case 'ignored':
188 await setPhase($, 'listening')
189 if (s.words.length) schedule($, s)
190 return
191 case 'error':
192 $.ui.log(`live voice: ${event.message}`)
193 return
194 case 'text':
195 s.words.push(event.text)
196 await update($, heard, () => s.words.join(' '))
197 await setPhase($, 'listening')
198 schedule($, s)
199 }
200}
201
202function schedule($: EngineInterface, s: State) {
203 cancelSend(s)
204 s.sendTimer = $.clock.after(SEND_AFTER_MS, () => {
205 void send($, s)
206 })
207}
208
209async function send($: EngineInterface, s: State) {
210 s.sendTimer = null
211 const text = s.words.join(' ').trim()
212 s.words = []
213 if (!text || !s.listener) return
214 await setControl($, s, 'pause')
215 await setPhase($, 'thinking')
216 // The person's own words, so Claude reads them bare, not framed as a plugin's message.
217 const entered = await $.prompt.submit({ text, asUser: true })
218 if (entered.drop !== undefined) {
219 $.ui.log(`live voice: not sent, ${entered.drop}`)
220 await resume($, s)
221 }
222}
223
224async function resume($: EngineInterface, s: State) {
225 if (!s.listener) return
226 await update($, heard, () => '')
227 await setControl($, s, 'listen')
228 await setPhase($, 'listening')
229}
230
231/** The command that speaks on this machine with this engine; null where the engine's own speech is used. */
232function speaker(root: string, s: State): string[] | null {
233 const language = s.settings.language === 'auto' ? 'nl' : s.settings.language
234 if (s.settings.engine === 'openai') {
235 return [python(s), '-I', `${root}/scripts/speak.py`, '--voice', s.settings.voice || OPENAI_VOICE,
236 '--language', language, '--control', s.control]
237 }
238 if (!s.isWindows) return null
239 const argv = ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
240 '-File', `${root}/scripts/speak.ps1`, '-Language', language, '-StopFile', s.control]
241 if (s.settings.voice) argv.push('-Voice', s.settings.voice)
242 return argv
243}
244
245async function say($: EngineInterface, s: State, text: string) {
246 s.speaking = true
247 try {
248 const argv = speaker($.plugin.root, s)
249 if (argv) {
250 let errors = ''
251 for await (const { stream, text: out } of $.process.spawn({ argv, input: text })) {
252 if (stream === 'stderr') errors += out
253 }
254 if (errors.trim()) $.ui.log(`live voice could not speak: ${errors.trim().split(/\r?\n/).at(-1)}`)
255 } else {
256 await $.audio.speak(text, s.settings.voice ? { voice: s.settings.voice } : undefined)
257 }
258 } catch (err) {
259 $.ui.log(`live voice could not speak: ${err instanceof Error ? err.message : String(err)}`)
260 } finally {
261 s.speaking = false
262 }
263}
264
265/** After a turn: read the reply aloud, then listen again. */
266async function answer($: EngineInterface, s: State, text: string) {
267 if (text) {
268 await setPhase($, 'speaking')
269 await say($, s, text)
270 }
271 await resume($, s)
272}
273
274async function setup($: EngineInterface, s: State): Promise<string> {
275 if (!s.isWindows) return 'setup runs on Windows only for now; elsewhere create the venv by hand (see the README)'
276 $.ui.toast(`live voice: setting up for ${s.settings.engine === 'openai' ? 'OpenAI, a minute' : 'Whisper, a few minutes'}…`)
277 const r = await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
278 '-File', `${$.plugin.root}/scripts/setup.ps1`, '-Dir', s.dir, '-Engine', s.settings.engine, '-Model', s.settings.model],
279 { timeoutMs: 600_000 })
280 if (r.exitCode === 0) return 'live voice is set up. /listen to start.'
281 const tail = `${r.stdout}\n${r.stderr}`.trim().split(/\r?\n/).slice(-4).join('\n')
282 return `live voice setup failed (exit ${r.exitCode}):\n${tail}`
283}
284
285async function save($: EngineInterface, s: State, change: Partial<Settings>, restart: boolean): Promise<string> {
286 s.settings = { ...s.settings, ...change }
287 await $.store.set('settings', s.settings)
288 if (!restart || !s.listener) return describe(s)
289 await stop($, s)
290 return start($, s)
291}
292
293async function command($: EngineInterface, s: State, args: string): Promise<string> {
294 const [word, ...rest] = args.trim().split(/\s+/).filter(Boolean)
295 const arg = rest.join(' ')
296 switch (word) {
297 case undefined:
298 return s.listener ? stop($, s) : start($, s)
299 case 'on':
300 return start($, s)
301 case 'off':
302 return stop($, s)
303 case 'status':
304 return `live voice ${LABELS[await read($, phase)]} · ${describe(s)}`
305 case 'setup':
306 return setup($, s)
307 case 'engine':
308 if (arg !== 'openai' && arg !== 'local') return '/listen engine takes openai (OpenAI hears and speaks) or local (Whisper and a Windows voice)'
309 return save($, s, { engine: arg, voice: '' }, true)
310 case 'language':
311 if (!/^([a-z]{2}|auto)$/.test(arg)) return '/listen language takes a code such as nl or en, or auto'
312 return save($, s, { language: arg }, true)
313 case 'speak':
314 if (arg !== 'on' && arg !== 'off') return '/listen speak takes on or off'
315 return save($, s, { speak: arg === 'on' }, false)
316 case 'device':
317 if (!arg) return '/listen device takes part of the microphone\'s name, its number, or default'
318 return save($, s, { device: arg === 'default' ? '' : arg }, true)
319 case 'model':
320 if (!MODELS.includes(arg)) return `/listen model takes ${MODELS.join(', ')}; a new one needs /listen setup to download it`
321 return save($, s, { model: arg }, true)
322 case 'voice':
323 if (!arg) {
324 return s.settings.engine === 'openai'
325 ? '/listen voice takes an OpenAI voice (marin, cedar, coral, sage, alloy, ash, ballad, echo, fable, nova, onyx, shimmer, verse) or default'
326 : '/listen voice takes a Windows voice\'s name (Microsoft Frank) or default'
327 }
328 return save($, s, { voice: arg === 'default' ? '' : arg }, false)
329 default:
330 return `/listen [${HINT}]`
331 }
332}
333
334export const register: Register = on => {
335 const s = freshState()
336
337 on('session.start', async ($, e, next) => {
338 const started = await next(e)
339 s.isWindows = (await $.env.get('OS')) === 'Windows_NT'
340 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '.'
341 s.dir = `${home}/.claude-live-voice`
342 s.control = `${s.dir}/control-${await $.session.id()}.txt`
343 s.settings = settingsFrom(await $.store.get('settings'))
344 await $.command.register({
345 name: 'listen',
346 description: 'Hands-free voice: talk to Claude and hear the reply (live-voice)',
347 argumentHint: `[${HINT}]`,
348 immediate: true,
349 })
350 return started
351 })
352
353 on('command.run', { command: 'listen' }, async ($, e) => ({ text: await command($, s, String(e.args ?? '')) }))
354
355 // A prompt the person types while voice is on stops the microphone for the turn too.
356 on('turn.start', async ($, e, next) => {
357 if (s.listener) {
358 cancelSend(s)
359 s.words = []
360 await setControl($, s, 'pause')
361 await setPhase($, 'thinking')
362 }
363 return next(e)
364 })
365
366 on('turn.complete', async ($, e, next) => {
367 const done = await next(e)
368 if (e.agentId || !s.listener) return done
369 const reply = 'answer' in e && typeof e.answer === 'string' ? e.answer : ''
370 void answer($, s, e.isAborted || !s.settings.speak ? '' : forSpeech(reply, SPOKEN_CHARS))
371 return done
372 })
373
374 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
375 const rest = await next(e)
376 const now = await read($, phase)
377 if (now === 'off' || e.props.hasSurvey) return rest
378 const said = await read($, heard)
379 const { Box, Button, Text } = $.ui.resolve(e)
380 const live = now === 'listening' || now === 'hearing'
381 return (
382 <Box flexDirection="column">
383 {rest}
384 <Box flexDirection="row">
385 <Text>
386 <Text bold color={live ? 'success' : undefined}>● live voice</Text>
387 <Text dimColor>{` · ${LABELS[now]}${said ? ` · “${clip(said, 80)}”` : ''} `}</Text>
388 </Text>
389 {now === 'speaking' && s.speaking ? (
390 <Button key="hush" label="Stop talking" onPress={() => setControl($, s, 'hush')} />
391 ) : null}
392 <Button key="off" label="Off" onPress={() => stop($, s)} />
393 </Box>
394 </Box>
395 )
396 })
397}
398hooks/speech.ts 61 lines1// What crosses between the mod and its two helpers: listen.py's JSON lines in,
2// plain speakable text out.
3
4export type ListenerEvent =
5 | { type: 'ready' | 'speech' | 'heard' | 'ignored' }
6 | { type: 'text'; text: string }
7 | { type: 'error'; message: string }
8
9/** One line of listen.py's output; null for anything that is not one of its events. */
10export function parseEvent(line: string): ListenerEvent | null {
11 let value: unknown
12 try {
13 value = JSON.parse(line)
14 } catch {
15 return null
16 }
17 if (typeof value !== 'object' || value === null) return null
18 const v = value as Record<string, unknown>
19 if (v.type === 'text') return typeof v.text === 'string' && v.text.trim() ? { type: 'text', text: v.text.trim() } : null
20 if (v.type === 'error') return { type: 'error', message: typeof v.message === 'string' ? v.message : 'unknown error' }
21 if (v.type === 'ready' || v.type === 'speech' || v.type === 'heard' || v.type === 'ignored') return { type: v.type }
22 return null
23}
24
25/** Cuts a stream of text pieces into whole lines; a piece may end mid-line. */
26export class LineReader {
27 private rest = ''
28
29 push(piece: string): string[] {
30 const lines = (this.rest + piece).split(/\r?\n/)
31 this.rest = lines.pop() ?? ''
32 return lines.filter(line => line.trim() !== '')
33 }
34}
35
36/**
37 * Turns a markdown reply into text worth reading aloud: code blocks, tables and
38 * links' targets go, the markup goes, and the result stops at a sentence end
39 * within `max` characters.
40 */
41export function forSpeech(markdown: string, max: number): string {
42 const text = markdown
43 .replace(/```[\s\S]*?(```|$)/g, ' ')
44 .split('\n')
45 .filter(line => !/^\s*\|/.test(line))
46 .join('\n')
47 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
48 .replace(/https?:\/\/\S+/g, ' ')
49 .replace(/`([^`]*)`/g, '$1')
50 .replace(/^\s{0,3}#{1,6}\s+/gm, '')
51 .replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, '')
52 .replace(/^\s*>\s?/gm, '')
53 .replace(/(\*\*|__|\*|_|~~)(?=\S)([^*_~\n]+?)(?<=\S)\1/g, '$2')
54 .replace(/\s+/g, ' ')
55 .trim()
56 if (text.length <= max) return text
57 const cut = text.slice(0, max)
58 const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('? '), cut.lastIndexOf('! '))
59 return end > max / 3 ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…'
60}
61types/index.d.ts 9 lines1/** Where the conversation stands: drawn in the band above the prompt. */
2export type Phase = 'off' | 'loading' | 'listening' | 'hearing' | 'transcribing' | 'thinking' | 'speaking'
3
4declare module 'claude-code' {
5 interface PluginState {
6 'live-voice': { phase: Phase; heard: string }
7 }
8}
9