SLOPSHOPPER

clauisc

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

newbandcommandprocesstimer
★ 5v0.1.0MITupdated 2026-10-07mireabot/Clauisc/plugins/clauisc
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clauisc
› 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 › /clauisc ⎿ clauisc: Clauisc band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Clauisc

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.

Clauisc in Claude Code: the head unit above the prompt, playing Hustlin' by Rick Ross in a violet theme

Status: early. Running on macOS 26 with Apple Music. Reports and screenshots are welcome in Issues.

Features

  • The display: PLAY or PAUSE, title · artist and the time, in a VFD-style glow over dark glass. Amber by default; any hue with /clauisc theme.
  • Six-band EQ: BASS, LOW, MID, VOX, HIGH and AIR, in eighth-height bars with peak hold and faint unlit segments.
  • Live EQ, optional: with /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.
  • Gauges: the VOL knob shows the system output volume, the SEEK knob how far into the track you are. They're for looking at; nothing on the console is a control.
  • Themes: hue, width, bar gain, fall speed, gaps, peak hold and ghost segments, set with one /clauisc theme line or the preview page's sliders; remembered across sessions.
  • Follows the player: play, pause, skip or scrub and the band follows within about 2 seconds.
  • Streamed songs too: reads macOS's system Now Playing info, so streamed Apple Music songs show up, not just your library.
  • Fits the terminal: 6 rows tall; in a small window it becomes one line.

Requirements

  • macOS with the Music app.
  • Claude Code in the terminal, on a build that supports function-hook plugins.
  • A true-color terminal (Terminal, iTerm2, Ghostty, kitty, WezTerm) for the display's colors.
  • For the live EQ only: Xcode command line tools to build the audio helper, and Screen & System Audio Recording permission for your terminal.
  • On first run macOS asks whether your terminal may control Music; allow it. You can change this later in System Settings → Privacy & Security → Automation.

Setup

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.

Usage

Play something in Music and the band appears above your prompt.

CommandEffect
/clauiscHide or show the band
/clauisc statusReport what Clauisc sees: which source answered, osascript's output, the track, timers, and whether the band was drawn
/clauisc audio on / offEQ 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 widthShow the current width
/clauisc width resetBack to the default, 64

The console

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.

Preview page

~/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.

Troubleshooting

Run /clauisc status first; it checks Music right away and reports what it found.

  • The band says macOS is blocking access to Music: open System Settings → Privacy & Security → Automation, turn on Music under your terminal app, and restart the terminal.
  • No band and no message: Music isn't open, or nothing is loaded in it. Start a track; the band appears within 2 seconds.
  • The band doesn't follow play/pause: check /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.
  • Check Music directly: osascript -e 'tell application "Music" to get name of current track'
  • Small window or the desktop app: the console needs 6 rows and 49 columns, and only the terminal draws it; otherwise it's one line of text.
  • The EQ doesn't follow the music: run /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.

How it works

  1. Track: every 2 seconds a JavaScript for Automation script reads macOS's system Now Playing info, the same data Control Center's media widget shows: title, artist, album, position and duration, plus the system volume. It describes streamed Apple Music songs, which Music's own AppleScript can't on recent macOS. If Now Playing can't be read, Clauisc falls back to asking Music through AppleScript. The EQ timer doubles as a backup: if the 2-second timer goes quiet, it checks itself.
  2. EQ: Now Playing carries no audio, so the bars come from one of two places. With live audio on, 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.
  3. Drawing: the knobs, the time and the EQ are Claude Code 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.

Customizing

Everything the preview's sliders set is a /clauisc theme setting:

SettingRangeWhat it does
width46–200Inside width of the frame, in columns (default 64)
hue0–359Display glow: 32 amber, 120 green, 190 ice, 0 red, 280 violet
gain50–150Bar height, percent
decay1–9How fast the bars fall
gap0–1A column between bars
peaks0–1Hold each bar's peak for a moment
ghost0–1Show 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.

Development

Live reload while you edit

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.

Checks

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 a change or a commit

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.

Files

.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

Known limits

  • Now Playing comes from macOS's private MediaRemote framework. It works on macOS 26 from 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.
  • Now Playing is system-wide: if another app (a browser, Spotify) is the current player, the band shows that.
  • Now Playing has no audio or BPM: without the live EQ, the bars follow a groove drawn from the track, not its sound.
  • The live EQ hears all system audio, not only the player, and its bands are frequency ranges: VOX is 800 Hz–2.5 kHz, shared with guitars, keys and snares, not an isolated vocal.
  • The console is terminal only; other Claude Code surfaces get one line of text.

Credits

  • Clauisc is a fan project, not affiliated with or endorsed by Anthropic. Claude is a trademark of Anthropic.

License

MIT

Source 3 files
hooks/register.tsx 493 lines
1import { 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}
493
hooks/lib.ts 576 lines
1import 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}
576
types/index.d.ts 42 lines
1export 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