빈 버퍼 2,000개
출처: Dev.to
원본 게시: thunderkitty.app/learnmacOS 오디오 캡처에 대해 어디에서도 찾아볼 수 없었던 내용 — Core Audio 탭, 조용한 TCC 거부, 그리고 Xcode가 무시하는 Info.plist 키에 대해 배운 점을 정리합니다.
Thunder Kitty를 출시한 지 이틀 뒤, 이전에 테스트했어야 할 시나리오를 실험했습니다: iPhone Continuity를 통해 MacBook에서 전화를 받는 경우. 내 목소리는 정상적으로 전달됐지만, 상대방의 오디오는 전사에 전혀 나타나지 않았습니다. 완전한 무음.
해결책을 찾는 과정에서 세 개의 Apple 프레임워크, 네 번의 잘못된 가설, 그리고 문서에 나오지 않은 Info.plist 키를 파헤쳐야 했습니다. 여기 macOS 오디오 캡처에 대해 어디에도 적힌 적 없는 제가 배운 내용이 있습니다.
ScreenCaptureKit을 사용해 Thunder Kitty용 시스템 오디오 캡처를 구현했습니다. ScreenCaptureKit은 화면과 오디오를 캡처하기 위한 Apple의 최신 API입니다.
SCStream을 만들고capturesAudio = true로 설정하면, 오디오 버퍼가 delegate 콜백으로 전달됩니다. 깔끔하고, 문서도 잘 정리돼 있으며, 동작도 훌륭합니다.Zoom, Google Meet, Teams, 브라우저나 창 기반 앱 등에서는 ScreenCaptureKit이 오디오를 완벽히 캡처합니다. 이는 애플리케이션/컴포지터 레이어에서 동작하기 때문입니다.
문제는 바로 “애플리케이션”이라는 단어에 있습니다.
Continuity를 통해 Mac에서 iPhone 전화를 받을 때, 통화 오디오는 창을 가진 애플리케이션에서 나오는 것이 아니라
callservicesd라는 백그라운드 시스템 데몬에서 나옵니다. FaceTime 오디오는avconferenced를 거칩니다. 두 프로세스 모두 창이 없으며, ScreenCaptureKit에 의해 보이지 않습니다.이것은 권한 문제도, 설정 문제도 아닙니다. API가 스택의 잘못된 레이어에서 동작하고 있기 때문입니다. ScreenCaptureKit은 표면(앱)에서만 작동하는 그물망에 불과하고, 이 데몬들은 그 아래에서 헤엄치고 있습니다.
해결책은 한 단계 아래 레이어로 내려가는 것이었습니다.
CATapDescription은 HAL(Hardware Abstraction Layer) 수준에서 오디오를 캡처하는 Core Audio API입니다. HAL은 오디오 하드웨어와 그 위의 모든 계층 사이에 위치합니다. 출력 장치에 전달되는 모든 사운드는 어떤 프로세스가 생성했든 간에 이 레이어를 통과합니다. 브라우저, Zoom,callservicesd등 어떤 것이든 바이트가 흐르면 탭이 이를 볼 수 있습니다.설정은 ScreenCaptureKit보다 복잡합니다:
CATapDescription(monoGlobalTapButExcludeProcesses:)로 프로세스 탭을 생성하고, 자기 자신을 제외해 피드백 루프를 방지합니다.AudioHardwareCreateAggregateDevice로 사설 집계 장치를 만들고 탭을 감쌉니다.AudioDeviceCreateIOProcIDWithBlock으로 IOProc 콜백을 연결합니다.AudioDeviceStart로 장치를 시작합니다.집계 장치는 눈에 잘 띄지 않는 핵심 요소입니다. 프로세스 탭만으로는 오디오 버퍼를 전달하지 못합니다—탭을 출력 장치와 결합한 집계 장치 안에 있어야 하며, 집계 장치의 입력 스트림을 통해 버퍼를 읽어야 합니다. 관련 문서는 대부분 WWDC 세션과 몇몇 샘플 프로젝트에 흩어져 있습니다.
모든 것을 연결하고 앱을 빌드한 뒤 녹음을 시작하고 YouTube 영상을 재생했습니다.
정적.
IOProc는 정상적으로 실행됐고, 로그에는 콜백이 초당 수백 번 호출되어 오디오 버퍼를 제때 전달한다는 내용이 찍혔습니다. 하지만 전달된 버퍼는 모두 0이었습니다. 2,000개의 제로 버퍼.
첫 번째 가설: 블루투스
AirPods가 연결되면 macOS는 두 프로파일(A2DP: 고음질 스테레오, 출력 전용 / HFP: 전화통화 품질, 양방향) 사이를 협상합니다. 마이크를 열면 A2DP에서 HFP로 전환되며, 이 과정에서 기본 출력 장치가 중간에 바뀔 수 있습니다. 어쩌면 집계 장치가 오래된 장치 레퍼런스로 생성됐을지도 모릅니다.
설정을 바꿔 내장 MacBook 스피커에 집계 장치를 연결했고, 스피커는 언제나 존재하고 안정적이며 헤드폰을 연결해도 바뀌지 않게 했습니다.
여전히 무음.이어서 유선 헤드폰으로 테스트했습니다. 블루투스가 전혀 개입하지 않았음에도 결과는 동일했습니다. 블루투스 가설은 기각되었습니다.
운이 좋게도
Thunder Kitty는 두 가지 녹음 모드를 지원합니다:
- “통합”(unified) – 마이크가 스피커와 내 목소리를 동시에 캡처하는 단일 스트림
- “듀얼 스트림”(dual stream) – 마이크와 시스템 오디오를 별도 채널로 캡처(헤드폰 사용 시)
통합 모드는 “작동”하고 있었고, 전사에는 대화 양쪽이 모두 표시됐습니다. 듀얼 스트림은 항상 무음이었습니다.직감에 따라, 스피커를 음소거한 상태에서 통합 모드를 실행했습니다. Core Audio 탭이 실제로 시스템 오디오를 전달한다면, 출력이 음소거돼도 전사는 여전히 작동해야 합니다—탭은 스피커에 도달하기 전 오디오 스트림을 캡처하므로 출력 음소거와는 무관합니다.
결과: 두 줄의 전사, 모두 내 목소리. 시스템 오디오는 전혀 없었습니다.통합 모드도 실제로는 작동한 적이 없었습니다. 마이크가 스피커에서 재생되는 YouTube 소리를 ‘음향 누출’ 형태로 잡아냈을 뿐이었고, Core Audio 탭은 언제나 무음을 전달하고 있었습니다. 나는 며칠 동안 스스로를 속이고 있었던 겁니다.
macOS는 민감한 API 접근을 TCC(Transparency, Consent, and Control)로 제어합니다. 시스템 오디오 캡처와 관련된 서비스는
kTCCServiceAudioCapture이며,Info.plist에NSAudioCaptureUsageDescription키가 필요합니다—이 문자열이 권한 대화 상에 표시됩니다.
나는 이 키를 추가했다고 생각했습니다.Xcode에서는 보통
INFOPLIST_KEY_*빌드 설정을 통해 키를 지정하면, 빌드 시점에 해당 키가 컴파일된Info.plist에 자동 삽입됩니다. 이는NSMicrophoneUsageDescription,NSSpeechRecognitionUsageDescription등 대부분의 프라이버시 키에 적용됩니다. 그래서 나는 빌드 설정에INFOPLIST_KEY_NSAudioCaptureUsageDescription을 넣고 넘어갔습니다.하지만
NSAudioCaptureUsageDescription에 대해서는 작동하지 않았습니다. Xcode는 이를 조용히 무시했고, 키는 컴파일된 plist에 들어가지 않았습니다.
plutil -p로 앱 번들을 확인했을 때, 마이크 설명은 존재했고, 음성 인식 설명도 존재했지만, 오디오 캡처 설명은 사라져 있었습니다.해결책은 바로
Info.plist파일에 직접 키를 추가하는 것이었습니다:
<key>NSAudioCaptureUsageDescription</key> <string>Thunder Kitty는 통화 상대방의 목소리를 전사하기 위해 시스템 오디오를 캡처합니다.</string>가장 끔찍한 부분은
NSAudioCaptureUsageDescription이 없을 때 TCC가 접근을 조용히 거부한다는 점입니다.
AudioHardwareCreateProcessTap은noErr를 반환하고,AudioHardwareCreateAggregateDevice도noErr를 반환합니다.AudioDeviceCreateIOProcIDWithBlock과AudioDeviceStart도 모두 성공 코드가 반환됩니다. IOProc 콜백은 일정대로 호출되고, 모든 것이 완벽해 보입니다.
하지만 전달되는 버퍼는 모두 0입니다.
오류 코드도, 로그 메시지도, 문제가 있다는 징후도 없습니다. 시스템은 무음만을 제공하고, 개발자는 직접 버퍼가 0인지 확인하지 않으면 절대 알 수 없습니다.
AudioHardwareCreateProcessTap이 “TCC denied” 라는 단일 오류를 반환했다면 몇 시간을 절약했을 텐데요. 보안상의 이유는 이해합니다—악성코드가 거부 사실을 쉽게 감지하지 못하게 하려는 것이지만, 정당한 개발자에게는 정말 고통스럽습니다.
Info.plist키를 넣은 뒤에도 또 다른 문제가 있었습니다. 첫 녹음 전에 사용자가 권한 프롬프트를 미리 보게 해서 혼란을 방지하고 싶었습니다.
첫 시도: 프로세스 탭을 만들고 바로 파괴하는 것이었습니다. 권한 게이트는 탭 생성 시점에 작동한다는 가정이었죠.AudioHardwareCreateProcessTap을 호출하면 시스템 프롬프트가 뜨리라 생각했습니다.
결과: 탭은 “성공적으로” 생성됐습니다(noErr반환). 프롬프트는 전혀 나타나지 않았습니다.