Recording macOS Without Root Access
I needed to record Zoom calls.
The easy way: install Soundflower.
The problem: Soundflower is dead.
I still needed reliable call capture for NBP. No hacks that die on the next macOS update.
What Soundflower Did
Simple.
Install a kernel extension. Create a virtual audio device. Route your system audio through it. Done.
Every audio tool on macOS used it.
Then macOS Catalina killed kernel extensions. Apple decided your audio routing was a security threat.
The old way stopped working.
BlackHole took over — open-source, Apple Silicon compatible, still the same idea. Virtual driver. Still works. I installed it.
Then I realized I didn’t want a driver. I wanted ownership. A driver is infrastructure someone else maintains. One macOS update away from breaking.
What You Actually Need
Two audio streams. At the same time.
Your microphone: Your voice.
System audio: The other person’s voice. The YouTube video. The notification ping.
Mix them. Save to disk.
Sounds trivial.
It’s not.
The API Apple Documented Badly
The answer isn’t AVAudioRecorder.
It’s not AVCaptureSession.
It’s not even ScreenCaptureKit — despite what most tutorials say.
It’s Core Audio Process Taps.
A low-level API that taps directly into macOS’s audio graph. No virtual device. No kernel extension. No driver. You tell the OS: give me everything going through the system mixer. It does.
The catch: Apple’s documentation for this is nearly nonexistent. I found cidre — Yury’s Rust bindings for Apple’s Core Audio — which made it usable.
ca::TapDesc::with_stereo_global_tap_excluding_processes(&[])
One call. Stereo global tap. Excludes nothing. Every app’s audio, mixed at the OS level, handed to you as a stream.
No root. No kernel extension. No screen recording permission.
Two Streams, One Problem
Core Audio Process Taps gives system audio.
CPAL gives the microphone.
Both arrive as continuous streams. Different callbacks. Theoretically different clocks.
Every approach I read about tries to align them by timestamp. Measure drift. Compensate. Adjust.
I did it differently.
Always write. Even silence.
Both streams write continuously to a ring buffer — mic and system — no gaps, no pauses. When there’s no audio, write zeros.
Continuous streams have no drift. Drift happens when you skip frames, buffer unevenly, or align by arrival time. Write constantly and the problem disappears.
Then mix on the fly. Every chunk: read from both buffers, combine, encode, write to disk.
No drift compensation. No timestamp alignment. No post-processing sync step.
The Normalization Problem
Your mic is quiet.
System audio is loud.
One Zoom participant whispers. Another yells.
You can’t just mix the streams.
Real-time loudness normalization. EBU R128. The broadcast standard.
Every frame: measure perceived loudness. Adjust gain. Apply.
Now your whisper and your yell both sound reasonable.
The cost: near zero CPU on M1. Rust + continuous ring buffer — no spikes, no pauses.
Permission Hell
You ask for microphone permission.
User clicks “Allow.”
You try to record.
Error: AVAudioSessionErrorCodeInsufficientPriority
Because macOS has three permission layers:
- App entitlements
- User permission prompt
- TCC database (the internal record)
Sometimes they desync. The user said yes. The system says no.
The fix:
tccutil reset Microphone com.yourapp.bundle
Relaunch. Ask again.
Works now. No explanation why it failed the first time.
What You Get
Three files per recording:
~/nbp-data/{uuid}/
├── raw_mic.ogg # 42 MB
├── raw_system.ogg # 38 MB
├── audio_mix.ogg # 45 MB
OGG Vorbis. ~1 MB per minute.
Open in VLC. Plays immediately.
No proprietary format. No custom player.
The Trade-Off
This works without root.
The cost:
CPU: near zero on M1.
Complexity: 900 lines of Rust. Core Audio is not friendly.
Stability: Core Audio Process Taps requires macOS 14.2+. Older systems need a different approach.
Compare to BlackHole:
CPU: near zero.
Complexity: install DMG, done.
Ownership: you depend on a third-party driver that needs to be rebuilt for every macOS release.
Core Audio Process Taps has no external dependencies. Ships inside your app. Works on every machine that runs macOS 14.2+. No reinstall after OS updates.
The Reality
This took three weeks.
Core Audio documentation is terrible.
Buffers arrive in unexpected sizes.
Permissions fail silently.
Once it works — it keeps working.
No kernel panics. No “this extension is incompatible with macOS 15.” No security prompts on every OS update.
Just audio. Two streams. Mixed. Saved.
Without asking for root.