跳到正文
Hacker News· victorbuilds·· 8 天前AI 评分30

如何在 macOS 上不用虚拟驱动录制单个 App 的音频:Core Audio 进程 tap 实践

Recording one app's audio on macOS without a virtual driver

AI 导读

开发者用 macOS 14.2 引入的 Core Audio 进程 tap 构建了录音应用 Tapelark,1.0 版于 10 月 10 日发布。

正文 · AI 翻译

长期以来,在 Mac 上录制某个应用的声音意味着要安装一个系统音频组件,通常是一个虚拟设备,让输出音频经过它路由。从 macOS 13 开始,ScreenCaptureKit 无需驱动就能做到这一点,但需要屏幕录制权限。

从 macOS 14.2 起有了第三种方式:Core Audio 进程 tap。我基于它们构建了我的应用 Tapelark,1.0 版本于 2026 年 10 月 10 日发布。本文介绍采集、它的实时规则,以及一次 8 小时浸泡测试暴露出的问题。

获取声音的三种方式

虚拟音频驱动,如 BlackHole,是 HAL 插件。你需要以管理员权限安装一个,并让输出音频经过它路由。它们很成熟,通常也是正确的工具,但对一个应用来说,它们增加了一个安装步骤,并改变了用户音频的路由方式。

ScreenCaptureKit 可以采集某个应用的音频,但它需要更宽泛的屏幕录制权限,而且当应用不经过系统选择器时,macOS 15 会要求用户每月重新批准该权限。

进程 tap 无需驱动,可以采集一个应用、多个应用或整个系统混音,还可以选择静音它所采集的内容,并且需要一种更窄的权限,系统设置目前称之为“仅系统音频录制”。

最后一种胜出。我找不到关于进程 tap 的 WWDC 会议,所以 Apple 的示例项目(“使用 Core Audio tap 采集系统音频”)和 SDK 头文件就是文档。

三个对象:一个 tap、一个聚合设备、一个 IOProc

tap 是从一个 CATapDescription 创建的。第一个意外之处:它接受的是 Core Audio 进程对象,而不是 PID。你需要用 HAL 属性来转换 PID。一个进程只有在连接到 Core Audio(打开过音频设备)之后才会有这样的对象,所以一个尚未这样做的应用,通常是自启动以来还没播放过任何内容的应用,无法被 tap。

// Simplified; error handling trimmed.
func makeTap(for pid: pid_t) throws -> AudioObjectID {
    var address = AudioObjectPropertyAddress(
        mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject,
        mScope: kAudioObjectPropertyScopeGlobal,
        mElement: kAudioObjectPropertyElementMain)
    var qualifier = pid
    var process = AudioObjectID(kAudioObjectUnknown)
    var size = UInt32(MemoryLayout<AudioObjectID>.size)
    AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address,
        UInt32(MemoryLayout<pid_t>.size), &qualifier, &size, &process)
    guard process != kAudioObjectUnknown else { throw TapError.notPlayingYet }

    let description = CATapDescription(stereoMixdownOfProcesses: [process])
    description.uuid = UUID()
    description.isPrivate = true         // visible only to this process
    description.muteBehavior = .unmuted  // .muted records without playing it

    var tap = AudioObjectID(kAudioObjectUnknown)
    let status = AudioHardwareCreateProcessTap(description, &tap)
    guard status == noErr else { throw TapError.status(status) }
    return tap
}

isPrivate 使 tap 仅对创建它的进程可见(这一点在头文件中有说明)。据我所知,它也会随该进程一起消失,这在 .muted 的情况下很重要:崩溃不应导致另一个应用静音。

tap 本身不会传递音频。你需要把它放入一个私有聚合设备,并用 IOProc 读取该设备:

let composition: [String: Any] = [
    kAudioAggregateDeviceUIDKey: "com.example.capture.\(UUID().uuidString)",
    kAudioAggregateDeviceNameKey: "Capture",
    kAudioAggregateDeviceIsPrivateKey: 1,
    kAudioAggregateDeviceSubDeviceListKey: [],   // "tap-only": no hardware device
    kAudioAggregateDeviceTapListKey: [[
        kAudioSubTapUIDKey: tapUID,              // read back with kAudioTapPropertyUID
        kAudioSubTapDriftCompensationKey: 1,
    ]],
    // No "tapautostart": start should not wait for the first sound.
]
var device = AudioObjectID(kAudioObjectUnknown)
AudioHardwareCreateAggregateDevice(composition as CFDictionary, &device)

var procID: AudioDeviceIOProcID?
AudioDeviceCreateIOProcIDWithBlock(&procID, device, nil) { @Sendable _, input, inputTime, _, _ in
    context.process(inputData: input, inputTime: inputTime)  // copy into the ring. That's all.
}
AudioDeviceStart(device, procID)

每个聚合设备都会获得一个新的 UID,因为销毁一个聚合设备是异步的,而复用 UID 可能会发生冲突。拆除按相反顺序进行:停止、销毁 IOProc、销毁聚合设备、销毁 tap。

只有实际运行后我才了解到的事情:

  • 权限提示会阻塞。创建 tap 和聚合设备从不提示;首次创建 IOProc 或对 tap 聚合设备执行 AudioDeviceStart 时会提示,并且在用户回答之前不会返回。所以它要在主线程之外运行,并设置超时。如果 Info.plist 中没有 NSAudioCaptureUsageDescription,你根本不会看到提示,只有一片寂静。
  • 权限被拒绝看起来就像寂静。每个调用仍然返回 noErr,IOProc 传递的是零。Tapelark 会监测在源声称正在播放时是否持续出现数字静音,并告知用户。
  • 浏览器不播放自己的音频。Safari 的声音来自一个 WebKit GPU 进程,Chrome 的则来自一个音频辅助进程。要 tap“Safari”,就意味着找到这些进程并将它们合并到应用中,且只能使用公开 API。
  • Swift 的 block 监听器无法被移除。AudioObjectRemovePropertyListenerBlock 移除的是 HAL 复制的那一个确切的 block 对象,而 Swift 在每次调用时都会把一个闭包桥接成一个全新的 block。因此移除操作返回 noErr,监听器继续触发(macOS 26.2,在我的测试中),即使该闭包只作为 @convention(block) 值存储一次也是如此。Objective-C 调用方传入同一个 block,或使用 C 函数指针 API,则可以干净地移除。

实时规则

IOProc 运行在 Core Audio 的 IO 线程上,错过截止时间就会丢失音频。因此它不能分配内存、加锁、记录日志、抛出异常、await,或回调进 HAL。它把缓冲区复制进一个无锁的单生产者单消费者环形缓冲区,然后返回。一个单独的写入器把环形缓冲区排空到磁盘。

环形缓冲区维护两个 64 位计数器:head(累计写入的帧数,由 IOProc 拥有)和 tail(累计读取的帧数,由写入器拥有)。容量是 2 的幂,因此计数器通过掩码变成索引。每个计数器位于各自的 128 字节缓存行上。

// Producer side, called from the IOProc. Simplified.
func write(_ source: UnsafePointer<Float>, frames: Int) -> Int {
    let produced = head.loadRelaxed()        // only this thread writes head
    let consumed = tail.loadAcquire()        // how far the writer got
    let accepted = min(frames, capacity - Int(produced &- consumed))
    if accepted < frames {                   // never wait: count the loss
        dropped.fetchAddRelaxed(UInt64(frames - accepted))
        overruns.fetchAddRelaxed(1)
    }
    let start = Int(produced & mask)
    let first = min(accepted, capacity - start)
    (storage + start * channels).update(from: source, count: first * channels)
    storage.update(from: source + first * channels, count: (accepted - first) * channels)
    head.storeRelease(produced &+ UInt64(accepted))  // publish after the copy
    return accepted
}

溢出是丢失音频的一种方式。另一种是时间戳间隙:在连续流中,每个缓冲区的采样时间恰好从上一个结束的地方开始,任何其他情况都是不连续。Tapelark 中的“丢弃缓冲区”指不连续加上溢出再加上 HAL 自身的处理器过载通知,三者都被计数并报告。

Swift 6 又增加了一些自己的陷阱:

  • AudioDeviceIOBlock 没有被导入为 @Sendable,因此严格并发对闭包捕获了什么不做任何说明。更糟的是,在 @MainActor 上下文中编写的闭包会继承主 actor 隔离,并在实时线程第一次调用它时触发陷阱(在 _swift_task_checkIsolatedSwift 中)。我在一个 AVAudioSourceNode 渲染 block 上遇到了这个问题;IOProc block 也不例外。每个回调都在一个 nonisolated 函数内写成显式的 @Sendable 字面量。
  • 部署目标是 macOS 14.4,而 Swift 的 Atomic 需要 macOS 15。原子操作是一个围绕 _Atomic(uint64_t) 的小型 C target,带有一个静态断言,使加锁回退成为编译错误。
  • 在 debug 构建中,for i in 0..<n 没有被特化:它在首次调用时通过 Swift 运行时在锁下实例化 Range 和迭代器元数据。环形缓冲区代码使用普通的 while 循环,一个测试读取两种环形缓冲区类型的已编译目标文件,如果它们的任何实时函数引用了可能加锁或分配的 Swift 运行时入口点,则测试失败。

在构建应用之前,在一台 M2 MacBook Air(macOS 26.2)上对静音的测试音播放器进行 60 分钟采集,产生了 337,504 次回调,0 次不连续、0 帧丢弃、0 次溢出和 0 次过载,占用单核的 0.35%。

失败的 8 小时浸泡测试

在 1.0 之前,我想要八小时的连续录音。一个测试工具循环播放音调并发布假的“轨道已更改”事件;录音器静音采集它,分割、编码并归档轨道,一个脚本采样内存、CPU、打开的文件和采集计数器。

10 月 8 日的运行失败了,但很有用。

  • 前 5.37 小时是干净的:928,115,200 帧,无不连续、无丢弃、无溢出(到 4.9 小时时保存了 1,459 条轨道)。
  • 然后 Mac 睡眠了 836 秒。录音器持有空闲睡眠断言,但 IOPMLib.h 明确指出系统仍可能因合盖、Apple 菜单或电量低而睡眠。引擎记录了间隙并恢复,符合设计。
  • 然后 8 核上的负载平均值达到了 251。那是同一台笔记本上三个并行构建的应用。所有丢失的帧都落在一个 15 秒的窗口里:环形缓冲区溢出了 15 次,丢掉了 7,680 帧,正好是 15 个 512 的缓冲区。在 50 分钟里还出现了 7 次不连续和 6 次处理器过载(其中 3 次和 2 次发生在那个窗口里),所以 HAL 自己的 IO 周期也延迟了。
  • 然后磁盘满了,同样是因为那些构建,运行以一次写入失败告终。1,849 条轨道中有 1,840 条被保存下来,但在负载下其中 197 条与真实长度的偏差超过 0.3 秒(最差的差了 13 秒)。

所以这次浸泡测试主要测的是我的其他构建,但它也暴露了两个薄弱点。环形缓冲区能容纳 4 秒,向上取整到 2^18 帧(48 kHz 下为 5.46 秒)。而写入器是 Swift 协作线程池上的一个 Task.detached(priority: .utility),阻塞在 write(2) 和 F_FULLFSYNC 中,运行优先级低于编译器运行的一切。

修复

30 秒的环形缓冲区。环形缓冲区现在要求 30 秒,向上取整到 2^21 帧:48 kHz 下为 43.7 秒,16 MiB 的立体声 float32。精确的 30 秒需要在 IOProc 中做一次除法或回绕测试,以节省约 5 MiB,所以还是保留了 2 的幂。每一页在启动时都会写入,因此 IOProc 永远不会触发首次访问缺页;这每次录音大约多消耗 15 MB 常驻内存。一个测试让写入器停顿 30 秒:新环形缓冲区没有丢帧,旧的有整整 1,177,856 帧被丢弃。

给写入器一个真正的线程。写入器现在是一个专用的 Thread,服务质量为 .userInteractive,在一个带超时的信号量上等待,以便 Stop 能立刻唤醒它。它不是实时线程,因为它要做文件 I/O。IOProc 仍然从不通知它;写入器采用轮询。代价是:录音期间的中断唤醒从每秒 24.0 次增加到 38.3 次(在一台繁忙笔记本上进行的 3 分钟 Debug 构建性能分析),因为写入器的定时器不再与线程池的合并。

在磁盘满之前停止。一次录音需要 500 MB 可用空间才能开始,当剩余空间不足时会干净地停止,保存每一条轨道。500 MB 大约相当于 48 kHz 立体声下 float32 工作文件的 21 分钟(约每分钟 23 MB)。检查使用 statfs,耗时 1–2 µs,而不是 volumeAvailableCapacityForImportantUsage,后者在这里耗时 26–43 ms,并且会把可清除空间也算进去,而写入可能在 macOS 释放它之前就失败。

干净的 8 小时重跑还在我的待办清单上。极端负载下的过载发生在 HAL 内部,那里任何环形缓冲区大小都无济于事;它们仍会被计数并报告。

额外内容:一首歌在哪里结束

这项工作的另一半是把流切分成歌曲。像 Music 应用这样的播放器会在曲目变化时发布分布式通知。Apple 没有记录它的名称或键,所以任何播放器更新都可能改变它们。Tapelark 在到达时用主机时间给它打上时间戳,并将其视为提示,因为投递没有延迟保证。

IOProc 为每个缓冲区向第二个环形缓冲区写入一个 32 字节的标记:其第一帧的主机时间和采样时间。这会把事件映射到精确的帧上。如果它的一秒内存在静音间隙(低于 −60 dBFS 至少 300 ms),剪切点会移到间隙末尾,这样下一个文件就不会以静音开头。交叉淡化没有静音,所以静音永远不会覆盖事件。用一个测试播放器,静音间隙后的剪切点落在 0.3 ms 以内;没有间隙时,它们提前了 56–72 ms,这是测试播放器自身的输出延迟。

Tapelark 用于私人录音,不会试图绕过受保护的内容:如果来源只提供静音,它会如实说明,而不是另寻他法。

如果你想试试:tapelark.com。

来源:Hacker News · victorantos.com