No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Erick Ruiz de Chavez c76bbc54f7 Add my Keyboard Maestro macro to contrib
contrib/keyboard-maestro/set-sound-devices.kmmacros is the macro my Keyboard Maestro callback runs. The README explains what the macro does and how to import it. It also states that audio-watcher does not need Keyboard Maestro and that this example is my own setup.

Assisted-By: Claude <noreply@anthropic.com>
2026-10-18 05:28:46 -04:00
contrib/keyboard-maestro Add my Keyboard Maestro macro to contrib 2026-10-18 05:28:46 -04:00
src Initial import of audio-watcher 2026-10-18 04:41:28 -04:00
tests Initial import of audio-watcher 2026-10-18 04:41:28 -04:00
.gitignore Initial import of audio-watcher 2026-10-18 04:41:28 -04:00
Cargo.lock Initial import of audio-watcher 2026-10-18 04:41:28 -04:00
Cargo.toml Initial import of audio-watcher 2026-10-18 04:41:28 -04:00
LICENSE Initial import of audio-watcher 2026-10-18 04:41:28 -04:00
README.md Add my Keyboard Maestro macro to contrib 2026-10-18 05:28:46 -04:00

audio-watcher

audio-watcher is a small, event-driven macOS CLI that reports the currently available Core Audio input and output devices. It uses Core Audio's system device-list notification, so it does not poll, and it does not react when only the default input or output selection changes.

It can also run a callback program each time the inventory changes. Running as a LaunchAgent shows a complete example.

Requirements

Build and install

From the project directory:

cargo install --path . --locked

That builds a release binary and installs it at ~/.cargo/bin/audio-watcher. rustup adds ~/.cargo/bin to your PATH when it installs Rust. Run the same command again to install a new version.

Commands

Print the current inventory once:

audio-watcher list

Print the inventory as newline-delimited JSON immediately and after each change:

audio-watcher watch

Run a command for the initial inventory and every changed inventory:

audio-watcher watch --exec /path/to/on-audio-change --an-option value

Everything after --exec is the callback and its arguments. audio-watcher passes them on unchanged, without a shell, so --exec must come last. That includes a --.

In --exec mode, each callback receives one JSON object followed by a newline on standard input. Callbacks run one at a time, and audio-watcher does not process the next inventory until the current callback exits. The callback shares audio-watcher's standard output and error. If a callback exits with a non-zero status, or exits without reading its input, audio-watcher reports it on standard error and keeps watching. If the callback cannot start, audio-watcher exits.

--exec replaces the watch stream rather than duplicating it on audio-watcher's own standard output. To both handle and record the JSON, use a callback such as /usr/bin/tee.

JSON format

Each inventory has this shape:

{"devices":[{"uid":"AppleUSBAudioEngine:example","name":"USB Headset","input":true,"output":true}]}
  • uid is Core Audio's persistent device UID.
  • name is the current Core Audio display name.
  • input and output say whether the device currently has streams in those scopes. audio-watcher leaves out devices that have neither.

audio-watcher sorts the device array by UID, so the same devices always produce the same JSON.

Change detection

watch always delivers the initial inventory. It prints it, or sends it to the first callback run. After that, it delivers an inventory only when it differs from the last one delivered:

  • Core Audio may send a burst of notifications for one physical connection change. watch waits 100 ms after a notification and treats the burst as one change. It ignores notifications that produce the same inventory.
  • Only additions and removals in the device list make watch read the inventory again. If a device is renamed in place, the new name appears the next time a device is added or removed.

While a device is connecting or disconnecting, Core Audio can briefly fail to report its UID, name, or streams. watch retries such a read after 250 ms, 500 ms and 1 s rather than reporting the device as disconnected. If the device is still unreadable after the retries, watch leaves it out and prints a message on standard error. If the device list itself still cannot be read after the retries, watch reports it and tries again at the next change. At startup, it exits instead.

list reads once without retrying and reports any unreadable device the same way.

Running as a LaunchAgent

A per-user LaunchAgent starts audio-watcher at login and restarts it if it exits. The callback can be any executable that reads one JSON object from standard input.

1. Install the binary

Install audio-watcher as described in Build and install, then confirm where it is:

command -v audio-watcher

2. Create the callback script

The LaunchAgent runs ~/.local/bin/on-audio-change for each inventory. Create that script from one of the examples below. The simple example works on its own. The Keyboard Maestro example is optional.

LaunchAgents run with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). A callback should call any other program by its absolute path, or set PATH itself. The examples call every program by absolute path. To read the JSON, they use jq, which macOS 15 Sequoia and later include at /usr/bin/jq.

Simple example: log each change

This script prints the time and the device names. The callback's output goes to the same log as audio-watcher's own messages, so each change shows up in ~/Library/Logs/audio-watcher.log.

mkdir -p ~/.local/bin
cat > ~/.local/bin/on-audio-change <<'EOF'
#!/bin/sh
# audio-watcher runs this with one JSON inventory on standard input. It prints
# the time and the name of each available device.

/bin/date '+%Y-%m-%d %H:%M:%S'
/usr/bin/jq -r '.devices[] | "  \(.name)"'
EOF
chmod 755 ~/.local/bin/on-audio-change

Each change adds lines such as:

2026-10-18 09:41:07
  Poly V4320 Series
  Poly V4320 Series
  MacBook Air Microphone
  MacBook Air Speakers

The headset in this example has separate input and output devices with the same name, so its name appears twice. The Keyboard Maestro example below removes duplicate names.

Keyboard Maestro example: run a macro

audio-watcher does not need Keyboard Maestro. Any program that reads JSON from standard input works as a callback. This example is my own setup, and I keep it here for reference.

This script passes the unique device names, one per line, to a Keyboard Maestro macro. Replace YOUR-MACRO-UUID with the UUID of your macro. In the Keyboard Maestro Editor, select the macro and choose Edit, then Copy as, then Copy UUID.

mkdir -p ~/.local/bin
cat > ~/.local/bin/on-audio-change <<'EOF'
#!/bin/sh
# audio-watcher runs this with one JSON inventory on standard input. It passes
# the unique device names, one per line, to a Keyboard Maestro macro.

jq=/usr/bin/jq

device_names=$("$jq" -r '[.devices[].name] | unique[]') || exit

# Keyboard Maestro's "do script" returns "missing value", and osascript would
# print it. Discard standard output only, so errors stay visible.
/usr/bin/osascript \
  -e 'on run argv' \
  -e 'set deviceNames to item 1 of argv' \
  -e 'tell application "Keyboard Maestro Engine" to do script "YOUR-MACRO-UUID" with parameter deviceNames' \
  -e 'end run' \
  "$device_names" >/dev/null
EOF
chmod 755 ~/.local/bin/on-audio-change

The macro receives a parameter such as:

MacBook Air Microphone
MacBook Air Speakers
Poly V4320 Series

contrib/keyboard-maestro/set-sound-devices.kmmacros is the macro I run with this script. It waits 1.5 seconds, then checks the device names in order. If it finds Poly V4320 Series, Bose QC35 II, EarPods, or the LG SMART WQHD display, it selects that device and stops. For the LG display, it uses the Anker PowerConf C200 as the microphone. If none of those are present, it selects the MacBook Air speakers and microphone.

To use it, open the file to import it into Keyboard Maestro, then enable the macro. The macro has no trigger of its own, because the script runs it. Copy its UUID into the script in place of YOUR-MACRO-UUID, and change the device names to match your hardware.

Try the script

Run the script by hand before installing the agent. With the Keyboard Maestro example, this runs the macro once.

audio-watcher list | ~/.local/bin/on-audio-change

3. Create the LaunchAgent

launchd does not expand ~ or $HOME, so the plist needs absolute paths. This command writes it with your paths filled in:

watcher=$(command -v audio-watcher)
mkdir -p ~/Library/LaunchAgents ~/Library/Logs
cat > ~/Library/LaunchAgents/sh.erick.code.audio-watcher.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>sh.erick.code.audio-watcher</string>
    <key>ProgramArguments</key>
    <array>
        <string>$watcher</string>
        <string>watch</string>
        <string>--exec</string>
        <string>$HOME/.local/bin/on-audio-change</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>ProcessType</key>
    <string>Background</string>
    <key>StandardOutPath</key>
    <string>$HOME/Library/Logs/audio-watcher.log</string>
    <key>StandardErrorPath</key>
    <string>$HOME/Library/Logs/audio-watcher.log</string>
</dict>
</plist>
EOF
plutil -lint ~/Library/LaunchAgents/sh.erick.code.audio-watcher.plist

4. Load it

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/sh.erick.code.audio-watcher.plist

The callback runs once immediately with the current devices, then after every change. launchd also starts the agent at each login.

If you use the Keyboard Maestro example, macOS may ask you to allow the callback to control Keyboard Maestro Engine the first time it runs. If the macro does not run and the log shows an authorization error from osascript, open System Settings, then Privacy & Security, then Automation, and allow it there.

Managing the agent

Check that it is running (look for state = running):

launchctl print gui/$(id -u)/sh.erick.code.audio-watcher | grep state

Read the log, which contains audio-watcher's messages and the callback's output and errors:

tail -f ~/Library/Logs/audio-watcher.log

After you change the callback script, you do not need to reload anything, because audio-watcher starts the script fresh for each inventory. After you install a new audio-watcher binary, restart the agent:

launchctl kickstart -k gui/$(id -u)/sh.erick.code.audio-watcher

To stop the agent until you next log in, unload it. launchd loads it again at login as long as the plist is in ~/Library/LaunchAgents.

launchctl bootout gui/$(id -u)/sh.erick.code.audio-watcher

If the callback script is missing or not executable, audio-watcher exits and launchd restarts it about every 10 seconds. The log shows could not start callback.

Uninstall

If you set up the LaunchAgent, stop it and remove its files. If the agent is not loaded, launchctl bootout prints Boot-out failed: 3: No such process, which is harmless.

launchctl bootout gui/$(id -u)/sh.erick.code.audio-watcher
rm ~/Library/LaunchAgents/sh.erick.code.audio-watcher.plist
rm ~/.local/bin/on-audio-change
rm ~/Library/Logs/audio-watcher.log

Then remove the binary:

cargo uninstall audio-watcher

Development checks

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --locked

The tests in tests/ run the real binary against Core Audio, so they need macOS. They check the list output and that watch --exec delivers the initial inventory. You still have to test connecting and disconnecting devices by hand.