Field notes·2026-07-23·9 min read

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.

macoscoreaudioswiftengineering

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.

Try Mac Note Taker

Lifetime $149 - $79 for the first 100 with code FOUNDER.

See pricing

Related reading

How macOS tells you exactly which app is using the microphone (CoreAudio process objects) · Mac Note Taker