ENGINEERING NOTES / AUDIO ENGINEERING

Muting Every App on macOS with Core Audio Process Taps

How to capture and silence every other app on macOS 14.2+ without a driver: a muted process tap, a private aggregate device, a permission gate and safe teardown.

Muting Every App on macOS with Core Audio Process Taps

On macOS 14.2 and later you can capture every other app’s audio and keep it off the speakers without installing a driver. You create a Core Audio process tap that excludes your own process and uses the muted mute behaviour, read it through a private aggregate device whose clock master is the real output, and play your processed audio from the same I/O callback. The capture is the easy part. The permission prompt, which can leave a Mac silent, and the teardown order are where the trouble is.

This is how nomus, the real-time music filter CloudGuys builds, replaces everything a Mac plays with a filtered copy. The code is Rust (objc2 bindings, no Swift, no C shim), but every call maps one to one onto Apple’s C API. Facts about Apple’s APIs link to Apple’s documentation, checked on 8 November 2026. Facts about nomus come from its source and its saved test logs.

The short answer

  • Use a process tap (AudioHardwareCreateProcessTap, macOS 14.2+) with a global stereo description that excludes your own process.
  • Set the mute behaviour to muted. Capture alone gives you a copy while the original keeps playing, which is useless for a filter.
  • Read the tap through a private aggregate device with the real output as main sub-device and drift compensation on the tap.
  • Never create a muting tap before the “System Audio Recording” permission is granted. Without it, the tap mutes everything and your callback never runs.
  • Destroy in reverse order. Destroying the tap unmutes every app. If your process dies, macOS does it for you: nomus measured that, 3 rounds out of 3.

Why capture alone is not enough

A filter that only captures plays the original plus its own processed copy. In nomus the copy arrives 20 ms or more late, and two copies of a voice 20 ms apart form a comb filter: notches every 50 Hz, starting at 25 Hz. That sounds worse than no filter at all.

So the requirement is capture and mute: every other process is heard by the filter and by nobody else, and the filter’s own output still reaches the hardware. Apple’s sample code page for taps describes exactly that option: a tap can mute the process output so it no longer plays to the selected device and all of it goes to the tap (Capturing system audio with Core Audio taps).

What are the options on macOS?

There are three routes, and only one does both jobs without an installer.

Route Captures other apps Silences the originals Install cost Minimum macOS
Core Audio process tap, mute behaviour muted Yes Yes, while the tap exists None; a permission prompt 14.2 (Apple)
HAL virtual device (Audio Server driver plug-in) set as default output Yes Yes, apps play into your device A driver bundle installed with admin rights Older versions too
ScreenCaptureKit audio (capturesAudio) Yes Not that we found: capture only A capture permission Not compared

nomus chose the tap: no driver, nothing to uninstall, and nothing that outlives the process. The price is macOS 14.2 as a floor (the app’s minimumSystemVersion is "14.2" in desktop/src-tauri/tauri.conf.json) and a permission with no public query API.

The four objects

A running nomus session on a Mac is four Core Audio objects, all created in one Session::build and destroyed in one Drop:

  1. A process tap on every process except nomus, mixed to stereo, mute behaviour muted.
  2. A private aggregate device: the tap on the input side, the current default output on the output side.
  3. An IOProc on the aggregate. Core Audio calls it on a real-time thread with the tap’s samples as input and the output buffer to fill.
  4. A property listener on the default output device, so plugging in headphones rebuilds the session.

Step 1: create the tap

The exclusion list is one process: nomus’s own Core Audio process object, found by reading kAudioHardwarePropertyTranslatePIDToProcessObject with its own pid. Here is the code from crates/nomus-system/src/macos.rs:

let me = own_process_object()?;

// SAFETY: plain Objective-C object construction and setters.
let description = unsafe {
    let exclude = NSArray::from_retained_slice(&[NSNumber::new_u32(me)]);
    let d = CATapDescription::initStereoGlobalTapButExcludeProcesses(CATapDescription::alloc(), &exclude);
    d.setName(&NSString::from_str("nomus"));
    d.setPrivate(true);
    d.setMuteBehavior(CATapMuteBehavior::Muted);
    d
};
let tap_uuid: Retained<NSUUID> = unsafe { description.UUID() };
let tap_uid = tap_uuid.UUIDString().to_string();
// SAFETY: valid description; the tap id is written on success.
check("creating the audio tap", unsafe {
    AudioHardwareCreateProcessTap(Some(&description), &mut self.tap)
})?;

CATapMuteBehavior has three values: unmuted (the default), muted and mutedWhenTapped. muted is the whole trick. Because nomus is excluded, its own output is neither captured nor muted.

This call changes the machine. Once AudioHardwareCreateProcessTap returns, every other app is silent, and stays silent until the IOProc runs. So any later failure in build has to undo the tap. The Session struct starts with every field in its “not created” state, which means an early ? drops a half-built session and Drop releases exactly what exists.

Step 2: the aggregate device

A tap is not a device you can run. You read it through an aggregate device created with AudioHardwareCreateAggregateDevice and a Core Foundation dictionary. nomus passes eight keys:

Key nomus’s value Why
kAudioAggregateDeviceNameKey "nomus" display name
kAudioAggregateDeviceUIDKey com.agrohi.nomus.aggregate.<tap UUID> unique per session
kAudioAggregateDeviceMainSubDeviceKey the output device’s UID the hardware is the clock master
kAudioAggregateDeviceIsPrivateKey 1 invisible to other apps
kAudioAggregateDeviceIsStackedKey 0 sub-devices side by side
kAudioAggregateDeviceTapAutoStartKey 1 runs only while a tapped app plays
kAudioAggregateDeviceSubDeviceListKey [{SubDeviceUID: output UID}] the real device
kAudioAggregateDeviceTapListKey [{SubTapUID: tap UID, SubTapDriftCompensation: true}] the tap, resampled onto the master clock

Two of these carry the design. The output device as main sub-device means the IOProc is paced by the hardware it writes to. Drift compensation means Core Audio resamples the tap onto that clock, so input and output never drift apart.

You can see that resampling in the data. In nomus’s end-to-end run on 25 September 2026, subtracting the played clip from what the tap delivered left a −24 dB residual. A 32-tap least-squares filter brought it to −72 to −92 dB on 4-second windows, with two dominant coefficients of about 0.68 and 0.55: a half-sample shift, most likely the drift-compensation resampler. It is harmless, but if you write a capture test, fit a short filter instead of subtracting samples.

Tap auto-start cost us a bug. With it on, a quiet Mac produces no callbacks at all, and an early build waited 1.5 s for a first callback and reported “the audio device did not start” when the user switched the filter on in silence.

Step 3: the IOProc

The IOProc is the only code that runs on the real-time thread. Its state is a struct allocated with Box::into_raw on the supervisor thread, including two scratch buffers of 4,096 stereo frames, so the callback never allocates. For each block it copies the tap’s channels to stereo (a mono tap is duplicated), runs the engine, and writes channels 0 and 1 of the first output stream. Every other channel and stream is zeroed, so a surround system is reduced to stereo while nomus runs.

If you copy this, check one assumption. An aggregate’s input side lists the sub-device’s own input streams first, then the taps. nomus sets the tap’s buffer index to the number of input streams on the output device (a headset with a microphone has one). Nothing in the repo tests that ordering.

The IOProc is extern "C-unwind" and the release profile sets panic = "abort", so a bug in the audio path is a crash, not an unwind into Core Audio. That makes crash behaviour the next question.

Why can a process tap leave the Mac silent?

Because, without the “System Audio Recording” permission, macOS still creates the tap and still mutes every app, but never runs your IOProc. In development, an unanswered prompt left a tap muting everything while AudioDeviceStop blocked inside coreaudiod. The Mac went silent with a modal prompt somewhere on screen. Killing that process restored sound.

So nomus never creates a tap until the permission is settled. From Session::start:

// Never create a muting tap that may not deliver audio. With the
// prompt unanswered, the HAL mutes everything, never runs the IOProc,
// and blocks AudioDeviceStop until the user answers, so an unknown
// state is refused too. Only if TCC cannot be queried at all do we
// fall back to the no-callback check in `build`.
match permission() {
    Permission::Granted => {}
    Permission::Unknown if tcc::get().preflight.is_none() => {}
    _ => return Err(Error::Permission),
}

There is no public API to ask whether the permission is granted. nomus loads TCCAccessPreflight and TCCAccessRequest from the private TCC framework at run time, with service kTCCServiceAudioCapture, the approach the open-source AudioCap sample documents. It is private API: a macOS update can remove it, and the code degrades to Unknown rather than failing to launch.

Three more permission details that cost us time:

  • Declare the usage string. The bundle should carry NSAudioCaptureUsageDescription, the text of the prompt; the AudioCap project notes that Xcode does not list the key, so you add it by hand. Apple’s sample page says the system prompts the first time you start recording from an aggregate device that contains a tap, and nomus’s own survey found the prompt appears only for properly signed binaries.
  • The grant belongs to the responsible process. A CLI started from Terminal inherits Terminal’s grant, so nomus’s macOS tests run through the signed app bundle with a --selftest flag.
  • Ad hoc signing forgets the grant on every rebuild. nomus’s build script says so in a comment; sign with a stable identity while you develop.

Step 4: teardown in reverse

The Session struct holds exactly what Drop must undo, and Drop undoes it in reverse:

unsafe {
    if let Some((block, queue)) = self.listener.take() {
        let addr = address(kAudioHardwarePropertyDefaultOutputDevice, kAudioObjectPropertyScopeGlobal);
        AudioObjectRemovePropertyListenerBlock(SYSTEM, NonNull::from(&addr), Some(&queue), RcBlock::as_ptr(&block));
    }
    if self.started {
        AudioDeviceStop(self.aggregate, self.proc_id);
    }
    if self.proc_id.is_some() {
        AudioDeviceDestroyIOProcID(self.aggregate, self.proc_id);
    }
    if !self.state.is_null() {
        drop(Box::from_raw(self.state));
    }
    if self.aggregate != kAudioObjectUnknown {
        AudioHardwareDestroyAggregateDevice(self.aggregate);
    }
    if self.tap != kAudioObjectUnknown {
        AudioHardwareDestroyProcessTap(self.tap);
    }
}

Freeing the IOProc state is safe only after AudioDeviceDestroyIOProcID, which guarantees no callback still holds the pointer. Destroying the tap last is what unmutes every app.

What happens if the process is killed?

macOS unmutes everything. nomus measured it: macos-kill-test.sh starts the signed bundle’s self-test (which creates the muting tap), sends SIGKILL after 3 seconds, and counts coreaudiod’s log lines. The saved log, from 26 September 2026:

round 1: pid 96361 killed; mute events 39, unmute events 39, process still alive: no
round 2: pid 96414 killed; mute events 39, unmute events 39, process still alive: no
round 3: pid 96458 killed; mute events 39, unmute events 39, process still alive: no

One gap, stated in the test notes: nothing was playing, so a kill in the middle of a callback is not covered. Compare Android, where an audio effect on another app’s session outlives the process that created it, and nomus needs a ledger and a watchdog process to undo it. On macOS the OS does that work.

Does it actually produce the same audio?

The end-to-end run on 25 September 2026 (arm64 Mac, wired headphones at 48 kHz, 512-frame buffers) played a held-out speech-over-music clip while nomus recorded what the tap heard and what it played. Results from macos-check.log:

Check Result
Capture fidelity, after a 32-tap fit −61.6 dB residual
Live output vs the offline engine on the same input −76.0 dB residual at a 20.0 ms lag
Music removed in the pauses between words 18.2 dB
Engine load per callback 6 to 10% of the budget

The delay the app reported was about 44 ms. That is an estimate (engine latency plus two buffers, plus the device’s own latency in that older build), not a measurement; nothing in the repo measures the true end-to-end delay. Today’s code asks for 256-frame buffers, which puts the estimate at about 31 ms at 48 kHz before the device’s own latency (960 engine frames plus two 256-frame buffers).

What is not verified yet

These are the gaps as of today:

  • Device switching (headphones in and out, Bluetooth) on real hardware: not tested. Each rebuild also plays a moment of unfiltered audio, because the old tap is destroyed before the new one runs.
  • A sample-rate change on the same device: not watched. The rate is read once per session.
  • The tap’s format: read but not validated; the IOProc assumes 32-bit float.
  • x86_64: only the arm64 half of the build has been run.
  • Notarisation: not done. Nobody has yet shown that a notarised Developer ID build keeps the capture prompt working.

The nomus macOS app is in development. Its builds are signed for development and not notarised, so we do not call it released here.

Lessons for your own tap

  1. Settle the permission before you start, and refuse an unknown answer.
  2. Keep every Core Audio object in one struct and destroy them in reverse in Drop (or your language’s equivalent).
  3. Make the output device the clock master and let Core Audio resample the tap.
  4. Test with a kill, not only a stop.
  5. Test through the signed bundle, because the grant follows the responsible process.

FAQ

Do I need a driver to mute other apps on macOS? No. On macOS 14.2 and later a process tap with the muted behaviour captures and silences other apps. The alternative, an Audio Server driver plug-in, needs an installer with admin rights.

Does a process tap work on macOS 13 or earlier? No. AudioHardwareCreateProcessTap is available from macOS 14.2, which is nomus’s minimum.

Can I check the System Audio Recording permission with a public API? No. The AudioCap project says there is no public API to request or check it. nomus loads TCCAccessPreflight and TCCAccessRequest from the private TCC framework at run time, and falls back to Unknown if they are missing.

Will a crash leave the Mac muted? In nomus’s test it did not: after SIGKILL, mute and unmute events matched 39 to 39 in 3 rounds out of 3. The exception is a muting tap created while the permission prompt is unanswered, which is why nomus refuses to create one.

This post goes deeper on one host from One Rust engine, four hosts, and the browser host is covered in 20 ms end to end in an AudioWorklet.

CloudGuys makes nomus, which is in development. If you are building real-time audio on macOS and want to compare notes, write to hello@cloudguys.io, or see the nomus product page.

BUILT AND OPERATED BY CLOUDGUYS

nomus: real-time music filter
Keep the voice, lose the music, on your own device.

nomus removes background music from live audio and keeps the voices, speech and singing. One Rust engine and a small neural network run in a Chrome tab, system-wide on macOS and Linux, and on Android, with 20 ms of delay and no server.

Visit nomus ↗ About nomus ↗
Prefer a text version?Read this page as Markdown · All pages