A soft soundtrack for Claude Code: a rising chord when Claude starts, a quiet tick while tools run, a chime when it's done.

Soft sounds for what Claude Code is doing:
A subagent's work stays silent, so a busy session doesn't turn into noise.
| Command | What it does |
|---|---|
/soundtrack test | Plays all four sounds |
/soundtrack off | Silence, in every session until you turn it back on |
/soundtrack on | Sounds back on |
Run /plugin configure soundtrack@renegaderocks to turn the tool ticks off and keep the rest.
Claude Code's own sound player only works on macOS, so on Windows the mod plays each sound through PowerShell. Linux plays nothing yet.
hooks/register.ts 96 lines1// soundtrack: soft sounds for what Claude is doing. A rising chord when it
2// starts on your prompt, a quiet tick as tools run, a chime when it's done,
3// two low notes when a turn is cut short.
4
5import type { EngineInterface, Register } from 'claude-code'
6
7type Clip = 'start' | 'tool' | 'done' | 'oops'
8
9// a burst of tool calls stays a soft patter: one tick per this many ms at most
10const TICK_GAP_MS = 3000
11
12let isOn = true
13let lastTick = Number.NEGATIVE_INFINITY
14let isWindows: boolean | null = null
15
16/**
17 * Plays one of the mod's clips without holding anything up. The engine's own
18 * player only sounds on macOS, so on Windows PowerShell plays the file.
19 */
20async function play($: EngineInterface, clip: Clip) {
21 if (!isOn) return
22 try {
23 if (isWindows === null) isWindows = (await $.env.get('OS')) === 'Windows_NT'
24 if (isWindows) {
25 const file = `${$.plugin.root}\\sounds\\${clip}.wav`.replace(/['‘’]/g, q => q + q)
26 await $.process.run(
27 ['powershell', '-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`],
28 { timeoutMs: 10_000 },
29 )
30 } else {
31 await $.audio.play({ asset: `sounds/${clip}.wav` })
32 }
33 } catch {
34 // a sound that cannot play is skipped
35 }
36}
37
38/** A tool's tick, unless one played within the last few seconds. */
39async function tick($: EngineInterface) {
40 try {
41 const now = await $.clock.now()
42 if (now - lastTick < TICK_GAP_MS) return
43 lastTick = now
44 await play($, 'tool')
45 } catch {
46 // skip this one
47 }
48}
49
50export const register: Register = (on, options) => {
51 const toolTicks = options.toolTicks !== false
52
53 on('session.start', async ($, e, next) => {
54 await $.command.register({
55 name: 'soundtrack',
56 description: 'Turn the soundtrack on or off, remembered across sessions, or hear it: /soundtrack [on|off|test]',
57 })
58 isOn = (await $.store.get('isOn')) !== false
59 return next(e)
60 })
61
62 on('turn.start', ($, e, next) => {
63 void play($, 'start')
64 return next(e)
65 })
66
67 on('tool.call', ($, e, next) => {
68 // the main loop's tools only; a subagent's calls would drown it out
69 if (toolTicks && !e.agentId) void tick($)
70 return next(e)
71 })
72
73 on('turn.complete', ($, e, next) => {
74 if (!e.agentId) void play($, e.reason === 'answer' ? 'done' : 'oops')
75 return next(e)
76 })
77
78 on('command.run', { command: 'soundtrack' }, async ($, e) => {
79 const arg = e.args.trim().toLowerCase()
80 if (arg === 'test') {
81 const wasOn = isOn
82 isOn = true
83 for (const clip of ['start', 'tool', 'done', 'oops'] as const) await play($, clip)
84 isOn = wasOn
85 return { text: 'That was: start, tool, done, oops.' }
86 }
87 // a word it doesn't know explains the command rather than flipping the sound
88 if (arg !== '' && arg !== 'on' && arg !== 'off' && arg !== 'toggle') {
89 return { text: `Soundtrack is ${isOn ? 'on' : 'off'}. Usage: /soundtrack [on|off|test]; on its own it switches.` }
90 }
91 isOn = arg === 'on' ? true : arg === 'off' ? false : !isOn
92 await $.store.set('isOn', isOn)
93 return { text: isOn ? 'Soundtrack on.' : 'Soundtrack off, in every session until /soundtrack on.' }
94 })
95}
96