Synced lyrics of what's playing in Music or Spotify, word by word, above the prompt

Synced lyrics inside Claude Code. Whatever is playing in Apple Music or Spotify shows up right above your prompt, word by word, with a tiny mascot dancing on the beat.
⠀⡔⣪⣭⣭⣕⢢⠀ the build is green, the coffee's warm
⣸⣿⣏⣻⣏⣻⣿⡇ one more commit before the storm
⠈⠙⢿⠮⠵⡿⠓⠄ and the agent hums along
A free Claude Code mod from the maker of Adlib, the synced-lyrics overlay for Mac. The mascot is Adlib's: in the app it dances when a song has no lyrics, and here it dances all the time.
In Claude Code:
/plugin marketplace add bixxter/adlib-lyrics
/plugin install adlib-lyrics@adlib-lyrics
/reload-plugins
Needs macOS and a Claude Code with mods (built and tested on 2.1.287). The first time a song plays, macOS asks whether your terminal may control Music or Spotify. Allow it, or nothing shows.
Two rows in /config (or /plugin configure adlib-lyrics@adlib-lyrics). A change applies at once.
| Setting | Choices |
|---|---|
| Lyrics | stack: the line before, the line being sung and the next one (default) · line: only the line being sung · off: no lyrics |
| Dancing mascot | on (default) / off. Terminal only: the desktop app's Code tab gets the lyrics alone. |
osascript (Apple Events). It never launches either app, and it checks every 3 s while nothing plays.git clone https://github.com/bixxter/adlib-lyrics
claude --plugin-dir ./adlib-lyrics # loads it for that session, reloading on save
claude plugin test ./adlib-lyrics # the tests
claude plugin validate ./adlib-lyrics
MIT for the code. The Adlib name and mascot artwork are not covered by it; see LICENSE.
hooks/register.tsx 182 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { AdlibBand } from '../types'
5import { lineAt, pick, type Line } from './lrc'
6import { MASCOT, MASCOT_COLUMNS, MASCOT_ROWS } from './mascot'
7
8const band = atom({ plugin: 'adlib-lyrics', key: 'band' } as const, null)
9
10// The app's own words for a song the mascot dances to instead.
11const CAPTIONS = {
12 instrumental: ["It's an instrumental", "…so let's dance"],
13 missing: ["Sorry, couldn't find the lyrics", '…but I can dance for you'],
14 offline: ["Sorry, I'm offline", '…but I can dance for you'],
15} as const
16
17// Prints one JSON line a second while Music or Spotify plays (every 3 s otherwise) and
18// never launches either app. Artist and duration are read only when the title changes:
19// every Apple Event costs Spotify ~18 ms. Spotify's duration is in ms, Music's in seconds.
20const NOW_PLAYING = `
21let last = {}
22const read = app => {
23 const player = Application(app)
24 if (!player.running() || player.playerState() !== 'playing') return null
25 const track = player.currentTrack
26 const name = track.name()
27 if (name !== last.name || app !== last.app) {
28 const d = track.duration()
29 last = { app, name, artist: track.artist(), duration: app === 'Spotify' ? d / 1000 : d }
30 }
31 return Object.assign({}, last, { position: player.playerPosition() })
32}
33while (true) {
34 let out = null
35 for (const app of ['Spotify', 'Music']) {
36 try { out = read(app) } catch (e) { if (e.errorNumber === -1743) out = { denied: app } }
37 if (out) break
38 }
39 console.log(JSON.stringify(out))
40 delay(out ? 1 : 3)
41}
42`
43
44type Playing = { app: string; name: string; artist: string; duration: number; position: number }
45type Polled = Playing | { denied: string } | null
46
47export const register: Register = (on, options) => {
48 const layout = options.lyrics // 'stack' | 'line' | 'off', from /config; a change reloads the mod
49 const isDancing = options.mascot !== false
50 if (layout === 'off' && !isDancing) return // nothing to draw, so nothing to poll
51
52 on('session.start', async ($, e, next) => {
53 const started = await next(e)
54 let playing: Playing | null = null
55 let polledAt = 0
56 let track = ''
57 let title = ''
58 let lyrics: Line[] | 'instrumental' | 'missing' | 'offline' | null = null // null: fetching
59 let shown = 'null'
60 let isWarned = false
61
62 const show = (value: AdlibBand | null) => {
63 const key = JSON.stringify(value)
64 if (key === shown) return
65 shown = key
66 void update($, band, () => value)
67 }
68
69 const fetchLyrics = async (song: Playing, key: string) => {
70 const query = new URLSearchParams({ track_name: song.name, artist_name: song.artist })
71 const res = await $.http
72 .fetch(`https://lrclib.net/api/search?${query}`, {
73 headers: { 'User-Agent': 'adlib-lyrics (Claude Code mod; https://tryadlib.web.app)' },
74 })
75 .catch(() => null)
76 if (key !== track) return
77 try {
78 lyrics = res?.ok ? (pick(JSON.parse(res.text), song.duration) ?? 'missing') : 'offline'
79 } catch {
80 lyrics = 'offline' // a 200 that isn't JSON: a captive portal, a proxy
81 }
82 if (Array.isArray(lyrics) && !(await $.store.get('toldAboutAdlib'))) {
83 await $.store.set('toldAboutAdlib', true)
84 $.ui.toast('Lyrics by LRCLIB · Adlib puts them on your desktop: tryadlib.web.app', {
85 timeoutMs: 8000,
86 })
87 }
88 }
89
90 const onPoll = async (polled: Polled) => {
91 if (polled && 'denied' in polled) {
92 if (!isWarned) {
93 isWarned = true
94 $.ui.toast(
95 `adlib-lyrics: allow your terminal to control ${polled.denied} in System Settings › Privacy & Security › Automation`,
96 { timeoutMs: 10000 },
97 )
98 }
99 return
100 }
101 playing = polled
102 polledAt = await $.clock.now()
103 // Spotify sometimes reports an empty title mid-song: keep the song we have.
104 if (!polled?.name || `${polled.artist}\n${polled.name}` === track) return
105 track = `${polled.artist}\n${polled.name}`
106 title = polled.artist ? `${polled.name} · ${polled.artist}` : polled.name
107 lyrics = null
108 // No artist (radio, ads) would match a stranger's song.
109 if (polled.artist && layout !== 'off') void fetchLyrics(polled, track)
110 }
111
112 void (async () => {
113 let buffer = ''
114 const player = $.process.spawn({ argv: ['/usr/bin/osascript', '-l', 'JavaScript', '-e', NOW_PLAYING] })
115 try {
116 for await (const { text } of player) {
117 const rows = (buffer + text).split('\n')
118 buffer = rows.pop() ?? ''
119 for (const row of rows) {
120 try {
121 await onPoll(JSON.parse(row))
122 } catch {
123 // osascript's own warnings are not JSON
124 }
125 }
126 }
127 } catch {
128 // No osascript: not a Mac. The band simply never shows.
129 } finally {
130 playing = null
131 }
132 })()
133
134 $.clock.every(250, async () => {
135 if (!playing) return show(null)
136 const t = playing.position + ((await $.clock.now()) - polledAt) / 1000
137 // ponytail: dances at a fixed ~120 bpm on the song clock, as the app does; real tempo needs audio analysis.
138 const pose = Math.floor(t * 2) & 1
139 const words = Array.isArray(lyrics) ? lineAt(lyrics, t, playing.duration) : null
140 const caption = typeof lyrics === 'string' ? CAPTIONS[lyrics] : null
141 show({
142 pose,
143 ...(words ??
144 (caption
145 ? // "…but I can dance for you" only while the mascot is there to dance
146 { above: title, sung: caption[0], rest: '', below: isDancing ? caption[1] : '' }
147 : { above: '', sung: '', rest: title, below: '' })),
148 ...(layout === 'line' ? { above: '', below: '' } : {}),
149 })
150 })
151
152 return started
153 })
154
155 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
156 const words = await read($, band)
157 const hasMascot = isDancing && e.surface === 'terminal' // desktop has no Raster
158 if (!words || e.props.hasSurvey || (layout === 'off' && !hasMascot)) return next(e)
159 const { Box, Text } = $.ui.resolve(e)
160 const isTall = layout === 'stack' || hasMascot // one row only for a bare line
161 const text = layout !== 'off' && (
162 <Box flexDirection="column" width={Math.max(1, e.props.bodyColumns - (hasMascot ? MASCOT_COLUMNS + 2 : 0))}>
163 {isTall && <Text dimColor wrap="truncate-end">{words.above || ' '}</Text>}
164 <Text wrap="truncate-end">
165 <Text bold>{words.sung}</Text>
166 <Text dimColor>{words.rest || (words.sung ? '' : ' ')}</Text>
167 </Text>
168 {isTall && <Text dimColor wrap="truncate-end">{words.below || ' '}</Text>}
169 </Box>
170 )
171 if (e.surface !== 'terminal' || !hasMascot) return text || next(e)
172 const { Raster } = $.ui.resolve(e)
173
174 return (
175 <Box>
176 <Raster key="mascot" columns={MASCOT_COLUMNS} rows={MASCOT_ROWS} cells={MASCOT[words.pose] ?? MASCOT[0]!} />
177 {text && <Box marginLeft={2}>{text}</Box>}
178 </Box>
179 )
180 })
181}
182hooks/lrc.ts 45 lines1import type { AdlibWords } from '../types'
2
3export type Line = { at: number; text: string }
4export type Hit = { duration: number | null; syncedLyrics: string | null; instrumental: boolean }
5
6// "[01:02.34][01:40.00] words" → one Line per timestamp, sorted by time.
7export function parse(lrc: string): Line[] {
8 const lines: Line[] = []
9 for (const row of lrc.split('\n')) {
10 const text = row.replace(/\[[^\]]*\]/g, '').trim()
11 for (const [, m, s] of row.matchAll(/\[(\d+):(\d+(?:\.\d+)?)\]/g)) {
12 lines.push({ at: Number(m) * 60 + Number(s), text })
13 }
14 }
15 return lines.sort((a, b) => a.at - b.at)
16}
17
18// LRCLIB search hits → the synced version closest in length to what plays.
19export function pick(hits: Hit[], duration: number): Line[] | 'instrumental' | null {
20 const off = (hit: Hit) => Math.abs((hit.duration ?? Infinity) - duration)
21 const best = hits.filter(hit => hit.syncedLyrics).sort((a, b) => off(a) - off(b))[0]
22 if (best && off(best) <= 5) return parse(best.syncedLyrics!)
23 return hits.some(hit => hit.instrumental) ? 'instrumental' : null
24}
25
26// The line at `t` seconds, split where the word being sung ends, between its neighbours.
27// ponytail: LRCLIB times lines, not words, so words are spread by character count over
28// 90% of the gap to the next line (6 s at most); word-synced (enhanced LRC) if that drifts.
29export function lineAt(lines: Line[], t: number, end: number): AdlibWords | null {
30 let i = -1
31 while (i + 1 < lines.length && lines[i + 1]!.at <= t) i++
32 const line = lines[i]
33 if (!line?.text) return null
34 const span = Math.min(((lines[i + 1]?.at ?? end) - line.at) * 0.9, 6)
35 const chars = Math.floor((Math.min(t - line.at, span) / span) * line.text.length)
36 const cut = line.text.indexOf(' ', chars)
37 const at = cut < 0 ? line.text.length : cut
38 return {
39 above: lines[i - 1]?.text ?? '',
40 sung: line.text.slice(0, at),
41 rest: line.text.slice(at),
42 below: lines[i + 1]?.text ?? '',
43 }
44}
45hooks/mascot.ts 48 lines1// Adlib's dancing mascot (16×12 px, Adlib.swift `enum Mascot`), drawn in braille: each cell
2// holds 2×4 px, so the sprite is 8×3 cells, about the app's own 64×48 pt. Eyes and mouth stay
3// unlit, so they read as holes; a cell takes the color most of its lit pixels have.
4const HEAD = [
5 '.....HHHHHH.....',
6 '...HH......HH...',
7 '..H..BBBBBB..H..',
8 '..H.BBBBBBBB.H..',
9 '.HHBBBBBBBBBBHH.',
10 '.HHBBEWBBEWBBHH.',
11 '.HHBBEEBBEEBBHH.',
12]
13const POSES = [
14 ['BHHBCBBBBBBCBHH.', '.BBBBBMBBMBBB...', '...BBBBMMBBBBB..', '....BBBBBBBB..B.', '.....B....B.....'],
15 ['.HHBCBBBBBBCBHHB', '...BBBMBBMBBBBB.', '..BBBBBMMBBBB...', '.B..BBBBBBBB....', '....B......B....'],
16]
17const COLOR: Record<string, number> = { B: 0xf5f5f5, H: 0x666666, C: 0xc7c7c7, W: 0xffffff }
18const DOTS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]] // braille bit at [dy][dx]
19const DEFAULT = 0x01000000 // the terminal's own color
20
21export const MASCOT_COLUMNS = 8
22export const MASCOT_ROWS = 3
23
24function encode(sprite: string[]): string {
25 const words: number[] = []
26 for (let cy = 0; cy < MASCOT_ROWS; cy++) {
27 for (let cx = 0; cx < MASCOT_COLUMNS; cx++) {
28 let dots = 0
29 const count: Record<string, number> = {}
30 for (let dy = 0; dy < 4; dy++) {
31 for (let dx = 0; dx < 2; dx++) {
32 const px = sprite[cy * 4 + dy]![cx * 2 + dx]!
33 if (!(px in COLOR)) continue
34 dots |= DOTS[dy]![dx]!
35 count[px] = (count[px] ?? 0) + 1
36 }
37 }
38 const most = Object.keys(count).sort((a, b) => count[b]! - count[a]!)[0]
39 words.push(0x2800 + dots, most ? COLOR[most]! : DEFAULT, DEFAULT)
40 }
41 }
42 // Raster cells: base64 of little-endian u32 [codePoint, fg, bg] triplets (Macs are little-endian).
43 return btoa(String.fromCharCode(...new Uint8Array(Uint32Array.from(words).buffer)))
44}
45
46/** The Raster `cells` of each dance pose. */
47export const MASCOT = POSES.map(pose => encode([...HEAD, ...pose]))
48types/index.d.ts 10 lines1/** The three rows beside the mascot; the middle one is `sung` (bold) then `rest` (dim). */
2export type AdlibWords = { above: string; sung: string; rest: string; below: string }
3export type AdlibBand = AdlibWords & { pose: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'adlib-lyrics': { band: AdlibBand | null }
8 }
9}
10