Speak the last reply aloud on demand, in a macOS Personal Voice, with pause, skip and speed above the prompt.

Curated developer mods for Claude Code by See Stack.
Upgrade your terminal with live context window telemetry, compaction headroom alerts, and personal voice narration with media transport controls.
Add the See Stack Marketplace to your Claude Code installation:
claude plugin marketplace add see-stack/claude-code-mods
Then install any of the mods:
# 1. Interactive Context Bar with Headroom Tracking
claude plugin install context-bar@seestack-mods
# 2. Text-to-Speech Voice Player with Transport Scrubber & Queue
claude plugin install read-aloud@seestack-mods
To update mods anytime:
claude plugin update context-bar@seestack-mods
claude plugin update read-aloud@seestack-mods
Before installing, make sure your environment meets these requirements:
v2.1.287 or higher)Native plugin and mod support was introduced in Claude Code v2.1.287. Check your version:
claude --version
If you are on an older build, upgrade to the latest version:
npm install -g @anthropic-ai/claude-code
Once installed, verify that Claude Code recognizes the mods:
claude plugin list
If a mod shows as disabled, enable it with:
claude plugin enable context-bar@seestack-mods
claude plugin enable read-aloud@seestack-mods
read-aloud mod only)say / AVSpeechSynthesizer). System Settings > Accessibility > Spoken Content.context-barA live stacked context window audit HUD placed directly above your prompt with an interactive collapsible accordion dropdown so you never burn 40,000 tokens on stale logs or get surprised by auto-compaction.

detail), or hit [ Minimize ] to collapse back to a single compact line.▶ directly on memory files or skills to expand their itemized breakdown without repeating headers.compacts at 167k [38%])./context-bar inside any Claude Code session to toggle visibility.┌──────────────────────────────────────────────────────────────┐
│ ◆ context 75k of 200k · compacts at 167k 38% ▾ │
│ █■■■███████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ detail │
│ ▶ ■ system prompt █························· 2k 1% │
│ ▶ ■ system tools ██████████················ 19k 9.5% │
│ ▶ ■ memory files █························· 3.1k 1.5% │
│ ▶ ■ skills █························· 2.6k 1.3% │
│ ▶ ■ messages ██████████████████········ 49k 24% │
│ ─ free space ────────────────────────── 92k 46% │
│ ░ autocompact buf… ░░░░░░░░·················· 33k 17% │
│ │
│ slash commands 42 of 42 · 1.4k │
│ window claude-sonnet-5-5 · compacts at 167k │
│ │
│ [ Minimize ] │
└──────────────────────────────────────────────────────────────┘
read-aloudBrings a high-quality audio deck and Text-to-Speech into Claude Code with full media transport controls.

/read-aloud or click the read button above your prompt.| Key | Action | Description |
|---|---|---|
p | Pause / Resume | Instantly toggles speech playback |
s | Stop | Halts audio and clears speech buffer |
◀ / ▶ | Sentence Step | Steps one sentence backward or forward |
⏮ / ⏭ | Reply Step | Jumps across queued assistant replies |
r | Speed Multiplier | Cycles playback speed: 1× ➔ 1.25× ➔ 1.5× ➔ 2× |
| Slash Command / Key | What it does |
|---|---|
/context-bar | Toggles the Context Bar HUD above prompt |
/read-aloud | Speaks the latest response out loud |
/plugins | Opens the interactive Claude Code plugin manager |
/reload-plugins | Hot-reloads all mods without restarting your terminal session |
To inspect, tweak, or test these mods locally:
git clone https://github.com/see-stack/claude-code-mods.git
cd claude-code-mods
# Load mods in development mode:
claude --plugin-dir ./mods/context-bar
claude --plugin-dir ./mods/read-aloud
Reload changes instantly in your active session with /reload-plugins.
hooks/register.tsx 921 lines1// Read Aloud: speaks the reply on screen when asked, in the voice the Stop hook
2// already reads in, with transport.
3//
4// The hook this follows (claude-config/hooks/speak-output) finds the last turn by
5// tailing the transcript after every reply. This one is handed the reply instead:
6// `turn.complete` carries the turn's final visible text, so there is no transcript
7// to tail, no flush to wait out, and nothing to walk back through when the band
8// wants to know whether there is anything waiting to be read.
9//
10// Speech goes through EmberSpeak.app rather than $.audio.speak, because Ember is a
11// Personal Voice: macOS offers those to AVSpeechSynthesizer alone, under an
12// application identity, which is the whole reason that bundle exists. The built-in
13// $.audio.speak (the platform's own `say`) is the fallback for when the bundle is
14// gone - it cannot speak Ember, so it is handed Nora for a Norwegian reply and no
15// voice at all otherwise, and it has no transport at all.
16//
17// Transport lives in two halves, because a running process can be told nothing but
18// signals. Pause and resume are signals, and stop kills the process; speed and
19// position are settled at launch, so the app is relaunched from the offset its
20// progress file reports. That file is the only channel back from the app: one
21// line, "<offset> <state>", in UTF-16 units of the whole text - which is what the
22// mod seeks by, and what the app's own range callbacks count in too.
23//
24// A reading begins only when it is asked for, and flows as long as it has
25// anything left to read: a reply that arrives while it is talking takes a place
26// at the end of it rather than cutting in, and the reading stops flowing when the
27// last reply has been read. Nothing here reaches for a turn's reply on its own -
28// the Stop hook in settings.json is muted (CLAUDE_SPEAK_OFF) so that this mod is
29// the only voice, which is what makes a list of replies possible in the first
30// place.
31//
32// /read-aloud speak the last reply, or add it to the reading
33// /read-aloud stop silence it: what was after this reply is dropped
34// /read-aloud prev the reply before this one (the `prev` button)
35// /read-aloud forward the reply after it - `skip`, from before the button
36// /read-aloud clear drop what is after this reply, leave this one talking
37// /read-aloud back|next a sentence back or on, inside this reply
38// /read-aloud pause|resume|faster|slower
39//
40// The band above the prompt draws one row: the transport while something is
41// playing, an offer to read while a reply is waiting, and nothing at all when
42// there is neither. The transport names the voice, then draws one run of glyphs
43// with the progress bar in the middle of them: the reply before this one, stop, a
44// sentence back, the bar - filled and empty in the same two glyphs the context
45// band above it draws with, and measuring the reply being read - a sentence on,
46// play or pause, the rate, the reply after it, and how far into the reading it is.
47// It measures what it is about to draw rather than trusting a threshold - the
48// engine writes a plain Button's hotkey itself, and a glyph may draw two cells -
49// and what does not fit beside the heading takes a second row, which is what a
50// 54-column terminal needs. A reading that has ended leaves its list and the reply before
51// the last one on the ready row, since that is when going back is wanted.
52import { atom, read, update } from 'claude-code'
53import type { EngineInterface, PluginOptions, Register, SessionMessage } from 'claude-code'
54
55import type { Mine, Run, Speech } from '../types'
56
57type Config = {
58 voice: string
59 norwegianVoice: string
60 /** The multipliers the rate button walks, in order. */
61 rates: number[]
62 maxChars: number
63 speaker: string
64}
65
66/** The manifest's options as the engine hands them over: whatever is set over the
67 * defaults, with anything that makes no sense falling back rather than refusing
68 * to load - a bad rate list should cost the rate button, not the whole mod. */
69export function readOptions(options: PluginOptions): Config {
70 const text = (value: unknown, fallback: string) =>
71 typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
72 const rates = String(text(options.rates, DEFAULTS.rates))
73 .split(',')
74 .map(part => Number(part.trim()))
75 .filter(rate => Number.isFinite(rate) && rate > 0)
76 const asked = Number(options.maxChars)
77 return {
78 voice: text(options.voice, DEFAULTS.voice),
79 norwegianVoice: text(options.norwegianVoice, DEFAULTS.norwegianVoice),
80 rates: rates.length > 0 ? rates : [1],
81 maxChars: Number.isFinite(asked) ? Math.min(20000, Math.max(200, Math.round(asked))) : DEFAULTS.maxChars,
82 speaker: text(options.speaker, DEFAULTS.speaker),
83 }
84}
85
86// What the manifest's userConfig offers, with the value used when nothing is set.
87// Every one of these can be changed in /config, which stores what is chosen in
88// settings.json under pluginConfigs.
89const DEFAULTS = {
90 voice: 'Ember',
91 norwegianVoice: 'Nora',
92 rates: '1,1.25,1.5,2,0.75',
93 maxChars: 6000,
94 speaker: '~/Control-Panel/tools/ember-speak/EmberSpeak.app/Contents/MacOS/EmberSpeak',
95}
96// Both under the home directory read at session.start: a hooks module has no
97// environment of its own, and this keeps its runtime files where the hook keeps
98// its own (~/Control-Panel/local-config).
99const SCRATCH = '/Control-Panel/local-config/hooks/read-aloud/speak.txt' // the text being read
100const PROGRESS = '/Control-Panel/local-config/hooks/read-aloud/speak.state' // where the app is
101// $.audio.speak speaks the first 4096 characters and says so; cut it here instead,
102// so the truncation is ours and lands at a word.
103const MAX_CHARS_FALLBACK = 4096
104const POLL_MS = 1000
105const IDLE_EVERY = 5 // idle, look every fifth tick rather than every tick
106const GRACE_MS = 3000 // a launch is not up this soon: never read that as "done"
107const KILL_SETTLE_MS = 150 // a killed speaker needs a moment to let go of the audio
108const GAP = 2 // cells between the bar and the first control on its row
109// The transport as glyphs rather than words: at 54 columns the words for seven
110// controls do not fit on one row, and a plain Button whose label is one glyph
111// draws as that glyph and nothing else. Bare, so the engine prints no hotkey in
112// front of them - the words are still there as /read-aloud arguments.
113const GLYPH = { play: '▶', pause: '⏸', stop: '⏹', prev: '⏮', forward: '⏭', back: '‹', next: '›' } as const
114// What the media glyphs are counted at, and what they may draw in: counting one
115// cell for a glyph a font gives two would overflow the row it was chosen to fit.
116const GLYPH_CELLS = 2
117const RUN_MAX = 6 // replies a reading holds; past this the oldest falls off the front
118const BAR = 12 // cells in the progress bar
119// The context band's own two: the accent it marks this conversation with, and the
120// grey it leaves empty room in. Filled and empty cells are its glyphs too.
121const ACCENT = '#d97757'
122const FREE = '#808080'
123
124// What the person has set, read again whenever the module loads.
125let config = readOptions({})
126
127// Held by the host, so the band survives a hot reload of this file.
128const speech = atom({ plugin: 'read-aloud', key: 'speech' } as const, null as Speech | null)
129
130let home = ''
131let ticks = 0
132
133export const register: Register = (on, options) => {
134 config = readOptions(options)
135 on('session.start', async ($, e, next) => {
136 const r = await next(e)
137 // A name Claude Code already has is refused: start anyway.
138 await $.command
139 .register({ name: 'read-aloud', description: 'Speak the last reply aloud', argumentHint: '[stop|prev|forward|back|next|clear|pause|resume|faster|slower]' })
140 .catch(() => {})
141 home = await homeDir($)
142 $.clock.every(POLL_MS, () => void poll($))
143 return r
144 })
145
146 // The reply, kept for the band to offer and for the command to read out. A
147 // subagent's turn is not this conversation's, and a turn that answered nothing
148 // leaves whatever was waiting before it still waiting.
149 on('turn.complete', async ($, e, next) => {
150 const r = await next(e)
151 if (e.agentId) return r
152 const answer = e.reason === 'answer' ? speakable(e.answer, config.maxChars) : ''
153 if (answer === '') return r
154 const s = await read($, speech)
155 const run = s?.run
156 // Inside a reading the reply takes a place at its end, and stops being what
157 // the band offers: it is not waiting to be asked for any more, it is going to
158 // be read. Speech with no list behind it - the fallback voice - leaves it on
159 // offer instead, since there is no reading for it to join.
160 if (run && flowing(s)) {
161 await merge($, { run: joinRun(run, answer), answer: undefined })
162 return r
163 }
164 // A reply arriving after a reading has ended ends that reading: the band is
165 // back to offering one reply, and `prev` no longer walks through the old one.
166 await merge($, { answer, run: undefined })
167 return r
168 })
169
170 on('command.run', { command: 'read-aloud' }, async ($, e) => {
171 switch (e.args.trim().toLowerCase()) {
172 case 'stop':
173 await stop($)
174 return { text: 'Speech stopped.' }
175 case 'pause':
176 return { text: (await setPaused($, true)) ? 'Paused.' : 'Nothing is playing.' }
177 case 'resume':
178 return { text: (await setPaused($, false)) ? 'Resumed.' : 'Nothing is paused.' }
179 // Replies: prev and forward walk the reading, as the buttons of those names
180 // do. skip is forward's other name, from before there was a button.
181 case 'prev':
182 return { text: await prevReply($) }
183 case 'forward':
184 case 'skip':
185 return { text: await forwardReply($) }
186 case 'clear':
187 return { text: await clearAhead($) }
188 // Sentences, inside whichever reply is playing.
189 case 'back':
190 return { text: await seek($, -1) }
191 case 'next':
192 return { text: await seek($, 1) }
193 case 'faster':
194 return { text: await cycleRate($, 1) }
195 case 'slower':
196 return { text: await cycleRate($, -1) }
197 }
198 return { text: await readLast($) }
199 })
200
201 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202 const rest = await next(e) // what other mods and Claude Code draw here stays
203 const s = await read($, speech)
204 const playing = s?.isSpeaking === true
205 const waiting = s?.answer ?? ''
206 if (e.props.hasSurvey || (!playing && waiting === '')) return rest
207 const { Box, Text, Button } = $.ui.resolve(e)
208
209 if (!playing) {
210 // A reading that has ended leaves its list behind, so the reply before the
211 // one on offer is still one press away rather than gone.
212 const behind = (s?.run?.at ?? 0) > 0
213 return (
214 <Box flexDirection="column">
215 {rest}
216 <Box flexDirection="row" paddingX={1}>
217 <Text color={ACCENT}>{'♪ '}</Text>
218 <Text dimColor>{`${looksNorwegian(waiting) ? config.norwegianVoice : config.voice} · ready`}</Text>
219 <Text>{' '}</Text>
220 {/* The same two glyphs the transport uses, for the same two things:
221 play this reply, or go back to the one before it. */}
222 <Button key="read" plain label={GLYPH.play} onPress={() => readLast($)} />
223 {behind && <Text>{' '}</Text>}
224 {behind && <Button key="prev" plain label={GLYPH.prev} onPress={() => prevReply($)} />}
225 </Box>
226 </Box>
227 )
228 }
229
230 const mine = s.mine
231 const run = s.run
232 const filled = mine ? fill(share(mine), BAR) : 0
233 // What the head says, and how wide it is. Speech with no text behind it - the
234 // fallback voice - has no bar and no count, which is why this is a number and
235 // not a constant.
236 const voice = mine ? mine.voice : s.isPaused ? 'speaking · paused' : 'speaking'
237 const count = run && run.items.length > 1 ? `· ${run.at + 1} of ${run.items.length}` : ''
238 const head = 2 + voice.length
239
240 // The controls, and the sets of them to try in turn: the sentences inside one
241 // reply go before the rate, and both before the reply pair the row is for.
242 // Pause and stop are the floor - pause is the only control that reaches speech
243 // with no text behind it, and stop is the only way out.
244 //
245 // The bar is one of the row's items rather than something the head draws, so
246 // that it sits in the middle of the glyphs - between the sentence steps it is
247 // measuring the distance within - and so that the row is measured with it.
248 // Its onPress is never called: no Button is made of it, it draws two Texts.
249 const bar: Control = { key: 'bar', label: '', cells: BAR, onPress: () => {} }
250 // How far into the reading it is, as the row's last item rather than something
251 // the heading carries: it belongs with the transport it counts.
252 const counter: Control = { key: 'count', label: count, cells: count.length, onPress: () => {} }
253 const pause: Control = {
254 key: 'pause',
255 label: s.isPaused ? GLYPH.play : GLYPH.pause,
256 cells: GLYPH_CELLS,
257 onPress: () => setPaused($, !s.isPaused),
258 }
259 const prev: Control = { key: 'prev', label: GLYPH.prev, cells: GLYPH_CELLS, onPress: () => prevReply($) }
260 const stopIt: Control = { key: 'stop', label: GLYPH.stop, cells: GLYPH_CELLS, onPress: () => stop($) }
261 // Not `next`: that is the hook's own third argument.
262 const stepBack: Control = { key: 'back', label: GLYPH.back, onPress: () => seek($, -1) }
263 const stepOn: Control = { key: 'next', label: GLYPH.next, onPress: () => seek($, 1) }
264 // The rate keeps its number: a glyph for it would say nothing about which rate,
265 // and the number is what the button is for.
266 const rate: Control = { key: 'rate', label: `${mine?.rate ?? 1}×`, onPress: () => cycleRate($, 1) }
267 const forward: Control = { key: 'forward', label: GLYPH.forward, cells: GLYPH_CELLS, onPress: () => forwardReply($) }
268
269 // Left to right, the order the row draws: back a reply and stop it, the two
270 // sentence steps with the bar between them, then the toggle, the rate, and on
271 // to the next reply.
272 const controls: Control[] = [
273 ...(run ? [prev] : []),
274 stopIt,
275 ...(mine ? [stepBack, bar, stepOn] : []),
276 pause,
277 ...(mine ? [rate] : []),
278 ...(run ? [forward] : []),
279 ...(count === '' ? [] : [counter]),
280 ]
281 const withoutSteps = controls.filter(c => c.key !== 'back' && c.key !== 'next')
282 const withoutRate = withoutSteps.filter(c => c.key !== 'rate')
283 const floor = controls.filter(c => c.key === 'pause' || c.key === 'stop' || c.key === 'bar')
284 const sets = [controls, withoutSteps, withoutRate, floor]
285 // Measured, never guessed: a plain Button is drawn by the engine as "p: pause",
286 // so a row of five is about ten cells wider than its labels look. Beside the bar
287 // if that fits what the row is for, and otherwise the bar keeps its own row and
288 // the controls take the next one - but only when that buys more of them, since a
289 // narrow terminal pays for the second row in prompt space.
290 const room = (e.props.bodyColumns ?? 0) - 2 // the row's own padding, one cell either side
291 const beside = widestFit(sets, room - head - GAP)
292 const below = widestFit(sets, room)
293 const split = below.length > beside.length
294 const chosen = split ? below : beside
295 const buttons = chosen.flatMap((c, i) => {
296 const gap = i === 0 ? [] : [<Text key={`gap-${c.key}`}>{' '}</Text>]
297 // The bar is the one item that is drawn rather than pressed: where the app
298 // says it is, in the accent, and the room left in the grey.
299 if (c.key === 'bar') {
300 return [
301 ...gap,
302 <Text key="bar-played" color={ACCENT}>{'█'.repeat(filled)}</Text>,
303 <Text key="bar-left" color={FREE}>{'─'.repeat(BAR - filled)}</Text>,
304 ]
305 }
306 if (c.key === 'count') return [...gap, <Text key="count" dimColor>{count}</Text>]
307 return [...gap, <Button key={c.key} plain hotkey={c.hotkey} label={c.label} onPress={c.onPress} />]
308 })
309
310 // Drawn after `rest` rather than before it, so this row is the band's last
311 // one whichever way the engine nests the two: outermost, it follows what the
312 // other mods drew; innermost, this whole box is already placed beneath them.
313 return (
314 <Box flexDirection="column">
315 {rest}
316 <Box flexDirection="row" paddingX={1}>
317 <Text color={ACCENT}>{'♪ '}</Text>
318 {/* Which voice, and how far into the reading: the bar itself is in the
319 middle of the glyphs, so the row repeats no percentage. */}
320 <Text dimColor>{voice}</Text>
321 {count !== '' && <Text dimColor>{count}</Text>}
322 {!split && chosen.length > 0 && <Text>{' '}</Text>}
323 {!split && buttons}
324 </Box>
325 {split && <Box flexDirection="row" paddingX={1}>{buttons}</Box>}
326 </Box>
327 )
328 })
329}
330
331/** How far into the text the app says it is, as a percentage. */
332function share(mine: Mine) {
333 return mine.chars > 0 ? (mine.offset / mine.chars) * 100 : 0
334}
335
336/** How many cells of a `width`-wide bar a percentage fills, clamped: the app can
337 * report an offset at or past the end of the text it was handed. */
338export function fill(percent: number, width: number) {
339 return Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
340}
341
342/** One control of the transport, as the row draws it. */
343export type Control = {
344 key: string
345 label: string
346 /** The cells the label draws in, where that is not its length: a glyph may draw
347 * two, and a row measured by `length` would then be one that overflows. */
348 cells?: number
349 hotkey?: string
350 onPress: () => void | Promise<unknown>
351}
352
353/** How wide a row of controls draws. A plain Button carrying a hotkey is written
354 * by the engine itself, as "p: pause" - the hotkey, a colon, the label - so the
355 * widths are not the labels' own, and a row that looks as if it fits may not. */
356export function controlsWidth(controls: readonly Control[]) {
357 const labels = controls.reduce((n, c) => n + (c.hotkey ? c.hotkey.length + 2 : 0) + (c.cells ?? c.label.length), 0)
358 return labels + Math.max(0, controls.length - 1) // the single space between two of them
359}
360
361/** The first of `sets` that fits in `columns` - widest first - or nothing when
362 * none of them does, which is a row too narrow for any control at all. */
363export function widestFit(sets: readonly (readonly Control[])[], columns: number) {
364 return sets.find(set => controlsWidth(set) <= columns) ?? []
365}
366
367// One place the state changes, so the band is asked to draw again from one place.
368async function merge($: EngineInterface, patch: Partial<Speech>) {
369 await update($, speech, prev => ({
370 isSpeaking: false,
371 isPaused: false,
372 startedAt: 0,
373 ...prev,
374 ...patch,
375 })).catch(() => {})
376 $.ui.invalidate('ui.render')
377}
378
379// ---- the reading -----------------------------------------------------------
380//
381// A reading is a list of replies and a cursor into it, not a queue: `prev` needs
382// somewhere to go back to, and the reply it goes back to has to still be there.
383// Whatever is after the cursor is still to be read, in order; whatever is before
384// it has been read and can be read again.
385
386/** Whether a reading is going on by itself: something is playing, or a reply is
387 * still to be read after the one at the cursor. Inside one a new reply joins the
388 * list; outside one nothing is read until it is asked for, which is what keeps
389 * this mod from speaking over a session the person is reading themselves. */
390export function flowing(s: Speech | null) {
391 const run = s?.run
392 if (s?.isSpeaking === true) return true
393 return run ? run.at + 1 < run.items.length : false
394}
395
396/** Where a step lands in a reading, or null at either end of it. */
397export function step(run: Run, direction: 1 | -1) {
398 const at = run.at + direction
399 return at >= 0 && at < run.items.length ? at : null
400}
401
402/** A reading with one more reply at its end, and the cursor moved back by however
403 * many fell off the front to keep the list at `max`. */
404export function joinRun(run: Run, text: string, max = RUN_MAX): Run {
405 const items = [...run.items, text].slice(-max)
406 return { items, at: Math.max(0, run.at - (run.items.length + 1 - items.length)) }
407}
408
409/** Moves the cursor back one reply and reads it. The reply it is leaving stays in
410 * the list, so `forward` walks straight back to it. */
411async function prevReply($: EngineInterface) {
412 const s = await read($, speech)
413 const run = s?.run
414 const at = run ? step(run, -1) : null
415 if (!run || at === null) return 'This is the first reply of the reading.'
416 return (await playAt($, s, run, at)) ? 'Reading the reply before this one.' : 'Could not reach EmberSpeak.'
417}
418
419/** The other way: on to the reply after this one, whether it is waiting mid-reading
420 * or reached from the row that a finished reading leaves behind. */
421async function forwardReply($: EngineInterface) {
422 const s = await read($, speech)
423 const run = s?.run
424 const at = run ? step(run, 1) : null
425 if (!run || at === null) return 'This is the last reply of the reading.'
426 return (await playAt($, s, run, at)) ? 'Reading the next reply.' : 'Could not reach EmberSpeak.'
427}
428
429/** Drops everything after the cursor and leaves what is playing alone: the reading
430 * then ends with the reply that is talking instead of going on to the rest. */
431async function clearAhead($: EngineInterface) {
432 const run = (await read($, speech))?.run
433 const n = run ? run.items.length - run.at - 1 : 0
434 if (!run || n === 0) return 'Nothing is waiting to be read.'
435 await merge($, { run: { items: run.items.slice(0, run.at + 1), at: run.at } })
436 return `Cleared ${n} ${n === 1 ? 'reply' : 'replies'} from the reading.`
437}
438
439// Puts the cursor on `at` and starts reading that reply, at the rate the reading
440// is already at - a rate chosen mid-reply is a choice about the reading, not about
441// that one reply.
442//
443// The handoff is stamped as a launch: between here and the app being up, the poll
444// has to read the state as "starting", not as "finished, go on to the next one".
445//
446// When the app cannot be reached the reading is dropped rather than left standing:
447// a list nothing is playing is a reading that never ends, and every reply after it
448// would be filed behind a voice that is not coming.
449async function playAt($: EngineInterface, s: Speech | null, run: Run, at: number) {
450 const text = run.items[at]
451 if (text === undefined) return false
452 await merge($, { run: { items: run.items, at }, isSpeaking: false, isPaused: false, startedAt: Date.now(), mine: undefined })
453 const voice = await voiceFor($, text)
454 if (await start($, text, voice, s?.mine?.rate ?? 1, 0)) return true
455 await merge($, { run: undefined, isSpeaking: false, isPaused: false, startedAt: 0, mine: undefined })
456 $.ui.log('read-aloud: could not start the next reply')
457 return false
458}
459
460// The end of a reading's last reply: the list stays and the cursor stays on it, so
461// `prev` still walks back through it, and the reply just read goes back to being
462// what the band offers. Nothing after the cursor means nothing starts on its own.
463async function drained($: EngineInterface, run: Run) {
464 await merge($, {
465 isSpeaking: false,
466 isPaused: false,
467 startedAt: 0,
468 mine: undefined,
469 run,
470 answer: run.items[run.at],
471 })
472}
473
474async function homeDir($: EngineInterface) {
475 try {
476 const { exitCode, stdout } = await $.process.run(['/usr/bin/printenv', 'HOME'], { timeoutMs: 5000 })
477 return exitCode === 0 ? stdout.trim() : ''
478 } catch {
479 return ''
480 }
481}
482
483function path(under: string) {
484 return home + under
485}
486
487// A configured path, with ~ standing for the home read at session.start.
488function expand(configured: string) {
489 return configured.startsWith('~/') ? home + configured.slice(1) : configured
490}
491
492// ---- reading it ------------------------------------------------------------
493
494// The reply waiting to be read: what the last turn answered, or - when the session
495// was resumed or this mod reloaded and holds none - the last reply in the
496// conversation, which is what it would have been.
497async function waiting($: EngineInterface) {
498 const s = await read($, speech)
499 if (s?.answer) return s.answer
500 return speakable(lastReply(await $.session.messages().catch(() => [])), config.maxChars)
501}
502
503async function readLast($: EngineInterface) {
504 const text = await waiting($)
505 if (text === '') return 'Nothing to read: nothing has been answered in this conversation yet.'
506 const s = await read($, speech)
507 // Asked for inside a reading, the reply takes a place at its end rather than
508 // cutting in. The reply already talking - or already in the list - is not added
509 // a second time.
510 const run = s?.run
511 if (run && flowing(s)) {
512 if (text === (await speakingText($)) || run.items.includes(text)) return 'Already reading that one.'
513 const joined = joinRun(run, text)
514 await merge($, { run: joined, answer: undefined })
515 const waiting = joined.items.length - joined.at - 1
516 return `Queued. ${waiting} ${waiting === 1 ? 'reply' : 'replies'} to come.`
517 }
518 // Nothing is playing, so this is a reading of one reply, from the start. The
519 // cursor is the list's one entry; an answer already on offer stops being one.
520 const voice = await voiceFor($, text)
521 if (await start($, text, voice, 1, 0)) {
522 await merge($, { run: { items: [text], at: 0 }, answer: undefined })
523 return `Speaking ${text.length} characters with ${voice}.`
524 }
525 return await fallback($, text, voice)
526}
527
528// ---- transport ------------------------------------------------------------
529
530// Pause and resume of the app's own voice, which keeps the synthesizer's place:
531// the process is not frozen, so it stops at a word and picks up from there. It
532// reaches whatever EmberSpeak is speaking, the hook's replies included - usefully
533// so, since the hook reads every turn and this is the only way to stop it talking.
534async function setPaused($: EngineInterface, paused: boolean) {
535 const s = await read($, speech)
536 if (!s?.isSpeaking) return false
537 const signal = paused ? '-USR1' : '-USR2'
538 try {
539 await $.process.run(['/usr/bin/pkill', signal, '-x', 'EmberSpeak'], { timeoutMs: 5000 })
540 } catch {
541 return false
542 }
543 await merge($, { isPaused: paused })
544 return true
545}
546
547// Speed and position cannot be changed in a running app, so the reply is read
548// again from where it had got to, at the new rate. The progress file is what
549// makes that exact: the app reports the offset every word.
550async function cycleRate($: EngineInterface, direction: 1 | -1) {
551 const s = await read($, speech)
552 const mine = s?.mine
553 if (!mine) return 'Nothing is playing.'
554 const at = config.rates.indexOf(mine.rate)
555 const next = config.rates[(at + direction + config.rates.length) % config.rates.length]!
556 const text = await speakingText($)
557 if (!(await start($, text, mine.voice, next, mine.offset))) return 'Could not reach EmberSpeak.'
558 return `Reading again at ${next}×.`
559}
560
561async function seek($: EngineInterface, direction: 1 | -1) {
562 const s = await read($, speech)
563 const mine = s?.mine
564 if (!mine) return 'Nothing is playing.'
565 const text = await speakingText($)
566 const offset = sentenceStart(text, mine.offset, direction)
567 if (offset >= text.length) {
568 // Past the end of this reply there is no sentence left to step to: it is over,
569 // and the reply after it either starts now or the reading ends here.
570 const run = s?.run
571 const next = run ? step(run, 1) : null
572 if (run && next !== null && (await playAt($, s, run, next))) return 'Reading the next reply.'
573 await stop($)
574 return 'End of the reply.'
575 }
576 await start($, text, mine.voice, mine.rate, offset)
577 return direction === 1 ? 'Skipped a sentence forward.' : 'Skipped a sentence back.'
578}
579
580// The text being read, from the file the app is reading it out of - so a seek or
581// a rate change hands back exactly what is playing, not a fresh reading of the
582// conversation.
583async function speakingText($: EngineInterface) {
584 if (!home) return ''
585 return await $.fs.read(path(SCRATCH)).catch(() => '')
586}
587
588// Stopping ends the reading, not the list. What was after the cursor is dropped,
589// so nothing starts up again on its own; the reply it stopped on goes back to
590// being what the band offers, and `prev` still has somewhere to walk back to.
591async function stop($: EngineInterface) {
592 const run = (await read($, speech))?.run
593 await kill($)
594 const patch: Partial<Speech> = { isSpeaking: false, isPaused: false, startedAt: 0, mine: undefined }
595 if (run) {
596 patch.run = { items: run.items.slice(0, run.at + 1), at: run.at }
597 patch.answer = run.items[run.at]
598 } else {
599 patch.run = undefined
600 }
601 await merge($, patch)
602}
603
604// Kills by name, which is the only handle there is: neither $.process.run nor
605// $.process.spawn reports a pid. It stops the hook's speech too, on purpose - the
606// two of them reading at once is the thing to avoid.
607async function kill($: EngineInterface) {
608 try {
609 await $.process.run(['/usr/bin/pkill', '-x', 'EmberSpeak'], { timeoutMs: 5000 })
610 } catch {
611 // Nothing to kill, or no pkill: whatever follows still runs.
612 }
613}
614
615// ---- the app ---------------------------------------------------------------
616
617// Writes `text` and reads it from the start, replacing anything already playing.
618async function start($: EngineInterface, text: string, voice: string, rate: number, offset: number) {
619 if (!home || text === '') return false
620 await kill($)
621 try {
622 await $.fs.write(path(SCRATCH), text)
623 } catch {
624 return false
625 }
626 // A killed speaker takes a moment to let go of the audio device, and the one
627 // launched into that moment is the one that comes out silent.
628 await $.clock.sleep(KILL_SETTLE_MS).catch(() => {})
629 return await launch($, voice, rate, offset, text.length)
630}
631
632// Launches the app detached, and returns as soon as it is away.
633//
634// $.process.run waits for its child, and EmberSpeak holds itself open for as long
635// as the speech lasts - six minutes for a long reply - so the launch has to go
636// through a shell that backgrounds it. Both streams and the child's stdin are
637// redirected inside that command: run() reads the pipes until they close, and a
638// grandchild still holding them would keep it waiting exactly as an awaited child
639// does. The `-x` guard is what reports the bundle missing, which is the one case
640// the built-in voice is used instead.
641async function launch($: EngineInterface, voice: string, rate: number, offset: number, chars: number) {
642 const speaker = expand(config.speaker)
643 const scratch = path(SCRATCH)
644 const progress = path(PROGRESS)
645 const cmd = [
646 `if [ -x ${q(speaker)} ]; then`,
647 `nohup ${q(speaker)} -v ${q(voice)} -r ${rate} -s ${offset} -p ${q(progress)} -f ${q(scratch)}`,
648 `</dev/null >/dev/null 2>&1 & else exit 3; fi`,
649 ].join(' ')
650 try {
651 const { exitCode } = await $.process.run(['/bin/sh', '-c', cmd], { timeoutMs: 10_000 })
652 if (exitCode !== 0) return false
653 } catch {
654 return false
655 }
656 await merge($, {
657 isSpeaking: true,
658 isPaused: false,
659 startedAt: Date.now(),
660 mine: { voice, chars, rate, offset },
661 })
662 return true
663}
664
665// The platform's own synthesizer, for when EmberSpeak is not there. Not awaited: it
666// resolves when the utterance ends, which can be minutes, and nothing here waits on
667// that. Ember is a Personal Voice that `say` cannot speak, so an English reply is
668// left to the system default voice rather than naming one macOS would substitute.
669// It has no transport, so it is not marked as this mod's own.
670async function fallback($: EngineInterface, text: string, voice: string) {
671 const spoken = speakable(text, MAX_CHARS_FALLBACK)
672 void $.audio
673 .speak(spoken, voice === config.voice ? {} : { voice })
674 .catch((err: unknown) => $.ui.log(`read-aloud: could not speak: ${String(err)}`))
675 await merge($, { isSpeaking: true, isPaused: false, startedAt: Date.now(), mine: undefined })
676 const spokenWith = voice === config.voice ? 'the system voice' : voice
677 return `EmberSpeak is not installed: spoke ${spoken.length} characters with ${spokenWith} instead.`
678}
679
680async function voiceFor($: EngineInterface, text: string) {
681 if (!looksNorwegian(text)) return config.voice
682 return (await listsVoiceNow($, config.norwegianVoice)) ? config.norwegianVoice : config.voice
683}
684
685// Asks `say` what it has, the way the hook does: EmberSpeak resolves a name it
686// cannot find to the system default rather than failing, which would read Norwegian
687// in an English voice. Only ever asked about a Norwegian reply.
688async function listsVoiceNow($: EngineInterface, name: string) {
689 try {
690 const { stdout } = await $.process.run(['/usr/bin/say', '-v', '?'], { timeoutMs: 5000 })
691 return listsVoice(stdout, name)
692 } catch {
693 return false
694 }
695}
696
697// ---- watching it -----------------------------------------------------------
698
699async function poll($: EngineInterface) {
700 const s = await read($, speech)
701 const was = s?.isSpeaking === true
702 // While something is playing, look every tick, so the position keeps up and the
703 // band leaves as soon as the voice does; idle, every fifth, so a quiet session is
704 // not spawning a process a second to learn nothing.
705 if (ticks++ % (was ? 1 : IDLE_EVERY) !== 0) return
706 const playing = await isPlaying($)
707 // The progress file belongs to this mod's own launches. Speech the hook started
708 // has none, and what is left in the file from the last reading is not about it:
709 // read then, a stale "done" would take the band down while a voice is talking.
710 const progress = playing && s?.mine ? await readProgress($) : null
711 // A speaker that has said everything is finished, whether or not its process has
712 // gone yet.
713 const live = playing && progress?.state !== 'done'
714 const moved = progress !== null && progress.offset !== s?.mine?.offset
715 // With no progress file there is nothing to say what the voice is doing, so the
716 // pause this mod was asked for stands until this mod is asked to lift it.
717 const paused = progress ? progress.state === 'paused' : s?.isPaused === true
718 // A reply still to be read keeps the poll interested even with nothing playing:
719 // that is the handoff between two of them, and the case where the speaker never
720 // came up at all.
721 const run = s?.run
722 const next = run ? step(run, 1) : null
723 if (live === was && !moved && paused === (s?.isPaused === true) && next === null) return
724 // Right after a launch EmberSpeak is not up yet - and the handoff to the next
725 // reply is a launch too - so a reading taken in that window would take the band
726 // down and start the reply after the one being started.
727 if (!live && Date.now() - (s?.startedAt ?? 0) < GRACE_MS) return
728 if (!live) {
729 // The end of a reply is where the next one begins: the app has stopped and a
730 // reply is still to be read, so the reading goes on without being asked for it
731 // again. This is also how a list whose speaker never came up is read or
732 // dropped, rather than left to hold every reply after it.
733 if (run && next !== null) {
734 await playAt($, s, run, next)
735 return
736 }
737 // Nothing after the cursor is the last reply read: the list stays for `prev`,
738 // and the reply just read goes back to being what the band offers.
739 if (run && run.items.length > 0) {
740 await drained($, run)
741 return
742 }
743 await merge($, { isSpeaking: false, isPaused: false, startedAt: 0, mine: undefined, run: undefined })
744 return
745 }
746 await merge($, {
747 isSpeaking: true,
748 isPaused: paused,
749 startedAt: s?.mine ? (s.startedAt ?? Date.now()) : Date.now(),
750 mine: s?.mine ? { ...s.mine, offset: progress?.offset ?? s.mine.offset } : undefined,
751 })
752}
753
754/** The app's progress file: where it is, and whether it is speaking. */
755export function parseProgress(raw: string) {
756 const [offset, state] = raw.trim().split(/\s+/)
757 const at = Number(offset)
758 if (!Number.isFinite(at)) return null
759 if (state !== 'speaking' && state !== 'paused' && state !== 'done') return null
760 return { offset: at, state }
761}
762
763async function readProgress($: EngineInterface) {
764 if (!home) return null
765 const raw = await $.fs.read(path(PROGRESS)).catch(() => '')
766 return parseProgress(raw)
767}
768
769async function isPlaying($: EngineInterface) {
770 try {
771 const { exitCode } = await $.process.run(['/usr/bin/pgrep', '-x', 'EmberSpeak'], { timeoutMs: 5000 })
772 return exitCode === 0
773 } catch {
774 return false
775 }
776}
777
778// ---- text ------------------------------------------------------------------
779
780/** True when a `say -v '?'` listing carries a voice by that name. */
781export function listsVoice(listing: string, name: string) {
782 const wanted = name.trim().toLowerCase()
783 if (wanted === '') return false
784 return listing
785 .split('\n')
786 .some(line => (line.trim().split(/\s{2,}/)[0] ?? '').trim().toLowerCase() === wanted)
787}
788
789/** The assistant text of the last turn: what is on screen as the last reply. */
790export function lastReply(messages: readonly SessionMessage[]) {
791 const turn: string[] = []
792 let started = false
793 for (let i = messages.length - 1; i >= 0; i--) {
794 const m = messages[i]!
795 // A user row is the command's own record, a notice, or a tool result: the turn
796 // has not begun until some assistant text has been read, and a row before that
797 // is walked past rather than stopped at.
798 if (m.role === 'user') {
799 if (started) break
800 continue
801 }
802 // An assistant row with no text is a step that only called tools. It joins
803 // nothing and marks nothing: taken as the turn's start, it would make the walk
804 // stop at the next tool result and read out an empty reply.
805 const text = m.text.trim()
806 if (text === '') continue
807 turn.unshift(text)
808 started = true
809 }
810 return turn.join('\n').trim()
811}
812
813/** Where the sentence one step away from `offset` begins, in UTF-16 units. */
814export function sentenceStart(text: string, offset: number, direction: 1 | -1) {
815 const starts = [0]
816 const ends = /[.!?\n]+[ \t]*/g
817 let m: RegExpExecArray | null
818 while ((m = ends.exec(text)) !== null) starts.push(m.index + m[0].length)
819 let i = 0
820 while (i + 1 < starts.length && starts[i + 1]! <= offset) i++
821 if (direction === 1) return i + 1 < starts.length ? starts[i + 1]! : text.length
822 // Back: the start of the sentence being read, and the one before it when the
823 // offset is already sitting on that start.
824 return offset > starts[i]! ? starts[i]! : starts[i - 1] ?? 0
825}
826
827/** Norwegian rather than English, by word vote; English wins ties and near-ties. */
828export function looksNorwegian(text: string) {
829 const lowered = text.toLowerCase()
830 const words = lowered.match(/[a-zæøå]+/g) ?? []
831 if (words.length < 3) return false
832 let nb = 0
833 let en = 0
834 for (const w of words) {
835 if (NORWEGIAN_WORDS.has(w)) nb++
836 else if (ENGLISH_WORDS.has(w)) en++
837 }
838 // æ and ø are Norwegian/Danish only, so they count for more than the words.
839 // å is shared with Swedish and Danish, so on its own it proves nothing.
840 const marks = (lowered.match(/[æø]/g) ?? []).length
841 return 2 * nb + 3 * marks > 2 * en && nb + marks >= 2
842}
843
844/** Strips the markdown that reads badly out loud, and caps the length. */
845export function speakable(text: string, max: number) {
846 return truncate(
847 text
848 .replace(/```[\s\S]*?```/g, ' ')
849 .replace(/~~~[\s\S]*?~~~/g, ' ')
850 .replace(/^[ \t]*\|.*\|[ \t]*$/gm, tableRow)
851 .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
852 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
853 .replace(/^ {0,3}#{1,6}[ \t]*/gm, '')
854 .replace(/^ {0,3}>[ \t]?/gm, '')
855 .replace(/^ {0,3}[-*+][ \t]+/gm, '')
856 .replace(/^[ \t]*([-*_])\1{2,}[ \t]*$/gm, '')
857 .replace(/\*\*|__|`|~~/g, '')
858 .replace(/(?<!\w)[*_](\S[^*_]*?)[*_](?!\w)/g, '$1')
859 .replace(/[ \t]+/g, ' ')
860 .replace(/\n{2,}/g, '\n')
861 .trim(),
862 max,
863 )
864}
865
866// A table row as it is read out: its cells, and nothing at all for a rule.
867function tableRow(row: string) {
868 const cells = row
869 .trim()
870 .replace(/^\|/, '')
871 .replace(/\|$/, '')
872 .split('|')
873 .map(c => c.trim())
874 if (cells.every(c => /^[-: ]*$/.test(c))) return ''
875 return cells.filter(c => c !== '').join(', ')
876}
877
878function truncate(text: string, max: number) {
879 if (text.length <= max) return text
880 const cut = text.slice(0, max)
881 const at = cut.lastIndexOf(' ')
882 return `${at > 0 ? cut.slice(0, at) : cut}. Output truncated.`
883}
884
885// One argument, quoted for the shell this app is launched through. Only ever our
886// own paths, voice names and numbers: the reply text travels through the scratch
887// file, so nothing a reply contains can reach a command line.
888function q(argument: string | number) {
889 return `'${String(argument).replace(/'/g, `'\\''`)}'`
890}
891
892// Function words that are Norwegian and not English. Every entry has to be a word
893// English does not also use, because a false positive here means an English reply
894// read out in Norwegian, which is worse than the reverse.
895const NORWEGIAN_WORDS = new Set(
896 `er og å et en så ikke jeg det som til på med av den han hun fra om når hva
897 hvordan hvorfor også skal kan har hadde blir ble vært gjør gjorde ser får
898 fikk kommer kom tok gir ga sier sa vet tror tenker trenger bruker brukte
899 lager lagde finner fant sjekker kjører virker fungerer feil ting dag tid sted
900 fil filen filer kode jobb måte litt mer mest alle noen ingen ingenting
901 der nå da jo nok kanskje selv sammen tilbake igjen fortsatt allerede
902 snart nesten godt dårlig stor liten ny gammel første siste neste samme annen
903 hver hvilken hvem hvor denne dette disse vil må være oss dere dem seg
904 sitt våre kun helt veldig mye
905 endret endrer endre endring stemme språk norsk norske prøv prøve prøver
906 tekst teksten linje linjen svar svare svarte ord ordet navn navnet verdi
907 verdien lese leser skrive skriver skrevet bruk bruke brukt lage laget
908 kjør kjøre kjørt bygge bygget feilen eksempel eksempler mål målet hjelp
909 hjelpe hjelper sjekk sjekke vise viser viste gå går gikk finne
910 fordi derfor hvis etter før mens ganske rundt gjennom mellom opp videre
911 egen eget egne hele halv ganger tusen hundre klokka klokken uke uken
912 måned år`.split(/\s+/),
913)
914
915const ENGLISH_WORDS = new Set(
916 `the is are was were and of to in that it you we they this with for on have
917 has had be been not but from at as by or so if then than there their them
918 what when where which who how why all any some more most very just only also
919 into out up down over under`.split(/\s+/),
920)
921types/index.d.ts 44 lines1/** Speech this mod started, and everything transport needs to know about it. */
2export type Mine = {
3 voice: string
4 /** The whole text that was launched, in characters. */
5 chars: number
6 /** The multiplier the app was launched at. */
7 rate: number
8 /** Where the app says it is, in UTF-16 units of that text. */
9 offset: number
10}
11
12/** What is playing right now, as the band above the prompt reads it. */
13export type Speech = {
14 isSpeaking: boolean
15 isPaused: boolean
16 /** Milliseconds since the epoch; 0 when nothing is playing. */
17 startedAt: number
18 /** Absent for speech this mod did not start: the Stop hook's, read off pgrep.
19 * There is no text or position behind it, so the band offers no transport. */
20 mine?: Mine
21 /** The last turn's answer, already stripped and capped: what the band offers to
22 * read while nothing is playing. Absent until a turn has answered, and absent
23 * while a reading has the reply in its list instead. */
24 answer?: string
25 /** The reading this mod is in the middle of. Kept after its last reply has been
26 * read, so `prev` still has somewhere to walk back to. */
27 run?: Run
28}
29
30/** A reading: the replies it holds, oldest first, and where it has got to.
31 * A reply that arrives while it is flowing takes a place at the end. */
32export type Run = {
33 items: string[]
34 /** Which reply is playing, or - with nothing playing - which one it stopped on.
35 * Everything after the cursor is still to be read, in order. */
36 at: number
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'read-aloud': { speech: Speech | null }
42 }
43}
44