Skip to content

Native macOS system audio capture via Core Audio tap - #127

Open
loganprosser wants to merge 1 commit into
projectM-visualizer:masterfrom
loganprosser:macos-system-audio-pr
Open

Native macOS system audio capture via Core Audio tap#127
loganprosser wants to merge 1 commit into
projectM-visualizer:masterfrom
loganprosser:macos-system-audio-pr

Conversation

@loganprosser

@loganprosser loganprosser commented Jul 21, 2026

Copy link
Copy Markdown

On macOS the SDL capture backend can only read a microphone, so if you want projectM to react to whatever's actually playing (Spotify, a YouTube tab, etc.) you have to install BlackHole or another virtual device and reroute your output through it. Windows already avoids that with the WASAPI loopback backend. This does the macOS equivalent using a Core Audio process tap, so system audio just works with no extra driver.

How it works: it creates a global CATapDescription, attaches it to a private aggregate device, and forwards the PCM into projectM from the IO proc. Nothing exotic beyond the tap plumbing itself.

Backend selection / older systems. The process-tap API landed in macOS 14.4, so rather than dropping the existing mic path below that, macOS now selects a backend at runtime (AudioCaptureImpl_macOS): the Core Audio tap on 14.4+, falling back to the current SDL microphone capture on older systems. The shared SDL backend is compiled a second time under a distinct class name (via a COMPILE_DEFINITIONS rename in src/CMakeLists.txt) so both can live in one binary — the SDL sources and the cross-platform AudioCapture proxy are left completely untouched, so Windows and Linux are unaffected.

Other changes are small: link CoreAudio/AudioToolbox/Foundation on Darwin, and add NSAudioCaptureUsageDescription so the permission prompt has a description string.

I tested it on macOS 26.5 (Apple silicon): the dispatcher selects the tap, which comes up at 48kHz stereo and only produces samples while something is actually playing (checked with a temporary peak meter — flat at idle, jumps as soon as audio plays). Both backends compile and link into the one binary. One gotcha worth noting: capture only works if the app bundle is signed, because the system ties the audio-capture permission to the signing identity. Ad-hoc signing is enough for local builds.

Follow-ups I left out on purpose: per-app capture, a richer device list that includes inputs, handling default-output-device changes, and recovering from the known "all zeros after a long session" tap bug. Happy to adjust the structure if you'd prefer a different shape.


Authored with AI assistance.

On macOS the SDL backend can only grab a microphone, so to visualize whatever's
actually playing you currently have to install BlackHole (or similar) and reroute
your output through it. This adds a Core Audio process-tap backend that captures
system output directly, the same idea as the existing WASAPI loopback path on
Windows.

The process-tap API only exists on macOS 14.4+, so a small dispatcher
(AudioCaptureImpl_macOS) picks the backend at runtime: the Core Audio tap on
14.4+, falling back to the existing SDL microphone capture on older systems. The
shared SDL backend is compiled a second time under a distinct class name so both
can live in one binary; its sources are left untouched so the Linux build is
unaffected.

The tap hangs a global CATapDescription off a private aggregate device and pushes
the PCM straight into projectM from the IO proc.

Also links CoreAudio/AudioToolbox/Foundation on Darwin and adds the
NSAudioCaptureUsageDescription key so the permission prompt has a message.

Tested on macOS 26.5: on 14.4+ the tap comes up at 48kHz stereo and only produces
audio while something is actually playing. Capture needs the bundle to be
code-signed since the permission is tied to the signing identity.
@loganprosser
loganprosser force-pushed the macos-system-audio-pr branch from 964a03b to 13585b7 Compare July 28, 2026 13:51
@loganprosser
loganprosser marked this pull request as ready for review July 28, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant