Apple Music above the prompt as an old car head unit: track display, six-band EQ (live system audio optional) and volume/progress gauges

Apple Music, live in your Claude Code prompt. Clauisc is a Claude Code plugin that sits right-aligned just above your input line, drawn like an old car head unit: a volume knob, a glowing display with the track and a six-band EQ, and a seek knob.

Status: early. Running on macOS 26 with Apple Music. Reports and screenshots are welcome in Issues.
/clauisc theme./clauisc audio on the bars follow the Mac's real system audio. Off, they're drawn from the beat: the track's BPM when Apple Music has one, a groove of the track's own otherwise./clauisc theme line or the preview page's sliders; remembered across sessions.git clone https://github.com/mireabot/Clauisc.git ~/Clauisc
Try it in one session
claude --plugin-dir ~/Clauisc/plugins/clauisc
Load it in every session
~/Clauisc/install.sh # adds the plugin to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json
~/Clauisc/install.sh --remove # removes it again
install.sh backs up ~/.claude/settings.json to settings.json.bak before changing it.
Update: git pull. A running session reloads the plugin when its files change. If you installed before the plugin folder was renamed to plugins/clauisc, run install.sh again.
Play something in Music and the band appears above your prompt.
| Command | Effect |
|---|---|
/clauisc | Hide or show the band |
/clauisc status | Report what Clauisc sees: which source answered, osascript's output, the track, timers, and whether the band was drawn |
/clauisc audio on / off | EQ from the real system audio, or from the beat (default) |
/clauisc theme k=v … | Set the look: width hue gain decay gap peaks ghost; theme reset restores it |
/clauisc width <n> | Set the frame's inside width (46–200 columns); remembered across sessions |
/clauisc width | Show the current width |
/clauisc width reset | Back to the default, 64 |
An old car head unit, to look at: a VOL knob showing the system volume, an amber display with the track, time and a six-band EQ (BASS LOW MID VOX HIGH AIR), and a SEEK knob showing how far into the track you are.
Live EQ. macOS's Now Playing info has no audio levels, so by default the bars are drawn from the beat. For the real sound, build the helper once and turn it on:
~/Clauisc/plugins/clauisc/audio/build.sh # needs Xcode command line tools
then /clauisc audio on. It captures system audio with ScreenCaptureKit, so macOS asks to allow your terminal under Privacy & Security → Screen & System Audio Recording. Each band is a frequency range: VOX is 800 Hz–2.5 kHz, where voices sit, shared with guitars and keys; it is not a separated vocal track.
~/Clauisc/preview.sh # then open http://localhost:4747 (needs Bun)
A cell-for-cell copy of the console, built from the plugin's own lib.ts, with sliders for every theme setting. It shows your real Now Playing track (or a demo track), and uses the live EQ helper when it's built. Copy gives the /clauisc theme … line that applies the look in Claude Code.
Run /clauisc status first; it checks Music right away and reports what it found.
/clauisc status. timer ticks should climb by about one every 2 seconds; if it doesn't, backup polls shows the EQ timer covering for it.band drawn: 0 times: Claude Code isn't loading the plugin. Check claude --version, run claude plugin validate ~/Clauisc/plugins/clauisc, and start with claude --debug --plugin-dir ~/Clauisc/plugins/clauisc to see why.osascript -e 'tell application "Music" to get name of current track'/clauisc audio on; its answer says why if it can't. /clauisc status shows EQ: live audio and a climbing count of level lines while it works.audio/clauisc-levels captures system audio through ScreenCaptureKit, runs a 4096-point FFT and prints 24 levels (four per band) about 20 times a second; Clauisc reads them through $.process.spawn. Otherwise each band is drawn from the beat: the kick in BASS and LOW, phrases in VOX, off-beat hats in HIGH and AIR.Raster elements, repainted in place about 11 times a second without redrawing the band. The knobs are braille dials: a scale from seven to five o'clock, lit up to the value, with a pointer.Everything the preview's sliders set is a /clauisc theme setting:
| Setting | Range | What it does |
|---|---|---|
width | 46–200 | Inside width of the frame, in columns (default 64) |
hue | 0–359 | Display glow: 32 amber, 120 green, 190 ice, 0 red, 280 violet |
gain | 50–150 | Bar height, percent |
decay | 1–9 | How fast the bars fall |
gap | 0–1 | A column between bars |
peaks | 0–1 | Hold each bar's peak for a moment |
ghost | 0–1 | Show unlit segments faintly |
Layout lives in plugins/clauisc/hooks/register.tsx, drawing in plugins/clauisc/hooks/lib.ts: layout places the knobs, display and bars, palette turns the hue into colors, bandLevel shapes the beat-drawn EQ, and knobCells, eqCells and timeCells draw the Rasters.
Start Claude Code on the plugin folder in debug mode:
~/Clauisc/dev.sh # builds the audio helper if needed, then: claude --debug --plugin-dir plugins/clauisc
An interactive session watches that folder: save a file and the plugin reloads in place (its hooks run again, the band redraws), with no restart. If a hook throws or a drawing is refused, the transcript shows one dim line naming the hook and the reason; --debug writes every occurrence to the debug log. /clauisc status shows the plugin's own view at any time.
You can also ask Claude in that same session to change the plugin: edits it makes reload when its turn ends.
cd ~/Clauisc/plugins/clauisc
claude plugin validate . # what the engine will load, and anything it would refuse
claude plugin test . # the tests; they mock osascript and the audio helper, so they run on any OS
preview.sh serves the preview page for any version of the console:
./preview.sh # your working tree: edits to hooks/lib.ts show on refresh
./preview.sh HEAD~1 # a commit, branch or tag, e.g. before your change
git fetch origin pull/12/head:pr-12 && ./preview.sh pr-12 # someone's pull request
PORT=4748 ./preview.sh main # a second one on another port, to compare side by side
A commit is checked out into a temporary git worktree, served from there, and removed when you stop the server with ctrl+c; your checkout is not touched. The page's title bar names the version it draws. It borrows your built audio helper for the live EQ, if there is one. Commits from before the preview page existed can't be previewed.
Once the plugin has loaded, Claude Code writes its API types to plugins/clauisc/.claude-plugin/types/ (git-ignored), so an editor or tsc -p plugins/clauisc type-checks it.
.claude-plugin/marketplace.json marketplace manifest
plugins/clauisc/
.claude-plugin/plugin.json plugin manifest
hooks/register.tsx hooks: polling, EQ frames, band layout, /clauisc
hooks/lib.ts JXA/AppleScript, parsing, theme, layout, knob/EQ/time cells
audio/levels.swift clauisc-levels: system audio to EQ levels
audio/build.sh builds clauisc-levels (git-ignored)
types/index.d.ts state contract
tests/band.test.tsx tests
preview/
server.ts localhost preview: Now Playing, live levels, page
app.ts the console cell for cell, and the sliders
index.html the page
assets/clauisc-demo.png the README screenshot
dev.sh claude --debug --plugin-dir, building the helper first
preview.sh the preview page for the working tree or any commit
install.sh local install for every session
osascript, but Apple has restricted it before and a macOS update could again; Clauisc then falls back to Music's AppleScript, which only sees library songs.hooks/register.tsx 493 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Theme, Track } from '../types'
5import {
6 EQ_ROWS,
7 FRAME_MS,
8 FRAME_ROWS,
9 INFO_SCRIPT,
10 KNOB_COLS,
11 KNOB_IDLE,
12 KNOB_ROWS,
13 NOW_SCRIPT,
14 THEME_DEFAULT,
15 THEME_RANGE,
16 TIME_COLS,
17 beatMs,
18 emptySpectrum,
19 eqCells,
20 explainFailure,
21 hex,
22 isQuiet,
23 knobCells,
24 layout,
25 palette,
26 parseInfo,
27 parseLevels,
28 parseNowPlaying,
29 parseTheme,
30 seedOf,
31 stepSpectrum,
32 themeFrom,
33 themeText,
34 timeCells,
35} from './lib'
36import type { Layout } from './lib'
37
38const track = atom({ plugin: 'clauisc', key: 'track' } as const, null)
39const isHidden = atom({ plugin: 'clauisc', key: 'isHidden' } as const, false)
40const problem = atom({ plugin: 'clauisc', key: 'problem' } as const, null)
41// /clauisc theme and /clauisc width: the console's look, kept in $.store too.
42const theme = atom({ plugin: 'clauisc', key: 'theme' } as const, null)
43const volume = atom({ plugin: 'clauisc', key: 'volume' } as const, null)
44
45const POLL_MS = 2000
46// An absolute path: apps that start Claude Code may give it a bare PATH.
47const OSASCRIPT = '/usr/bin/osascript'
48// Long enough for the person to answer macOS's first "control Music?" prompt,
49// which holds osascript until it is answered.
50const INFO_TIMEOUT_MS = 60000
51// Repaint the knobs and the time every this many EQ frames.
52const KNOB_EVERY = 5
53// Live levels older than this are stale: the EQ falls back to the beat.
54const LEVELS_FRESH_MS = 500
55
56// Module state for polling and the animation only; what the band draws lives in $.state.
57const live = {
58 current: null as Track | null,
59 polledAt: 0,
60 source: 'none yet',
61 spectrum: emptySpectrum(0),
62 wasQuiet: false,
63 look: THEME_DEFAULT as Theme,
64 at: null as Layout | null,
65 volume: null as number | null,
66 bandId: null as string | null,
67 hasFrame: false,
68 isPolling: false,
69 poller: null as Timer | null,
70 animator: null as Timer | null,
71 // Diagnostics for /clauisc status.
72 polls: 0,
73 ticks: 0,
74 frames: 0,
75 watchdogPolls: 0,
76 renders: 0,
77 surface: null as string | null,
78 lastExit: null as number | null,
79 nowPlayingExit: null as number | null,
80 nowPlayingStdout: '',
81 nowPlayingStderr: '',
82 lastStdout: '',
83 lastStderr: '',
84 lastError: null as string | null,
85 // The live EQ: audio/clauisc-levels, the system audio's bands.
86 audio: 'off' as 'off' | 'starting' | 'live' | 'failed',
87 audioReason: '',
88 audioRun: 0,
89 levels: null as number[] | null,
90 levelsAt: 0,
91 levelLines: 0,
92 // Resolves once the current run of the helper is capturing or has failed.
93 settle: () => {},
94}
95
96/** Waits until the helper is capturing or has said why not, at most `ms`. */
97function settled($: EngineInterface, ms: number): Promise<void> {
98 if (live.audio !== 'starting') return Promise.resolve()
99 return new Promise<void>(resolve => {
100 live.settle = resolve
101 $.clock.after(ms, resolve)
102 })
103}
104
105/** The audio helper's path: built by audio/build.sh, git-ignored. */
106const levelsBinary = ($: EngineInterface) => `${$.plugin.root.replace(/\/\.claude-plugin\/?$/, '')}/audio/clauisc-levels`
107
108/**
109 * Runs the audio helper and keeps its latest levels until it ends or a newer
110 * run starts; a stop ends the stream, which closes the helper's output.
111 */
112async function listen($: EngineInterface) {
113 const run = ++live.audioRun
114 live.audio = 'starting'
115 live.audioReason = ''
116 live.levels = null
117 try {
118 let rest = ''
119 for await (const { stream, text } of $.process.spawn({ argv: [levelsBinary($)] })) {
120 if (run !== live.audioRun) return
121 if (stream === 'stderr') {
122 live.audioReason = text.trim().slice(0, 200) || live.audioReason
123 continue
124 }
125 const lines = (rest + text).split('\n')
126 rest = lines.pop() ?? ''
127 for (const line of lines) {
128 const said = parseLevels(line)
129 if (!said) continue
130 if (said.kind === 'ready') live.audio = 'live'
131 else if (said.kind === 'error') {
132 live.audio = 'failed'
133 live.audioReason = said.reason
134 live.settle()
135 } else {
136 live.audio = 'live'
137 live.levels = said.levels
138 live.levelsAt = await $.clock.now()
139 live.levelLines += 1
140 }
141 if (live.audio === 'live') live.settle()
142 }
143 }
144 if (run === live.audioRun && live.audio !== 'failed') {
145 live.audio = 'failed'
146 live.audioReason ||= 'the audio helper exited'
147 }
148 } catch (error) {
149 if (run !== live.audioRun) return
150 live.audio = 'failed'
151 live.audioReason = /ENOENT|not found|no such file/i.test(String(error))
152 ? `not built yet: run ${levelsBinary($).replace(/clauisc-levels$/, 'build.sh')}`
153 : String(error).slice(0, 200)
154 } finally {
155 if (run === live.audioRun) {
156 live.levels = null
157 live.settle()
158 }
159 }
160}
161
162function stopListening() {
163 live.audioRun += 1
164 live.audio = 'off'
165 live.levels = null
166}
167
168/** System Now Playing first: it describes streamed songs that Music's AppleScript cannot. */
169async function pollNowPlaying($: EngineInterface): Promise<boolean> {
170 const ran = await $.process.run(
171 [OSASCRIPT, '-l', 'JavaScript', '-e', NOW_SCRIPT],
172 { timeoutMs: INFO_TIMEOUT_MS },
173 )
174 live.nowPlayingExit = ran.exitCode
175 live.nowPlayingStdout = ran.stdout.trim().slice(0, 300)
176 live.nowPlayingStderr = ran.stderr.trim().slice(0, 300)
177 const found = ran.exitCode === 0 ? parseNowPlaying(ran.stdout) : null
178 if (!found) return false
179
180 live.source = 'system Now Playing'
181 live.current = found.track
182 if (found.volume !== live.volume) {
183 live.volume = found.volume
184 await update($, volume, () => found.volume)
185 }
186 await update($, problem, () => null)
187 await update($, track, () => found.track)
188 return true
189}
190
191/** Music's own AppleScript: the fallback when Now Playing is unreadable. */
192async function pollMusic($: EngineInterface) {
193 const ran = await $.process.run([OSASCRIPT, '-e', INFO_SCRIPT], { timeoutMs: INFO_TIMEOUT_MS })
194 live.source = 'Music AppleScript'
195 live.lastExit = ran.exitCode
196 live.lastStdout = ran.stdout.trim().slice(0, 300)
197 live.lastStderr = ran.stderr.trim().slice(0, 300)
198 const now = ran.exitCode === 0 ? parseInfo(ran.stdout) : null
199 live.current = now
200 await update($, problem, () => (ran.exitCode === 0 ? null : explainFailure(ran.stderr)))
201 await update($, track, () => now)
202}
203
204async function poll($: EngineInterface) {
205 if (live.isPolling) return
206 live.isPolling = true
207 live.polls += 1
208 try {
209 if (!(await pollNowPlaying($))) await pollMusic($)
210 live.polledAt = await $.clock.now()
211 live.lastError = null
212 } catch (error) {
213 // osascript could not start or ran past the timeout: say so and keep trying.
214 live.polledAt = await $.clock.now()
215 live.lastError = String(error).slice(0, 300)
216 await update($, problem, () => `Could not run osascript: ${live.lastError}`)
217 } finally {
218 live.isPolling = false
219 }
220}
221
222/** Seconds into the track, counting the time since the last poll while playing. */
223function elapsed(t: Track, now: number): number {
224 const since = t.isPlaying && live.polledAt ? (now - live.polledAt) / 1000 : 0
225 return t.position + since
226}
227
228function progress(t: Track, now: number): number {
229 return t.duration > 0 ? elapsed(t, now) / t.duration : 0
230}
231
232async function statusReport($: EngineInterface): Promise<string> {
233 const t = live.current
234 const age = live.polledAt ? Math.round(((await $.clock.now()) - live.polledAt) / 1000) : null
235 return [
236 'Clauisc status',
237 `- source: ${live.source}`,
238 `- polls: ${live.polls} (timer ticks ${live.ticks}, EQ frames ${live.frames}, backup polls ${live.watchdogPolls}), last ${age === null ? 'never' : `${age}s ago`}`,
239 `- last error: ${live.lastError ?? 'none'}`,
240 `- Now Playing: exit ${live.nowPlayingExit ?? 'not run'}, stdout ${JSON.stringify(live.nowPlayingStdout)}, stderr ${JSON.stringify(live.nowPlayingStderr)}`,
241 `- Music AppleScript: exit ${live.lastExit ?? 'not run'}, stdout ${JSON.stringify(live.lastStdout)}, stderr ${JSON.stringify(live.lastStderr)}`,
242 `- track: ${t ? `${t.isPlaying ? 'playing' : 'paused'} "${t.name}" by ${t.artist || 'unknown'} at ${Math.round(t.position)}/${Math.round(t.duration)}s (bpm ${t.bpm})` : 'none'}`,
243 `- volume: ${live.volume ?? 'unknown'}`,
244 `- theme: ${themeText(live.look)}`,
245 `- EQ: ${live.audio === 'live' ? `live audio (${live.levelLines} level lines)` : live.audio === 'off' ? 'drawn from the beat (/clauisc audio on for the real sound)' : `${live.audio}${live.audioReason ? `: ${live.audioReason}` : ''}; drawn from the beat`}`,
246 `- band drawn: ${live.renders} times, surface ${live.surface ?? 'never asked'}`,
247 ].join('\n')
248}
249
250/** One EQ frame; every few frames the knobs and the time too. */
251async function frame($: EngineInterface) {
252 live.frames += 1
253 const now = await $.clock.now()
254 // A backup for the poll timer: if it has gone quiet, ask Now Playing from here.
255 if (!live.isPolling && now - live.polledAt > POLL_MS * 2) {
256 live.watchdogPolls += 1
257 void poll($)
258 }
259 const t = live.current
260 const at = live.at
261 if (!t || !live.bandId || !live.hasFrame || !at) return
262 const requestId = live.bandId
263 const seed = seedOf(t.id)
264 live.spectrum = stepSpectrum(
265 live.spectrum,
266 at,
267 // No BPM: a groove of the track's own, 90 to 130.
268 {
269 ms: elapsed(t, now) * 1000,
270 beat: beatMs(t.bpm || 90 + Math.round(seed * 40), 0),
271 seed,
272 isPlaying: t.isPlaying,
273 levels: live.levels && now - live.levelsAt < LEVELS_FRESH_MS ? live.levels : null,
274 },
275 live.look,
276 Math.random,
277 )
278 // Paused and settled: nothing moves, so nothing to repaint.
279 const quiet = isQuiet(live.spectrum)
280 if (!(quiet && live.wasQuiet)) {
281 $.ui.blit({ requestId, key: 'eq', cells: eqCells(live.spectrum, at, live.look) }).catch(() => undefined)
282 }
283 live.wasQuiet = quiet
284 if (live.frames % KNOB_EVERY === 0) {
285 const lit = t.isPlaying ? palette(live.look).glow : KNOB_IDLE
286 $.ui.blit({ requestId, key: 'seek', cells: knobCells(progress(t, now), lit) }).catch(() => undefined)
287 $.ui.blit({ requestId, key: 'time', cells: timeCells(elapsed(t, now), t.duration, live.look) }).catch(() => undefined)
288 }
289}
290
291async function setTheme($: EngineInterface, next: Theme | null) {
292 if (next) await $.store.set('theme', next)
293 else await $.store.delete('theme')
294 await update($, theme, () => next)
295}
296
297export const register: Register = on => {
298 on('session.start', async ($, e, next) => {
299 await $.command.register({
300 name: 'clauisc',
301 description:
302 'Toggle the Apple Music console; "status", "audio on|off", "theme k=v…", "width <n>"',
303 })
304 const saved = await $.store.get('theme').catch(() => undefined)
305 if (saved && typeof saved === 'object') {
306 await update($, theme, () => themeFrom(saved))
307 } else {
308 // A width set before themes existed carries over.
309 const width = await $.store.get('width').catch(() => undefined)
310 if (typeof width === 'number') await update($, theme, () => themeFrom({ width }))
311 }
312 if ((await $.store.get('audio').catch(() => undefined)) === true) void listen($)
313 void poll($)
314 live.poller?.cancel()
315 live.poller = $.clock.every(POLL_MS, () => {
316 live.ticks += 1
317 void poll($)
318 })
319 live.animator?.cancel()
320 live.animator = $.clock.every(FRAME_MS, () => {
321 void frame($)
322 })
323
324 return next(e)
325 })
326
327 on('command.run', { command: 'clauisc' }, async ($, e) => {
328 const arg = e.args.trim()
329 const [word = '', ...rest] = arg.split(/\s+/)
330 const now = themeFrom(await read($, theme))
331 const [lo, hi] = THEME_RANGE.width
332 switch (word.toLowerCase()) {
333 case 'width': {
334 const value = rest[0]?.toLowerCase()
335 if (value === undefined) {
336 return { text: `Clauisc frame width: ${now.width} (default ${THEME_DEFAULT.width}). Set it with /clauisc width <${lo}-${hi}> or /clauisc width reset.` }
337 }
338 if (value === 'reset') {
339 await setTheme($, { ...now, width: THEME_DEFAULT.width })
340 return { text: `Clauisc frame width reset to ${THEME_DEFAULT.width}.` }
341 }
342 const n = Number(value)
343 if (!Number.isInteger(n) || n < lo || n > hi) {
344 return { text: `Clauisc: width must be a whole number from ${lo} to ${hi}.` }
345 }
346 await setTheme($, { ...now, width: n })
347 return { text: `Clauisc frame width set to ${n}.` }
348 }
349 case 'theme': {
350 if (rest.length === 0) {
351 return { text: `Clauisc theme: ${themeText(now)}\nChange it with /clauisc theme hue=120 gain=90 …, or /clauisc theme reset. The preview (bun preview/server.ts) builds this line for you.` }
352 }
353 if (rest[0]?.toLowerCase() === 'reset') {
354 await setTheme($, null)
355 return { text: `Clauisc theme reset: ${themeText(THEME_DEFAULT)}` }
356 }
357 const next = parseTheme(rest.join(' '), now)
358 if (typeof next === 'string') return { text: `Clauisc: ${next}.` }
359 await setTheme($, next)
360 return { text: `Clauisc theme set: ${themeText(next)}` }
361 }
362 case 'audio': {
363 const value = rest[0]?.toLowerCase()
364 if (value === 'on') {
365 await $.store.set('audio', true)
366 stopListening()
367 void listen($)
368 // Long enough for the helper to start or to say why it cannot.
369 await settled($, 3000)
370 return {
371 text:
372 live.audio === 'failed'
373 ? `Clauisc live EQ failed: ${live.audioReason}. If macOS blocked it, allow your terminal under System Settings → Privacy & Security → Screen & System Audio Recording, restart it, and run /clauisc audio on again.`
374 : 'Clauisc live EQ on: the bars follow the system audio.',
375 }
376 }
377 if (value === 'off') {
378 await $.store.set('audio', false)
379 stopListening()
380 return { text: 'Clauisc live EQ off: the bars follow the beat.' }
381 }
382 return { text: `Clauisc EQ: ${live.audio}${live.audioReason ? ` (${live.audioReason})` : ''}. /clauisc audio on follows the system audio (asks for Screen & System Audio Recording); off draws it from the beat.` }
383 }
384 case 'status':
385 await poll($)
386 return { text: await statusReport($) }
387 default: {
388 const hidden = await update($, isHidden, was => !was)
389 return { text: hidden ? 'Clauisc band hidden.' : 'Clauisc band shown.' }
390 }
391 }
392 })
393
394 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
395 live.renders += 1
396 live.surface = e.surface
397 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
398 const t = await read($, track)
399 const cols = e.props.bodyColumns
400
401 if (t === null) {
402 const why = await read($, problem)
403 if (!why) return next(e)
404 const { Box, Text } = $.ui.resolve(e)
405 return (
406 <Box justifyContent="flex-end" width={cols}>
407 <Text color="#d97757" wrap="wrap">
408 ♪ Clauisc: {why}
409 </Text>
410 </Box>
411 )
412 }
413
414 const look = themeFrom(await read($, theme))
415 const vol = await read($, volume)
416 const at = layout(look, cols)
417 live.look = look
418 live.at = at
419 const hasFrame = e.surface === 'terminal' && e.props.maxRows >= FRAME_ROWS + 2 && at !== null
420 live.hasFrame = hasFrame
421
422 if (!hasFrame || !at) {
423 // Too small, or a surface without Rasters: one line of text.
424 const status = t.isPlaying ? '▶' : '⏸'
425 const { Box, Text } = $.ui.resolve(e)
426 return (
427 <Box justifyContent="flex-end" width={cols}>
428 <Text wrap="truncate-end">
429 {status} {t.name}{t.artist ? ` · ${t.artist}` : ''}
430 </Text>
431 </Box>
432 )
433 }
434
435 // The head unit: top edge, a side each way and the bottom. Inside, the
436 // volume gauge, the display (track over the EQ) and the progress gauge.
437 live.bandId = e.requestId
438 const { Box, Text, Raster } = $.ui.resolve(e)
439 const now = await $.clock.now()
440 const colors = palette(look)
441 const screen = hex(colors.screen)
442 const lit = t.isPlaying ? colors.glow : KNOB_IDLE
443 const titleCols = at.display - 6 - TIME_COLS - 1
444 const side = (
445 <Box flexDirection="column">
446 {Array.from({ length: FRAME_ROWS }, () => (
447 <Text dimColor>|</Text>
448 ))}
449 </Box>
450 )
451 const knob = (key: string, value: number | null, label: string) => (
452 <Box flexDirection="column" width={KNOB_COLS}>
453 <Raster key={key} columns={KNOB_COLS} rows={KNOB_ROWS} cells={knobCells(value, lit)} />
454 <Text dimColor>{label}</Text>
455 </Box>
456 )
457
458 return (
459 <Box flexDirection="row" justifyContent="flex-end" alignItems="flex-start" width={cols}>
460 <Box flexDirection="column">
461 <Text dimColor>{` ${'_'.repeat(at.inner)}`}</Text>
462 <Box flexDirection="row" alignItems="flex-start">
463 {side}
464 <Box flexDirection="column" width={at.inner}>
465 <Box flexDirection="row" alignItems="flex-start">
466 <Box marginLeft={1}>{knob('volume', vol === null ? null : vol / 100, ' VOL ')}</Box>
467 <Box flexDirection="column" marginLeft={2} width={at.display} backgroundColor={screen}>
468 <Box flexDirection="row" width={at.display} backgroundColor={screen}>
469 <Text color={hex(colors.label)} backgroundColor={screen}>
470 {t.isPlaying ? ' PLAY ' : ' PAUSE'}
471 </Text>
472 <Box width={titleCols} backgroundColor={screen}>
473 <Text color={hex(colors.glow)} backgroundColor={screen} bold wrap="truncate-end">
474 {` ${t.name}${t.artist ? ` · ${t.artist}` : ''}`}
475 </Text>
476 </Box>
477 <Raster key="time" columns={TIME_COLS} rows={1} cells={timeCells(elapsed(t, now), t.duration, look)} />
478 <Text backgroundColor={screen}> </Text>
479 </Box>
480 <Raster key="eq" columns={at.display} rows={EQ_ROWS} cells={eqCells(live.spectrum, at, look)} />
481 </Box>
482 <Box marginLeft={2} marginRight={1}>{knob('seek', progress(t, now), 'SEEK ')}</Box>
483 </Box>
484 </Box>
485 {side}
486 </Box>
487 <Text dimColor>{`\`${'-'.repeat(at.inner)}'`}</Text>
488 </Box>
489 </Box>
490 )
491 })
492}
493hooks/lib.ts 576 lines1import type { Theme, Track } from '../types'
2
3export type { Theme, Track }
4
5// The console, inside its frame: a gauge knob each side of the display.
6export const KNOB_COLS = 5
7export const KNOB_ROWS = 3
8/** Columns each side of the display: a pad, the knob and a gap. */
9export const SIDE_COLS = 1 + KNOB_COLS + 2
10/** The display: one row of text over the EQ, two rows of bars and their band labels. */
11export const BAR_ROWS = 2
12export const EQ_ROWS = BAR_ROWS + 1
13export const DISPLAY_ROWS = 1 + EQ_ROWS
14/** Rows inside the frame: the knobs and the display. */
15export const FRAME_ROWS = DISPLAY_ROWS
16/** The time readout at the display's top right, "12:34/56:07". */
17export const TIME_COLS = 11
18export const BANDS = ['BASS', 'LOW', 'MID', 'VOX', 'HIGH', 'AIR'] as const
19
20const DEFAULT = 0x01000000
21
22const SEP = '\u001f'
23
24/** AppleScript printing the player state and current track, fields split by U+001F. */
25export const INFO_SCRIPT = `
26if application "Music" is not running then return "stopped"
27tell application "Music"
28 set stateText to player state as string
29 if stateText is "stopped" then return "stopped"
30 set {trackKey, trackName, trackArtist, trackAlbum, trackBpm, trackLength} to {"", "", "", "", 0, 0}
31 set {trackKind, trackProblem} to {"", ""}
32 try
33 set nowTrack to current track
34 try
35 set trackKind to (class of nowTrack) as string
36 end try
37 try
38 set trackKey to persistent ID of nowTrack
39 end try
40 try
41 set trackName to name of nowTrack
42 end try
43 try
44 set trackArtist to artist of nowTrack
45 end try
46 try
47 set trackAlbum to album of nowTrack
48 end try
49 try
50 set trackBpm to bpm of nowTrack
51 end try
52 try
53 set trackLength to duration of nowTrack
54 end try
55 on error errorText number errorNumber
56 set trackProblem to (errorNumber as string) & " " & errorText
57 end try
58 if trackName is "" then
59 try
60 set trackName to current stream title
61 end try
62 end if
63 set playhead to 0
64 try
65 set playhead to player position
66 end try
67 set sep to character id 31
68 return stateText & sep & trackKey & sep & trackName & sep & trackArtist & sep & trackAlbum & sep & trackBpm & sep & playhead & sep & trackLength & sep & trackKind & sep & trackProblem
69end tell
70`
71
72/**
73 * JXA reading macOS's system Now Playing info (what Control Center shows),
74 * which also describes streamed Apple Music songs that Music's AppleScript
75 * cannot, and the system output volume. Prints JSON for parseNowPlaying.
76 *
77 * Now Playing carries metadata only (title, artist, album, times, rate,
78 * artwork): no audio levels, so the console's EQ is drawn from the beat.
79 */
80export const NOW_SCRIPT = `
81ObjC.import('Foundation');
82function run() {
83 var volume = null;
84 try {
85 var app = Application.currentApplication();
86 app.includeStandardAdditions = true;
87 var v = app.getVolumeSettings().outputVolume;
88 if (typeof v === 'number') volume = v;
89 } catch (e) {}
90 $.NSBundle.bundleWithPath('/System/Library/PrivateFrameworks/MediaRemote.framework/').load;
91 var request = $.NSClassFromString('MRNowPlayingRequest');
92 if (request.isNil()) return JSON.stringify({ error: 'MRNowPlayingRequest is unavailable' });
93 var item = request.localNowPlayingItem;
94 if (item.isNil()) return JSON.stringify({ state: 'stopped', volume: volume });
95 var info = item.nowPlayingInfo;
96 if (info.isNil()) return JSON.stringify({ state: 'stopped', volume: volume });
97 function raw(key) { return info.objectForKey('kMRMediaRemoteNowPlayingInfo' + key); }
98 function get(key) { var value = raw(key); return value.isNil() ? null : ObjC.unwrap(value); }
99 var rate = Number(get('PlaybackRate') || 0);
100 var elapsed = Number(get('ElapsedTime') || 0);
101 var stamp = raw('Timestamp');
102 var since = stamp.isNil() ? 0 : -stamp.timeIntervalSinceNow;
103 return JSON.stringify({
104 state: rate > 0 ? 'playing' : 'paused',
105 id: String(get('UniqueIdentifier') || get('ContentItemIdentifier') || ''),
106 title: get('Title') || '',
107 artist: get('Artist') || '',
108 album: get('Album') || '',
109 position: elapsed + rate * since,
110 duration: Number(get('Duration') || 0),
111 volume: volume,
112 });
113}
114`
115
116const num = (s: string | undefined) => {
117 const n = parseFloat((s ?? '').trim().replace(',', '.'))
118 return Number.isFinite(n) ? n : 0
119}
120
121export function parseInfo(stdout: string): Track | null {
122 // AppleScript spells an absent value "missing value" when it joins text.
123 const f = stdout
124 .replace(/\n$/, '')
125 .split(SEP)
126 .map(field => (field.trim() === 'missing value' ? '' : field))
127 if (f.length < 8 || (f[0] !== 'playing' && f[0] !== 'paused')) return null
128 const name = f[2] ?? ''
129 const artist = f[3] ?? ''
130 if (!name && !artist) {
131 // Paused with nothing loaded: nothing to show.
132 if (f[0] !== 'playing') return null
133 // Playing, but Music describes no track (often a streamed song): still show the band.
134 return {
135 isPlaying: true,
136 id: 'undescribed',
137 name: 'Playing in Music',
138 artist: 'Music shares no track details',
139 album: '',
140 bpm: 0,
141 position: num(f[6]),
142 duration: num(f[7]),
143 }
144 }
145 return {
146 isPlaying: f[0] === 'playing',
147 id: f[1] || `${name}|${artist}`,
148 name: name || 'Unknown track',
149 artist,
150 album: f[4] ?? '',
151 bpm: num(f[5]),
152 position: num(f[6]),
153 duration: num(f[7]),
154 }
155}
156
157/**
158 * What NOW_SCRIPT reports, parsed: the track, or null when nothing is playing,
159 * and the system output volume (0..100), null when the output has none.
160 */
161export type NowPlaying = { track: Track | null; volume: number | null }
162
163/**
164 * NOW_SCRIPT's JSON as a track; null when it is not Now Playing JSON at all
165 * (the caller then falls back to Music's AppleScript).
166 */
167export function parseNowPlaying(stdout: string): NowPlaying | null {
168 let raw: Record<string, unknown>
169 try {
170 raw = JSON.parse(stdout.trim()) as Record<string, unknown>
171 } catch {
172 return null
173 }
174 if (typeof raw !== 'object' || raw === null || typeof raw.error === 'string') return null
175 const text = (key: string) => (typeof raw[key] === 'string' ? (raw[key] as string) : '')
176 const number = (key: string) => (typeof raw[key] === 'number' && Number.isFinite(raw[key]) ? (raw[key] as number) : 0)
177 const state = text('state')
178 const name = text('title')
179 const artist = text('artist')
180 const hasTrack = (state === 'playing' || state === 'paused') && (name !== '' || artist !== '')
181 return {
182 track: hasTrack
183 ? {
184 isPlaying: state === 'playing',
185 id: text('id') || `${name}|${artist}`,
186 name: name || 'Unknown track',
187 artist,
188 album: text('album'),
189 bpm: 0,
190 position: number('position'),
191 duration: number('duration'),
192 }
193 : null,
194 volume: typeof raw.volume === 'number' && Number.isFinite(raw.volume) ? raw.volume : null,
195 }
196}
197
198const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
199
200/** Little-endian u32 words as standard padded base64, the Raster's `cells`. */
201export function encode(words: number[]): string {
202 const bytes = new Uint8Array(Uint32Array.from(words).buffer)
203 let out = ''
204 for (let i = 0; i < bytes.length; i += 3) {
205 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
206 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
207 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
208 out += i + 2 < bytes.length ? B64[n & 63]! : '='
209 }
210 return out
211}
212
213/** Milliseconds per beat: the track's beat when it has a BPM, else a loose random groove. */
214export function beatMs(bpm: number, random: number): number {
215 if (bpm > 0) {
216 let ms = 60000 / bpm
217 while (ms < 300) ms *= 2
218 while (ms > 1200) ms /= 2
219 return ms
220 }
221 return 380 + random * 320
222}
223
224/** A string hashed to 0..1: gives each track its own groove on the EQ. */
225export function seedOf(text: string): number {
226 let h = 2166136261
227 for (let i = 0; i < text.length; i++) {
228 h ^= text.charCodeAt(i)
229 h = Math.imul(h, 16777619)
230 }
231 return (h >>> 0) / 4294967296
232}
233
234// Theme: what /clauisc theme and the preview's sliders set.
235
236export const THEME_DEFAULT: Theme = { width: 64, hue: 32, gain: 100, decay: 5, gap: 1, peaks: 1, ghost: 1 }
237
238export const THEME_RANGE: { readonly [K in keyof Theme]: readonly [number, number] } = {
239 width: [46, 200],
240 hue: [0, 359],
241 gain: [50, 150],
242 decay: [1, 9],
243 gap: [0, 1],
244 peaks: [0, 1],
245 ghost: [0, 1],
246}
247
248export const THEME_KEYS = Object.keys(THEME_DEFAULT) as (keyof Theme)[]
249
250/** Any stored value as a Theme: known keys kept, clamped and rounded; the rest the defaults. */
251export function themeFrom(raw: unknown): Theme {
252 const theme = { ...THEME_DEFAULT }
253 if (typeof raw !== 'object' || raw === null) return theme
254 for (const key of THEME_KEYS) {
255 const value = (raw as Record<string, unknown>)[key]
256 if (typeof value !== 'number' || !Number.isFinite(value)) continue
257 const [lo, hi] = THEME_RANGE[key]
258 theme[key] = Math.max(lo, Math.min(hi, Math.round(value)))
259 }
260 return theme
261}
262
263/** `hue=120 gain=90` applied over `base`, or a message naming what is wrong. */
264export function parseTheme(args: string, base: Theme): Theme | string {
265 const next: Record<string, number> = { ...base }
266 for (const pair of args.trim().split(/\s+/).filter(Boolean)) {
267 const [key, value] = pair.split('=')
268 if (!key || !(THEME_KEYS as string[]).includes(key)) {
269 return `unknown setting "${key ?? pair}"; use ${THEME_KEYS.join(', ')}`
270 }
271 const n = Number(value)
272 const [lo, hi] = THEME_RANGE[key as keyof Theme]
273 if (value === undefined || value === '' || !Number.isInteger(n) || n < lo || n > hi) {
274 return `${key} must be a whole number from ${lo} to ${hi}`
275 }
276 next[key] = n
277 }
278 return themeFrom(next)
279}
280
281/** A theme as /clauisc theme takes it. */
282export const themeText = (theme: Theme) => THEME_KEYS.map(key => `${key}=${theme[key]}`).join(' ')
283
284// Colors.
285
286/** An HSL color as 0xRRGGBB; `s` and `l` in percent. */
287export function hsl(h: number, s: number, l: number): number {
288 const hue = (((h % 360) + 360) % 360) / 60
289 const sat = s / 100
290 const lum = l / 100
291 const c = (1 - Math.abs(2 * lum - 1)) * sat
292 const x = c * (1 - Math.abs((hue % 2) - 1))
293 const [r, g, b] = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(hue) % 6]!
294 const m = lum - c / 2
295 const byte = (v: number) => Math.round((v + m) * 255)
296 return (byte(r!) << 16) | (byte(g!) << 8) | byte(b!)
297}
298
299export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
300
301export type Palette = {
302 /** Lit segments and text. */
303 glow: number
304 /** The top row of the bars and the peaks. */
305 hot: number
306 /** Unlit segments. */
307 ghost: number
308 /** The display's glass. */
309 screen: number
310 /** Band labels and the PLAY tag. */
311 label: number
312}
313
314export function palette(theme: Theme): Palette {
315 return {
316 glow: hsl(theme.hue, 100, 60),
317 hot: hsl(theme.hue - 30, 100, 62),
318 ghost: hsl(theme.hue, 50, 12),
319 screen: hsl(theme.hue, 40, 5),
320 label: hsl(theme.hue, 55, 42),
321 }
322}
323
324export const KNOB_IDLE = 0x8a8a8a
325const KNOB_SCALE = 0x444444
326const KNOB_POINTER = 0xf0f0f0
327
328// Layout.
329
330/** Narrowest display that still fits the PLAY tag, ten columns of title and the time. */
331export const MIN_DISPLAY = 30
332
333export type Layout = {
334 /** Inside width of the frame. */
335 inner: number
336 /** Width of the display between the knobs. */
337 display: number
338 /** Each bar's column within the display, left to right. */
339 barAt: number[]
340 /** Each bar's band, an index into BANDS. */
341 bandOf: number[]
342 /** Each bar's sub-band in the live levels: SUB_BANDS of each band, low to high. */
343 subOf: number[]
344 /** Each band's label and its column within the display. */
345 labels: { col: number; text: string }[]
346}
347
348/** Where everything sits for `theme` in `columns` of terminal; null when the console does not fit. */
349export function layout(theme: Theme, columns: number): Layout | null {
350 const inner = Math.min(theme.width, columns - 3)
351 const display = inner - 2 * SIDE_COLS
352 if (display < MIN_DISPLAY) return null
353 const step = theme.gap ? 2 : 1
354 const count = theme.gap ? Math.floor((display + 1) / 2) : display
355 const offset = Math.floor((display - ((count - 1) * step + 1)) / 2)
356 const barAt = Array.from({ length: count }, (_, i) => offset + i * step)
357 const bandOf = barAt.map((_, i) => Math.min(BANDS.length - 1, Math.floor((i * BANDS.length) / count)))
358 const subOf = bandOf.map((band, i) => {
359 const first = bandOf.indexOf(band)
360 const size = bandOf.lastIndexOf(band) - first + 1
361 return band * SUB_BANDS + Math.floor(((i - first) * SUB_BANDS) / size)
362 })
363 const labels = BANDS.map((name, band) => {
364 const first = barAt[bandOf.indexOf(band)]!
365 const last = barAt[bandOf.lastIndexOf(band)]!
366 const text = name.slice(0, last - first + 1)
367 return { col: first + Math.floor((last - first + 1 - text.length) / 2), text }
368 })
369 return { inner, display, barAt, bandOf, subOf, labels }
370}
371
372// The EQ. Now Playing shares no audio, so without the live levels of
373// audio/clauisc-levels each band is drawn from the beat: the kick in BASS and
374// LOW, phrases in VOX, off-beat hats in HIGH and AIR.
375
376/** Live levels per band: clauisc-levels prints BANDS.length * SUB_BANDS of them. */
377export const SUB_BANDS = 4
378
379/** What clauisc-levels says on one line. */
380export type LevelsLine = { kind: 'ready' } | { kind: 'levels'; levels: number[] } | { kind: 'error'; reason: string }
381
382export function parseLevels(line: string): LevelsLine | null {
383 const [word, ...rest] = line.trim().split(' ')
384 if (word === 'R') return { kind: 'ready' }
385 if (word === 'E') return { kind: 'error', reason: rest.join(' ') || 'the audio helper failed' }
386 if (word !== 'L' || rest.length !== BANDS.length * SUB_BANDS) return null
387 const levels = rest.map(Number)
388 return levels.every(Number.isFinite) ? { kind: 'levels', levels: levels.map(v => Math.max(0, Math.min(1, v))) } : null
389}
390
391/** Milliseconds between EQ frames. */
392export const FRAME_MS = 90
393
394export type Spectrum = { levels: number[]; peaks: number[]; holds: number[] }
395
396export const emptySpectrum = (bars: number): Spectrum => ({
397 levels: Array(bars).fill(0),
398 peaks: Array(bars).fill(0),
399 holds: Array(bars).fill(0),
400})
401
402/** How loud `band` is `ms` into the groove, 0..1 before gain. */
403export function bandLevel(band: number, ms: number, beat: number, seed: number, noise: number): number {
404 const phase = (ms % beat) / beat
405 const kick = Math.exp(-6 * phase)
406 const hat = Math.exp(-9 * ((phase * 2) % 1)) * (phase >= 0.5 ? 1 : 0.45)
407 const phrase = Math.max(0, Math.sin((2 * Math.PI * ms) / (beat * 8) + seed * 2 * Math.PI))
408 switch (band) {
409 case 0: return 0.3 + 0.66 * kick
410 case 1: return 0.28 + 0.42 * kick + 0.18 * noise
411 case 2: return 0.4 + 0.2 * Math.sin(ms / 700 + seed * 9) + 0.18 * noise
412 case 3: return 0.18 + 0.55 * phrase + 0.15 * noise
413 case 4: return 0.16 + 0.48 * hat + 0.14 * noise
414 default: return 0.1 + 0.28 * hat * noise + 0.12 * noise
415 }
416}
417
418/**
419 * One EQ frame: each bar jumps up to its band's level and falls back at the
420 * theme's decay; peaks hold a moment, then fall. Paused, everything falls.
421 * With `levels` (the live audio's sub-bands) the bars show those instead.
422 */
423export function stepSpectrum(
424 spectrum: Spectrum,
425 at: Layout,
426 input: { ms: number; beat: number; seed: number; isPlaying: boolean; levels?: readonly number[] | null },
427 theme: Theme,
428 random: () => number,
429): Spectrum {
430 const count = at.barAt.length
431 const was = spectrum.levels.length === count ? spectrum : emptySpectrum(count)
432 const fall = 0.012 * theme.decay
433 const next = emptySpectrum(count)
434 for (let i = 0; i < count; i++) {
435 const heard = input.levels?.[at.subOf[i]!]
436 const level = !input.isPlaying
437 ? 0
438 : heard !== undefined
439 ? heard * (theme.gain / 100)
440 : bandLevel(at.bandOf[i]!, input.ms, input.beat, input.seed, random()) * (0.85 + 0.3 * random()) * (theme.gain / 100)
441 const now = Math.max(0, Math.min(1, Math.max(level, was.levels[i]! - fall)))
442 next.levels[i] = now
443 if (now >= was.peaks[i]!) {
444 next.peaks[i] = now
445 next.holds[i] = 8
446 } else if (was.holds[i]! > 0) {
447 next.peaks[i] = was.peaks[i]!
448 next.holds[i] = was.holds[i]! - 1
449 } else {
450 next.peaks[i] = Math.max(now, was.peaks[i]! - 0.03)
451 }
452 }
453 return next
454}
455
456export const isQuiet = (spectrum: Spectrum) => spectrum.levels.every(v => v === 0) && spectrum.peaks.every(v => v === 0)
457
458const BLOCK = 0x2580 // + 1..8: lower eighths, ▁ to █
459const PEAK = 0x2594 // ▔, the upper eighth
460
461/** The EQ, `display` x EQ_ROWS cells: BAR_ROWS of bars in eighths, then the band labels. */
462export function eqCells(spectrum: Spectrum, at: Layout, theme: Theme): string {
463 const colors = palette(theme)
464 const unlit = theme.ghost ? colors.ghost : colors.screen
465 const units = BAR_ROWS * 8
466 const words: number[] = []
467 const barOf = new Map(at.barAt.map((col, i) => [col, i]))
468 for (let row = 0; row < BAR_ROWS; row++) {
469 // Row 0 is the top; each row holds 8 eighths of the bar.
470 const below = (BAR_ROWS - 1 - row) * 8
471 for (let col = 0; col < at.display; col++) {
472 const bar = barOf.get(col)
473 if (bar === undefined) {
474 words.push(0x20, colors.screen, colors.screen)
475 continue
476 }
477 const lit = Math.max(0, Math.min(8, Math.round((spectrum.levels[bar] ?? 0) * units) - below))
478 const fg = row === 0 ? colors.hot : colors.glow
479 const peak = Math.round((spectrum.peaks[bar] ?? 0) * units) - below
480 if (lit > 0) words.push(BLOCK + lit, fg, unlit)
481 else if (theme.peaks && peak > 0 && peak <= 8) words.push(PEAK, colors.hot, unlit)
482 else words.push(0x20, unlit, unlit)
483 }
484 }
485 const labels = Array<number>(at.display).fill(0x20)
486 for (const { col, text } of at.labels) {
487 for (let i = 0; i < text.length; i++) labels[col + i] = text.charCodeAt(i)
488 }
489 for (const glyph of labels) words.push(glyph, colors.label, colors.screen)
490 return encode(words)
491}
492
493// The knobs: a braille dial, its scale swept from seven to five o'clock and
494// lit up to the value, with a pointer from the middle.
495
496const BRAILLE_BIT = [
497 [0x01, 0x08],
498 [0x02, 0x10],
499 [0x04, 0x20],
500 [0x40, 0x80],
501]
502
503const KNOB_CX = (KNOB_COLS * 2 - 1) / 2
504const KNOB_CY = (KNOB_ROWS * 4 - 1) / 2
505const SWEEP = 135
506
507const KNOB_SCALE_DOTS: { x: number; y: number; at: number }[] = (() => {
508 const dots: { x: number; y: number; at: number }[] = []
509 for (let y = 0; y < KNOB_ROWS * 4; y++) {
510 for (let x = 0; x < KNOB_COLS * 2; x++) {
511 if (Math.abs(Math.hypot(x - KNOB_CX, y - KNOB_CY) - 4) > 0.55) continue
512 // Degrees clockwise from twelve o'clock.
513 const angle = (Math.atan2(x - KNOB_CX, KNOB_CY - y) * 180) / Math.PI
514 if (Math.abs(angle) <= SWEEP + 5) dots.push({ x, y, at: (angle + SWEEP) / (2 * SWEEP) })
515 }
516 }
517 return dots
518})()
519
520/**
521 * A knob, KNOB_COLS x KNOB_ROWS cells, turned to `value` (0..1): the scale lit
522 * in `color` up to it and a pointer at it; `null` draws it unlit, pointerless.
523 * A cell shows one color: the pointer's, else the lit scale's, else the scale's.
524 */
525export function knobCells(value: number | null, color: number): string {
526 const cells = Array.from({ length: KNOB_COLS * KNOB_ROWS }, () => ({ bits: 0, rank: 0 }))
527 const mark = (x: number, y: number, rank: number) => {
528 if (x < 0 || y < 0 || x >= KNOB_COLS * 2 || y >= KNOB_ROWS * 4) return
529 const cell = cells[Math.floor(y / 4) * KNOB_COLS + Math.floor(x / 2)]!
530 cell.bits |= BRAILLE_BIT[y % 4]![x % 2]!
531 cell.rank = Math.max(cell.rank, rank)
532 }
533 const v = value === null ? null : Math.max(0, Math.min(1, value))
534 for (const dot of KNOB_SCALE_DOTS) mark(dot.x, dot.y, v !== null && dot.at <= v ? 2 : 1)
535 if (v !== null) {
536 const angle = ((-SWEEP + 2 * SWEEP * v) * Math.PI) / 180
537 for (const r of [1.2, 2.1, 2.9]) {
538 mark(Math.round(KNOB_CX + r * Math.sin(angle)), Math.round(KNOB_CY - r * Math.cos(angle)), 3)
539 }
540 }
541 const colors = [DEFAULT, KNOB_SCALE, color, KNOB_POINTER]
542 const words: number[] = []
543 for (const cell of cells) {
544 if (cell.bits) words.push(0x2800 + cell.bits, colors[cell.rank]!, DEFAULT)
545 else words.push(0x20, DEFAULT, DEFAULT)
546 }
547 return encode(words)
548}
549
550/** Seconds as m:ss. */
551export function clockText(seconds: number): string {
552 const s = Math.max(0, Math.floor(seconds))
553 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
554}
555
556/** The time readout, TIME_COLS x 1 cells, right-aligned: "1:23/3:40". */
557export function timeCells(position: number, duration: number, theme: Theme): string {
558 const colors = palette(theme)
559 const text = (duration > 0 ? `${clockText(Math.min(position, duration))}/${clockText(duration)}` : clockText(position))
560 .slice(-TIME_COLS)
561 .padStart(TIME_COLS)
562 const words: number[] = []
563 for (const glyph of text) words.push(glyph.charCodeAt(0), colors.glow, colors.screen)
564 return encode(words)
565}
566
567/** A one-line, actionable reading of osascript's error output. */
568export function explainFailure(stderr: string): string {
569 if (/-1743|not authori[sz]ed|Not allowed to send Apple events/i.test(stderr)) {
570 return 'macOS is blocking access to Music. Allow your terminal app under System Settings → Privacy & Security → Automation → Music, then restart it.'
571 }
572 if (/-1728|-1708/.test(stderr)) return 'Music has no current track it can describe.'
573 const line = stderr.trim().split('\n').pop() ?? ''
574 return line ? `osascript failed: ${line.slice(0, 160)}` : 'osascript failed without saying why.'
575}
576types/index.d.ts 42 lines1export type Track = {
2 id: string
3 isPlaying: boolean
4 name: string
5 artist: string
6 album: string
7 bpm: number
8 position: number
9 duration: number
10}
11
12export type Theme = {
13 /** Inside width of the frame, in columns. */
14 width: number
15 /** Display glow hue in degrees: 32 amber, 120 green, 190 ice, 0 red. */
16 hue: number
17 /** EQ bar height, percent. */
18 gain: number
19 /** How fast the bars fall, 1 slow to 9 fast. */
20 decay: number
21 /** 1 leaves a column between bars. */
22 gap: number
23 /** 1 holds each bar's peak for a moment. */
24 peaks: number
25 /** 1 shows the unlit segments faintly, as a VFD does. */
26 ghost: number
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'clauisc': {
32 track: Track | null
33 isHidden: boolean
34 problem: string | null
35 /** The console's look; null for the defaults. */
36 theme: Theme | null
37 /** System output volume, 0..100; null when unknown. */
38 volume: number | null
39 }
40 }
41}
42