系统设计系列 · 下篇

录音这件小事,在 macOS 上有多难扛住

技术笔记 · good fun 谷放出品 · 作者 Ivy

这是我个人做的一个项目——一个录音、转写、AI 摘要一体的应用。项目分两个子系统,这篇讲第二个:一个 macOS 原生录音客户端。

服务端那一侧的前提是"转写调用随时可能失败,要为失败准备接盘路径"。客户端这一侧的前提类似,只是换了个对象:不能假设录音过程中设备环境不会变。用户会在录音进行到一半时拔耳机、断开蓝牙、插入新麦克风——这些都是真实会发生的中途事件,不是边界情况。

01 · 先搞清楚在录什么:系统音频 + 麦克风,两条独立的采集通路

客户端做的是混合录音——把系统音频(比如对方在视频会议里说的话)和麦克风输入(自己说的话)合并成一路录音文件。这两路音频的采集方式必须是独立采集,这是 macOS 平台本身决定的,不是设计上的选择:系统音频靠 macOS 的 Process Tap 技术(AudioHardwareCreateProcessTap)从正在输出音频的进程里抓取 PCM 数据,麦克风则通过 AVCaptureSession 独立采集,两者走的是完全不同的系统 API,没有办法合并成一条采集管线。

真正需要做决定的地方是采集之后怎么合成:两条通路分别做重采样、下混到单声道,再各自过一层"声像"(pan,控制声音在左右声道中的位置)节点(系统音频在左声道,麦克风在右声道)汇入同一个混音器,最终混出立体声写入 M4A 文件。选择让两路音频保持声道分离而不是直接叠加平均,是为了后续转写时能大致分辨"这段是对方说的还是自己说的"。

两条独立通路,各占一个声道,汇入同一个文件

系统音频
Process Tap
麦克风
AVCaptureSession
声像:左声道
声像:右声道
混音器
M4A
立体声文件

02 · 系统音频 Tap 不是接上就完事:自愈监控 + 自动重定向

Process Tap 创建时需要指定"目标进程"——即从哪个正在播放声音的应用抓音频。这里有一个前提假设很容易出错:认为"录音开始时选对了目标进程,之后就没事了"。实际上,录音过程中用户完全可以切到其他窗口去做别的事,会议软件本身也可能因为焦点切走而改变音频输出行为——这两件事叠在一起,会让 Tap 正在抓的数据流悄悄断掉,变成全程静音或长期低能量。程序本身察觉不到,录音状态还显示"进行中",等到转写结果回来是空的才发现漏录了。

这里没有假设"Tap 一旦建立就会一直稳定工作",而是加了一层轻量级自愈监控:每秒检查一次累计帧数和平均能量,连续 5 秒没有新数据、或者连续 10 秒能量低于阈值(且此前从未检测到有效能量),就判定为"目标可能已经失效",自动触发一次重定向——停掉当前 Tap,重新扫描一遍正在输出音频的进程列表,重建一条新的采集管线。重定向最多尝试 3 次,避免在极端情况下陷入无限重建的死循环。

自愈监控:检测失效 → 自动重定向,最多循环 3 次

Tap 持续采集
自愈监控
每秒查帧数 / 能量
判定失效
连续5秒无数据 或
连续10秒低能量
停止旧Tap →
重新扫描进程 →
重建采集管线

重建后回到"Tap 持续采集"重新监控,最多尝试 3 次,避免无限重建

这层自愈监控解决的是"采集目标本身失效"这种更隐蔽的问题——不是设备被拔掉那种明显信号,而是数据流悄悄断掉、程序自己都不容易第一时间发现。

03 · 用户中途换设备:停旧、等、建新,录音不中断

更常见的场景是用户主动换设备——这在视频会议场景里几乎是日常操作:开会时先接着有线耳机,中途切到蓝牙耳机;或者蓝牙耳机信号不稳,自动断开又重新连上。这些操作发生的那一刻,操作系统层面的默认音频设备变了,而 Tap 并不是直接对着"系统输出设备"采集的,它依赖一个聚合设备——把"系统输出设备"和"Tap 本身"打包绑定在一起。默认设备一换,这个聚合设备里绑定的还是旧设备,整条原来搭好的采集通路(包括聚合设备本身)就直接失效了,不是设备变了就自动跟着切,而是必须重新搭一条通路。

处理方式是监听 CoreAudio 的默认设备变化通知,一旦触发:先停掉旧的采集通路,等待约 100 毫秒让新设备状态稳定下来,再重新建立一条新的采集通路接到新的默认设备上。整个过程录音不中断,只是中间有一个极短的重建窗口——用户体感上录音"没停",实际上底层已经无缝换了一条通路。麦克风这一侧遇到设备变化时也是同样的"停止旧监听、重建新采集"套路,并设了最多 3 次重建尝试的上限,避免设备状态本身在异常抖动时无限重试。

设备切换:停旧通路 → 等待稳定 → 建新通路,录音不中断

CoreAudio
默认设备变化通知
停掉旧的
采集通路
等待约100毫秒
新设备状态稳定
重建新采集通路
接到新默认设备
短淡入~5毫秒
掩盖切换瞬态

麦克风侧同样套路,重建最多尝试 3 次,避免设备状态异常抖动时无限重试

监听通知是事件驱动的,但系统通知本身也可能因为各种原因没有触发。所以还加了一层不依赖任何通知机制的轮询兜底:定期主动查询当前默认输入/输出设备的标识,和录音开始时记录的初始设备标识做对比——如果发现设备标识变了但没收到对应的通知,照样触发一次重建,防止漏掉设备变化。

04 · "看起来在录"不等于"真的在录":心跳检测

第2节的自愈监控针对的是 Tap 这一个具体组件——知道病因是"目标进程失效",所以能做针对性的修复动作(重扫、重建)。但录音断流的原因不止这一种:系统音频权限被收回、麦克风被其他进程抢占,都会造成同样的症状——数据不流入了,而这些原因各自的处理方式都不一样,不可能对每一种可能原因都单独写一层监控。所以还需要一层不关心具体病因、只看"数据到底还在不在流入"的通用兜底检测。

两条路径各自维护一个"最近一次收到有效数据"的时间戳,每 5 秒检查一次心跳:系统音频超过一定时间没数据判定异常(允许的静默窗口更长,因为会议本身可能有大段静音),麦克风路径同理。检测到异常之后不是只记一条日志了事,而是分别触发对应路径的重建——系统音频这一侧走的还是第2节那套停止 Tap、重新搭建的流程,麦克风这一侧则重建 session,两条路径各自也设了最多 3 次重建上限,超过上限就不再自己硬扛,直接上报一个错误状态出去。

通用兜底:不判断病因,只看数据是否还在流入,异常后按路径分别重建

两条路径各自维护
"最近有效数据"时间戳
每5秒检查心跳
超过窗口无数据判定异常
系统音频异常:
重建 Tap(最多3次)
麦克风异常:
重建 session(最多3次)

系统音频这一侧还叠加了一个独立的静音检测器:持续监控系统音频的 RMS 能量,如果长时间(数量级为 10 分钟)保持静音,判定用户可能只是在录一段纯麦克风内容,自动收敛掉这条路径的资源占用——这一层判断的不是"故障",而是"资源该不该花",所以处理方式也不是重建,而是主动降级。

05 · 停止录音时,"文件存在"不代表"录成了"

录音结束的那一刻,还有最后一道校验:不是简单检查输出文件是否存在,而是实际用 AVAsset 读取一次音频时长——时长是个有效正数才判定为录制成功。如果时长读取本身失败(偶尔会因为文件刚写完、元数据还没落盘完整而读取失败),会有一层兜底:用录制过程中实际累计写入的帧数,按采样率倒算出一个估算时长,而不是直接把这次失败当成"录音失败"上报给用户。

停止录音:先校验时长,而不是只看文件是否存在

点击"停止"
AVAsset
回读实际音频时长
时长为有效正数?
是:判定录制成功
读取失败:按累计写入帧数
估算时长,不直接报失败

这一步存在的理由很直接:文件被成功创建,不代表里面真的有声音数据。一次 Tap 目标选错、或者混音器管线中途卡死,都可能产出一个"看起来是正常大小的 M4A,但实际是静音"的文件——只有真正回读时长,才能把这类问题在用户点击"停止"的那一刻拦下来,而不是等转写结果空空如也时才发现。

06 · 这套设计的代价,以及哪些问题它接不住

音频采集这一层的可靠性设计,复杂度主要花在"状态监控"上——自愈式重定向、心跳检测、设备轮询、静音检测,这几层监控本身需要持续消耗一点 CPU 和维护成本,排查一次"录音效果不对"的问题也要多看几层日志。这和转写服务那篇里的"用复杂度换可靠性"是同一笔交易。

它接不住的是系统层面的硬限制:如果用户在录音过程中彻底关闭了系统音频录制权限,或者某个音频驱动本身崩溃到 CoreAudio 层面都拿不到设备列表,再怎么重定向和重建也无法凭空恢复采集能力——这类问题只能靠上层给用户明确的错误提示和权限引导,而不是指望客户端自己悄悄修复。

但对"设备环境会在录音过程中变化"这个更常见的问题——拔耳机、切换默认设备、Tap 目标进程失效——让每一种变化都有对应的检测和重建路径接住它,结果就是:一次录音的完整性,不再取决于用户从头到尾都不动任何硬件。

这是《录音、转写、可靠性》系列的下篇,讲的是录音客户端这一侧。上篇讲的是它的下游——转写服务的可靠性设计:异步任务队列、同步快速通道、分级重试与回调补偿,同一个项目的另一半技术方案。

如果你也在做原生客户端的音频/设备处理,或者需要在不可控的外部环境里做可靠性设计,可以聊聊。

聊聊你的想法 →