Native macOS system audio capture via Core Audio tap#127
Draft
loganprosser wants to merge 1 commit into
Draft
Conversation
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. It hangs a global CATapDescription off a private aggregate device and pushes the PCM straight into projectM from the IO proc. The tap setup is wrapped in @available(macOS 14.4, *) so it weak-links and just logs instead of crashing on older systems. Also links CoreAudio/AudioToolbox/Foundation on Darwin and adds the NSAudioCaptureUsageDescription key so the permission prompt has a message. Tested on macOS 26.5: 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.The whole tap setup is behind
if (@available(macOS 14.4, *))(the process-tap API landed in 14.4), so on older systems the symbols weak-link and it just logs a message instead of crashing.Other changes are small: link CoreAudio/AudioToolbox/Foundation on Darwin, and add
NSAudioCaptureUsageDescriptionso the permission prompt has a description string.I tested it on macOS 26.5 (Apple silicon). The tap 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). 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.
One thing I want your call on before polishing: right now this replaces the SDL backend on macOS entirely, so on < 14.4 there's no mic fallback, it just idles. Since there's no declared minimum macOS in the project, I can instead build both backends on macOS and pick at runtime (tap on 14.4+, SDL mic below that) if you'd prefer not to drop the old path. Happy to go either way.
Marking this a draft since that decision affects the shape of the code. 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.