SLOPSHOPPER

Hablo

Hear Claude Code's replies aloud in your language: a Listen button under each reply and /hablo. Free and offline on macOS and Windows.

newrowscommandtoaststatusprocess
v0.3.0MITupdated 2026-10-09Jarvizx/hablo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hablo
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ⟨Claude Code's own drawing⟩ [ ⏵ Listen ] ✻ Worked for 42s · done 4:20 PM › /hablo ⎿ hablo: Nothing to read yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Claude's reply
⟨Claude Code's own drawing⟩ [ ⏵ Listen ]
README

Hablo

Hear Claude Code's replies aloud, in your language.

CI License: MIT

Leer en español

<!-- Demo: add docs/demo.gif here once it is recorded. -->

Hablo is a plugin for Claude Code that reads replies aloud: to rest your eyes, to listen while you look at the code, or to practice a language you are learning. It puts a Listen button under each reply, adds a /hablo command, and picks a natural voice for the language of each reply. It uses the voices that come with macOS and Windows: free, offline, no API keys.

Install

At the Claude Code prompt:

/plugin install hablo --marketplace Jarvizx/hablo

Answer y to add the marketplace and pick a scope (user is the default). There is nothing to configure.

Requirements: Claude Code 2.1.287 or later, on macOS or Windows.

Use

  • [ ⏵ Listen ] under every reply: press it to hear that reply. While it reads, the row shows ✻ Reading… [ ■ Stop ]. The button shows in Claude Code's fullscreen layout (turn it on with /tui fullscreen) and in the desktop app.
  • /hablo: reads the text you selected with the mouse, or the last reply. Run it again to stop, even while Claude is working. It prints nothing in the conversation, so Claude's context stays clean.
CommandWhat it does
/habloReads the selection or the last reply. Stops if it is already reading.
/hablo stopStops reading.
/hablo <text>Reads that text.
/hablo voicesShows the voice for each language, and your settings.

Hablo detects Spanish, English, Portuguese, French, German and Italian, and leaves out code blocks, links and markdown symbols when it reads.

Settings

It works with no setup. To change it:

CommandWhat it does
/hablo rate 220Words per minute, up to 500. 0 goes back to the system's speed.
/hablo voice es MónicaThe voice for a language. /hablo voice es goes back to the automatic one.
/hablo auto onReads every reply as soon as Claude finishes. off turns it off.

The words also work in Spanish: parar, voces, velocidad, voz, and sí or no after auto. Settings stay on your machine for every session.

Better voices: macOS has free Enhanced and Premium voices that sound far more natural. Download them in System Settings › Accessibility › Spoken Content › System voice › Manage Voices…, and Hablo prefers them automatically. On Windows, add languages in Settings › Time & language › Speech.

Compatibility

WhereStatus
macOS, terminal✅ Tested
Windows, terminal✅ Tested on Windows 11
VS Code, integrated terminal✅ Tested
Desktop app (Code tab)⚠️ The mods API draws there and the tests cover it, but it has not been tried by hand yet
VS Code extension, chat panel⚠️ No button: Claude Code draws nothing from mods there. /hablo is untested
Linux⚠️ Falls back to the speech synthesizer Claude Code finds, without voice choice or stop. Untested, help wanted
SSH, VS Code Remote, containers❌ The sound plays on the machine where Claude Code runs, not on yours

Hablo is a mod, a plugin of function hooks, tested on Claude Code 2.1.292. The mods API may still change between releases.

What Hablo sends, runs and keeps

Network: none. Hablo makes no network requests.

What it sends, and where: only the text it reads aloud, a reply or the text you selected, and only to your operating system's speech synthesizer on your machine, on standard input: to say on macOS, and to a PowerShell script that uses Windows' System.Speech on Windows. It goes nowhere else.

Programs it runs, and why:

SystemCommandWhy
macOSsay -v '?'Once per session: lists the installed voices
macOS/bin/sh -c 'echo $$; exec say "$@"' hablo [-v <voice>] [-r <rate>], with the text on standard inputEach reading: the shell prints its process id, then becomes say and speaks the text
macOSkill <pid>Stops that say process when you stop a reading
Windowspowershell.exe -NoProfile -NonInteractive -EncodedCommand <script>Once per session, a fixed script lists the voices; each reading, a fixed script loads System.Speech, selects the voice and rate, reads the text as base64 from standard input and speaks it
Windowstaskkill /PID <pid> /FStops that PowerShell process when you stop a reading

<voice> is a voice installed on your machine, <rate> your /hablo rate, and <pid> the process Hablo started. The Windows scripts are encoded only so that PowerShell receives them whole: their plain text is in hooks/sapi.ts, and nothing else in them changes.

Its hooks:

HookWhat it does
session.startRegisters /hablo, and picks English or Spanish for its messages from LC_ALL, LC_MESSAGES or LANG
turn.completeKeeps the reply's text for /hablo, and reads it when /hablo auto is on
command.run for /habloStarts or stops a reading, and shows or changes the settings
ui.render for each replyDraws [ ⏵ Listen ] under it, or ✻ Reading… [ ■ Stop ] while it reads
session.endStops the voice on /clear, /resume, /branch and when the session ends

What it reads and keeps: the text of a reply or of your selection, only to speak it; the LC_ALL, LC_MESSAGES, LANG and OS environment variables. It keeps the last reply in the session's memory, and saves only your settings (rate, a voice per language, and auto) in its own store file under ~/.claude/plugins/store/.

Hablo uses only Claude Code's own mods API, and calls no other plugin. See also the privacy policy.

Contributing

Issues and pull requests are welcome, especially new languages and Linux support. Start with CONTRIBUTING.md, which also explains how Hablo works, and the roadmap.

License

MIT © Robin Buitrago

Hablo is an independent project. It is not affiliated with, endorsed by, or sponsored by Anthropic. Claude and Claude Code are trademarks of Anthropic.

Source 4 files
hooks/register.tsx 380 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { VOICES_SCRIPT, parseSapiVoices, powershell, sapiRate, speakScript, toBase64, utf8 } from './sapi'
5import { LANGS, cleanForSpeech, detectLanguage, findVoice, isLang, parseVoices, pickVoice } from './speech'
6import type { Lang, Voice } from './speech'
7
8// What is being read now (a message id, `last` or `selection`), the frame of its
9// reading indicator, and the last reply.
10const speaking = atom({ plugin: 'hablo', key: 'speaking' } as const, null)
11const frame = atom({ plugin: 'hablo', key: 'frame' } as const, 0)
12const lastReply = atom({ plugin: 'hablo', key: 'lastReply' } as const, '')
13
14// The glyphs of Claude Code's own spinner, there and back, without ✳: it has an emoji
15// form, which some terminals (Windows Terminal) draw in color. None of these has one.
16const GLYPHS = ['·', '✢', '✶', '✻', '✽', '✻', '✶', '✢']
17// Under the 10 redraws a second a transcript row is allowed.
18const FRAME_MS = 140
19const MAX_RATE = 500
20
21const MESSAGES = {
22  en: {
23    reading: '⏵ Reading aloud · /hablo to stop',
24    readingShort: 'Reading…',
25    nothing: 'Nothing to read yet.',
26    voices: 'Voices',
27    systemVoice: 'system default',
28    noSay: 'No macOS or Windows voices were found: using the system synthesizer, which cannot be stopped.',
29    failed: 'could not read aloud',
30    listen: 'Listen',
31    stop: 'Stop',
32    rate: (n: number) => `Speaking rate: ${n > 0 ? `${n} words per minute` : 'system default'}.`,
33    rateRange: `The rate is a number of words per minute, from 0 (system default) to ${MAX_RATE}.`,
34    voice: (lang: string, name: string | undefined) => `Voice for ${lang}: ${name ?? 'automatic'}.`,
35    noVoice: (lang: string, name: string, names: string[]) =>
36      `No ${lang} voice called "${name}". Installed: ${names.length > 0 ? names.join(', ') : 'none'}.`,
37    auto: (isOn: boolean) => `Read every reply: ${isOn ? 'on' : 'off'}.`,
38    unknownLang: (lang: string) => `Hablo has no "${lang}": use one of ${LANGS.join(', ')}.`,
39  },
40  es: {
41    reading: '⏵ Leyendo en voz alta · /hablo para parar',
42    readingShort: 'Leyendo…',
43    nothing: 'Todavía no hay nada que leer.',
44    voices: 'Voces',
45    systemVoice: 'la del sistema',
46    noSay: 'No se encontraron voces de macOS ni de Windows: se usa el sintetizador del sistema, que no se puede parar.',
47    failed: 'no se pudo leer en voz alta',
48    listen: 'Escuchar',
49    stop: 'Parar',
50    rate: (n: number) => `Velocidad: ${n > 0 ? `${n} palabras por minuto` : 'la del sistema'}.`,
51    rateRange: `La velocidad es un número de palabras por minuto, de 0 (la del sistema) a ${MAX_RATE}.`,
52    voice: (lang: string, name: string | undefined) => `Voz para ${lang}: ${name ?? 'automática'}.`,
53    noVoice: (lang: string, name: string, names: string[]) =>
54      `No hay ninguna voz de ${lang} llamada "${name}". Instaladas: ${names.length > 0 ? names.join(', ') : 'ninguna'}.`,
55    auto: (isOn: boolean) => `Leer cada respuesta: ${isOn ? 'sí' : 'no'}.`,
56    unknownLang: (lang: string) => `Hablo no tiene "${lang}": usa uno de ${LANGS.join(', ')}.`,
57  },
58}
59
60// `hasStatus`: the reading shows on the status line, for when no reply row shows it
61// (started from /hablo or autoRead, not from a reply's own button).
62type Job = { id: string; text: string; hasStatus: boolean }
63
64// What the person set with /hablo rate, /hablo voice and /hablo auto. It lives in the
65// plugin's store, so it lasts across sessions and needs no setup screen at install.
66type Settings = { rate: number; voices: Partial<Record<string, string>>; autoRead: boolean }
67
68// /hablo's own words, in English and Spanish. Anything else after /hablo is text to read.
69const STOP = /^(stop|parar)$/i
70const VOICES = /^(voices|voces)$/i
71const RATE = /^(rate|velocidad)(?:\s+(\S+))?$/i
72const VOICE = /^(?:voice|voz)\s+([a-z]{2})(?:\s+(.+))?$/i
73const AUTO = /^auto(?:\s+(on|off|s[ií]|no))?$/i
74
75// The module's own: they start over on a reload, which also ends any reading.
76let t = MESSAGES.en
77let voices: Voice[] | undefined
78// Who speaks: macOS `say`, Windows SAPI through PowerShell, or the engine's own synthesizer.
79let synth: 'say' | 'sapi' | 'system' = 'system'
80let queue: Job[] = []
81let isWorking = false
82let pid: string | undefined
83let isStopping = false
84// A text too short or ambiguous to tell is read in the language read last.
85let lastLang: Lang | undefined
86
87// Read from the store each time, so a change made in another session applies here too.
88const loadSettings = async ($: EngineInterface): Promise<Settings> => {
89  const rate = await $.store.get('rate')
90  const voiceMap = await $.store.get('voices')
91  const autoRead = await $.store.get('autoRead')
92  const isVoiceMap = typeof voiceMap === 'object' && voiceMap !== null && !Array.isArray(voiceMap)
93
94  return {
95    rate: typeof rate === 'number' && rate >= 0 && rate <= MAX_RATE ? rate : 0,
96    voices: isVoiceMap
97      ? Object.fromEntries(Object.entries(voiceMap).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
98      : {},
99    autoRead: autoRead === true,
100  }
101}
102
103// Finds the synthesizer and its voices once per load.
104const listVoices = async ($: EngineInterface) => {
105  if (voices === undefined) {
106    const isWindows = (await $.env.get('OS')) === 'Windows_NT'
107    try {
108      const { exitCode, stdout } = await $.process.run(isWindows ? powershell(VOICES_SCRIPT) : ['say', '-v', '?'])
109      synth = exitCode !== 0 ? 'system' : isWindows ? 'sapi' : 'say'
110      voices = synth === 'system' ? [] : isWindows ? parseSapiVoices(stdout) : parseVoices(stdout)
111    } catch {
112      synth = 'system'
113      voices = []
114    }
115  }
116
117  return voices
118}
119
120const voiceFor = async ($: EngineInterface, text: string, settings: Settings) => {
121  const lang = detectLanguage(text) ?? lastLang
122  lastLang = lang
123
124  return lang ? pickVoice(await listVoices($), lang, settings.voices) : undefined
125}
126
127const kill = ($: EngineInterface, id: string) =>
128  $.process.run(synth === 'sapi' ? ['taskkill', '/PID', id, '/F'] : ['kill', id]).catch(() => undefined)
129
130// What starts one utterance. Each prints its pid first so it can be stopped:
131// on macOS a shell that then becomes `say`, on Windows a PowerShell script.
132const speech = (voice: string | undefined, text: string, rate: number) => {
133  if (synth === 'sapi') {
134    return { argv: powershell(speakScript(voice, sapiRate(rate))), input: toBase64(utf8(text)) }
135  }
136
137  const args = [...(voice ? ['-v', voice] : []), ...(rate > 0 ? ['-r', String(rate)] : [])]
138  return { argv: ['/bin/sh', '-c', 'echo $$; exec say "$@"', 'hablo', ...args], input: text }
139}
140
141const say = async ($: EngineInterface, text: string) => {
142  const settings = await loadSettings($)
143  const voice = await voiceFor($, text, settings)
144  if (synth === 'system') {
145    await $.audio.speak(text.slice(0, 4096))
146    return
147  }
148
149  const child = $.process.spawn(speech(voice, text, settings.rate))
150  let errors = ''
151
152  for await (const { stream, text: out } of child) {
153    if (stream === 'stderr') {
154      errors += out
155    } else if (pid === undefined) {
156      pid = out.trim().split('\n')[0]
157      if (isStopping && pid) {
158        await kill($, pid)
159      }
160    }
161  }
162
163  const end = await child.result
164  if (end.code !== 0 && end.signal === null && !isStopping) {
165    throw new Error(errors.trim() || `${synth} exited with ${end.code}`)
166  }
167}
168
169// Reads the queue until it is empty; started by whoever queues the first job.
170// While it reads, a timer turns the indicator of the row being read.
171const work = async ($: EngineInterface) => {
172  isWorking = true
173  const spinner = $.clock.every(FRAME_MS, () => void update($, frame, n => (n + 1) % GLYPHS.length))
174  try {
175    for (let job = queue.shift(); job; job = queue.shift()) {
176      isStopping = false
177      await update($, speaking, () => job.id)
178      $.ui.status(job.hasStatus ? t.reading : undefined)
179      try {
180        await say($, job.text)
181      } catch (error) {
182        $.ui.toast(`hablo: ${t.failed}: ${error instanceof Error ? error.message : String(error)}`)
183      }
184      pid = undefined
185    }
186  } finally {
187    spinner.cancel()
188    isWorking = false
189    await update($, speaking, () => null)
190    $.ui.status(undefined)
191  }
192}
193
194const stop = async ($: EngineInterface) => {
195  queue = []
196  isStopping = true
197  if (pid) {
198    await kill($, pid)
199  }
200}
201
202const start = async ($: EngineInterface, id: string, markdown: string, hasStatus: boolean) => {
203  const text = cleanForSpeech(markdown)
204  if (text === '') {
205    return false
206  }
207
208  await stop($)
209  queue = [{ id, text, hasStatus }]
210  if (!isWorking) {
211    void work($)
212  }
213
214  return true
215}
216
217// The voices Hablo picks for each language, and the settings, as /hablo voices shows them.
218const describe = async ($: EngineInterface) => {
219  const settings = await loadSettings($)
220  const installed = await listVoices($)
221  const rows = LANGS.map(lang => `${lang}: ${pickVoice(installed, lang, settings.voices) ?? t.systemVoice}`)
222
223  return [`${t.voices}:`, ...rows, ...(synth === 'system' ? [t.noSay] : []), '', t.rate(settings.rate), t.auto(settings.autoRead)].join('\n')
224}
225
226const setRate = async ($: EngineInterface, value: string | undefined) => {
227  if (value === undefined) {
228    return t.rate((await loadSettings($)).rate)
229  }
230  const rate = Number(value)
231  if (!Number.isInteger(rate) || rate < 0 || rate > MAX_RATE) {
232    return t.rateRange
233  }
234  await $.store.set('rate', rate)
235
236  return t.rate(rate)
237}
238
239// Saves the installed voice the person means, by its full name, so `say` and SAPI find it.
240const setVoice = async ($: EngineInterface, lang: Lang, name: string | undefined) => {
241  const chosen = { ...(await loadSettings($)).voices }
242  if (name === undefined) {
243    delete chosen[lang]
244  } else {
245    const installed = await listVoices($)
246    const voice = findVoice(installed, lang, name)
247    if (!voice) {
248      const names = installed.filter(one => one.locale.toLowerCase().startsWith(`${lang}_`)).map(one => one.name)
249      return t.noVoice(lang, name, names)
250    }
251    chosen[lang] = voice.name
252  }
253  await $.store.set('voices', chosen)
254
255  return t.voice(lang, chosen[lang])
256}
257
258const setAuto = async ($: EngineInterface, value: string | undefined) => {
259  if (value === undefined) {
260    return t.auto((await loadSettings($)).autoRead)
261  }
262  const isOn = !/^(off|no)$/i.test(value)
263  await $.store.set('autoRead', isOn)
264
265  return t.auto(isOn)
266}
267
268export const register: Register = on => {
269  on('session.start', async ($, e, next) => {
270    const started = await next(e)
271    const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG')) || ''
272    t = locale.toLowerCase().startsWith('es') ? MESSAGES.es : MESSAGES.en
273
274    await $.command.register({
275      name: 'hablo',
276      description: 'Read aloud the selection or the last reply; again to stop',
277      argumentHint: '[stop | voices | rate <n> | voice <lang> <name> | auto on|off | text]',
278      // So that /hablo stops a reading at once, even while Claude is working.
279      immediate: true,
280    })
281    // A reading cut by a reload leaves its id behind.
282    await update($, speaking, () => null)
283
284    return started
285  })
286
287  // /clear, /resume and /branch reset the plugin's state: stop the voice with it,
288  // or it would go on with nothing left on screen to stop it.
289  on('session.end', async ($, e, next) => {
290    await stop($)
291
292    return next(e)
293  })
294
295  // Keeps the last reply for /hablo, and reads it when /hablo auto is on.
296  on('turn.complete', async ($, e, next) => {
297    const result = await next(e)
298    if (e.reason === 'answer' && result.text.trim() !== '') {
299      await update($, lastReply, () => result.text)
300      if ((await loadSettings($)).autoRead) {
301        await start($, 'last', result.text, true)
302      }
303    }
304
305    return result
306  })
307
308  // Starting and stopping print nothing: the status line shows the reading, and a
309  // line in the transcript would also land in what Claude reads. Settings answer in a line.
310  on('command.run', { command: 'hablo' }, async ($, e) => {
311    const arg = e.args.trim()
312    const rate = RATE.exec(arg)
313    const voice = VOICE.exec(arg)
314    const auto = AUTO.exec(arg)
315
316    if (STOP.test(arg) || (arg === '' && (await read($, speaking)) !== null)) {
317      await stop($)
318      return {}
319    }
320    if (VOICES.test(arg)) {
321      return { text: await describe($) }
322    }
323    if (rate) {
324      return { text: await setRate($, rate[2]) }
325    }
326    if (voice?.[1]) {
327      const lang = voice[1].toLowerCase()
328      return { text: isLang(lang) ? await setVoice($, lang, voice[2]) : t.unknownLang(lang) }
329    }
330    if (auto) {
331      return { text: await setAuto($, auto[1]) }
332    }
333
334    const selection = arg === '' ? await $.ui.selection() : undefined
335    const source = arg !== '' ? arg : selection?.text ?? (await read($, lastReply))
336    const isStarted = await start($, selection ? 'selection' : 'last', source, true)
337
338    return isStarted ? {} : { text: t.nothing }
339  })
340
341  // Under each reply: [ ⏵ Listen ]. While that reply is read: a turning glyph in
342  // Claude's accent color, "Reading…", and [ ■ Stop ].
343  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
344    const own = await next(e)
345    const hasPointer = e.surface !== 'terminal' || e.viewport?.isFullscreen === true
346    if (!hasPointer || e.props.text.trim() === '') {
347      return own
348    }
349
350    const { Box, Button, Text } = $.ui.resolve(e)
351    const isReading = (await read($, speaking)) === e.requestId
352    // Only the row being read reads the frame, so only it redraws as it turns.
353    const glyph = isReading ? GLYPHS[(await read($, frame)) % GLYPHS.length] : undefined
354
355    return (
356      <Box flexDirection="column">
357        {own}
358        <Box flexDirection="row" alignItems="center" paddingLeft={2} columnGap={2}>
359          {/* A list, not <>…</>: a fragment draws as a column Box and would stack them. */}
360          {isReading ? (
361            [
362              <Text key="reading" color="claude">
363                {glyph} {t.readingShort}
364              </Text>,
365              <Button key="stop" variant="primary" label={`■ ${t.stop}`} onPress={() => stop($)} />,
366            ]
367          ) : (
368            <Button
369              key="play"
370              variant="primary"
371              label={`⏵ ${t.listen}`}
372              onPress={() => start($, e.requestId, e.props.text, false)}
373            />
374          )}
375        </Box>
376      </Box>
377    )
378  })
379}
380
hooks/sapi.ts 80 lines
1// Windows: the voices of System.Speech (SAPI), driven through Windows PowerShell.
2// Pure helpers: the scripts, their encoding, and parsing what they print.
3// (PowerShell 7 has no System.Speech, so these target powershell.exe, which every Windows has.)
4
5import type { Voice } from './speech'
6
7const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
8
9export const toBase64 = (bytes: Uint8Array): string => {
10  let out = ''
11  for (let i = 0; i < bytes.length; i += 3) {
12    const a = bytes[i] ?? 0
13    const b = bytes[i + 1]
14    const c = bytes[i + 2]
15    out += BASE64.charAt(a >> 2)
16    out += BASE64.charAt(((a & 3) << 4) | ((b ?? 0) >> 4))
17    out += b === undefined ? '=' : BASE64.charAt(((b & 15) << 2) | ((c ?? 0) >> 6))
18    out += c === undefined ? '=' : BASE64.charAt(c & 63)
19  }
20
21  return out
22}
23
24export const utf8 = (text: string) => new TextEncoder().encode(text)
25
26// -EncodedCommand takes the script as base64 of its UTF-16LE text.
27export const utf16le = (text: string) => {
28  const bytes = new Uint8Array(text.length * 2)
29  for (let i = 0; i < text.length; i += 1) {
30    const code = text.charCodeAt(i)
31    bytes[i * 2] = code & 0xff
32    bytes[i * 2 + 1] = code >> 8
33  }
34
35  return bytes
36}
37
38const quote = (value: string) => `'${value.replace(/'/g, "''")}'`
39
40// SAPI's rate runs from -10 to 10, 0 being about 180 words per minute.
41export const sapiRate = (wordsPerMinute: number) =>
42  wordsPerMinute > 0 ? Math.max(-10, Math.min(10, Math.round((wordsPerMinute - 180) / 15))) : 0
43
44export const VOICES_SCRIPT = [
45  'Add-Type -AssemblyName System.Speech',
46  '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer',
47  "$s.GetInstalledVoices() | Where-Object { $_.Enabled } | ForEach-Object { $_.VoiceInfo.Name + '|' + $_.VoiceInfo.Culture.Name }",
48].join('\n')
49
50// Prints its pid first so it can be stopped, then reads the text as base64 of UTF-8 on
51// standard input: plain ASCII, whatever code page the console uses.
52export const speakScript = (voice: string | undefined, rate: number) =>
53  [
54    "$ErrorActionPreference = 'Stop'",
55    '[Console]::Out.WriteLine($PID)',
56    '[Console]::Out.Flush()',
57    'Add-Type -AssemblyName System.Speech',
58    '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer',
59    ...(voice ? [`$s.SelectVoice(${quote(voice)})`] : []),
60    `$s.Rate = ${rate}`,
61    '$t = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()))',
62    '$s.Speak($t)',
63  ].join('\n')
64
65export const powershell = (script: string) => [
66  'powershell.exe',
67  '-NoProfile',
68  '-NonInteractive',
69  '-EncodedCommand',
70  toBase64(utf16le(script)),
71]
72
73// `Microsoft Helena Desktop|es-ES` -> { name: 'Microsoft Helena Desktop', locale: 'es_ES' }
74export const parseSapiVoices = (output: string): Voice[] =>
75  output.split(/\r?\n/).flatMap(line => {
76    const [name, culture] = line.trim().split('|')
77
78    return name && culture ? [{ name, locale: culture.replace('-', '_') }] : []
79  })
80
hooks/speech.ts 145 lines
1// Pure helpers: turning markdown into speakable text, guessing its language
2// and choosing an installed voice for it. Nothing here touches `$`.
3
4export type Lang = 'es' | 'en' | 'pt' | 'fr' | 'de' | 'it'
5export type Voice = { name: string; locale: string }
6
7export const MAX_CHARS = 20_000
8
9// Common words of each language; a word shared by several counts for each of them.
10const MARKERS: Record<Lang, readonly string[]> = {
11  es: ['de', 'la', 'que', 'el', 'en', 'y', 'los', 'las', 'del', 'se', 'por', 'un', 'una', 'con', 'no', 'para', 'es', 'lo', 'como', 'más', 'pero', 'ya', 'este', 'esta', 'esto', 'eso', 'hay', 'muy', 'también', 'sí', 'qué', 'cuando', 'hola', 'gracias', 'puedes', 'tengo', 'está', 'son', 'mi', 'tu'],
12  en: ['the', 'and', 'is', 'are', 'you', 'this', 'that', 'with', 'for', 'it', 'of', 'to', 'can', 'will', 'your', 'have', 'not', 'what', 'a', 'in', 'on', 'be', 'do', 'i', 'my', 'hello', 'thanks'],
13  pt: ['de', 'que', 'não', 'você', 'é', 'do', 'da', 'em', 'um', 'uma', 'com', 'são', 'isso', 'mais', 'muito', 'pode', 'os', 'ao', 'seu', 'olá', 'obrigado', 'está', 'para', 'eu', 'como'],
14  fr: ['le', 'la', 'les', 'de', 'des', 'et', 'est', 'un', 'une', 'pas', 'vous', 'qui', 'dans', 'pour', 'avec', 'sur', 'ce', 'sont', 'il', 'je', 'bonjour', 'merci', 'que', 'en'],
15  de: ['der', 'die', 'und', 'ist', 'nicht', 'ein', 'eine', 'mit', 'auf', 'für', 'das', 'sie', 'auch', 'werden', 'ich', 'es', 'zu', 'den', 'hallo', 'danke'],
16  it: ['di', 'che', 'è', 'non', 'sono', 'della', 'per', 'gli', 'anche', 'questo', 'più', 'nel', 'alla', 'ci', 'sei', 'il', 'la', 'un', 'una', 'ciao', 'grazie', 'come', 'con'],
17}
18
19// Letters that give a language away.
20const LETTERS: Record<Lang, RegExp | undefined> = {
21  es: /[ñ¿¡]/g,
22  en: undefined,
23  pt: /[ãõ]/g,
24  fr: /[œëàù]/g,
25  de: /[ßäöü]/g,
26  it: undefined,
27}
28
29export const LANGS = Object.keys(MARKERS) as Lang[]
30
31export const detectLanguage = (text: string): Lang | undefined => {
32  const words = text.toLowerCase().match(/\p{L}+/gu) ?? []
33  const scores = new Map<Lang, number>()
34
35  for (const lang of LANGS) {
36    const markers = new Set(MARKERS[lang])
37    const hits = words.filter(word => markers.has(word)).length
38    const letters = LETTERS[lang] ? (text.toLowerCase().match(LETTERS[lang]) ?? []).length * 3 : 0
39    scores.set(lang, hits + letters)
40  }
41
42  // The language with the most hits, when no other has as many.
43  const [best, second] = [...scores].sort((a, b) => b[1] - a[1])
44
45  return best && best[1] > 0 && best[1] > (second?.[1] ?? 0) ? best[0] : undefined
46}
47
48// `say -v '?'` prints one voice per line: `Paulina   es_MX   # Hola, me llamo Paulina.`
49export const parseVoices = (output: string): Voice[] =>
50  output.split('\n').flatMap(line => {
51    const match = /^(.+?)\s+([a-z]{2,3}_[A-Za-z0-9]+)\s+#/.exec(line)
52
53    return match?.[1] && match[2] ? [{ name: match[1].trim(), locale: match[2] }] : []
54  })
55
56const PREFERRED: Record<Lang, readonly string[]> = {
57  es: ['Paulina', 'Mónica', 'Monica', 'Jorge', 'Juan', 'Diego'],
58  en: ['Samantha', 'Alex', 'Daniel', 'Karen', 'Moira', 'Tessa'],
59  pt: ['Luciana', 'Joana', 'Felipe'],
60  fr: ['Thomas', 'Amélie', 'Amelie', 'Audrey'],
61  de: ['Anna', 'Markus', 'Petra', 'Yannick'],
62  it: ['Alice', 'Federica', 'Luca'],
63}
64
65// Voices macOS ships for fun or with a robotic sound: picked only when nothing else is installed.
66const NOVELTY = new Set([
67  'Albert', 'Bad News', 'Bahh', 'Bells', 'Boing', 'Bubbles', 'Cellos', 'Eddy', 'Flo', 'Fred',
68  'Good News', 'Grandma', 'Grandpa', 'Jester', 'Junior', 'Kathy', 'Organ', 'Ralph', 'Reed',
69  'Rocko', 'Sandy', 'Shelley', 'Superstar', 'Trinoids', 'Whisper', 'Wobble', 'Zarvox',
70])
71
72const baseName = (name: string) => name.replace(/\s*\(.*\)\s*$/, '')
73
74// Premium and Enhanced downloads sound better than the compact voice of the same name.
75const quality = (name: string) => (/premium/i.test(name) ? 2 : /enhanced|mejorada/i.test(name) ? 1 : 0)
76
77export const pickVoice = (
78  voices: readonly Voice[],
79  lang: Lang,
80  overrides: Partial<Record<string, string>> = {},
81): string | undefined => {
82  const chosen = overrides[lang]
83  if (chosen) {
84    return chosen
85  }
86
87  const ofLang = voices.filter(voice => voice.locale.toLowerCase().startsWith(`${lang}_`))
88  const byQuality = (list: readonly Voice[]) => [...list].sort((a, b) => quality(b.name) - quality(a.name))
89
90  for (const name of PREFERRED[lang]) {
91    const [best] = byQuality(ofLang.filter(voice => baseName(voice.name) === name))
92    if (best) {
93      return best.name
94    }
95  }
96
97  const [plain] = byQuality(ofLang.filter(voice => !NOVELTY.has(baseName(voice.name))))
98
99  return (plain ?? ofLang[0])?.name
100}
101
102const isTableRow = (line: string) => /^\|.*\|$/.test(line)
103const isTableRule = (line: string) => /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?$/.test(line)
104
105// Markdown as a reader would say it: no code blocks, links read by their text,
106// no markup symbols, one sentence per line so `say` pauses between them.
107export const cleanForSpeech = (markdown: string): string =>
108  markdown
109    .replace(/```[\s\S]*?(```|$)/g, '\n')
110    .replace(/<[^>\n]+>/g, ' ')
111    .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
112    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
113    .replace(/https?:\/\/\S+/g, ' ')
114    .replace(/`([^`\n]*)`/g, '$1')
115    .replace(/(\*\*|__|~~)(\S[\s\S]*?)\1/g, '$2')
116    .replace(/\*(\S[^*\n]*?)\*/g, '$1')
117    .replace(/\p{Extended_Pictographic}️?/gu, '')
118    .split('\n')
119    .map(line => line.trim())
120    .filter(line => line !== '' && !isTableRule(line))
121    .map(line =>
122      isTableRow(line)
123        ? line.slice(1, -1).split('|').map(cell => cell.trim()).filter(Boolean).join(', ')
124        : line,
125    )
126    .map(line => line.replace(/^#{1,6}\s+/, '').replace(/^>\s?/, '').replace(/^(?:[-*+]|\d+[.)])\s+/, ''))
127    .filter(line => /[\p{L}\p{N}]/u.test(line))
128    .map(line => (/[.!?:;,]$/.test(line) ? line : `${line}.`))
129    .join('\n')
130    .slice(0, MAX_CHARS)
131
132// The installed voice of `lang` a person means by `name`: its full name, or one of its
133// words, so `Mónica` finds `Mónica (Spanish (Spain))` and `Helena` `Microsoft Helena Desktop`.
134export const findVoice = (voices: readonly Voice[], lang: Lang, name: string): Voice | undefined => {
135  const wanted = name.trim().toLowerCase()
136  const ofLang = voices.filter(voice => voice.locale.toLowerCase().startsWith(`${lang}_`))
137
138  return (
139    ofLang.find(voice => voice.name.toLowerCase() === wanted) ??
140    ofLang.find(voice => voice.name.toLowerCase().split(/[\s()]+/).includes(wanted))
141  )
142}
143
144export const isLang = (value: string): value is Lang => (LANGS as string[]).includes(value)
145
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    hablo: { speaking: string | null; frame: number; lastReply: string }
4  }
5}
6