- Rust 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| contrib/keyboard-maestro | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE | ||
| README.md | ||
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
- macOS (including macOS Golden Gate)
- A current stable Rust toolchain (install with rustup)
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}]}
uidis Core Audio's persistent device UID.nameis the current Core Audio display name.inputandoutputsay whether the device currently has streams in those scopes.audio-watcherleaves 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.
watchwaits 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
watchread 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.