Record macOS system audio and the microphone to two separate WAV files, from the command line, with no kernel extension, no virtual audio driver, and no screen recording.
Two small Swift tools:
| Tool | What it does | Permission it costs |
|---|---|---|
audiotap |
captures system output + mic to two WAVs | System Audio Recording Only, and Microphone |
audioprocs |
lists which apps currently hold the mic, as JSON | none |
Requires macOS 14.2+ (Core Audio process taps) and Xcode command line tools. Built and run on macOS 26.
./build.sh
open -a "$PWD/AudioTap.app" --args /tmp/system.wav /tmp/mic.wav 600 # 600s, or omit to run until SIGTERM
./audioprocs # one JSON array
./audioprocs --watch --heartbeat 10 # a line per change, foreverA bare binary gets silence, not an error.
macOS attributes an audio-capture permission to a code-signed bundle identity, and only when the
process was launched through LaunchServices — open -a Something.app. Run the same compiled binary
straight from a shell and Core Audio hands it a stream of zeroes. Nothing fails. No prompt appears.
The WAV is exactly the right length and completely empty.
That is why this repo builds an .app bundle around a command line tool and signs it, and why you
launch it with open -a, not by path. If you take one idea from here, take that one.
It also explains a trap one layer down: don't shell out to sox or ffmpeg for the mic half.
A helper spawned from a background shell is a different responsible process and gets silently
denied the same way. One signed bundle captures both streams, so there is exactly one thing to
authorise.
Never bind to a device ID at launch. Both capture chains here re-read whatever device macOS is currently defaulting to, and rebuild themselves when that changes.
The version that read the default input and output device once at startup produced 38 minutes of digital silence on both tracks because someone put AirPods in a minute after recording began. The aggregate device sat watching a route with no audio on it, the engine's input node stopped delivering buffers, and nothing errored. The files were full-length and full of zeroes; the transcriber then hallucinated the same sentence 342 times into the output. Pulling the AirPods back out restored both tracks in the same instant, which is what identified the cause.
Anything that pins a device ID and never re-reads it will lose a whole recording, quietly.
The speakers carry everyone else; the mic only ever carries you. Kept apart, "who said what" is free — no diarisation model required. Merge them into one stereo WAV afterwards if that is what your transcriber wants, or feed the two tracks separately and label them.
audioprocs reads a Core Audio property list that says which processes hold audio input. When
Zoom, Meet or Slack opens the mic, a call started. That read costs zero TCC permissions, so a
watcher can poll forever without a single privacy prompt — useful when you want the expensive,
permission-carrying capture to start only once there is actually something to record.
--watch re-registers a per-process listener every time the process list changes, because an app
that opens the mic for the first time after the watcher started is the normal case, not an edge
case. It also emits {"heartbeat": <unix seconds>} on an interval, so a parent can tell "nothing
changed" apart from "the child died".
- Was it launched with
open -a? If you ran the binary directly, that is the whole answer. - Did you just rebuild? An ad-hoc signature's identity includes the code hash, so a rebuild can invalidate the grant. Remove the entry under System Settings → Privacy & Security → System Audio Recording Only (and Microphone), then launch once more and approve again.
- Check the log:
~/Library/Logs/audiotap/audiotap.log. - Did the audio route change? That is fixed here, but it is the first thing to suspect in any other tool that does this.
- No frames, no screenshots, no screen recording permission — audio only.
objc_try.mis a small Objective-C shim, because AVFoundation throwsNSExceptions that Swift cannot catch, and an uncaught one takes the whole capture down with it.- Extracted from a larger private meeting-notes system, so the commit history starts here. Issues and PRs welcome; treat it as a working reference rather than a supported product.
MIT.