Animates a waveform in Claude Code while vox speaks or listens

A mod that draws a voice visualizer above the prompt while vox speaks, and a short one beside the spinner.
While vox plays, the bars are the real spectrum of the audio: vox analyzes each utterance before playing it and announces it in now-playing.json, in its config directory, for as long as the sound lasts (src/levels.rs). The mod reads that file and follows it from the start time. A backend that plays while it is still generating (pocket) announces the frames it has and adds the rest as they come.
vox · preparing: Claude has called vox and no sound has started yetvox · speaking: the spectrum of what the speakers are playing nowvox · listening: vox hear is recording. These bars are synthetic.It reacts to the vox MCP tools (vox_speak, vox_hear, vox_pack_play), to vox ... run from the shell, and to a Stop hook that speaks.
Install it in a Claude Code session:
/plugin marketplace add rtk-ai/vox
/plugin install vox@vox
Needs Claude Code 2.1.287 or later, and a vox build that announces its playback. Tested with Claude Code 2.1.289. The say backend plays outside vox and announces nothing, so it stays on preparing.
The speaking bars are drawn in a gradient you choose, kept from one session to the next. The terminal rounds each color to its own palette.
/vox-wave color # the current colors and the presets
/vox-wave color ocean # a preset: sunset, ocean, forest, fire, violet, rainbow, mono
/vox-wave color #00ff00 #0000ff # one to three stops, left to right; one is a solid color
claude --plugin-dir plugins/vox # from a checkout: load it for one session
/vox-wave [speak|hear] [seconds] # synthetic preview, no vox or audio needed
claude plugin validate plugins/vox
cd plugins/vox && claude plugin testhooks/register.js 338 lines1import {
2 PRESETS,
3 TONES,
4 barsText,
5 colorAt,
6 describeColors,
7 hexOf,
8 isStops,
9 levels,
10 parseColors,
11 rasterCells,
12 rasterColumns,
13 resample,
14} from './wave.js'
15
16// Milliseconds between frames: 20 a second, under the band's limit of 30, and
17// the pace of the spectrum vox announces
18const TICK_MS = 50
19// Milliseconds between two looks for vox's announcement, while none is playing
20const POLL_MS = 100
21// The most bars the band draws, which is the bands vox analyzes, and the
22// fewest worth drawing as a grid
23const MAX_BARS = 20
24const MIN_BARS = 6
25// Bars in the short wave drawn beside the spinner
26const SPINNER_BARS = 5
27// The file vox writes in its config directory while it plays (src/levels.rs)
28const ANNOUNCEMENT = 'now-playing.json'
29// Frames the last known row stays up while vox is still generating the next
30// ones: two seconds, after which the announcement is taken for abandoned
31const HOLD_FRAMES = 40
32// The store key the speaking gradient is kept under, from one session to the next
33const COLORS_KEY = 'colors'
34// Seconds the bars show after a change of colors
35const COLOR_PREVIEW_SECONDS = 4
36
37// vox subcommands that make no sound and record nothing
38const SILENT = new Set([
39 'clone', 'config', 'stats', 'setup', 'daemon', 'init', 'serve', 'pack', 'mcp',
40 'help', '-h', '--help', '-V', '--version',
41])
42
43// What vox may be doing now, the newest last: each is { mode, caption }.
44// A mode is 'speak', 'hear', 'preview' (a speak drawn without vox), or
45// 'watch' (vox may speak, as from a Stop hook: drawn only once it does).
46let running = []
47// The frame the next synthetic drawing shows
48let frame = 0
49// The timer that advances the frame, while something is running
50let ticker = null
51// Where vox announces what it plays: undefined until looked for, null if unknown
52let announcementPath
53// The playback vox last announced: { startedMs, frameMs, frames, isComplete }.
54// vox announces a playback it is still generating as incomplete, and adds
55// frames to it as they come.
56let playing = null
57// The time at the last tick, and at the last look for an announcement
58let now = 0
59let polledAt = 0
60// The gradient the speaking bars are drawn in, as 0xRRGGBB stops
61let speakStops = PRESETS.sunset
62
63// Where vox's config directory is, the way the `dirs` crate finds it
64async function findAnnouncement($) {
65 const override = await $.env.get('VOX_CONFIG_DIR')
66 if (override) return override + '/' + ANNOUNCEMENT
67 const appData = await $.env.get('APPDATA')
68 if (appData) return appData + '/vox/' + ANNOUNCEMENT
69 const home = await $.env.get('HOME')
70 if (!home) return null
71 const mac = home + '/Library/Application Support'
72 if (await $.fs.exists(mac)) return mac + '/vox/' + ANNOUNCEMENT
73 const xdg = await $.env.get('XDG_CONFIG_HOME')
74 return (xdg || home + '/.config') + '/vox/' + ANNOUNCEMENT
75}
76
77// Read what vox is playing, if it is playing and says so
78async function poll($) {
79 if (announcementPath === undefined) announcementPath = await findAnnouncement($)
80 if (!announcementPath) return
81 let data
82 try {
83 data = JSON.parse(await $.fs.read(announcementPath))
84 } catch {
85 // No file: vox isn't playing, or is a build that doesn't announce. A
86 // playback still waiting for frames has ended without them.
87 if (playing && !playing.isComplete) playing = null
88 return
89 }
90 if (data.version !== 1 || !Array.isArray(data.frames) || !(data.frame_ms > 0)) return
91 playing = {
92 startedMs: data.started_ms,
93 frameMs: data.frame_ms,
94 frames: data.frames,
95 // Builds that announce the whole utterance at once don't say so
96 isComplete: data.complete !== false,
97 }
98}
99
100// The spectrum row sounding now, or null when vox isn't playing
101function currentRow() {
102 if (!playing) return null
103 const index = Math.floor((now - playing.startedMs) / playing.frameMs)
104 if (index < 0) return null
105 const row = playing.frames[index]
106 if (row) return row
107 // Past the last frame of a playback vox is still generating: hold that
108 // frame rather than drop to "preparing" between two updates
109 const missing = index - playing.frames.length
110 if (!playing.isComplete && playing.frames.length > 0 && missing < HOLD_FRAMES) {
111 return playing.frames.at(-1)
112 }
113 return null
114}
115
116// Start animating for one activity
117function begin($, activity) {
118 running.push(activity)
119 if (!ticker) {
120 ticker = $.clock.every(TICK_MS, async () => {
121 frame += 1
122 now = await $.clock.now()
123 const isWaitingForFrames = playing !== null && !playing.isComplete
124 if ((!currentRow() || isWaitingForFrames) && now - polledAt >= POLL_MS) {
125 polledAt = now
126 await poll($)
127 }
128 $.ui.invalidate('ui.render')
129 })
130 }
131 $.ui.invalidate('ui.render')
132}
133
134// Stop animating for one activity, and stop the timer after the last one
135function end($, activity) {
136 running = running.filter(other => other !== activity)
137 if (running.length === 0 && ticker) {
138 ticker.cancel()
139 ticker = null
140 }
141 $.ui.invalidate('ui.render')
142}
143
144// What a shell command does with vox: an activity, or null when it's silent
145// or isn't a vox command at all
146function voxActivity(command) {
147 const call = /(?:^|&&|\|\||[;|(])\s*vox(?:\s+([^;&|]*)|$)/.exec(command ?? '')
148 if (!call) return null
149 const args = (call[1] ?? '').trim()
150 const first = args.split(/\s+/)[0]
151 if (SILENT.has(first)) return null
152 if (first === 'hear') return { mode: 'hear', caption: '' }
153 const quoted = /"([^"]*)"|'([^']*)'/.exec(args)
154 return { mode: 'speak', caption: quoted ? (quoted[1] ?? quoted[2]) : '' }
155}
156
157// What to draw for one activity in that many bars: { stops, label, heights,
158// isDim }, or null when there is nothing to show yet
159function scene(activity, bars) {
160 if (activity.mode === 'hear') {
161 return { stops: TONES.hear, label: 'vox · listening', heights: levels('hear', frame, bars) }
162 }
163 if (activity.mode === 'preview') {
164 return { stops: speakStops, label: 'vox · speaking', heights: levels('speak', frame, bars) }
165 }
166 const row = currentRow()
167 if (row) {
168 // The real spectrum of what the speakers are playing now
169 const heights = resample(row.map(level => level / 100), bars)
170 return { stops: speakStops, label: 'vox · speaking', heights }
171 }
172 if (activity.mode === 'watch') return null
173 // The call has started and the sound hasn't: hooks, model load, synthesis
174 return { stops: TONES.wait, label: 'vox · preparing', heights: levels('wait', frame, bars), isDim: true }
175}
176
177// Show the synthetic bars for some seconds
178function preview($, mode, seconds, caption) {
179 const activity = { mode, caption }
180 begin($, activity)
181 $.clock.after(seconds * 1000, () => end($, activity))
182}
183
184// The newest activity with something to show, and what it shows
185function drawing(bars) {
186 for (let i = running.length - 1; i >= 0; i--) {
187 const shown = scene(running[i], bars)
188 if (shown) return { ...shown, caption: running[i].caption }
189 }
190 return null
191}
192
193// Cut a caption to a width, on one line
194function clip(text, width) {
195 const line = text.replace(/\s+/g, ' ').trim()
196 if (line.length <= width) return line
197 return width > 1 ? line.slice(0, width - 1) + '…' : ''
198}
199
200export function register(on) {
201 on('session.start', async ($, e, next) => {
202 // The colors chosen in an earlier session, when they still read as colors
203 const saved = await $.store.get(COLORS_KEY)
204 if (isStops(saved)) speakStops = saved
205 // A preview that needs no vox binary and no audio device
206 await $.command.register({
207 name: 'vox-wave',
208 description: 'Preview the vox animation, or set its colors',
209 argumentHint: '[speak|hear] [seconds] | color [preset | #rrggbb …]',
210 immediate: true,
211 })
212 return next(e)
213 })
214
215 on('command.run', { command: 'vox-wave' }, async ($, e) => {
216 const words = e.args.trim().split(/\s+/).filter(Boolean)
217 if (words[0] === 'color' || words[0] === 'colors') {
218 const presets = Object.keys(PRESETS).join(', ')
219 if (words.length === 1) {
220 return {
221 text:
222 'Speaking colors: ' + describeColors(speakStops) + '. Presets: ' + presets +
223 '. Or give one to three #rrggbb for a gradient, left to right.',
224 }
225 }
226 const stops = parseColors(words.slice(1))
227 if (!stops) {
228 return {
229 text:
230 'Not a color: ' + words.slice(1).join(' ') + '. Use a preset (' + presets +
231 ') or one to three #rrggbb.',
232 }
233 }
234 speakStops = stops
235 await $.store.set(COLORS_KEY, stops)
236 preview($, 'preview', COLOR_PREVIEW_SECONDS, 'colors: ' + describeColors(stops))
237 return { text: 'Speaking colors set to ' + describeColors(stops) }
238 }
239 const mode = words.includes('hear') ? 'hear' : 'preview'
240 const asked = Number(words.find(word => /^\d+(\.\d+)?$/.test(word)))
241 const seconds = Math.min(60, Math.max(1, asked || 5))
242 preview($, mode, seconds, 'preview, no audio')
243 const name = mode === 'hear' ? 'hear' : 'speak'
244 return { text: 'Showing the ' + name + ' animation for ' + seconds + ' s' }
245 })
246
247 // The vox MCP tools that use the speakers or the microphone, under whatever
248 // name the server was added with
249 on('tool.call', { tool: /^mcp__.*__vox_(speak|hear|pack_play)$/ }, async ($, e, next) => {
250 const isHearing = e.tool.endsWith('__vox_hear')
251 const activity = {
252 mode: isHearing ? 'hear' : 'speak',
253 caption: typeof e.text === 'string' ? e.text : '',
254 }
255 begin($, activity)
256 try {
257 // The permission check and the tool both run inside next
258 return await next(e)
259 } finally {
260 end($, activity)
261 }
262 })
263
264 // vox run from the shell, as the CLAUDE.md that `vox init` writes asks Claude to do
265 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
266 const activity = voxActivity(e.command)
267 if (!activity) return next(e)
268 begin($, activity)
269 try {
270 return await next(e)
271 } finally {
272 end($, activity)
273 }
274 })
275
276 // The Stop hook that `vox init` adds speaks when a turn ends. Nothing says
277 // whether one is configured, so watch while the Stop hooks run and draw only
278 // if vox announces a playback.
279 on('classic.Stop', async ($, e, next) => {
280 const activity = { mode: 'watch', caption: '' }
281 begin($, activity)
282 try {
283 return await next(e)
284 } finally {
285 end($, activity)
286 }
287 })
288
289 // The band above the prompt: the bars, then what vox is saying
290 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
291 const theirs = await next(e)
292 if (running.length === 0 || e.props.hasSurvey) return theirs
293
294 const columns = e.props.bodyColumns
295 const bars = Math.min(MAX_BARS, Math.floor((columns - 20) / 2))
296 // The Desktop app has no Raster, and a narrow band has no room for one
297 const hasGrid = e.surface === 'terminal' && bars >= MIN_BARS
298 const shown = drawing(hasGrid ? bars : Math.max(3, Math.min(12, columns - 20)))
299 if (!shown) return theirs
300
301 const { Box, Text, Raster } = $.ui.resolve(e)
302 const rows = e.props.maxRows >= 3 ? 2 : 1
303 const wave = hasGrid
304 ? Raster({
305 key: 'vox-wave',
306 columns: rasterColumns(bars),
307 rows,
308 cells: rasterCells(shown.heights, shown.stops, rows),
309 })
310 : Text({
311 // A line of text takes one color: the middle of the gradient
312 color: hexOf(colorAt(shown.stops, 0.5)),
313 dimColor: shown.isDim === true,
314 children: [barsText(shown.heights)],
315 })
316 const room = columns - (hasGrid ? rasterColumns(bars) : 12) - 2
317 const caption = clip(shown.caption, room)
318 const words = [Text({ bold: true, children: [shown.label] })]
319 if (caption && rows > 1) words.push(Text({ dimColor: true, wrap: 'truncate-end', children: [caption] }))
320
321 const mine = Box({
322 flexDirection: 'row',
323 columnGap: 2,
324 children: [wave, Box({ flexDirection: 'column', children: words })],
325 })
326 // Keep what the mods after this one draw in the band
327 return theirs ? Box({ flexDirection: 'column', children: [mine, theirs] }) : mine
328 })
329
330 // The spinner: a short wave after its word, while Claude waits on vox
331 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
332 const shown = running.length > 0 ? drawing(SPINNER_BARS) : null
333 if (!shown) return next(e)
334 const suffix = ' · ' + barsText(shown.heights) + ' ' + shown.label
335 return next({ ...e, props: { ...e.props, suffix } })
336 })
337}
338hooks/wave.js 183 lines1// Drawing for the vox visualizer. Pure functions, so a frame is reproducible
2// and the drawing can be tested without a session.
3//
4// The bars come from one of two places. While vox plays, they are the real
5// spectrum vox announced (see src/levels.rs). Otherwise they are synthetic:
6// they show that vox is busy, not the level of any audio.
7
8// The value a Raster reads as "the terminal's default color"
9const DEFAULT_COLOR = 0x01000000
10
11// A cell filled from the bottom, in eighths: index 0 is empty, 8 is full
12const BLOCKS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
13
14// Gradients for the speaking bars, as 0xRRGGBB stops from left to right
15export const PRESETS = {
16 sunset: [0xff8a00, 0xff3d81],
17 ocean: [0x00c2ff, 0x3d5afe],
18 forest: [0xb2ff59, 0x00c853],
19 fire: [0xffd600, 0xff6d00, 0xdd2c00],
20 violet: [0xb388ff, 0xff4081],
21 rainbow: [0xff5252, 0xffd740, 0x40c4ff],
22 mono: [0xe0e0e0],
23}
24
25// The gradients vox's other states are drawn in
26export const TONES = {
27 hear: [0x00c2ff, 0x3ddc84],
28 wait: [0x6b7280, 0x9ca3af],
29}
30
31// The most stops a gradient typed by hand may have
32const MAX_STOPS = 3
33
34// Read colors as typed: one preset name, or one to three #rrggbb stops.
35// Returns the stops, or null when the words are neither.
36export function parseColors(words) {
37 if (words.length === 1 && Object.hasOwn(PRESETS, words[0].toLowerCase())) {
38 return PRESETS[words[0].toLowerCase()]
39 }
40 if (words.length < 1 || words.length > MAX_STOPS) return null
41 const stops = []
42 for (const word of words) {
43 const hex = /^#?([0-9a-f]{6})$/i.exec(word)
44 if (!hex) return null
45 stops.push(parseInt(hex[1], 16))
46 }
47 return stops
48}
49
50// Whether a value is a list of stops, as one read back from the store must be
51export function isStops(value) {
52 return (
53 Array.isArray(value) &&
54 value.length >= 1 &&
55 value.length <= MAX_STOPS &&
56 value.every(stop => Number.isInteger(stop) && stop >= 0 && stop <= 0xffffff)
57 )
58}
59
60// Stops as text, by preset name when they are one: 'sunset', or '#ff8a00 #ff3d81'
61export function describeColors(stops) {
62 for (const [name, preset] of Object.entries(PRESETS)) {
63 if (preset.length === stops.length && preset.every((stop, i) => stop === stops[i])) return name
64 }
65 return stops.map(hexOf).join(' ')
66}
67
68// A color as the #rrggbb a Text takes
69export function hexOf(color) {
70 return '#' + color.toString(16).padStart(6, '0')
71}
72
73// Synthetic bar heights, each from 0 to 1, for a kind of motion at a frame
74export function levels(mode, frame, bars) {
75 const out = []
76 for (let i = 0; i < bars; i++) {
77 if (mode === 'hear') out.push(hearLevel(frame, i, bars))
78 else if (mode === 'wait') out.push(waitLevel(frame, i, bars))
79 else out.push(speakLevel(frame, i, bars))
80 }
81 return out
82}
83
84// Speech: two beating sines under a syllable-like envelope, louder in the middle
85function speakLevel(frame, i, bars) {
86 const t = frame * 0.35
87 const envelope = 0.6 + 0.4 * Math.sin(t * 0.9)
88 const middle = 0.45 + 0.55 * Math.sin((Math.PI * (i + 0.5)) / bars)
89 const ripple = Math.abs(Math.sin(i * 0.55 + t) * Math.sin(i * 0.21 - t * 0.6))
90 return clamp(envelope * middle * (0.25 + 0.75 * ripple))
91}
92
93// Listening: a low ripple that travels outward from the middle
94function hearLevel(frame, i, bars) {
95 const t = frame * 0.18
96 const distance = Math.abs(i - (bars - 1) / 2)
97 const wave = Math.max(0, Math.sin(distance * 0.7 - t))
98 const falloff = 1 - distance / bars
99 return clamp(0.12 + 0.6 * wave * falloff)
100}
101
102// Waiting for the sound: a flat line with one small bump that walks across
103function waitLevel(frame, i, bars) {
104 const at = (frame * 0.4) % (bars + 6) - 3
105 const distance = Math.abs(i - at)
106 return clamp(0.07 + 0.2 * Math.max(0, 1 - distance / 3))
107}
108
109function clamp(value) {
110 return Math.min(1, Math.max(0, value))
111}
112
113// Fit a row of heights to a number of bars, by averaging the ones that merge
114// and repeating the ones that spread
115export function resample(heights, bars) {
116 if (heights.length === bars) return heights
117 const out = []
118 for (let i = 0; i < bars; i++) {
119 const from = Math.floor((i * heights.length) / bars)
120 const to = Math.max(from + 1, Math.floor(((i + 1) * heights.length) / bars))
121 let sum = 0
122 for (let j = from; j < to; j++) sum += heights[j]
123 out.push(sum / (to - from))
124 }
125 return out
126}
127
128// The color at a point of a gradient, from 0 at its left end to 1 at its right
129export function colorAt(stops, ratio) {
130 if (stops.length === 1) return stops[0]
131 const scaled = Math.min(1, Math.max(0, ratio)) * (stops.length - 1)
132 const index = Math.min(stops.length - 2, Math.floor(scaled))
133 const within = scaled - index
134 const channel = shift => {
135 const a = (stops[index] >> shift) & 0xff
136 const b = (stops[index + 1] >> shift) & 0xff
137 return Math.round(a + (b - a) * within)
138 }
139 return (channel(16) << 16) | (channel(8) << 8) | channel(0)
140}
141
142// The color of bar i, interpolated across a gradient
143export function barColor(stops, i, bars) {
144 return colorAt(stops, bars > 1 ? i / (bars - 1) : 0)
145}
146
147// How many terminal columns a Raster of that many bars takes: a gap between bars
148export function rasterColumns(bars) {
149 return bars * 2 - 1
150}
151
152// The character of one bar at one row, counting rows from the bottom
153function blockAt(level, rows, rowFromBottom) {
154 const eighths = Math.round(level * rows * 8)
155 return BLOCKS[Math.min(8, Math.max(0, eighths - rowFromBottom * 8))]
156}
157
158// Pack bar heights into the base64 string a Raster takes: row-major cells,
159// each three little-endian u32s (code point, foreground, background)
160export function rasterCells(heights, stops, rows) {
161 const bars = heights.length
162 const columns = rasterColumns(bars)
163 const words = new Uint32Array(columns * rows * 3)
164 let at = 0
165 for (let row = 0; row < rows; row++) {
166 for (let column = 0; column < columns; column++) {
167 const bar = column / 2
168 // Odd columns are the gaps between bars
169 const char = column % 2 === 0 ? blockAt(heights[bar], rows, rows - 1 - row) : ' '
170 words[at++] = char.codePointAt(0)
171 words[at++] = column % 2 === 0 ? barColor(stops, bar, bars) : DEFAULT_COLOR
172 words[at++] = DEFAULT_COLOR
173 }
174 }
175 return new Uint8Array(words.buffer).toBase64()
176}
177
178// The same heights as one line of block characters, for where a Raster can't
179// draw. A bar never goes fully empty, so the line keeps its width.
180export function barsText(heights) {
181 return heights.map(level => blockAt(Math.max(level, 0.125), 1, 0)).join('')
182}
183