<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alexander Mishin</title>
    <description>The latest articles on DEV Community by Alexander Mishin (@alexander_mishin_1c72158e).</description>
    <link>https://dev.to/alexander_mishin_1c72158e</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4069048%2Fbff04e8f-2814-4e60-8aa5-9232f940efdf.jpg</url>
      <title>DEV Community: Alexander Mishin</title>
      <link>https://dev.to/alexander_mishin_1c72158e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexander_mishin_1c72158e"/>
    <language>en</language>
    <item>
      <title>How I record system audio on macOS without a virtual driver</title>
      <dc:creator>Alexander Mishin</dc:creator>
      <pubDate>Sat, 08 Aug 2026 16:48:46 +0000</pubDate>
      <link>https://dev.to/alexander_mishin_1c72158e/how-i-record-system-audio-on-macos-without-a-virtual-driver-54e1</link>
      <guid>https://dev.to/alexander_mishin_1c72158e/how-i-record-system-audio-on-macos-without-a-virtual-driver-54e1</guid>
      <description>&lt;p&gt;Recording your own microphone on a Mac is a solved problem. Recording the other person — the voice coming out of your speakers — is where macOS quietly fights you. Here’s how the audio pipeline actually works.&lt;/p&gt;




&lt;p&gt;For years the only answer to that was a virtual audio driver like BlackHole and a Multi-Output Device you wired up by hand before every call. It works, and it breaks constantly.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://meetingsrecorder.com/" rel="noopener noreferrer"&gt;MeetingRecorder&lt;/a&gt;, a free Mac app that records both sides of a call locally. I’ve run &lt;strong&gt;636 meetings — 284 hours — through it since January&lt;/strong&gt;, so this is the version of the audio pipeline that survived daily use, not a whiteboard diagram: capturing system audio with &lt;strong&gt;ScreenCaptureKit&lt;/strong&gt;, reconciling two independent clocks into one file, and doing it all without dropping a single sample on the real-time thread.&lt;/p&gt;

&lt;p&gt;I started building it after one too many recordings that turned out to be just my own voice — the other side of the call kept coming back silent, and none of the usual fixes stuck for long. I wanted a recorder where system audio simply worked: nothing to route before the call, nothing to remember to undo after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem in one paragraph
&lt;/h2&gt;

&lt;p&gt;A meeting recording needs two signals: your &lt;strong&gt;microphone&lt;/strong&gt; (what you say) and the &lt;strong&gt;system audio&lt;/strong&gt; (what everyone else says, coming out of your speakers). macOS hands you the microphone freely. It deliberately does not let an app silently read the system audio — that would be a privacy hole — so ⌘⇧5 and QuickTime only ever capture your mic. Record a call that way and you get yourself, loud and clear, talking to total silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old way: BlackHole and a Multi-Output Device
&lt;/h2&gt;

&lt;p&gt;The classic workaround installs a virtual audio driver (BlackHole, or the old Soundflower) that presents itself as an output device. You then build an &lt;strong&gt;aggregate/Multi-Output Device&lt;/strong&gt; so your Mac plays sound to your speakers and the virtual device at once, point your system output at it, and record the virtual device as an input.&lt;/p&gt;

&lt;p&gt;It works. It also has sharp edges I got tired of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have to change your system output before the call and change it back after. Forget the “after,” and your next recording is silent.&lt;/li&gt;
&lt;li&gt;If you don’t route through a Multi-Output, you can record the call but can’t hear it while it happens.&lt;/li&gt;
&lt;li&gt;One wrong toggle in Audio MIDI Setup and you capture nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every meeting started with a little audio-routing ritual. I wanted to press one button.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new way: ScreenCaptureKit, but only for the audio
&lt;/h2&gt;

&lt;p&gt;Since macOS 13, Apple ships &lt;strong&gt;ScreenCaptureKit&lt;/strong&gt; — the framework behind the modern screen recorder. It can capture system audio directly, no virtual driver, gated behind the one-time &lt;strong&gt;Screen Recording&lt;/strong&gt; permission. The catch: the API is built for screen capture, so to get audio-only you set up a screen stream and then throw the video away.&lt;/p&gt;

&lt;p&gt;Here’s the actual configuration from the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;SCShareableContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;excludingDesktopWindows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;onScreenWindowsOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;displays&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;SystemAudioCaptureError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noDisplayFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SCContentFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;excludingWindows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;configuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SCStreamConfiguration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// Video is required by the API, so make it as cheap as physically possible.&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minimumFrameInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CMTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;timescale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 1 fps&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queueDepth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

&lt;span class="c1"&gt;// The part we actually want:&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capturesAudio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;excludesCurrentProcessAudio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;// don't record our own sounds&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;48000&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channelCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 2×2, 1-fps dummy video&lt;/strong&gt;. ScreenCaptureKit won’t run an audio-only stream, so you give it the smallest, slowest video surface it will accept and ignore every frame. It costs almost nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;excludesCurrentProcessAudio = true&lt;/strong&gt;. Without this, the recorder records its own UI sounds and any playback you do — an instant feedback loop. This one flag is the difference between a clean capture and garbage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Audio then arrives as CMSampleBuffers on a delegate callback, and you convert each one into an AVAudioPCMBuffer you can actually work with. That conversion is the least glamorous code in the whole app — pulling the format description, handling interleaved vs. non-interleaved layouts, and memcpy-ing channel data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SCStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;didOutputSampleBuffer&lt;/span&gt; &lt;span class="nv"&gt;sampleBuffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CMSampleBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SCStreamOutputType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isPaused&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;pcmBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sampleBuffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;presentationTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CMSampleBufferGetPresentationTimeStamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sampleBuffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;delegate&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;systemAudioCapture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;didReceiveBuffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pcmBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;presentationTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compared to the BlackHole ritual, the user-facing story collapses to: grant Screen Recording once, press record. No driver, no routing, nothing to reset afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two streams, two clocks
&lt;/h2&gt;

&lt;p&gt;Now the interesting problem. The app runs &lt;strong&gt;two independent capture sources&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;microphone&lt;/strong&gt;, through AVFoundation, timestamped with AVAudioTime (a mach_absolute_time host tick);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;system audio&lt;/strong&gt;, through ScreenCaptureKit, timestamped with CMTime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different frameworks, different clocks, and buffers that arrive at different moments in different sizes. If you naively append each stream to its own channel as it arrives, they drift — the mic and the other person’s voice slide out of sync over a long call, which wrecks both playback and transcription.&lt;/p&gt;

&lt;p&gt;The fix is to stop trusting arrival order and put everything on one shared timeline — the machine’s own mach_absolute_time. Both timestamps get converted to nanoseconds against that clock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;enqueueMic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;nanos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;machTicksToNanos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hostTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// mic clock → host nanos&lt;/span&gt;
    &lt;span class="c1"&gt;// ... hand off to the processing queue, channel 0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;enqueueSystem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CMTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;nanos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cmTimeToNanos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;// system clock → host nanos&lt;/span&gt;
    &lt;span class="c1"&gt;// ... hand off to the processing queue, channel 1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The very first buffer from either source stamps startHostTimeNanos. After that, every buffer computes its exact position on the timeline from the elapsed time, not from how many buffers came before it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hostNanos&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;startNanos&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;framePos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pauseOff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1_000_000_000.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;framePos is the sample index where this chunk belongs. The writer places each mono chunk at its absolute frame position and fills any gap with silence, so a late or dropped buffer leaves a correctly-sized hole instead of shoving everything after it out of alignment. Because both sources reference the same hardware clock, they stay locked together for the length of the call — no accumulating drift.&lt;/p&gt;

&lt;p&gt;(machTicksToNanos is just the mach_timebase_info numer/denom ratio applied to the tick count — the standard way to turn mach_absolute_time into real nanoseconds. Cheap, and the same clock both frameworks ultimately hang off.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the real-time thread sacred
&lt;/h2&gt;

&lt;p&gt;Audio callbacks run on high-priority real-time threads. Block one — with file I/O, a lock held too long, an allocation at the wrong moment — and you get glitches or dropped buffers you can’t get back. So the writer is a three-stage pipeline, and each stage does the least possible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture callback (real-time thread)&lt;/strong&gt;: copy the buffer, read the timestamp, hand it off. Nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing queue&lt;/strong&gt;: compute framePos, convert to mono at the target sample rate with a cached AVAudioConverter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write queue&lt;/strong&gt;: the only place that touches files.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;enqueueQueue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;processBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;hostNanos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nanos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// stage 2&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ... later, inside processBuffer:&lt;/span&gt;
&lt;span class="n"&gt;writeQueue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;monoData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;framePos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// stage 3&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real-time thread never waits on a disk. That’s the whole trick to a recorder that doesn’t stutter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mix vs. separate tracks
&lt;/h2&gt;

&lt;p&gt;By default the output is a single stereo .m4a: &lt;strong&gt;left channel = microphone, right channel = system audio&lt;/strong&gt;. That already carries both sides.&lt;/p&gt;

&lt;p&gt;But there’s an option to also save &lt;strong&gt;separate mono tracks&lt;/strong&gt; — one for the mic, one for the system — and it matters more than it looks. When you later transcribe the call, feeding the two speakers as separate tracks gives you clean “you vs. them” attribution instead of one blurred column where the diarizer has to guess who spoke. The channel separation you preserved at capture time becomes speaker labels at transcription time for free. I turn it on for calls I know I’ll transcribe — about 140 of mine so far — and the transcript quality difference is not subtle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I learned the hard way: never lose the recording
&lt;/h2&gt;

&lt;p&gt;The app writes raw PCM to a temp file during the call and only encodes to AAC (.m4a) at &lt;strong&gt;stop&lt;/strong&gt;. Encoding a whole meeting in one pass at the end is efficient — my longest recording is a 2-hour-53-minute call that finalizes into a single 118 MB .m4a — but it’s also the single most dangerous moment, because if that encode throws, the naive thing to do is clean up the temp files and you’ve just deleted an hour-long meeting.&lt;/p&gt;

&lt;p&gt;So the failure path does the opposite of cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Do NOT delete the captured PCM on a conversion failure — that silently&lt;/span&gt;
    &lt;span class="c1"&gt;// destroys the whole recording. Rescue the raw stereo mix next to the&lt;/span&gt;
    &lt;span class="c1"&gt;// intended output so it can be recovered later, and report the failure.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rescued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rescuePCM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;stereoTemp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stereoTemp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;outURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;outURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;conversionFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rescuedPCM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rescued&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw PCM gets moved out of the temp directory (where the OS would eventually purge it) to a sibling .caf next to where the .m4a should have been, and the app recovers it on next launch. A recording someone can’t reproduce — you were only in that meeting once — is worth more than clean temp files. Same reasoning drove pause handling (a running pauseOffsetNanos keeps the timeline continuous instead of leaving a gap) and crash recovery on relaunch.&lt;/p&gt;

&lt;p&gt;Here’s the honest part: across those 636 recordings on my own machine, that rescue path has &lt;strong&gt;never once fired&lt;/strong&gt; — the encode has never failed for me. I built it anyway. The day it does fail is the day someone loses a meeting that exists nowhere else, and “it never happened in testing” is exactly the sentence you don’t want to be holding then.&lt;/p&gt;

&lt;h2&gt;
  
  
  What macOS 27 changes — and what it doesn’t
&lt;/h2&gt;

&lt;p&gt;Apple is adding system-audio capture to the built-in screen recorder in macOS 27 this autumn, so ⌘⇧5 is expected to grab desktop audio without a third-party app at all. That kills the BlackHole premise for casual grabs, and it’s genuinely good for users.&lt;/p&gt;

&lt;p&gt;It does not make a dedicated recorder pointless, because “capture the audio” was never the whole job. The built-in tool still gives you a &lt;strong&gt;video file&lt;/strong&gt;, not an audio archive you can search and keep; it has no transcript, no speaker labels, no automatic call detection, and no separate mic/system tracks. If what you want is a meeting — recorded as audio, transcribed, attributed, and filed automatically — the OS primitive is the easy 20%. The rest is the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ScreenCaptureKit is the modern way to read system audio on macOS&lt;/strong&gt; — no virtual driver — but you configure a screen stream and discard the video (2×2, 1 fps), and remember excludesCurrentProcessAudio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two capture sources means two clocks.&lt;/strong&gt; Put everything on one mach_absolute_time timeline and write by absolute frame position, not arrival order, or you’ll drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect the real-time thread&lt;/strong&gt; with a copy → process → write pipeline; only the last stage touches disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode late, but never delete raw capture on failure.&lt;/strong&gt; The user was only in that meeting once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the driver-free version as a finished app, that’s &lt;a href="https://meetingsrecorder.com/" rel="noopener noreferrer"&gt;MeetingRecorder&lt;/a&gt; — free, Mac-only, records both sides locally, and keeps the M4A on your machine. And if you’re building your own, I’m happy to compare notes on the parts that fought back.&lt;/p&gt;

&lt;p&gt;If you’ve built anything that captures from two sources at once, I’d genuinely like to know how you handle the clock drift — a shared host-time timeline like this, resampling to a master clock, or something smarter I haven’t tried yet.&lt;/p&gt;

</description>
      <category>audio</category>
      <category>showdev</category>
      <category>macos</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
