For a long time, recording the sound of one app on a Mac meant installing a system audio component, often a virtual device you route your output through. Since macOS 13, ScreenCaptureKit can do it without a driver, but only with the Screen Recording permission.
Since macOS 14.2 there is a third way: Core Audio process taps. I built my app Tapelark on them, and version 1.0 went out on 10 October 2026. This post covers the capture, its real-time rules, and what an 8-hour soak test broke.
Three ways to get at the sound
Virtual audio drivers such as BlackHole are HAL plug-ins. You install one with admin rights and route your output through it. They are mature and often the right tool, but for an app they add an install step and change how the user's audio is routed.
ScreenCaptureKit can capture an app's audio, but it needs the broader Screen Recording permission, and macOS 15 asks users to re-approve that monthly when an app does not go through the system picker.
Process taps need no driver, can capture one app, several apps or the whole system mix, can optionally mute what they capture, and need a narrower permission that System Settings currently calls "System Audio Recording Only".
That last one won. I couldn't find a WWDC session on process taps, so Apple's sample project ("Capturing system audio with Core Audio taps") and the SDK headers were the documentation.
Three objects: a tap, an aggregate device, an IOProc
A tap is created from a CATapDescription. The first surprise: it takes Core Audio process objects, not PIDs. You translate a PID with a HAL property. A process only has such an object once it has connected to Core Audio (opened an audio device), so an app that hasn't done that yet, typically one that hasn't played anything since launch, cannot be tapped.
// Simplified; error handling trimmed.
func makeTap(for pid: pid_t) throws -> AudioObjectID {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var qualifier = pid
var process = AudioObjectID(kAudioObjectUnknown)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address,
UInt32(MemoryLayout<pid_t>.size), &qualifier, &size, &process)
guard process != kAudioObjectUnknown else { throw TapError.notPlayingYet }
let description = CATapDescription(stereoMixdownOfProcesses: [process])
description.uuid = UUID()
description.isPrivate = true // visible only to this process
description.muteBehavior = .unmuted // .muted records without playing it
var tap = AudioObjectID(kAudioObjectUnknown)
let status = AudioHardwareCreateProcessTap(description, &tap)
guard status == noErr else { throw TapError.status(status) }
return tap
}
isPrivate makes the tap visible only to the process that created it (that much is in the header). As far as I can tell it also goes away with that process, which matters with .muted: a crash should not leave another app silent.
A tap does not deliver audio by itself. You put it inside a private aggregate device and read that device with an IOProc:
let composition: [String: Any] = [
kAudioAggregateDeviceUIDKey: "com.example.capture.\(UUID().uuidString)",
kAudioAggregateDeviceNameKey: "Capture",
kAudioAggregateDeviceIsPrivateKey: 1,
kAudioAggregateDeviceSubDeviceListKey: [], // "tap-only": no hardware device
kAudioAggregateDeviceTapListKey: [[
kAudioSubTapUIDKey: tapUID, // read back with kAudioTapPropertyUID
kAudioSubTapDriftCompensationKey: 1,
]],
// No "tapautostart": start should not wait for the first sound.
]
var device = AudioObjectID(kAudioObjectUnknown)
AudioHardwareCreateAggregateDevice(composition as CFDictionary, &device)
var procID: AudioDeviceIOProcID?
AudioDeviceCreateIOProcIDWithBlock(&procID, device, nil) { @Sendable _, input, inputTime, _, _ in
context.process(inputData: input, inputTime: inputTime) // copy into the ring. That's all.
}
AudioDeviceStart(device, procID)
Each aggregate gets a fresh UID because destroying one is asynchronous and a reused UID can collide. Teardown runs in reverse: stop, destroy the IOProc, destroy the aggregate, destroy the tap.
Things I only learned by running it:
- The permission prompt blocks. Creating the tap and the aggregate never prompts; the first IOProc creation or
AudioDeviceStarton a tap aggregate does, and doesn't return until the user answers. So it runs off the main thread, with a timeout. WithoutNSAudioCaptureUsageDescriptionin Info.plist you get no prompt at all, just silence. - A denied permission looks like silence. Every call still returns
noErrand the IOProc delivers zeros. Tapelark watches for sustained digital silence while the source says it is playing, and tells the user. - Browsers don't play their own audio. Safari's sound comes from a WebKit GPU process, Chrome's from an audio helper. Tapping "Safari" means finding those processes and folding them into the app, with public APIs only.
- Swift block listeners can't be removed.
AudioObjectRemovePropertyListenerBlockremoves the exact block object the HAL copied, and Swift bridges a closure into a fresh block on every call. So the remove returnsnoErrand the listener keeps firing (macOS 26.2, in my tests), even when the closure is stored once as a@convention(block)value. An Objective-C caller passing the same block, or the C function-pointer API, removes cleanly.
The real-time rules
The IOProc runs on Core Audio's IO thread, and a missed deadline loses audio. So it may not allocate, lock, log, throw, await, or call back into the HAL. It copies the buffer into a lock-free single-producer, single-consumer ring and returns. A separate writer drains the ring to disk.
The ring keeps two 64-bit counters: head (frames ever written, owned by the IOProc) and tail (frames ever read, owned by the writer). The capacity is a power of two, so a counter becomes an index with a mask. Each counter sits on its own 128-byte cache line.
// Producer side, called from the IOProc. Simplified.
func write(_ source: UnsafePointer<Float>, frames: Int) -> Int {
let produced = head.loadRelaxed() // only this thread writes head
let consumed = tail.loadAcquire() // how far the writer got
let accepted = min(frames, capacity - Int(produced &- consumed))
if accepted < frames { // never wait: count the loss
dropped.fetchAddRelaxed(UInt64(frames - accepted))
overruns.fetchAddRelaxed(1)
}
let start = Int(produced & mask)
let first = min(accepted, capacity - start)
(storage + start * channels).update(from: source, count: first * channels)
storage.update(from: source + first * channels, count: (accepted - first) * channels)
head.storeRelease(produced &+ UInt64(accepted)) // publish after the copy
return accepted
}
Overruns are one way to lose audio. The other is a timestamp gap: in a continuous stream each buffer's sample time starts exactly where the previous one ended, and anything else is a discontinuity. "Dropped buffers" in Tapelark means discontinuities plus overruns plus the HAL's own processor-overload notifications, all three counted and reported.
Swift 6 added a few traps of its own:
AudioDeviceIOBlockis not imported as@Sendable, so strict concurrency says nothing about what the closure captures. Worse, a closure written in a@MainActorcontext inherits main-actor isolation and traps (in_swift_task_checkIsolatedSwift) the first time a real-time thread calls it. I hit it with an AVAudioSourceNode render block; the IOProc block is no different. Every callback is written as an explicit@Sendableliteral inside a nonisolated function.- The deployment target is macOS 14.4, and Swift's
Atomicneeds macOS 15. The atomics are a tiny C target around_Atomic(uint64_t), with a static assert that makes a locking fallback a compile error. - In a debug build,
for i in 0..<nis not specialised: it instantiatesRangeand iterator metadata through the Swift runtime on the first call, under a lock. The ring code uses plainwhileloops, and a test reads the compiled object files of the two ring types and fails if any of their real-time functions references a Swift runtime entry point that can lock or allocate.
Before building the app, a 60-minute capture of a muted test tone player on an M2 MacBook Air (macOS 26.2) made 337,504 callbacks with 0 discontinuities, 0 dropped frames, 0 overruns and 0 overloads, at 0.35 % of one core.
The 8-hour soak test that failed
Before 1.0 I wanted eight hours of continuous recording. A test tool loops tones and posts fake "track changed" events; the recorder taps it muted, splits, encodes and files the tracks, and a script samples memory, CPU, open files and the capture counters.
The run on 8 October failed, usefully.
- The first 5.37 hours were clean: 928,115,200 frames, no discontinuities, no drops, no overruns (1,459 tracks saved by the 4.9-hour mark).
- Then the Mac slept for 836 seconds. The recorder holds an idle-sleep assertion, but
IOPMLib.his explicit that the system may still sleep for lid close, the Apple menu or low battery. The engine recorded the gap and resumed, as designed. - Then the load average hit 251 on 8 cores. That was three parallel builds of the app on the same laptop. All the lost frames fell in one 15-second window: the ring overran 15 times, dropping 7,680 frames, exactly 15 buffers of 512. Over the 50 minutes there were also 7 discontinuities and 6 processor overloads (3 and 2 of them in that window), so the HAL's own IO cycle was late too.
- Then the disk filled up, again thanks to the builds, and the run ended in a failed write. 1,840 of 1,849 tracks were saved, but under the load 197 of them came out more than 0.3 s off their true length (the worst by 13 s).
So the soak mostly measured my other builds, but it also showed two weak spots. The ring held 4 seconds, rounded up to 2^18 frames (5.46 s at 48 kHz). And the writer was a Task.detached(priority: .utility) on Swift's cooperative pool, blocking in write(2) and F_FULLFSYNC and running below everything the compilers ran.
The fixes
A 30-second ring. The ring now asks for 30 seconds, which rounds up to 2^21 frames: 43.7 seconds at 48 kHz, 16 MiB of stereo float32. An exact 30 seconds would need a division or a wrap test in the IOProc to save about 5 MiB, so the power of two stayed. Every page is written at start, so the IOProc never takes a first-touch page fault; that costs about 15 MB of resident memory per recording. A test holds the writer for 30 seconds: no drops with the new ring, exactly 1,177,856 dropped frames with the old one.
A real thread for the writer. The writer is now a dedicated Thread at .userInteractive quality of service, waiting on a semaphore with a timeout so that Stop can wake it at once. It is not a real-time thread, since it does file I/O. The IOProc still never signals it; the writer polls. The cost: interrupt wake-ups while recording went from 24.0 to 38.3 a second (a 3-minute Debug-build profile on a busy laptop), because the writer's timer is no longer coalesced with the pool's.
Stop before the disk is full. A recording needs 500 MB free to start, and stops cleanly, saving every track, when less is left. 500 MB is about 21 minutes of the float32 working file at 48 kHz stereo (about 23 MB a minute). The check uses statfs, which takes 1–2 µs, not volumeAvailableCapacityForImportantUsage, which took 26–43 ms here and counts purgeable space that a write can fail on before macOS frees it.
The clean 8-hour re-run is still on my list. Overloads under extreme load happen inside the HAL, where no ring size helps; they stay counted and reported.
Bonus: where one song ends
The other half of the job is cutting the stream into songs. Players such as the Music app post a distributed notification when the track changes. Apple does not document its name or its keys, so any player update could change them. Tapelark stamps it with the host time on arrival and treats it as a hint, because delivery has no latency guarantee.
The IOProc writes a 32-byte mark per buffer into a second ring: the host time and sample time of its first frame. That maps the event onto an exact frame. If there is a silent gap within a second of it (below −60 dBFS for at least 300 ms), the cut moves to the end of the gap, so the next file doesn't start with silence. Crossfades have no silence, so silence never overrides an event. With a test player, cuts after a silent gap landed within 0.3 ms; without a gap they came 56–72 ms early, the test player's own output latency.
Tapelark is for private recordings and does not try to get around protected content: if a source delivers only silence, it says so instead of looking for another way in.
If you want to try it: tapelark.com.