Recording system audio on macOS without a virtual driver
For years, recording what your Mac plays meant installing a virtual audio driver and rerouting your output through it. Since macOS 14.2 there is a better way: Core Audio process taps. Here is how Overheard uses them to record calls with no driver and no bot in the meeting — and the three bugs that only showed up in real calls.
The old way, and why it hurts
The classic recipe is BlackHole or Loopback: install a virtual audio driver, build a Multi-Output Device in Audio MIDI Setup, make it your default output, then record from the virtual device. It works, but it asks a lot of the user. Your volume keys stop working on a Multi-Output Device, every new pair of headphones needs the setup redone, and "install this audio driver" is a hard sell for a meeting notes app.
The other common answer is a bot: a "notetaker" that joins the call as a participant. Nobody needs to install anything, but everyone sees it, and the recording lives on someone else's server.
Overheard does neither. It is a menu bar app that asks macOS for the audio of the call directly.
Process taps in 30 seconds
macOS 14.2 added AudioHardwareCreateProcessTap. A tap is a Core Audio object that copies the output of one process, several processes, or all of them. You describe it with a CATapDescription; it never changes what the user hears unless you ask it to mute.
A tap is not something you can read from directly. You wrap it in an aggregate device — the same kind of object Audio MIDI Setup creates — and then read that device like any input, with an IOProc. Here is the whole setup, lightly trimmed from Capture.swift:
// Everything the Mac plays, from every process
let desc = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
desc.uuid = UUID()
desc.muteBehavior = .unmuted // the user keeps hearing the call
desc.isPrivate = true // invisible to other apps
try check("Create tap", AudioHardwareCreateProcessTap(desc, &tapID))
// A private aggregate device clocked by the current output device
outputUID = try currentOutputUID()
let agg: [String: Any] = [
kAudioAggregateDeviceNameKey: "Overheard-Tap",
kAudioAggregateDeviceUIDKey: UUID().uuidString,
kAudioAggregateDeviceMainSubDeviceKey: outputUID,
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceTapAutoStartKey: true,
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputUID]],
kAudioAggregateDeviceTapListKey: [[kAudioSubTapDriftCompensationKey: true,
kAudioSubTapUIDKey: desc.uuid.uuidString]],
]
try check("Create aggregate", AudioHardwareCreateAggregateDevice(agg as CFDictionary, &aggregateID))
// Read it like any input device
try check("IOProc", AudioDeviceCreateIOProcIDWithBlock(&procID, aggregateID, ioQueue) { _, input, _, _, _ in
if let buf = AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: input, deallocator: nil) {
writer.write(buf)
}
})
try check("Start", AudioDeviceStart(aggregateID, procID))
Three details matter. The aggregate device is private, so it never shows up in the user's sound settings. Its clock comes from the current output device, with drift compensation on the tap. And the tap is global: Overheard records whatever the Mac plays during a call — Meet in a browser tab, Teams, a Slack huddle — instead of guessing which process is the meeting.
The permission
Taps need the NSAudioCaptureUsageDescription key in Info.plist, and macOS shows its own "System Audio Recording" prompt the first time you start one. What there isn't is a public API to check that permission or ask for it ahead of time — which you want for an onboarding screen. Overheard does what AudioCap does: it looks up TCCAccessPreflight and TCCAccessRequest in the private TCC framework at runtime, for the kTCCServiceAudioCapture service. If those symbols ever disappear, it falls back to "unknown" and lets macOS ask on the first recording.
Bug 1: the format lies
The first real call with AirPods produced a recording at chipmunk speed, half as long as the meeting.
The tap reports its format through kAudioTapPropertyFormat, and that format said 48 kHz. But when a call turns on the AirPods' microphone, Bluetooth switches to the hands-free profile and the output device drops to 24 kHz. The buffers arriving in the IOProc were 24 kHz audio labelled as 48 kHz, so everything played back twice as fast.
The fix is to trust the device, not the tap: read the aggregate device's nominal sample rate and use it for the input format.
var asbd = AudioStreamBasicDescription()
try getProp(tapID, kAudioTapPropertyFormat, &asbd)
// The tap says 48 kHz, but data arrives at the device's real rate
// (24 kHz with AirPods in a call): without this, audio was 2x fast.
var deviceRate = Float64(0)
try getProp(aggregateID, kAudioDevicePropertyNominalSampleRate, &deviceRate)
if deviceRate > 0 { asbd.mSampleRate = deviceRate }
Everything after that goes through an AVAudioConverter into one fixed format on disk — 48 kHz stereo, 16-bit, about 690 MB an hour — so the rest of the app never has to care what the hardware was doing.
Bug 2: a deadlock in AudioDeviceStop
Sometimes, pressing Stop left the app frozen on "Saving…" forever.
The IOProc block ran on the same serial queue as the rest of the capture code. Stop ran on that queue too and called AudioDeviceStop, which waits for the in-flight IOProc callback to finish. That callback was waiting for the queue Stop was holding. Classic deadlock, and only when Stop landed while a buffer was being delivered.
Give the IOProc its own queue. Never call AudioDeviceStop from the queue your IOProc runs on.
In Overheard the IOProc runs on ioQueue, everything else on queue, and the file writer is only ever touched from ioQueue.
Bug 3: Bluetooth headphones and the rebuild loop
A tap is tied to the output device that clocks its aggregate device. When the output changes — AirPods connect mid-call, the user picks other speakers — the tap has to be torn down and rebuilt. So Overheard listens for changes to the default output device, the tap format and the sample rate, and rebuilds on each.
With Bluetooth headphones that was a disaster. When a call opens their microphone, they switch profile, and for a moment the default output bounces between the headphones and the built-in speakers. Every notification triggered a rebuild; every rebuild reopened the headphones in a new aggregate device; that caused another profile change and another notification. Dozens of rebuilds a second, and the call itself lost both audio and microphone.
The fix is a debounce: wait until the configuration has been still for 1.5 seconds, and for output-device notifications, skip the rebuild entirely if the output ended up the same and audio is still flowing.
private func scheduleRebuild(_ why: String, onlyIfOutputChanged: Bool = false) {
pending?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
pending = nil
if onlyIfOutputChanged, aggregateID != kAudioObjectUnknown,
Date().timeIntervalSince(lastCallback) < 1,
(try? currentOutputUID()) == outputUID { return } // bounced back: nothing to do
rebuild(why)
}
pending = work
queue.asyncAfter(deadline: .now() + 1.5, execute: work)
}
Keeping two files in sync
Rebuilds mean gaps: half a second here, two seconds there. For a transcript with timestamps, gaps are worse than silence, because they shift every line after them. So both tracks go through a small writer that knows when the recording started and how many frames it has written. Once a second a watchdog checks how far behind real time the file is, and if it is more than half a second, it writes silence until it catches up.
The same watchdog is the safety net for everything else: no audio for two seconds, or a capture that isn't running, means rebuild. The result is that recording.wav and mic.wav are always exactly as long as the meeting, and a timestamp in one is the same moment in the other.
Your voice, on its own track
The tap only hears what the Mac plays, so your own voice comes from the microphone, through a plain AVCaptureSession, into a separate 16 kHz mono file. Two tracks make speaker separation much easier later: everything on mic.wav is you.
Two details from real use. Overheard defaults to the built-in microphone even when AirPods are connected, because recording from a Bluetooth headset's mic switches it to the low-quality hands-free profile. And the raw Mac mic comes in around −34 dBFS on speech while the call audio is already normalized near −17, so at the end of a recording the mic track is measured and raised towards −18 dBFS, with a soft tanh limiter above 0.7 so peaks never clip.
Surviving a crash
While recording, the files are called recording.tmp.wav and mic.tmp.wav, so anything reading ~/Meetings knows they aren't finished. If the app quits mid-call, the WAV headers still hold the sizes from when the file was opened. On the next launch Overheard walks the RIFF chunks, rewrites the data and RIFF sizes from the real file length, renames the files and transcribes them as if nothing happened.
What I'd tell you before you start
- Test with Bluetooth headphones in a real call, not with music. Profile switches and 24 kHz output only happen when a call opens the mic.
- Trust the device's sample rate over the tap's declared format.
- Separate the IOProc's queue from the one that starts and stops capture.
- Debounce device changes, and check whether anything actually changed before rebuilding.
- Log every rebuild. Overheard writes a
capture.lognext to each recording; every bug above was found by reading one.
The whole capture side — both tracks, the watchdogs, crash recovery and mic leveling — is one Swift file, Capture.swift, about 550 lines, MIT licensed. If you're building something with process taps, take what's useful.
Overheard
Records your calls from the menu bar, transcribes them on your Mac, and makes them answerable from Claude. Free and open source.