Cachalot
Baixar

Blog · 2026-08-24

Recording the other side of a call on macOS, without a bot

Every AI notetaker has to solve the same problem: you can record your own microphone with three lines of AVFoundation, but the other side of the call lives in Zoom’s or Chrome’s process, not yours. The industry’s usual answer is to send a bot into the meeting, which is why so many calls now have a silent participant called “Notetaker”.

There is another way on macOS, and it has been sitting in Core Audio since macOS 14.2: process taps. This is how we built Cachalot, which records, transcribes and summarizes calls without joining them and without uploading anything. Here is what worked, and what broke.

The three pieces

A tap on its own produces no data. You need three objects: a tap description, an aggregate device that contains the tap, and an IOProc that receives buffers.

// 1. A global tap: every process's output, nothing excluded.
let tapDescription = CATapDescription(stereoGlobalTapButExcludeProcesses: [])
tapDescription.uuid = UUID()
tapDescription.isPrivate = true       // don't show up in other apps' device lists
tapDescription.muteBehavior = .unmuted // the user still hears the call

var tapID = AudioObjectID(kAudioObjectUnknown)
AudioHardwareCreateProcessTap(tapDescription, &tapID)

The tap has to be wrapped in an aggregate device whose main sub-device is the current output device — that device supplies the clock, and drift compensation keeps the tap aligned to it.

let outputUID = try AudioObjectID.readDefaultOutputDeviceUID()
let description: [String: Any] = [
    kAudioAggregateDeviceNameKey: "Cachalot Tap",
    kAudioAggregateDeviceUIDKey: UUID().uuidString,
    kAudioAggregateDeviceMainSubDeviceKey: outputUID,
    kAudioAggregateDeviceIsPrivateKey: true,
    kAudioAggregateDeviceTapAutoStartKey: true,
    kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputUID]],
    kAudioAggregateDeviceTapListKey: [[
        kAudioSubTapDriftCompensationKey: true,
        kAudioSubTapUIDKey: tapDescription.uuid.uuidString,
    ]],
]
var aggregateID = AudioObjectID(kAudioObjectUnknown)
AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateID)

Then read the tap’s format (kAudioTapPropertyFormat), open a file with it, and write buffers from the IOProc block. That is the whole capture path — no kernel extension, no virtual audio driver to install, no bot.

Four things that broke

1. Echo cancellation silently destroys the recording

We record the microphone at the same time. Turning on voice-processing (AEC) on the mic input is the obvious thing to do — and it removes the other party from the tap, because AEC treats the system output as an echo to be cancelled. The result is a recording where only you are audible, and you find out after the meeting. We keep AEC off deliberately, and there is a unit test whose only job is to fail if someone turns it back on.

2. Switching audio devices mid-recording

The aggregate device is pinned to the output device that existed when you created it. Put on AirPods halfway through a call and the IOProc simply stops being called: no error, no exception, a file that keeps growing with silence. We watch for the default-device change, stop the recording, and tell the user what happened — a stopped recording is recoverable, an hour of silence is not.

3. App Sandbox is not an option, which rules out the App Store

Process taps do not work inside the App Sandbox. That is a hard fork in the road: either you rebuild capture on ScreenCaptureKit and accept its constraints, or you distribute outside the Mac App Store with Developer ID signing, notarization and your own update mechanism. We chose the latter.

4. Zip archives break your own updater

Unrelated to audio, but it cost us an afternoon: if you ship a .zip and the user unarchives it in Downloads and launches it from there, macOS App Translocation runs your app from a randomised read-only path. Sparkle then cannot replace the bundle, so updates fail for exactly the users who never dragged the app to Applications. Ship a DMG; the “drag to Applications” step is not decoration.

The payoff: speaker separation without diarization

Your micTheir audioWho said what
Two independent streams stay two tracks, so the transcript never has to guess who spoke.

Because the microphone and the tap are two independent streams, we write them as two channels and never have to guess who spoke. Diarization — clustering voices in a single mixed track — is the usual approach, and it is the part that gets embarrassing in a transcript when two people have similar voices. Two channels make “who said what” structurally exact: one side is your microphone, by definition.

Everything after that is on-device too: transcription with Apple’s speech models or Whisper large-v3-turbo, and the meeting notes from Apple’s on-device model. No audio leaves the machine, which you can verify yourself with a network monitor — there is no server on our side that could receive it.

Cachalot is the app this came out of: private AI meeting notes that never leave your Mac. Free to use, no account.

Download free for Mac