Pauses your music while you dictate a prompt, and resumes it once the prompt is submitted. Leaves meetings alone.

Pauses your music when you start dictating a prompt, and brings it back once you hit Enter and Claude starts working.
<img src="assets/how-it-works.svg" alt="Music is playing. You start dictating and the music pauses. You hit Enter and the music resumes where it stopped." width="900">
xcode-select --install): the mic watcher is a small Swift program built on first run brew install media-control
Clone the repository once:
git clone https://github.com/yash-coded/claude-code-mods ~/.claude/claude-code-mods
Then add the mod to ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/claude-code-mods/mods/dictation-music"
}
}
Start a new session (in the desktop app, quit and reopen it once), then run /dictation-music to check that everything was found:
<img src="assets/status-command.svg" alt="/dictation-music lists the apps that pause your music, where media-control was found, and that the mic watcher is running." width="900">
Run this in any session, then start dictating:
/dictation-music learn
The next app that uses your microphone is added. Or add one by bundle ID:
/dictation-music add com.example.yourapp
To find an app's bundle ID:
osascript -e 'id of app "Wispr Flow"'
| Command | What it does |
|---|---|
/dictation-music | Show which apps pause your music, and whether media-control and the mic watcher are working |
/dictation-music learn | Add the next app that uses the mic |
/dictation-music add <bundle-id> | Add an app |
/dictation-music remove <bundle-id> | Stop an app from pausing music |
/dictation-music reset | Back to the defaults |
/dictation-music log | Show recent activity: which apps used the mic, and why the music was or wasn't paused or resumed |
The list lives in ~/.claude/dictation-music/config.json if you'd rather edit it by hand. Add "fade": false there to pause and play without fading.
Meeting apps and browsers can't be added: browsers are where Google Meet and other web calls run, so treating them as dictation would pause your music on every call.
The fade works on the system volume, so other sounds dip with it for that half second, including your dictation app's start chime. Your volume ends up exactly where it was. If you have several Claude sessions open, only one of them handles each pause and resume.
Sending a message while Claude is already working. Claude Code holds that message until Claude reaches its next step, so the music comes back when Claude picks the message up rather than the moment you press send. It never comes back while your dictation app still has the mic.
Dictating somewhere else. If you dictate into another app and never submit to Claude, the music stays paused. Start dictating again within 2 minutes and the next submit still brings it back. After that, the mod treats the silence as your choice and forgets its pause.
Run /dictation-music first: it reports what's missing. Then /dictation-music log shows what happened on your last dictation and why.
brew install media-control.xcode-select --install, then start a new session./dictation-music learn and dictate once./dictation-music log gives the reason, such as the track changing while it was paused or a call holding the mic.hooks/register.ts the mod: what pauses, when it resumes, the /dictation-music command
helper/dictation-watch.swift prints "start <app>" / "stop <app>" as apps open and close the mic
helper/fade.applescript fades the system volume around a pause or play
tests/ tests against a fake machine (files, mic and player)
assets/ the images in this README
Changed the Swift helper? Bump HELPER in hooks/register.ts so existing installs rebuild it. See the repository README for validating, testing and type-checking.
Remove this mod's path from CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, then delete ~/.claude/dictation-music.
Now Playing control by media-control by Jonas van den Berg.
hooks/register.ts 398 lines1import type { Hook, Register } from 'claude-code'
2
3type Dollar = Parameters<Hook<'prompt.submit'>>[0]
4
5// Bump when helper/dictation-watch.swift changes so sessions rebuild it.
6const HELPER = 'dictation-watch-v2'
7
8// Apps whose microphone use counts as dictation.
9// Add yours with `/dictation-music learn` or `/dictation-music add <bundle-id>`.
10const DEFAULT_APPS = ['com.typewhisper.mac', 'com.superduper.superwhisper']
11
12// Calls and meetings, matched by bundle ID prefix. While any of these holds the
13// mic the mod does nothing, and none of them can be added as a dictation app.
14const MEETING_APPS = [
15 'us.zoom.',
16 'com.microsoft.teams',
17 'com.tinyspeck.slackmacgap',
18 'com.apple.FaceTime',
19 'com.cisco.webex',
20 'com.webex.',
21 'com.hnc.Discord',
22 'com.skype.',
23 'net.whatsapp.',
24 // Browsers: Google Meet and other web calls capture the mic through them.
25 'com.google.Chrome',
26 'com.apple.Safari',
27 'com.apple.WebKit',
28 'company.thebrowser.',
29 'org.mozilla.firefox',
30 'com.microsoft.edgemac',
31 'com.brave.Browser',
32 'com.operasoftware.',
33 'com.vivaldi.',
34 'app.zen-browser.',
35]
36
37// https://github.com/ungive/media-control, the system Now Playing from the CLI.
38const MEDIA_CONTROL_PATHS = ['/opt/homebrew/bin/media-control', '/usr/local/bin/media-control']
39const INSTALL_HINT = 'Install media-control to control your music: brew install media-control'
40
41// A pause older than this is left alone rather than blasting music back on.
42const STALE_MS = 15 * 60 * 1000
43// Dictating into silence this long after the mod's pause: the silence is the
44// person's choice now, so that pause is forgotten.
45const FORGET_MS = 2 * 60 * 1000
46// How far the track may have moved while paused (seconds) and still be ours.
47const POSITION_SLACK = 3
48// Fade lengths. Short going out, so the music is gone as you start talking.
49const FADE_OUT_MS = 500
50const FADE_IN_MS = 1500
51// A lock older than this was left by a session that died mid-fade.
52const LOCK_STALE_MS = 20_000
53
54// The activity log `/dictation-music log` shows: lines kept, and shown.
55const LOG_KEEP = 500
56const LOG_SHOW = 25
57
58const USAGE = 'Usage: /dictation-music [learn | add <bundle-id> | remove <bundle-id> | reset | log]'
59
60// What the mod paused, so it resumes only that, untouched since.
61type Marker = { pausedAt?: number; app?: string; track?: string; position?: number }
62type NowPlaying = { playing: boolean; app?: string; track?: string; position?: number; reportedAt?: number }
63// `fade: false` in config.json pauses and plays without fading.
64type Config = { apps: string[]; fade?: boolean }
65
66// Apps holding the mic right now, as this session's helper last reported.
67const capturing = new Set<string>()
68// Set by `/dictation-music learn`: the next app to use the mic is added.
69let learning = false
70// What `/dictation-music` reports about the mic watcher.
71let watcher = 'starting'
72// Tells this session's lines apart in the shared activity log.
73const SESSION = Date.now().toString(36).slice(-4)
74
75const isMeetingApp = (app: string) => MEETING_APPS.some(prefix => app.startsWith(prefix))
76const inMeeting = () => [...capturing].some(isMeetingApp)
77// Dictation apps still holding the mic. Only the configured ones count: a
78// screen recorder or other app using the mic is no sign of dictating.
79const stillDictating = (config: Config) => [...capturing].filter(app => config.apps.includes(app))
80
81async function stateDir($: Dollar): Promise<string> {
82 return `${await $.env.get('HOME')}/.claude/dictation-music`
83}
84
85async function readJson<T>($: Dollar, path: string): Promise<T | undefined> {
86 try {
87 return JSON.parse(String(await $.fs.read(path)))
88 } catch {
89 return undefined
90 }
91}
92
93// Appends one line to the activity log. Every session writes to the same file,
94// so it appends (atomic for a short line) instead of rewriting it.
95async function log($: Dollar, message: string) {
96 const line = `${new Date().toISOString()} [${SESSION}] ${message}`
97 await $.process
98 .run(['/bin/sh', '-c', 'printf "%s\\n" "$1" >> "$2"', 'sh', line, `${await stateDir($)}/activity.log`])
99 .catch(() => {})
100}
101
102async function trimLog($: Dollar) {
103 const path = `${await stateDir($)}/activity.log`
104 const text = await $.fs.read(path).then(String, () => '')
105 const lines = text.split('\n').filter(Boolean)
106 if (lines.length > LOG_KEEP * 2) await $.fs.write(path, lines.slice(-LOG_KEEP).join('\n') + '\n')
107}
108
109const describe = (at: { app?: string; track?: string; position?: number }) =>
110 `${at.app ?? '?'} ${at.track ?? '?'} @${at.position === undefined ? '?' : at.position.toFixed(1)}s`
111
112async function exists($: Dollar, path: string): Promise<boolean> {
113 return $.fs.stat(path).then(
114 () => true,
115 () => false,
116 )
117}
118
119async function readConfig($: Dollar): Promise<Config> {
120 const config = await readJson<Config>($, `${await stateDir($)}/config.json`)
121 return Array.isArray(config?.apps) ? config : { apps: DEFAULT_APPS }
122}
123
124async function writeConfig($: Dollar, config: Config) {
125 await $.fs.write(`${await stateDir($)}/config.json`, JSON.stringify(config, null, 2) + '\n')
126}
127
128async function mediaControl($: Dollar): Promise<string | undefined> {
129 for (const path of MEDIA_CONTROL_PATHS) if (await exists($, path)) return path
130 return undefined
131}
132
133async function nowPlaying($: Dollar, media: string): Promise<NowPlaying> {
134 const { exitCode, stdout } = await $.process.run([media, 'get'], { timeoutMs: 5000 })
135 if (exitCode !== 0) return { playing: false }
136 try {
137 const now = JSON.parse(stdout)
138 return {
139 playing: now?.playing === true,
140 app: now?.bundleIdentifier,
141 track: now?.contentItemIdentifier ?? now?.title,
142 position: positionNow(now),
143 reportedAt: typeof now?.timestamp === 'string' ? Date.parse(now.timestamp) : undefined,
144 }
145 } catch {
146 return { playing: false }
147 }
148}
149
150// media-control reports the position as of `timestamp` (whole seconds, and
151// stale for a moment after a pause), so a playing track is projected to now.
152function positionNow(now: { elapsedTime?: unknown; timestamp?: unknown; playing?: unknown; playbackRate?: unknown }) {
153 if (typeof now?.elapsedTime !== 'number') return undefined
154 const at = typeof now.timestamp === 'string' ? Date.parse(now.timestamp) : NaN
155 if (now.playing !== true || Number.isNaN(at)) return now.elapsedTime
156 return now.elapsedTime + ((Date.now() - at) / 1000) * (Number(now.playbackRate) || 1)
157}
158
159// Right after a pause media-control still reports the old position for a
160// moment, so wait for a reading taken after `since`.
161async function stoppedAt($: Dollar, media: string, since: number, fallback?: number) {
162 for (let attempt = 0; attempt < 10; attempt++) {
163 const now = await nowPlaying($, media)
164 if (!now.playing && (now.reportedAt ?? 0) >= since - 1000) return now.position
165 await $.clock.sleep(250)
166 }
167 return fallback
168}
169
170// Pauses or plays, fading the system volume around it unless turned off.
171async function fade($: Dollar, media: string, verb: 'pause' | 'play', config: Config) {
172 if (config.fade === false) {
173 await $.process.run([media, verb], { timeoutMs: 5000 })
174 return
175 }
176 const [direction, ms] = verb === 'pause' ? ['out', FADE_OUT_MS] : ['in', FADE_IN_MS]
177 await $.process.run(
178 ['/usr/bin/osascript', `${$.plugin.root}/helper/fade.applescript`, direction, media, String(ms)],
179 { timeoutMs: 10_000 },
180 )
181}
182
183// Every session hears the same mic events. Only one at a time touches the
184// music, so two fades never fight over the volume; mkdir is atomic, so a
185// folder is the lock. Answers false, doing nothing, when another holds it.
186async function withLock($: Dollar, work: () => Promise<unknown>): Promise<boolean> {
187 const lock = `${await stateDir($)}/lock`
188 const take = () => $.process.run(['/bin/mkdir', lock]).then(r => r.exitCode === 0, () => false)
189 let taken = await take()
190 if (!taken) {
191 const age = await $.fs.stat(lock).then(stat => Date.now() - stat.mtimeMs, () => Infinity)
192 if (age > LOCK_STALE_MS) {
193 await $.process.run(['/bin/rmdir', lock]).catch(() => {})
194 taken = await take()
195 }
196 }
197 if (!taken) return false
198 try {
199 await work()
200 } finally {
201 await $.process.run(['/bin/rmdir', lock]).catch(() => {})
202 }
203 return true
204}
205
206const sameSpot = (marker: Marker, now: NowPlaying) =>
207 now.app === marker.app &&
208 now.track === marker.track &&
209 (marker.position === undefined ||
210 now.position === undefined ||
211 Math.abs(now.position - marker.position) <= POSITION_SLACK)
212
213async function onMicStart($: Dollar, app: string) {
214 capturing.add(app)
215 await log($, `mic start: ${app}`)
216 if (isMeetingApp(app) || inMeeting()) return log($, 'on a call: not pausing')
217 const config = await readConfig($)
218 if (learning) {
219 learning = false
220 if (!config.apps.includes(app)) await writeConfig($, { ...config, apps: [...config.apps, app] })
221 $.ui.toast(`dictation-music: added ${app}`)
222 await log($, `learned ${app}`)
223 } else if (!config.apps.includes(app)) {
224 return
225 }
226 const media = await mediaControl($)
227 if (!media) return log($, 'media-control not found: not pausing')
228 await withLock($, () => pause($, media, config))
229}
230
231async function pause($: Dollar, media: string, config: Config) {
232 const path = `${await stateDir($)}/paused.json`
233 const before = await nowPlaying($, media)
234 if (!before.playing) {
235 const marker = await readJson<Marker>($, path)
236 if (marker?.pausedAt && Date.now() - marker.pausedAt > FORGET_MS) {
237 await $.fs.write(path, '{}')
238 return log($, 'nothing playing: forgot an old pause')
239 }
240 return log($, `nothing playing${marker?.pausedAt ? ': keeping the recent pause' : ''}`)
241 }
242 const since = Date.now()
243 await fade($, media, 'pause', config)
244 const position = await stoppedAt($, media, since, before.position)
245 const marker: Marker = { pausedAt: Date.now(), app: before.app, track: before.track, position }
246 await $.fs.write(path, JSON.stringify(marker))
247 await log($, `paused ${describe(marker)}`)
248}
249
250async function onMicStop($: Dollar, app: string) {
251 capturing.delete(app)
252 await log($, `mic stop: ${app}`)
253}
254
255// `submitted`: the person sent the prompt, so they're done dictating even if
256// the dictation app hasn't let go of the mic yet (it often submits first).
257async function resumeIfPaused($: Dollar, submitted = false) {
258 const path = `${await stateDir($)}/paused.json`
259 if (!(await readJson<Marker>($, path))?.pausedAt) return
260 const config = await readConfig($)
261 const via = submitted ? 'prompt submitted' : 'Claude took a step'
262 if (!submitted) {
263 const dictating = stillDictating(config)
264 if (dictating.length) return log($, `${via}: ${dictating.join(', ')} still has the mic, waiting`)
265 }
266 // Busy: a submit and the step right after it both get here; one resumes.
267 await withLock($, () => resume($, path, config, via))
268}
269
270async function resume($: Dollar, path: string, config: Config, via: string) {
271 // Read again under the lock: whoever held it may have resumed already.
272 const marker = await readJson<Marker>($, path)
273 if (!marker?.pausedAt) return
274 // Clear first so concurrent sessions don't both resume.
275 await $.fs.write(path, '{}')
276 // Joined a call since dictating: leave the music off.
277 if (inMeeting()) return log($, `${via}: on a call, leaving the music off`)
278 if (Date.now() - marker.pausedAt > STALE_MS) return log($, `${via}: pause is too old, leaving it`)
279 const media = await mediaControl($)
280 if (!media) return log($, `${via}: media-control not found`)
281 // Played, skipped or switched since the pause: the person took over.
282 const now = await nowPlaying($, media)
283 if (now.playing) return log($, `${via}: music is already playing`)
284 if (!sameSpot(marker, now)) {
285 return log($, `${via}: music changed since the pause (now ${describe(now)}, paused ${describe(marker)})`)
286 }
287 await fade($, media, 'play', config)
288 await log($, `${via}: resumed ${describe(marker)}`)
289}
290
291// Builds the mic watcher on first run, then reports each app that starts or
292// stops using the mic for as long as the session lives.
293async function watchMic($: Dollar) {
294 const dir = await stateDir($)
295 const bin = `${dir}/${HELPER}`
296 if (!(await exists($, bin))) {
297 watcher = 'building'
298 await $.fs.write(`${dir}/.keep`, '') // swiftc needs the folder to exist
299 const build = await $.process.run(
300 ['/usr/bin/swiftc', '-O', '-o', bin, `${$.plugin.root}/helper/dictation-watch.swift`],
301 { timeoutMs: 300_000 },
302 )
303 if (build.exitCode !== 0) {
304 watcher = `build failed (is Xcode Command Line Tools installed?): ${build.stderr.trim().slice(0, 300)}`
305 $.ui.toast('dictation-music: could not build the mic watcher. Run /dictation-music for details.')
306 return
307 }
308 }
309 watcher = 'running'
310 let buffer = ''
311 for await (const chunk of $.process.spawn({ argv: [bin] })) {
312 if (!('text' in chunk) || chunk.stream !== 'stdout') continue
313 buffer += chunk.text
314 const lines = buffer.split('\n')
315 buffer = lines.pop() ?? ''
316 for (const line of lines) {
317 const [event, ...rest] = line.trim().split(' ')
318 const app = rest.join(' ')
319 if (event === 'start') await onMicStart($, app)
320 else if (event === 'stop') await onMicStop($, app)
321 }
322 }
323 watcher = 'stopped'
324}
325
326async function runCommand($: Dollar, args: string): Promise<string> {
327 const [verb = '', ...rest] = args.trim().split(/\s+/)
328 const target = rest.join(' ')
329 const config = await readConfig($)
330 switch (verb) {
331 case 'learn':
332 learning = true
333 return 'Start your dictation app now: the next app to use the microphone will be added.'
334 case 'add':
335 if (!target) return USAGE
336 if (isMeetingApp(target)) return `${target} is a meeting app or browser, so it can't pause music.`
337 if (!config.apps.includes(target)) await writeConfig($, { ...config, apps: [...config.apps, target] })
338 return `Added ${target}.`
339 case 'remove':
340 if (!target) return USAGE
341 await writeConfig($, { ...config, apps: config.apps.filter(app => app !== target) })
342 return `Removed ${target}.`
343 case 'log': {
344 const text = await $.fs.read(`${await stateDir($)}/activity.log`).then(String, () => '')
345 const lines = text.split('\n').filter(Boolean).slice(-LOG_SHOW)
346 return lines.length ? lines.join('\n') : 'Nothing logged yet.'
347 }
348 case 'reset':
349 await writeConfig($, { ...config, apps: DEFAULT_APPS })
350 return `Back to the defaults: ${DEFAULT_APPS.join(', ')}.`
351 default: {
352 const media = await mediaControl($)
353 return [
354 `Pausing music for: ${config.apps.join(', ') || 'no apps yet'}`,
355 `Media control: ${media ?? `not found. ${INSTALL_HINT}`}`,
356 `Mic watcher: ${watcher}`,
357 USAGE,
358 ].join('\n')
359 }
360 }
361}
362
363export const register: Register = on => {
364 on('session.start', async ($, e, next) => {
365 const started = await next(e)
366 await $.command.register({
367 name: 'dictation-music',
368 description: 'Choose which dictation apps pause your music',
369 argumentHint: '[learn | add <bundle-id> | remove <bundle-id> | reset | log]',
370 })
371 if (!(await mediaControl($))) $.ui.toast(`dictation-music: ${INSTALL_HINT}`)
372 await trimLog($)
373 void watchMic($).catch(err => {
374 watcher = `failed: ${String(err)}`
375 $.ui.log(`dictation-music: ${String(err)}`, { to: 'debug' })
376 })
377 return started
378 })
379
380 on('command.run', { command: 'dictation-music' }, async ($, e) => ({
381 text: await runCommand($, e.args),
382 }))
383
384 on('prompt.submit', async ($, e, next) => {
385 const submitted = await next(e)
386 // In the background: the fade-in takes a moment, and Claude shouldn't wait.
387 void resumeIfPaused($, true).catch(() => {})
388 return submitted
389 })
390
391 // A message sent while Claude is already working joins the running turn
392 // instead of arriving as a new prompt: catch it at the next model request.
393 on('turn.step', async function* ($, e, next) {
394 if (!e.agentId) void resumeIfPaused($).catch(() => {})
395 return yield* next(e)
396 })
397}
398