8-bit sound cues on macOS, Windows and Linux: a permission prompt or question waiting on you, a long turn finished, an error, a guard refusing a call, a save

8-bit sound cues, so you know what the session needs without watching it.
| Cue | Plays when | casual | hardcore |
|---|---|---|---|
ask | a permission prompt is about to open, or Claude asks you a question | ✓ | ✓ |
done | a turn finished | turns of 30 s or longer | every turn |
miss | a turn ended in an API error | ✓ | ✓ + every failed tool call |
block | a GAME MODE guard refused a call | ✓ | ✓ |
save | /save made a save point | ✓ | ✓ |
hit | a file changed | — | ✓ |
The same cue twice within 0.4 s plays once. The permission prompt itself cannot be changed by a mod; this is how the pack points at it.
The clips are the plugin's own WAV files in sounds/ (square waves, a few kB each), written by scripts/make-earcons.py. How they play:
| System | Player | Volume |
|---|---|---|
| macOS | Claude Code's own (afplay) | ✓ |
| Windows | PowerShell's SoundPlayer; the clip goes in on stdin, its samples scaled to the volume first, no file written | ✓ |
| Linux | paplay (PulseAudio, PipeWire), else aplay (ALSA) | paplay only |
The system is found on the first cue (OS=Windows_NT, else uname -s).
Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-earcons@bitgame-mods
intensity (casual) | off · casual · hardcore (table above) |
volume (0.6) | 0 to 1 |
minTurnSeconds (30) | in casual, the shortest turn that plays done |
/earcons | what plays when |
/earcons test | play every cue, one after another, and say which player is used |
/earcons off · on | mute and unmute; remembered |
권한 확인·질문 대기(ask), 긴 작업 완료(done), 오류(miss), 가드 차단(block), 세이브(save)를 8비트 효과음으로 알려줍니다. macOS·Windows(PowerShell)·Linux(paplay/aplay)에서 소리가 납니다. /earcons test로 미리 들어보세요.
hooks/register.ts 295 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// GAME MODE · EARCONS
4//
5// 8-bit cues so you know what the session needs without watching it:
6// ask a permission prompt or a question from Claude is waiting on you
7// done a turn finished (casual: turns of at least `minTurnSeconds`)
8// miss a turn ended in an API error (hardcore: also every failed tool call)
9// block a GAME MODE guard (trap guard, barrier, loop breaker) refused a call
10// save /save made a save point
11// hit a file changed (hardcore only)
12// Clips are the plugin's own WAV files (sounds/). macOS plays them through the
13// engine (afplay). Windows plays them with PowerShell's SoundPlayer, the samples
14// scaled to the volume first. Linux plays them with paplay (PulseAudio or
15// PipeWire, at the volume) or else aplay (ALSA, at the mixer's level).
16
17type Cue = 'ask' | 'done' | 'miss' | 'block' | 'save' | 'hit'
18
19const WRITES = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
20const BLOCKED = /\bgame-(?:trap-guard|barrier|loop-breaker)\b.*\bblocked\b|^(?:TRAP|BARRIER|LOOP)!/
21const GAP_MS = 400
22
23type Player = {
24 intensity: 'off' | 'casual' | 'hardcore'
25 gain: number
26 isMuted: boolean
27 lastAt: Map<Cue, number>
28 /** Where the clips play, found on the first cue. */
29 platform: Platform | undefined
30 /** Linux: the player that worked, or `none` once both failed. */
31 linux: 'paplay' | 'aplay' | 'none' | undefined
32 /** Windows: each clip scaled to the volume, as base64, read once. */
33 scaled: Map<Cue, string>
34}
35
36/** `engine`: the engine's own player (macOS; elsewhere silent). */
37export type Platform = 'engine' | 'windows' | 'linux'
38
39// Reads a WAV from stdin as base64 and plays it to the end. No file is written.
40const PS_PLAY =
41 '$b=[Convert]::FromBase64String([Console]::In.ReadToEnd().Trim());' +
42 '$s=New-Object System.IO.MemoryStream(,$b);' +
43 '(New-Object System.Media.SoundPlayer($s)).PlaySync()'
44
45function textOf(content: unknown): string {
46 if (typeof content === 'string') return content
47 if (!Array.isArray(content)) return ''
48 return content
49 .map(block => {
50 if (block === null || typeof block !== 'object') return ''
51 const b = block as { type?: unknown; text?: unknown; content?: unknown }
52 if (b.type === 'text' && typeof b.text === 'string') return b.text
53 if (b.type === 'tool_result') return textOf(b.content)
54 return ''
55 })
56 .join('\n')
57}
58
59/** The cue a stored row calls for: a guard's refusal, a failed call (hardcore), a new save. */
60export function cueOf(door: string, content: readonly unknown[], intensity: Player['intensity']): Cue | undefined {
61 if (door === 'tool-result') {
62 for (const block of content) {
63 if (block === null || typeof block !== 'object') continue
64 const b = block as { type?: unknown; is_error?: unknown; content?: unknown }
65 if (b.type !== 'tool_result' || b.is_error !== true) continue
66 if (BLOCKED.test(textOf(b.content))) return 'block'
67 if (intensity === 'hardcore') return 'miss'
68 }
69 return undefined
70 }
71 if (door === 'command') {
72 const text = textOf(content as unknown[])
73 if (text.includes('◆ SAVE POINT · ') && text.includes('[PASSWORD]')) return 'save'
74 }
75 return undefined
76}
77
78/** Plays a cue unless muted, or the same cue played less than GAP_MS ago. */
79async function play($: EngineInterface, player: Player, cue: Cue): Promise<void> {
80 if (player.intensity === 'off' || player.isMuted || player.gain === 0) return
81 const now = await $.clock.now()
82 if (now - (player.lastAt.get(cue) ?? -Infinity) < GAP_MS) return
83 player.lastAt.set(cue, now)
84 void sound($, player, cue).catch(() => undefined)
85}
86
87/** Plays one clip on this machine's player; resolves once it played or was skipped. */
88async function sound($: EngineInterface, player: Player, cue: Cue): Promise<void> {
89 if (player.platform === undefined) player.platform = await platformOf($)
90 if (player.platform === 'windows') return soundOnWindows($, player, cue)
91 if (player.platform === 'linux') return soundOnLinux($, player, cue)
92 return $.audio.play({ asset: `sounds/${cue}.wav` }, { gain: player.gain })
93}
94
95/** Windows by its OS variable; else uname: Darwin plays through the engine, the rest is Linux. */
96async function platformOf($: EngineInterface): Promise<Platform> {
97 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
98 try {
99 const ran = await $.process.run(['uname', '-s'], { timeoutMs: 3000 })
100 return ran.stdout.trim() === 'Darwin' ? 'engine' : 'linux'
101 } catch {
102 // No process runner here (a remote surface): the engine's player decides.
103 return 'engine'
104 }
105}
106
107async function soundOnWindows($: EngineInterface, player: Player, cue: Cue): Promise<void> {
108 let wav = player.scaled.get(cue)
109 if (wav === undefined) {
110 const { base64 } = await $.fs.read(`${$.plugin.root}/sounds/${cue}.wav`, { as: 'bytes' })
111 wav = scaledWav(base64, player.gain)
112 player.scaled.set(cue, wav)
113 }
114 const args = ['-NoProfile', '-NonInteractive', '-Command', PS_PLAY]
115 try {
116 await $.process.run(['powershell.exe', ...args], { stdin: wav, timeoutMs: 15000 })
117 } catch {
118 // Not on PATH: the copy every Windows keeps.
119 const system = (await $.env.get('SystemRoot')) ?? 'C:\\Windows'
120 await $.process.run([`${system}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, ...args], { stdin: wav, timeoutMs: 15000 })
121 }
122}
123
124async function soundOnLinux($: EngineInterface, player: Player, cue: Cue): Promise<void> {
125 if (player.linux === 'none') return
126 const file = `${$.plugin.root}/sounds/${cue}.wav`
127 if (player.linux !== 'aplay') {
128 try {
129 const ran = await $.process.run(['paplay', `--volume=${Math.round(player.gain * 65536)}`, file], { timeoutMs: 15000 })
130 if (ran.exitCode === 0) {
131 player.linux = 'paplay'
132 return
133 }
134 } catch {
135 // paplay is not installed: try ALSA.
136 }
137 }
138 try {
139 const ran = await $.process.run(['aplay', '-q', file], { timeoutMs: 15000 })
140 player.linux = ran.exitCode === 0 ? 'aplay' : 'none'
141 } catch {
142 player.linux = 'none'
143 }
144}
145
146/** A 16-bit PCM WAV with its samples scaled by `gain`, as base64; any other file comes back as it was. */
147export function scaledWav(base64: string, gain: number): string {
148 if (gain >= 0.999) return base64
149 const bytes = fromBase64(base64)
150 if (bytes.length < 12 || ascii(bytes, 0) !== 'RIFF' || ascii(bytes, 8) !== 'WAVE') return base64
151 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
152 let format = 0
153 let bits = 0
154 let at = 12
155 while (at + 8 <= bytes.length) {
156 const id = ascii(bytes, at)
157 const size = view.getUint32(at + 4, true)
158 const body = at + 8
159 if (id === 'fmt ' && body + 16 <= bytes.length) {
160 format = view.getUint16(body, true)
161 bits = view.getUint16(body + 14, true)
162 } else if (id === 'data') {
163 if (format !== 1 || bits !== 16) return base64
164 const end = Math.min(body + size, bytes.length)
165 for (let i = body; i + 1 < end; i += 2) {
166 const sample = Math.round(view.getInt16(i, true) * gain)
167 view.setInt16(i, Math.max(-32768, Math.min(32767, sample)), true)
168 }
169 return toBase64(bytes)
170 }
171 at = body + size + (size % 2)
172 }
173 return base64
174}
175
176function ascii(bytes: Uint8Array, at: number): string {
177 return String.fromCharCode(bytes[at] ?? 0, bytes[at + 1] ?? 0, bytes[at + 2] ?? 0, bytes[at + 3] ?? 0)
178}
179
180const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
181
182export function fromBase64(text: string): Uint8Array {
183 const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
184 const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
185 let bits = 0
186 let value = 0
187 let n = 0
188 for (const ch of clean) {
189 value = (value << 6) | B64.indexOf(ch)
190 bits += 6
191 if (bits >= 8) {
192 bits -= 8
193 out[n++] = (value >> bits) & 0xff
194 }
195 }
196 return out.subarray(0, n)
197}
198
199export function toBase64(bytes: Uint8Array): string {
200 let out = ''
201 for (let i = 0; i < bytes.length; i += 3) {
202 const a = bytes[i] ?? 0
203 const b = bytes[i + 1]
204 const c = bytes[i + 2]
205 const triple = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
206 out += B64[(triple >> 18) & 63]
207 out += B64[(triple >> 12) & 63]
208 out += b === undefined ? '=' : B64[(triple >> 6) & 63]
209 out += c === undefined ? '=' : B64[triple & 63]
210 }
211 return out
212}
213
214export const register: Register = (on, options) => {
215 const intensity = options.intensity === 'off' || options.intensity === 'hardcore' ? options.intensity : 'casual'
216 const gain = Math.max(0, Math.min(1, Number(options.volume ?? 0.6)))
217 const minTurnMs = Math.max(0, Number(options.minTurnSeconds ?? 30)) * 1000
218 const player: Player = { intensity, gain, isMuted: false, lastAt: new Map(), platform: undefined, linux: undefined, scaled: new Map() }
219
220 on('session.start', async ($, e, next) => {
221 player.isMuted = (await $.store.get('muted')) === true
222 await $.command.register({
223 name: 'earcons',
224 description: 'GAME MODE sound cues: status, test, on or off',
225 argumentHint: '[test|on|off]',
226 })
227 return next(e)
228 })
229
230 // A permission prompt is about to open for this call.
231 on('tool.check', async ($, e, next) => {
232 const verdict = await next(e)
233 if (verdict.decision === 'ask' && e.tool_use_id !== undefined) await play($, player, 'ask')
234 return verdict
235 }).catch(($, e, next) => next(e))
236
237 on('tool.call', async ($, e, next) => {
238 const tool = String(e.tool)
239 if (tool === 'AskUserQuestion') await play($, player, 'ask')
240 const ran = await next(e)
241 if (intensity === 'hardcore' && ran.deny === undefined && ran.isError !== true && WRITES.has(tool)) await play($, player, 'hit')
242 return ran
243 }).catch(($, e, next) => next(e))
244
245 // Refusals, failures and saves are heard from the rows the transcript keeps: every row passes
246 // here whichever plugin made it and in whatever order the plugins load.
247 on('session.append', async ($, e, next) => {
248 const stored = await next(e)
249 const cue = cueOf(e.door, e.message.content, intensity)
250 if (cue !== undefined) await play($, player, cue)
251 return stored
252 }).catch(($, e, next) => next(e))
253
254 on('turn.complete', async ($, e, next) => {
255 if (e.agentId === undefined) {
256 if (e.reason === 'error') await play($, player, 'miss')
257 else if (e.reason === 'answer' && (intensity === 'hardcore' || e.durationMs >= minTurnMs)) await play($, player, 'done')
258 }
259 return next(e)
260 }).catch(($, e, next) => next(e))
261
262 on('command.run', { command: 'earcons' }, async ($, e) => {
263 const sub = e.args.trim().toLowerCase()
264 if (sub === 'on' || sub === 'off') {
265 player.isMuted = sub === 'off'
266 await $.store.set('muted', player.isMuted)
267 return { text: `earcons ${sub}` }
268 }
269 if (sub === 'test') {
270 const order: Cue[] = ['ask', 'done', 'miss', 'block', 'save', 'hit']
271 if (player.platform === undefined) player.platform = await platformOf($)
272 order.forEach((cue, i) => {
273 $.clock.after(i * 900, () => {
274 void sound($, player, cue).catch(() => undefined)
275 })
276 })
277 const how = player.platform === 'windows' ? 'PowerShell SoundPlayer' : player.platform === 'linux' ? 'paplay, else aplay' : 'afplay on macOS'
278 return { text: `playing ${order.join(' → ')} (${how})` }
279 }
280 if (sub !== '') return { text: `Unknown option "${sub}". Use /earcons test, on or off.` }
281 return {
282 text: [
283 `earcons ${player.isMuted ? 'off' : 'on'} · intensity ${intensity} · volume ${gain}`,
284 'ask a permission prompt or a question is waiting on you',
285 `done a turn finished${intensity === 'hardcore' ? '' : ` (${minTurnMs / 1000}s or longer)`}`,
286 `miss a turn ended in an error${intensity === 'hardcore' ? ', or a tool call failed' : ''}`,
287 'block a GAME MODE guard refused a call',
288 'save /save made a save point',
289 ...(intensity === 'hardcore' ? ['hit a file changed'] : []),
290 '/earcons test · on · off',
291 ].join('\n'),
292 }
293 })
294}
295