Recording your own microphone on a Mac is a solved problem. Recording the other person — the voice coming out of your speakers — is where macOS quietly fights you. Here’s how the audio pipeline actually works.
For years the only answer to that was a virtual audio driver like BlackHole and a Multi-Output Device you wired up by hand before every call. It works, and it breaks constantly.
I build MeetingRecorder, a free Mac app that records both sides of a call locally. I’ve run 636 meetings — 284 hours — through it since January, so this is the version of the audio pipeline that survived daily use, not a whiteboard diagram: capturing system audio with ScreenCaptureKit, reconciling two independent clocks into one file, and doing it all without dropping a single sample on the real-time thread.
I started building it after one too many recordings that turned out to be just my own voice — the other side of the call kept coming back silent, and none of the usual fixes stuck for long. I wanted a recorder where system audio simply worked: nothing to route before the call, nothing to remember to undo after it.
The problem in one paragraph
A meeting recording needs two signals: your microphone (what you say) and the system audio (what everyone else says, coming out of your speakers). macOS hands you the microphone freely. It deliberately does not let an app silently read the system audio — that would be a privacy hole — so ⌘⇧5 and QuickTime only ever capture your mic. Record a call that way and you get yourself, loud and clear, talking to total silence.
The old way: BlackHole and a Multi-Output Device
The classic workaround installs a virtual audio driver (BlackHole, or the old Soundflower) that presents itself as an output device. You then build an aggregate/Multi-Output Device so your Mac plays sound to your speakers and the virtual device at once, point your system output at it, and record the virtual device as an input.
It works. It also has sharp edges I got tired of:
- You have to change your system output before the call and change it back after. Forget the “after,” and your next recording is silent.
- If you don’t route through a Multi-Output, you can record the call but can’t hear it while it happens.
- One wrong toggle in Audio MIDI Setup and you capture nothing.
Every meeting started with a little audio-routing ritual. I wanted to press one button.
The new way: ScreenCaptureKit, but only for the audio
Since macOS 13, Apple ships ScreenCaptureKit — the framework behind the modern screen recorder. It can capture system audio directly, no virtual driver, gated behind the one-time Screen Recording permission. The catch: the API is built for screen capture, so to get audio-only you set up a screen stream and then throw the video away.
Here’s the actual configuration from the app:
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
guard let display = content.displays.first else { throw SystemAudioCaptureError.noDisplayFound }
let filter = SCContentFilter(display: display, excludingWindows: [])
let configuration = SCStreamConfiguration()
// Video is required by the API, so make it as cheap as physically possible.
configuration.width = 2
configuration.height = 2
configuration.minimumFrameInterval = CMTime(value: 1, timescale: 1) // 1 fps
configuration.queueDepth = 3
// The part we actually want:
configuration.capturesAudio = true
configuration.excludesCurrentProcessAudio = true // don't record our own sounds
configuration.sampleRate = 48000
configuration.channelCount = 2
Two details worth calling out:
- The 2×2, 1-fps dummy video. ScreenCaptureKit won’t run an audio-only stream, so you give it the smallest, slowest video surface it will accept and ignore every frame. It costs almost nothing.
- excludesCurrentProcessAudio = true. Without this, the recorder records its own UI sounds and any playback you do — an instant feedback loop. This one flag is the difference between a clean capture and garbage.
Audio then arrives as CMSampleBuffers on a delegate callback, and you convert each one into an AVAudioPCMBuffer you can actually work with. That conversion is the least glamorous code in the whole app — pulling the format description, handling interleaved vs. non-interleaved layouts, and memcpy-ing channel data:
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard type == .audio, !isPaused else { return }
guard let pcmBuffer = createPCMBuffer(from: sampleBuffer) else { return }
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
delegate?.systemAudioCapture(self, didReceiveBuffer: pcmBuffer, time: presentationTime)
}
Compared to the BlackHole ritual, the user-facing story collapses to: grant Screen Recording once, press record. No driver, no routing, nothing to reset afterwards.
Two streams, two clocks
Now the interesting problem. The app runs two independent capture sources:
- the microphone, through AVFoundation, timestamped with AVAudioTime (a mach_absolute_time host tick);
- system audio, through ScreenCaptureKit, timestamped with CMTime.
Different frameworks, different clocks, and buffers that arrive at different moments in different sizes. If you naively append each stream to its own channel as it arrives, they drift — the mic and the other person’s voice slide out of sync over a long call, which wrecks both playback and transcription.
The fix is to stop trusting arrival order and put everything on one shared timeline — the machine’s own mach_absolute_time. Both timestamps get converted to nanoseconds against that clock:
func enqueueMic(_ buffer: AVAudioPCMBuffer, time: AVAudioTime) {
let nanos = machTicksToNanos(time.hostTime) // mic clock → host nanos
// ... hand off to the processing queue, channel 0
}
func enqueueSystem(_ buffer: AVAudioPCMBuffer, time: CMTime) {
let nanos = cmTimeToNanos(time) // system clock → host nanos
// ... hand off to the processing queue, channel 1
}
The very first buffer from either source stamps startHostTimeNanos. After that, every buffer computes its exact position on the timeline from the elapsed time, not from how many buffers came before it:
let elapsed = hostNanos - startNanos
let framePos = Int64(Double(elapsed - pauseOff) * cfg.sampleRate / 1_000_000_000.0)
framePos is the sample index where this chunk belongs. The writer places each mono chunk at its absolute frame position and fills any gap with silence, so a late or dropped buffer leaves a correctly-sized hole instead of shoving everything after it out of alignment. Because both sources reference the same hardware clock, they stay locked together for the length of the call — no accumulating drift.
(machTicksToNanos is just the mach_timebase_info numer/denom ratio applied to the tick count — the standard way to turn mach_absolute_time into real nanoseconds. Cheap, and the same clock both frameworks ultimately hang off.)
Keeping the real-time thread sacred
Audio callbacks run on high-priority real-time threads. Block one — with file I/O, a lock held too long, an allocation at the wrong moment — and you get glitches or dropped buffers you can’t get back. So the writer is a three-stage pipeline, and each stage does the least possible:
- Capture callback (real-time thread): copy the buffer, read the timestamp, hand it off. Nothing else.
- Processing queue: compute framePos, convert to mono at the target sample rate with a cached AVAudioConverter.
- Write queue: the only place that touches files.
enqueueQueue.async { [weak self] in
self?.processBuffer(copy, hostNanos: nanos, channel: 0) // stage 2
}
// ... later, inside processBuffer:
writeQueue.async { [weak self] in
self?.writeChannel(ch, data: monoData, at: framePos) // stage 3
}
The real-time thread never waits on a disk. That’s the whole trick to a recorder that doesn’t stutter.
Mix vs. separate tracks
By default the output is a single stereo .m4a: left channel = microphone, right channel = system audio. That already carries both sides.
But there’s an option to also save separate mono tracks — one for the mic, one for the system — and it matters more than it looks. When you later transcribe the call, feeding the two speakers as separate tracks gives you clean “you vs. them” attribution instead of one blurred column where the diarizer has to guess who spoke. The channel separation you preserved at capture time becomes speaker labels at transcription time for free. I turn it on for calls I know I’ll transcribe — about 140 of mine so far — and the transcript quality difference is not subtle.
The rule I learned the hard way: never lose the recording
The app writes raw PCM to a temp file during the call and only encodes to AAC (.m4a) at stop. Encoding a whole meeting in one pass at the end is efficient — my longest recording is a 2-hour-53-minute call that finalizes into a single 118 MB .m4a — but it’s also the single most dangerous moment, because if that encode throws, the naive thing to do is clean up the temp files and you’ve just deleted an hour-long meeting.
So the failure path does the opposite of cleanup:
} catch {
// Do NOT delete the captured PCM on a conversion failure — that silently
// destroys the whole recording. Rescue the raw stereo mix next to the
// intended output so it can be recovered later, and report the failure.
let rescued = rescuePCM(stereoTemp: stereoTemp, outURL: outURL)
return .conversionFailed(rescuedPCM: rescued)
}
The raw PCM gets moved out of the temp directory (where the OS would eventually purge it) to a sibling .caf next to where the .m4a should have been, and the app recovers it on next launch. A recording someone can’t reproduce — you were only in that meeting once — is worth more than clean temp files. Same reasoning drove pause handling (a running pauseOffsetNanos keeps the timeline continuous instead of leaving a gap) and crash recovery on relaunch.
Here’s the honest part: across those 636 recordings on my own machine, that rescue path has never once fired — the encode has never failed for me. I built it anyway. The day it does fail is the day someone loses a meeting that exists nowhere else, and “it never happened in testing” is exactly the sentence you don’t want to be holding then.
What macOS 27 changes — and what it doesn’t
Apple is adding system-audio capture to the built-in screen recorder in macOS 27 this autumn, so ⌘⇧5 is expected to grab desktop audio without a third-party app at all. That kills the BlackHole premise for casual grabs, and it’s genuinely good for users.
It does not make a dedicated recorder pointless, because “capture the audio” was never the whole job. The built-in tool still gives you a video file, not an audio archive you can search and keep; it has no transcript, no speaker labels, no automatic call detection, and no separate mic/system tracks. If what you want is a meeting — recorded as audio, transcribed, attributed, and filed automatically — the OS primitive is the easy 20%. The rest is the app.
Takeaways
- ScreenCaptureKit is the modern way to read system audio on macOS — no virtual driver — but you configure a screen stream and discard the video (2×2, 1 fps), and remember excludesCurrentProcessAudio.
- Two capture sources means two clocks. Put everything on one mach_absolute_time timeline and write by absolute frame position, not arrival order, or you’ll drift.
- Protect the real-time thread with a copy → process → write pipeline; only the last stage touches disk.
- Encode late, but never delete raw capture on failure. The user was only in that meeting once.
If you want to see the driver-free version as a finished app, that’s MeetingRecorder — free, Mac-only, records both sides locally, and keeps the M4A on your machine. And if you’re building your own, I’m happy to compare notes on the parts that fought back.
If you’ve built anything that captures from two sources at once, I’d genuinely like to know how you handle the clock drift — a shared host-time timeline like this, resampling to a master clock, or something smarter I haven’t tried yet.
Top comments (0)