<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Thunder Kitty — Learn</title>
  <subtitle>Thunder Kitty — local-first meeting transcription for macOS.</subtitle>
  <link href="https://www.thunderkitty.app/learn/feed.xml" rel="self" />
  <link href="https://www.thunderkitty.app/learn/" />
  <updated>2026-05-24T00:00:00Z</updated>
  <id>https://www.thunderkitty.app/learn/</id>
  <author>
    <name>Thunder Kitty</name>
  </author>
  <entry>
    <title>2,000 Buffers of Nothing</title>
    <link href="https://www.thunderkitty.app/learn/2000-buffers-of-nothing/" />
    <updated>2026-05-24T00:00:00Z</updated>
    <id>https://www.thunderkitty.app/learn/2000-buffers-of-nothing/</id>
    <summary>What I learned about macOS audio capture that I couldn&#39;t find written down anywhere — Core Audio taps, silent TCC denials, and an Info.plist key Xcode ignores.</summary>
    <content type="html">&lt;p&gt;Two days after launching Thunder Kitty, I tested a scenario I should have tested earlier: picking up a phone call on my MacBook via iPhone Continuity. My voice came through fine. The other person&#39;s audio? Completely missing from the transcript. Silent.&lt;/p&gt;
&lt;p&gt;The fix took me down a rabbit hole through three Apple frameworks, four wrong hypotheses, and one undocumented Info.plist key. This is what I learned about macOS audio capture that I couldn&#39;t find written down anywhere.&lt;/p&gt;
&lt;h2&gt;ScreenCaptureKit can&#39;t see phone calls&lt;/h2&gt;
&lt;p&gt;When I built system audio capture for Thunder Kitty, I reached for ScreenCaptureKit. It&#39;s Apple&#39;s modern API for capturing screen and audio. You create an SCStream, set &lt;code&gt;capturesAudio = true&lt;/code&gt;, and audio buffers show up in a delegate callback. Clean, well-documented, works great.&lt;/p&gt;
&lt;p&gt;For Zoom, Google Meet, Teams, anything running in a browser or windowed app — ScreenCaptureKit captures the audio perfectly. It operates at the application/compositor layer.&lt;/p&gt;
&lt;p&gt;The problem is that word: &lt;em&gt;applications&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;When you pick up an iPhone call on your Mac via Continuity, the call audio doesn&#39;t come from an application with a window. It comes from &lt;code&gt;callservicesd&lt;/code&gt; — a background system daemon. FaceTime audio routes through &lt;code&gt;avconferenced&lt;/code&gt;. Neither has any window presence. They&#39;re invisible to ScreenCaptureKit.&lt;/p&gt;
&lt;p&gt;This isn&#39;t a permissions issue. It&#39;s not a configuration issue. The API operates at the wrong layer of the stack. ScreenCaptureKit is a net that only works on the surface; these daemons are swimming underneath.&lt;/p&gt;
&lt;p&gt;The fix is to drop down a layer.&lt;/p&gt;
&lt;h2&gt;Core Audio taps: the right layer&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;CATapDescription&lt;/code&gt; is a Core Audio API that captures audio at the HAL — the Hardware Abstraction Layer. The HAL sits between your audio hardware and everything above it. Every sound that hits your output device passes through it, regardless of which process produced it. Browser, Zoom, &lt;code&gt;callservicesd&lt;/code&gt;, whatever. If the bytes are flowing, the tap can see them.&lt;/p&gt;
&lt;p&gt;The setup is more involved than ScreenCaptureKit:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a process tap with &lt;code&gt;CATapDescription(monoGlobalTapButExcludeProcesses:)&lt;/code&gt;, excluding your own process so you don&#39;t create a feedback loop.&lt;/li&gt;
&lt;li&gt;Wrap it in a private aggregate device via &lt;code&gt;AudioHardwareCreateAggregateDevice&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Attach an IOProc callback with &lt;code&gt;AudioDeviceCreateIOProcIDWithBlock&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Start the device with &lt;code&gt;AudioDeviceStart&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The aggregate device is the non-obvious piece. A process tap alone doesn&#39;t deliver audio buffers — it has to live inside an aggregate device that combines the tap with an output device, and you read from the aggregate&#39;s input stream. Most of the documentation around this lives in WWDC sessions and a few sample projects.&lt;/p&gt;
&lt;p&gt;I wired it all up, built the app, started a recording, played a YouTube video.&lt;/p&gt;
&lt;p&gt;Silence.&lt;/p&gt;
&lt;h2&gt;2,000 buffers of nothing&lt;/h2&gt;
&lt;p&gt;The IOProc was running. My logs showed the callback firing hundreds of times per second, delivering audio buffers on schedule. But every buffer was zeros. Two thousand buffers of zeros.&lt;/p&gt;
&lt;p&gt;My first hypothesis was Bluetooth. When AirPods connect, macOS negotiates between two profiles: A2DP (high-quality stereo, output only) and HFP (phone-call quality, bidirectional). Opening the microphone forces a switch from A2DP to HFP, which can change the default output device mid-setup. Maybe the aggregate device was being created with a stale device reference.&lt;/p&gt;
&lt;p&gt;I rewrote the setup to host the aggregate on the built-in MacBook speakers — always present, always stable, doesn&#39;t change when headphones connect.&lt;/p&gt;
&lt;p&gt;Still silence.&lt;/p&gt;
&lt;p&gt;I tested with wired headphones. No Bluetooth in the chain. Same result. The Bluetooth hypothesis was dead.&lt;/p&gt;
&lt;h2&gt;The muted speaker test&lt;/h2&gt;
&lt;p&gt;Here&#39;s where I got lucky.&lt;/p&gt;
&lt;p&gt;Thunder Kitty supports two recording modes: &amp;quot;unified&amp;quot; (one stream where the mic picks up speakers and your voice together) and &amp;quot;dual stream&amp;quot; (separate channels for mic and system audio, used with headphones). Unified mode had been &amp;quot;working&amp;quot; — transcripts showed both sides of conversations. Dual stream was always silent.&lt;/p&gt;
&lt;p&gt;On a hunch, I ran unified mode with the speakers muted. If the Core Audio tap was actually delivering system audio, transcription should still work — the tap captures the audio stream before it reaches the speakers, so muting the output shouldn&#39;t matter.&lt;/p&gt;
&lt;p&gt;Two transcript lines. Both my voice. No system audio at all.&lt;/p&gt;
&lt;p&gt;Unified mode had never actually worked. The microphone had been picking up YouTube playing through the speakers — acoustic bleed dressed up as success. The Core Audio tap had been delivering silence the whole time, in every mode, and I&#39;d been fooling myself for days.&lt;/p&gt;
&lt;h2&gt;The Info.plist key Xcode silently ignores&lt;/h2&gt;
&lt;p&gt;macOS gates sensitive APIs through TCC (Transparency, Consent, and Control). For system audio capture, the relevant service is &lt;code&gt;kTCCServiceAudioCapture&lt;/code&gt;, and it requires &lt;code&gt;NSAudioCaptureUsageDescription&lt;/code&gt; in your Info.plist — the string shown in the permission dialog.&lt;/p&gt;
&lt;p&gt;I had added this key. Or so I thought.&lt;/p&gt;
&lt;p&gt;Xcode normally lets you set &lt;code&gt;INFOPLIST_KEY_*&lt;/code&gt; build settings, and it injects the corresponding key into your compiled Info.plist at build time. This works for &lt;code&gt;NSMicrophoneUsageDescription&lt;/code&gt;, &lt;code&gt;NSSpeechRecognitionUsageDescription&lt;/code&gt;, and most other privacy keys. So I&#39;d set &lt;code&gt;INFOPLIST_KEY_NSAudioCaptureUsageDescription&lt;/code&gt; in my build settings and moved on.&lt;/p&gt;
&lt;p&gt;It doesn&#39;t work for &lt;code&gt;NSAudioCaptureUsageDescription&lt;/code&gt;. Xcode silently ignores it. The key never makes it into the compiled plist.&lt;/p&gt;
&lt;p&gt;I verified by running &lt;code&gt;plutil -p&lt;/code&gt; on the app bundle. Microphone description: present. Speech recognition: present. Audio capture: gone.&lt;/p&gt;
&lt;p&gt;The fix was adding it directly to the Info.plist file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;key&amp;gt;NSAudioCaptureUsageDescription&amp;lt;/key&amp;gt;
&amp;lt;string&amp;gt;Thunder Kitty captures system audio to transcribe the other side of your calls.&amp;lt;/string&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The silent denial&lt;/h2&gt;
&lt;p&gt;Here&#39;s the truly nasty part: when &lt;code&gt;NSAudioCaptureUsageDescription&lt;/code&gt; is missing, TCC denies access silently.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AudioHardwareCreateProcessTap&lt;/code&gt; returns &lt;code&gt;noErr&lt;/code&gt;. &lt;code&gt;AudioHardwareCreateAggregateDevice&lt;/code&gt; returns &lt;code&gt;noErr&lt;/code&gt;. &lt;code&gt;AudioDeviceCreateIOProcIDWithBlock&lt;/code&gt; returns &lt;code&gt;noErr&lt;/code&gt;. &lt;code&gt;AudioDeviceStart&lt;/code&gt; returns &lt;code&gt;noErr&lt;/code&gt;. Your IOProc callback fires on schedule. Everything looks perfect.&lt;/p&gt;
&lt;p&gt;But every buffer is zeros.&lt;/p&gt;
&lt;p&gt;There&#39;s no error code. No log message. No indication anything is wrong. The system hands you silence and lets you figure it out. If you&#39;re not specifically checking whether audio data is non-zero, you&#39;ll never know.&lt;/p&gt;
&lt;p&gt;A single error from &lt;code&gt;AudioHardwareCreateProcessTap&lt;/code&gt; saying &amp;quot;TCC denied&amp;quot; would have saved me hours. I understand the security rationale — you don&#39;t want to make it easy for malware to detect denial — but it makes legitimate development genuinely painful.&lt;/p&gt;
&lt;h2&gt;And one more gotcha: triggering the prompt&lt;/h2&gt;
&lt;p&gt;Even with the Info.plist key in place, I had one more problem. I wanted Thunder Kitty&#39;s onboarding to trigger the permission prompt before the first recording, so users could grant access without confusion.&lt;/p&gt;
&lt;p&gt;My first attempt: create a process tap and immediately destroy it. The permission gate is on tap creation, right? Surely calling &lt;code&gt;AudioHardwareCreateProcessTap&lt;/code&gt; will trigger the system prompt.&lt;/p&gt;
&lt;p&gt;Nope. The tap creates &amp;quot;successfully&amp;quot; (returns &lt;code&gt;noErr&lt;/code&gt;, as we&#39;ve established it does regardless). No prompt appears.&lt;/p&gt;
&lt;p&gt;It turns out &lt;code&gt;AudioDeviceStart&lt;/code&gt; is the call that triggers the TCC prompt. Not creating the tap. Not creating the aggregate device. Not creating the IOProc. You have to actually start IO on a tap-backed aggregate device before macOS asks the user.&lt;/p&gt;
&lt;p&gt;There&#39;s no &lt;code&gt;requestAuthorization&lt;/code&gt;-style API for audio capture, the way there is for the microphone or speech recognition. You have to spin up the entire pipeline — tap, aggregate device, IOProc, start IO — wait for the system prompt, then tear it all down. Thunder Kitty&#39;s onboarding does exactly this: builds a throwaway audio pipeline, holds it for a beat, destroys it. It&#39;s the only way.&lt;/p&gt;
&lt;h2&gt;Working&lt;/h2&gt;
&lt;p&gt;After fixing the Info.plist key and rebuilding, I started a test recording and picked up a phone call on my Mac. My voice. Then the other person&#39;s voice. Then both of us, transcribed line by line in a single conversation.&lt;/p&gt;
&lt;p&gt;The permission prompt now reads &amp;quot;System Audio Recording&amp;quot; instead of &amp;quot;Screen &amp;amp; System Audio Recording.&amp;quot; It&#39;s a smaller ask, a less alarming privacy indicator, and it actually describes what the app does.&lt;/p&gt;
&lt;h2&gt;Six things I&#39;d tell other Mac developers&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Use Core Audio taps, not ScreenCaptureKit, if you need to hear everything.&lt;/strong&gt; SCK is great for capturing specific applications. But if your users might be on phone calls, FaceTime, or anything that runs through a background daemon, SCK will miss it. Don&#39;t ship a transcription product that misses phone calls.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add &lt;code&gt;NSAudioCaptureUsageDescription&lt;/code&gt; directly to your Info.plist file.&lt;/strong&gt; Do not rely on &lt;code&gt;INFOPLIST_KEY_*&lt;/code&gt; build settings for this key. Xcode ignores them silently. Verify with &lt;code&gt;plutil -p&lt;/code&gt; on your compiled app bundle to confirm the key is actually there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TCC enforcement is silent.&lt;/strong&gt; Every Core Audio API will return &lt;code&gt;noErr&lt;/code&gt; even when permission is denied. The only way to know is that your buffers contain zeros. Build a non-zero check into your audio pipeline early, before you spend a day debugging.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;AudioDeviceStart&lt;/code&gt; is what triggers the permission prompt.&lt;/strong&gt; Not creating the tap, not creating the aggregate device. If you want to prompt during onboarding, you need to build and start the full pipeline, then tear it down once macOS has shown the dialog.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start your mic engine before creating the aggregate device.&lt;/strong&gt; If your users have AirPods, opening the mic first forces the HFP profile negotiation. If the aggregate device starts first, AirPods stay in A2DP, and your mic channel fails silently. (Another silent failure. They&#39;re a theme.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Host the aggregate device on the built-in output device, not the default output device.&lt;/strong&gt; The default device can change when headphones connect or Bluetooth profiles switch. Built-in speakers are always there, always stable. Pin to them and your aggregate survives device changes mid-recording.&lt;/p&gt;
&lt;p&gt;None of this is documented in one place. I hope this post saves someone the days I spent on it.&lt;/p&gt;
</content>
  </entry>
</feed>
