A lofi soundtrack that follows the session: calm while Claude is idle, focus while it works, flow during heavy editing, a chime when tests pass and a low note…

A soundtrack that follows your session. A Claude Code mod.
▶ Watch with sound (30 s)
The mod's own tracks, sequenced the way a session drives them.
calm while Claude is idle, focus while it works, flow when it's editing hard (3 edits inside a minute); a chime when a test command passes, a low note when it fails, a chord when a long turn finishes, then silence after a few idle minutes. Off until /lofi on.
Every sound is original, synthesised from scratch by scripts/make_audio.py (Rhodes-style 9th chords, warm bass, swung drums, vinyl crackle), so there is nothing to license. Rerun it to make your own.
/lofi on, /lofi off, /lofi vol 0-100, /lofi statusIts own audio files, played through the system player (afplay on macOS).
Claude Code 2.1.287 or newer runs mods out of the box. On 2.1.273 to 2.1.286, start it with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 first.
claude plugin marketplace add saksham10arora-dotcom/claude-lofi
claude plugin install lofi@nerfsaksham-lofi
Every option has a default, so the installer's "userConfig options not yet set" note is safe to ignore.
Or try it without installing:
git clone https://github.com/saksham10arora-dotcom/claude-lofi && claude --plugin-dir claude-lofi
claude plugin validate .
claude plugin test .
Part of a set: teach-me · vhs · frugal · lofi. More community mods: awesome-claude-mods.
MIT. Made by Saksham Arora (@nerfsaksham).
hooks/register.ts 143 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// lofi: a soundtrack that follows the session.
4// calm while Claude is idle (then silence after a few minutes)
5// focus while a turn runs
6// flow when Claude is editing hard (3+ edits inside a minute)
7// cues: a chime when a test command passes, a low note when it fails,
8// a soft chord when a long turn finishes.
9// Off until /lofi on. Every sound is the mod's own file (audio/, synthesised
10// by scripts/make_audio.py), played through the system player.
11
12type Mood = 'calm' | 'focus' | 'flow'
13type Cue = 'pass' | 'fail' | 'done'
14
15const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
16const TEST_COMMAND = /\b(pytest|jest|vitest|mocha|go test|cargo test|npm (run )?test|pnpm (run )?test|yarn test|bun test|claude plugin test|unittest|rspec|phpunit)\b/
17const FLOW_EDITS = 3
18const FLOW_WINDOW_MS = 60_000
19const LONG_TURN_MS = 45_000
20const OVERLAP_MS = 250 // the old loop keeps playing this long under the new one
21
22// Module state: a hot reload starts the music over.
23let enabled = false
24let volume = 30
25let mood: Mood | null = null
26let loop: AbortController | null = null
27let idleTimer: { cancel: () => void } | null = null
28let turnStartedAt = 0
29let edits: number[] = []
30
31const gain = () => Math.max(0, Math.min(100, volume)) / 100
32
33async function setMood($: EngineInterface, next: Mood | null) {
34 if (next === mood && (next === null || loop)) return
35 const old = loop
36 mood = next
37 loop = null
38 if (next) {
39 const c = new AbortController()
40 loop = c
41 $.audio.play({ asset: `audio/${next}.mp3` }, { shouldLoop: true, gain: gain(), signal: c.signal }).catch(() => {
42 if (loop === c) loop = null // the player failed: let the next mood try again
43 })
44 }
45 if (old) {
46 if (next) $.clock.after(OVERLAP_MS, () => old.abort())
47 else old.abort()
48 }
49}
50
51async function cue($: EngineInterface, name: Cue) {
52 $.audio.play({ asset: `audio/${name}.mp3` }, { gain: Math.min(1, gain() * 1.6) }).catch(() => {})
53}
54
55export const register: Register = (on, options) => {
56 const idleMs = Math.max(0, Number(options.idleMinutes ?? 3)) * 60_000
57
58 on('session.start', async ($, e, next) => {
59 const r = await next(e)
60 enabled = (await $.store.get('enabled')) === true
61 const saved = Number(await $.store.get('volume'))
62 volume = Number.isFinite(saved) && saved > 0 ? saved : Number(options.volume ?? 30)
63 await $.command.register({
64 name: 'lofi',
65 description: 'lofi: a soundtrack that follows the session (on, off, vol 0-100, status)',
66 argumentHint: '[on | off | vol <0-100> | status]',
67 immediate: true,
68 })
69 return r
70 })
71
72 on('turn.start', async ($, e, next) => {
73 if (!e.agentId) {
74 turnStartedAt = await $.clock.now()
75 idleTimer?.cancel()
76 idleTimer = null
77 if (enabled) await setMood($, 'focus')
78 }
79 return next(e)
80 })
81
82 on('tool.call', async ($, e, next) => {
83 const ran = await next(e)
84 if (!enabled || ran.deny !== undefined) return ran
85 if (EDIT_TOOLS.has(e.tool) && ran.isError !== true) {
86 const now = await $.clock.now()
87 edits = [...edits.filter(t => now - t < FLOW_WINDOW_MS), now]
88 if (edits.length >= FLOW_EDITS && mood !== 'flow') await setMood($, 'flow')
89 } else if (e.tool === 'Bash') {
90 const command = String((e as unknown as { command?: unknown }).command ?? '')
91 if (TEST_COMMAND.test(command)) await cue($, ran.isError === true ? 'fail' : 'pass')
92 }
93 return ran
94 })
95
96 on('turn.complete', async ($, e, next) => {
97 const r = await next(e)
98 if (e.agentId || !enabled) return r
99 const now = await $.clock.now()
100 if (!e.isAborted && now - turnStartedAt > LONG_TURN_MS) await cue($, 'done')
101 edits = []
102 await setMood($, 'calm')
103 idleTimer?.cancel()
104 idleTimer = $.clock.after(idleMs, () => {
105 idleTimer = null
106 void setMood($, null)
107 })
108 return r
109 })
110
111 on('command.run', { command: 'lofi' }, async ($, e) => {
112 const [word, value] = String(e.args ?? '').trim().toLowerCase().split(/\s+/)
113 if (word === 'on') {
114 enabled = true
115 await $.store.set('enabled', true)
116 await setMood($, 'calm')
117 return { text: 'lofi is on. calm now, focus while Claude works, flow when it edits hard.' }
118 }
119 if (word === 'off') {
120 enabled = false
121 await $.store.set('enabled', false)
122 idleTimer?.cancel()
123 idleTimer = null
124 await setMood($, null)
125 return { text: 'lofi is off.' }
126 }
127 if (word === 'vol') {
128 const v = Number(value)
129 if (!(v >= 0 && v <= 100)) return { text: 'Give a volume from 0 to 100, like /lofi vol 25.' }
130 volume = v
131 await $.store.set('volume', v)
132 if (mood) {
133 const playing = mood
134 await setMood($, null) // restart the loop at the new volume
135 await setMood($, playing)
136 }
137 return { text: `Volume ${v}.` }
138 }
139 if (word && word !== 'status') return { text: 'Try /lofi on, /lofi off, /lofi vol 25 or /lofi status.' }
140 return { text: enabled ? `lofi is on, volume ${volume}, playing ${mood ?? 'nothing (idle)'}.` : 'lofi is off. /lofi on starts it.' }
141 })
142}
143