SLOPSHOPPER

dictation-music

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

newcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dictation-music
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ dictation-music │ ⏺ Read(src/auth.ts) │ dictation-music: Install media-control to │ ⎿ Read 6 lines │ control your music: brew install │ ⏺ Update(src/auth.ts) │ media-control │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dictation-music ⎿ dictation-music: Pausing music for: com.typewhisper.mac, com.superduper.superwhisper ⎿ dictation-music: Media control: not found. Install media-control to control your music: brew install media-control ⎿ dictation-music: Mic watcher: stopped ⎿ dictation-music: Usage: /dictation-music [learn | add <bundle-id> | remove <bundle-id> | reset | log] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

dictation-music

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">

  • Any music app. Spotify, Apple Music, Podcasts, YouTube or Spotify in a browser, VLC: anything that shows up in the macOS Now Playing control.
  • Any dictation app. TypeWhisper and superwhisper work out of the box. Add yours with one command.
  • Leaves meetings alone. While Zoom, Google Meet, Teams, Slack huddles, FaceTime, Webex or Discord is using your mic, it never pauses or resumes anything.
  • Only resumes what it paused. If the music was already off, or you played, skipped or changed it yourself, it stays the way you left it.
  • Fades, not cuts. The music fades out in half a second as you start talking and fades back in over a second and a half.
  • Doesn't touch your prompts. Nothing is added to your prompt, the system prompt or the context window. It only presses pause and play.

Requirements

  • macOS 14.2 or later
  • Xcode Command Line Tools (xcode-select --install): the mic watcher is a small Swift program built on first run
  • media-control, which pauses and plays whatever is in Now Playing:
  brew install media-control
  • A Claude Code build that supports mods (function-hook plugins), in the terminal or the desktop app's Code tab

Install

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">

Add your dictation app

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"'
CommandWhat it does
/dictation-musicShow which apps pause your music, and whether media-control and the mic watcher are working
/dictation-music learnAdd 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 resetBack to the defaults
/dictation-music logShow 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.

How it works

  1. When a session starts, a small Swift helper watches which apps are using the microphone through Core Audio. It never records or reads audio: it only checks whether each app has the mic open. Other apps on the mic, such as a screen recorder, are ignored unless they're in your list.
  2. When one of your dictation apps opens the mic and no meeting app has it, the mod asks Now Playing what's on. If something is playing, it fades the system volume down, pauses, puts the volume back, and notes the track and where it stopped.
  3. When you submit a prompt, the mod plays from silence and fades the volume back up, but only if:
  4. the same track is still paused at the same spot (you haven't taken over),
  5. no meeting has started since, and
  6. the pause is less than 15 minutes old.

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.

Troubleshooting

Run /dictation-music first: it reports what's missing. Then /dictation-music log shows what happened on your last dictation and why.

  • "Media control: not found": run brew install media-control.
  • "Mic watcher: build failed": install the Xcode Command Line Tools with xcode-select --install, then start a new session.
  • Music doesn't pause: check that your dictation app is in the list, or run /dictation-music learn and dictate once.
  • Music pauses but doesn't come back: /dictation-music log gives the reason, such as the track changing while it was paused or a call holding the mic.
  • Music comes back too early: your dictation app may pause media itself (TypeWhisper calls this "Pause media playback during recording"). Turn that off so the two don't fight.

Files

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.

Uninstall

Remove this mod's path from CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, then delete ~/.claude/dictation-music.

Credits

Now Playing control by media-control by Jonas van den Berg.

Source 1 files
hooks/register.ts 398 lines
1import 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