How macOS tells you exactly which app is using the microphone (CoreAudio process objects)
For years the only public answer to 'which app is using the mic?' was a boolean: kAudioDevicePropertyDeviceIsRunningSomewhere - SOMETHING is capturing, good luck finding out what. macOS 14.2 shipped a real answer, buried in the process-tap API family, and almost nobody has written about it. We use it in production; here is everything we learned.
Mac Note Taker detects when a meeting starts by noticing that a meeting app began using the microphone. Our first implementation watched the default input device's kAudioDevicePropertyDeviceIsRunningSomewhere property - a device-level boolean that flips when any process opens the device. It mostly worked, with two blind spots that turned into real bug reports: it can't tell you WHICH app is capturing, and it only watched the default device (Chrome remembers a per-site mic choice, so a Google Meet call on non-default AirPods was invisible to us).
The fix for the first blind spot is an API that, as far as we can tell, has near-zero coverage outside Apple's headers: CoreAudio process objects.
Process objects: audio state per process
Since macOS 14.2 (the same release that added Core Audio taps), coreaudiod tracks a live object per audio-active process. The system object exposes the list, and each process object answers questions:
- kAudioHardwarePropertyProcessObjectList - every process object currently known to coreaudiod
- kAudioProcessPropertyPID - the pid behind the object
- kAudioProcessPropertyBundleID - the bundle identifier (empty for bare binaries)
- kAudioProcessPropertyIsRunningInput - 1 when the process has at least one ACTIVE INPUT stream - i.e. it is capturing the mic right now
- kAudioProcessPropertyIsRunningOutput - same for playback
That fourth property is the whole story: ask coreaudiod who is capturing, get back bundle IDs. No Accessibility permission, no private API, no orange-dot scraping, no lsof heuristics. It works for any process - sandboxed, hardened, whatever - because you're asking the audio server, not the process.
The code
import CoreAudio
import Foundation
enum MicProcessInspector {
/// Bundle IDs of processes currently capturing mic input.
static func capturingBundleIDs() -> [String] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyProcessObjectList,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size
) == noErr, size > 0 else { return [] }
var objects = [AudioObjectID](
repeating: 0,
count: Int(size) / MemoryLayout<AudioObjectID>.size
)
guard AudioObjectGetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &objects
) == noErr else { return [] }
let myPID = ProcessInfo.processInfo.processIdentifier
var out: [String] = []
for obj in objects {
guard readUInt32(obj, kAudioProcessPropertyIsRunningInput) == 1 else { continue }
if let pid = readPID(obj), pid == myPID { continue } // exclude ourselves
if let bundle = readBundleID(obj), !bundle.isEmpty {
out.append(bundle)
}
}
return out
}
private static func readUInt32(_ obj: AudioObjectID, _ sel: AudioObjectPropertySelector) -> UInt32? {
var address = AudioObjectPropertyAddress(
mSelector: sel,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var value: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
guard AudioObjectGetPropertyData(obj, &address, 0, nil, &size, &value) == noErr else { return nil }
return value
}
private static func readPID(_ obj: AudioObjectID) -> pid_t? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioProcessPropertyPID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var value: pid_t = 0
var size = UInt32(MemoryLayout<pid_t>.size)
guard AudioObjectGetPropertyData(obj, &address, 0, nil, &size, &value) == noErr else { return nil }
return value
}
private static func readBundleID(_ obj: AudioObjectID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioProcessPropertyBundleID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
var value: Unmanaged<CFString>?
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
guard AudioObjectGetPropertyData(obj, &address, 0, nil, &size, &value) == noErr else { return nil }
return value?.takeRetainedValue() as String?
}
}Call it when you notice mic activity and you get something like ["com.google.Chrome.helper"] - which brings us to the first gotcha.
Gotcha 1: browsers capture through helper processes
Chrome's audio service runs in a helper: a Meet call reports com.google.Chrome.helper, not com.google.Chrome, and definitely not anything Meet-shaped. Map by prefix (com.google.Chrome*, com.brave.Browser*, com.microsoft.edgemac*) back to the parent browser. Safari is worse: WebKit's GPU process (com.apple.WebKit.GPU) does the capturing, which is also what any WebKit-embedding app reports - treat it as 'probably Safari' and verify by other means.
Knowing the browser still doesn't tell you WHICH meeting. For that we scan the browser's tabs via AppleScript - and when the call lives in another Chrome profile (whose windows Chrome's AppleScript interface won't even enumerate - a fun discovery of its own), we fall back to reading window titles via CGWindowList, where a title like 'Meet - abc-defg-hij' is unambiguous.
Gotcha 2: exclude yourself
If your app records audio, YOU are a process with an active input stream. Compare kAudioProcessPropertyIsRunningInput results against your own pid or you'll detect your own recording as a meeting - a very confusing infinite suggestion loop in development.
Gotcha 3: the listener-burst livelock (the expensive lesson)
You'll want change notifications rather than polling. We installed kAudioDevicePropertyDeviceIsRunningSomewhere listeners on every input device as the wake-up trigger, then queried process objects. What the docs don't say: CoreAudio fires these listeners in BURSTS - we measured 9+ callbacks per second during a Meet join with AirPods connected, most reporting no actual state change.
Our callback re-published UI state and ran a detection scan on every event. In a SwiftUI app, publishing observable state 9 times a second, indefinitely, does slow damage: every publish re-renders observers, every render registers Observation tracking, and after six days of uptime one cleanup pass had a large enough registrar set that Set teardown pinned the main thread at 100% CPU. The fix is boring and mandatory: remember the last reported state, only invoke your callback on actual transitions, and rate-limit any expensive work triggered by system listeners.
Availability and fallback
Process objects need macOS 14.2+. On failure (older point releases, future changes) the property reads return errors - return an empty list and fall back to whatever heuristic you used before. In our case the old frontmost-app detection remains as the fallback path, so the feature degrades instead of breaking.
Why we needed this at all
Mac Note Taker's whole pitch is: when a meeting starts, the app notices and offers to record - then transcribes and summarizes on-device, no bot, no cloud. 'Notices' used to mean guessing from the frontmost app, which broke for background Chrome windows, second profiles, and non-default mics. Asking coreaudiod who is actually capturing turned detection from a heuristic into a fact. If you're building anything that reacts to microphone use on macOS, this API is the foundation to start from.
Frequently asked
Does reading process objects require a permission prompt?
No. Enumerating process objects and reading IsRunningInput/BundleID needs no TCC permission - you are querying coreaudiod's public state, not accessing audio content. Capturing audio FROM another process (Core Audio taps) is a different API with its own authorization.
Can this tell me which browser tab is using the mic?
No - the granularity is the process. For tab-level attribution you need browser-side signals: AppleScript tab enumeration, or window titles via CGWindowList when scripting can't see the window (other Chrome profiles, incognito).
Is there a notification when a process starts capturing?
Listen on kAudioHardwarePropertyProcessObjectList for processes appearing, and on each process object's kAudioProcessPropertyIsRunningInput for state flips. Dedupe aggressively - listeners fire in bursts.
Related reading
- How to Transcribe Zoom Without a Bot (Mac, 2026)No bot in the call, no cloud upload. Capture mic + system audio via ScreenCaptureKit for speaker-labeled transcripts and AI summaries, all on-device.
- Record a Zoom call on Mac without a bot (2026 practical guide)A no-bot, no-cloud workflow for recording Zoom calls on macOS in 2026. ScreenCaptureKit setup, the permissions you actually need, and how to keep audio inside your trust boundary.