SLOPSHOPPER

vox

Animates a waveform in Claude Code while vox speaks or listens

newbandspinnerguardcommandtimer
★ 163v0.1.0Apache-2.0updated 2026-10-06rtk-ai/vox/plugins/vox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vox
› 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 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /vox-wave ⎿ vox: Showing the speak animation for 5 s ✻ Thinking · ▁▁▃▃▂ vox · speaking… ▁ ▁ vox · speaking ▁ ▂ ▃ ▅ ▅ ▄ ▃ ▇ █ █ ▇ ▄ ▄ ▄ ▃ ▂ ▂ ▂ ▃ ▃ preview, no audio ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▁ ▁ vox · speaking ▁ ▂ ▃ ▅ ▅ ▄ ▃ ▇ █ █ ▇ ▄ ▄ ▄ ▃ ▂ ▂ ▂ ▃ ▃ preview, no audio ⟨Claude Code's own drawing⟩
README

vox plugin for Claude Code

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 yet
  • vox · speaking: the spectrum of what the speakers are playing now
  • vox · 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 test
Source 2 files
hooks/register.js 338 lines
1import {
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}
338
hooks/wave.js 183 lines
1// 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