SLOPSHOPPER

adlib-lyrics

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

newbandtoastprocessnetworktimer
★ 3v0.1.0NOASSERTIONupdated 2026-10-02bixxter/adlib-lyrics
A shopper browsing a rack in a slop shop
README

adlib-lyrics

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.

Install

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.

Settings

Two rows in /config (or /plugin configure adlib-lyrics@adlib-lyrics). A change applies at once.

SettingChoices
Lyricsstack: the line before, the line being sung and the next one (default) · line: only the line being sung · off: no lyrics
Dancing mascoton (default) / off. Terminal only: the desktop app's Code tab gets the lyrics alone.

How it works

  • Once a second it asks Music and Spotify what is playing, through osascript (Apple Events). It never launches either app, and it checks every 3 s while nothing plays.
  • The lyrics come from LRCLIB, a free, community-synced lyrics database. The song's title and artist go to lrclib.net, and nothing else leaves your Mac.
  • LRCLIB times lines, not words, so each word lights up at a pace spread by its length.
  • The mascot is Adlib's 16×12 pixel sprite drawn in braille dots, 8×3 characters.
  • The first time it finds lyrics it shows one note about Adlib. That happens once ever.

Develop

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

Credits

  • Made by bixxter, the maker of Adlib. Adlib puts the same word-by-word lyrics on your Mac's desktop, with a blur-focus look and the album art behind them.
  • Lyrics by LRCLIB.

License

MIT for the code. The Adlib name and mascot artwork are not covered by it; see LICENSE.

Source 4 files
hooks/register.tsx 182 lines
1import { 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}
182
hooks/lrc.ts 45 lines
1import 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}
45
hooks/mascot.ts 48 lines
1// 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]))
48
types/index.d.ts 10 lines
1/** 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