Skip to content

fix: 重建麦克风采集层,修复冷启动卡顿与合盖蓝牙场景录不到声音 - #155

Closed
hehehai wants to merge 5 commits into
mainfrom
fix/microphone-capture-pipeline
Closed

hehehai wants to merge 5 commits into
mainfrom
fix/microphone-capture-pipeline

Conversation

@hehehai

@hehehai hehehai commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

背景

部分用户反馈:首次录音(以及闲置一段时间后的第一次录音)会卡住,速度明显变慢,经常录不到声音。典型环境:

  • MacBook 合盖 + 外接显示器
  • 使用只有麦克风、没有扬声器的蓝牙设备(DJI Mic 2)
  • macOS 27.0

用户确认 1.14.x 同样有这个问题,所以这不是 1.15 引入的回归,而是采集层一直存在的问题。

日志分析结论

现象 证据
每次冷启动,主线程固定阻塞约 6.1 秒 Recording started 之后,下一条日志总在 +6.1 秒出现;同一次模型加载的 3 个等待方中,第一个报 elapsedMs≈6600,另外两个只有 ~500ms;Esc 和停止键都要等到 +6.1 秒才被处理
输入节点先按蓝牙默认设备初始化,再改绑到目标设备 选的是内置麦克风(48k),却出现 adjusted input tap format … nodeSampleRate=16000,16k 正是 DJI 的采样率
冷启动时常常整段全是 0 例如 audioSpanMs=27300, levelMax=0.000,热启动则全部正常
合盖时内置麦克风仍在设备列表里,但录到的是精确的 0 内置麦克风上的录音都是 levelMax=0.000,结果为空;手动切到 DJI 后立刻正常
冷启动后按 Esc 会再次触发冷启动 取消后 2 秒内触发深度回收、释放 transcriber,下一次还是冷启动

根因

所有麦克风采集(9 个 tap 和 1 个 AVAudioRecorder)都用同一套写法:

  1. AVAudioEngine.inputNode 是第一次访问时才创建的,创建时先按系统默认输入初始化,之后才通过 AudioUnitSetProperty(CurrentDevice) 改绑到目标设备;
  2. macOS 上 AVAudioEngine 的 I/O 单元会把默认输入和默认输出放在一起。两者不是同一个设备时(合盖外接屏 + 只有输入的蓝牙麦克风),它会创建一个私有聚合设备 CADefaultDeviceAggregate;
  3. 上面这些操作全在主线程上同步执行。

captureTapFormat 采样率修正、4 份重复的 applyPreferredInputDeviceIfNeeded、带 6 秒超时的 engine.start、3 套"1.2 秒没有回调就重启"的看门狗,都是在给这个写法打补丁。另外,AVAudioRecorder 不支持指定设备,所以远程 ASR 文件模式一直在录系统默认输入。

修复方案

1. 新的统一采集组件 Core/Audio/MicrophoneCaptureSession

采用 Apple TN2091 的标准做法:只开输入的 HAL output unit(AUHAL)。

  • 在 AudioUnitInitialize 之前就绑定目标设备,从头到尾不打开系统默认输入,也不碰输出设备,所以不会产生聚合设备。
  • 创建、绑定、初始化、启动、停止、设备变化,全部在每个会话自己的控制队列上执行,主线程和快捷键路径不会再等 Core Audio 或蓝牙。
  • 实时 I/O 线程只做一件事:渲染到预分配的缓冲区,复制一份交给交付队列。
  • 交付队列统一转换成单声道、固定采样率(AVAudioConverter,会做降混),再按 100ms 重新分块,保持和旧 tap 一样的节奏。停止时会先把已经采到的数据(包括最后不足一块的尾部)交付出去,再关闭。
  • 监听设备掉线(DeviceIsAlive)和格式变化(采样率、声道数,比如蓝牙切换模式)。格式变了就在内部重建,交付给上层的格式保持不变。
  • 信号健康检测:连续 1.5 秒是精确的 0(数字静音)就上报事件,信号恢复后再上报一次。
  • 启动是异步的,带 10 秒超时,并支持取消:录音期间停止或按 Esc,会立即中止正在进行的启动。
  • 每次录音新建一个会话,冷启动和热启动走同一条路径。

2. 迁移所有采集路径

路径 变化
MLX 本地转录 改为收 16k 单声道;移除启动超时、启动看门狗和改绑设备的写法
系统听写 startRecordingSession() 改为异步;切换麦克风时识别请求不中断
远程 ASR 流式(豆包、阿里 Fun 和 Qwen、StepFun、Gemini) 共用 startMicrophoneCapture 入口;PCM16 改为正规重采样,不再是简单抽点
远程 ASR 文件模式 用所选麦克风录音,停止时根据采集到的样本写出上传 WAV;OpenAI 伪实时预览改为从样本快照生成;删除 RemoteASRPreviewAudio 的 WAV 头修补
会议、引导页麦克风检测 非阻塞启动;会议麦克风为 48k 单声道;最终启动失败时通过 safetyMessage 提示并记录
模型调试录音 和正式录音走同一条采集路径
看门狗 3 处"1.2 秒没有回调就重启"统一换成"所选设备启动失败,就用系统默认输入重试一次",不会再误杀启动慢的蓝牙设备

删除了只为旧引擎服务的代码:captureTapFormat、isAvailableInputDevice、makeDoubaoPCM16MonoData、RemoteASRPreviewAudio,以及对应的测试。

3. 设备可用性:合盖识别

  • LaptopLidState 读取 IOKit 的 AppleClamshellState。
  • MicrophoneAvailabilityPolicy 在合盖时把笔记本内置麦克风判为不可用。判断依据是连接方式为内置,并且 UID 是 BuiltInMicrophoneDevice 或数据源是 imic。接在内置耳机孔上的有线耳机麦克风(emic)不受影响。
  • 内置麦克风被排除后,按现有逻辑回退:优先级列表 → 系统默认 → 第一个可用设备。
  • 显示配置变化(合盖或开盖并接着外接屏时会触发)和唤醒时,重新刷新设备快照。

4. 录音中的体验

  • 录音中检测到数字静音时,提示"麦克风没有收到任何声音,请检查当前选择的麦克风"(中、英、日三语)。
  • 麦克风还在启动时用户停止录音:直接以空结果结束会话,不再弹出"麦克风启动失败"。
  • 录音中切换麦克风:采集会话在后台切换;如果新设备准备失败,原设备继续录音。

5. 蓝牙耳机提示音被截断

打开蓝牙耳机的麦克风,会让耳机从媒体模式(A2DP)切到通话模式(HFP),正在播放的开始提示音会被截断,音量也会变小。BluetoothAudioRoute 通过设备地址(UID 前缀)判断输入和当前输出是否属于同一副耳机。是的话就等提示音播完再打开麦克风;其他设备仍然立即启动。

6. 诊断日志(供测试版回归排查)

日志 内容
Microphone capture started 设备名、UID、连接方式、输入/输出声道数、采样率、是否回退到默认设备、设备格式、输出格式、合盖状态、默认输入和输出设备、分步耗时 stepsMs=resolve,create,bind,configure,initialize,start、totalMs
Microphone capture first buffer 从请求启动到收到第一帧的延迟 latencyMs、峰值
Microphone capture is delivering digital silence / signal recovered 静音持续时间、设备、合盖状态
Microphone capture stopped 缓冲区数量、采集时长、交付帧数、静音总时长、最大峰值、是否收到过第一帧
Microphone capture rebuilt / device format changed / device disappeared 录音中设备变化
Internal microphone excluded because the laptop lid is closed 合盖过滤是否生效
应用元信息新增字段 microphonePriorityUIDs、laptopLid、audioDefaults、audioDevices(所有输入和输出设备,带连接方式)、availableMicrophones

提交

  • 30599d5 fix: 以仅输入 AUHAL 重建麦克风采集并补充音频诊断
  • ab5a70f refactor: 所有麦克风采集统一走 MicrophoneCaptureSession
  • 5d6e69d fix: 蓝牙耳机上先播完开始提示音再打开麦克风
  • 91ea442 fix: 采集按 100ms 分块交付并提示会议麦克风启动失败
  • ea7194b fix: 渲染前设置 frameLength,修复 AUHAL 采集始终返回 -50(首轮本地测试发现:每次 AudioUnitRender 都拿到 0 字节缓冲区,录音全程无音频);新增启动 2 秒内无音频的警告与提示

预期效果

症状 预期
冷启动主线程卡约 6 秒,快捷键和 Esc 无响应 解决
合盖时选中已断开的内置麦克风,录到全 0 解决
先按默认蓝牙设备初始化、创建聚合设备的副作用 解决
DJI 冷启动整段全是 0 大概率解决;如果还发生,会有提示和日志
DJI 冷启动等约 6 秒才有声音 待日志确认:如果是蓝牙链路本身建立需要这么久,界面不会再卡,但第一帧音频仍会晚到。用 stepsMs 和 first buffer latencyMs 判断
蓝牙耳机开始提示音被截断 解决

行为变化

  • 远程 ASR 文件模式现在会使用所选麦克风(以前固定录系统默认输入)。
  • 会议麦克风从设备原生格式改为 48k 单声道。
  • 合盖时内置麦克风不在设备列表里。开盖后不会自动切回内置麦克风,这和拔掉其他设备时的现有逻辑一致。
  • 输入和输出是同一副蓝牙耳机时,麦克风会在开始提示音播完之后才打开。

风险与已知限制

  • ⚠️ 这个 PR 是在没有 Swift 和 Xcode 的环境下写的,还没有编译、也没有运行过测试,合并前必须在 macOS 上完成构建和回归。AUHAL 的 C 回调、nonisolated 嵌套类型、withTaskCancellationHandler 这几处最需要编译器把关。
  • 如果冷启动的约 6 秒是蓝牙链路建立本身的耗时,这个 PR 只能消除界面卡顿,不能让第一帧更早到达。下一步可以考虑保持链路,或者在按快捷键时提前拉起设备。
  • 如果蓝牙设备开头超过 1.5 秒都输出精确的 0,可能误报一次"没有收到声音"(提示只显示 3 秒)。
  • 主线程上仍有少量 Core Audio 属性读取(蓝牙提示音判断、会议启动时的设备快照、诊断元信息、原有的静音恢复),都是读属性,不会启动设备。
  • 沙盒里如果读不到 AppleClamshellState,会返回 unavailable,等于不做合盖过滤,不会比现在更糟。

测试

新增 VoxtTests/MicrophoneCaptureSupportTests,覆盖:

  • 合盖可用性策略:内置麦克风、imic、emic、外接设备;
  • 数字静音检测:阈值、恢复、短暂静音、-0 和 NaN;
  • 蓝牙耳机输入输出归属判断;
  • 设备连接方式映射、FourCC 格式化、诊断描述。

删除了 AudioInputDeviceManagerTests 中关于 captureTapFormat 的 3 个用例,因为被测代码已经删除。

合并前检查

  • xcodebuild build -project Voxt.xcodeproj -scheme Voxt -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO
  • xcodebuild test … -only-testing:VoxtTests/MicrophoneCaptureSupportTests,以及全量测试
  • MLX 录音:内置麦克风,冷启动(闲置 2 分钟以上)和热启动各一次
  • 蓝牙耳机:提示音完整,冷启动不卡
  • 录音中切换麦克风
  • 系统听写
  • 远程 ASR:流式选一个服务商;文件模式(OpenAI,开启伪实时预览)
  • 会议录制(麦克风 + 系统音频)
  • 本地 VAD 停顿检测和以前一致(验证 100ms 分块)
  • 测试版交给反馈的用户(DJI、合盖、外接屏),回收日志,确认 stepsMs、first buffer latencyMs、是否出现数字静音、合盖过滤是否生效

🤖 Generated with Claude Code

hehehai and others added 5 commits October 4, 2026 08:12
冷启动录音时 AVAudioEngine 会先按系统默认输入(蓝牙/连续互通麦克风)
实现输入节点,再改绑到目标设备,并在默认输入输出不同时创建聚合设备;
这些都在主线程同步执行,导致合盖外接屏、蓝牙麦克风场景下首次录音
卡顿约 6 秒且常常录到全零音频。

- 新增 Core/Audio/MicrophoneCaptureSession:仅输入 HAL 单元,在初始化前
  绑定目标设备,不触碰输出设备;创建/启动/停止/设备变化均在独立控制队列,
  交付队列统一转换为单声道目标采样率,并检测数字静音、设备丢失与格式变化
- MLXTranscriber 改用该会话,每次录音新建,冷/热启动走同一路径;移除
  AVAudioEngine 启动超时、启动看门狗与改绑设备的旧实现
- 合盖时将笔记本内置麦克风视为不可用(LaptopLidState /
  MicrophoneAvailabilityPolicy),显示配置变化与唤醒时刷新设备快照
- 录音中检测到数字静音时提示检查麦克风;麦克风启动期间用户停止录音时
  直接以空结果结束会话
- 诊断:采集启动日志包含设备连接方式、输入输出声道、采样率、默认输入
  输出、合盖状态与分步耗时,另记录首帧延迟、静音与停止汇总;应用元信息
  增加合盖状态、默认设备、全部音频设备与可用麦克风

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
将剩余的 AVAudioEngine / AVAudioRecorder 采集路径迁移到仅输入 AUHAL
采集会话,移除为其打的补丁:

- 系统听写:异步启动,切换麦克风时识别请求不中断
- 远程 ASR:豆包、阿里 Fun/Qwen、StepFun、Gemini 共用一个采集入口;
  文件录音改为从采集样本写出上传 WAV(AVAudioRecorder 无法使用所选麦克风),
  OpenAI 预览从样本快照生成,删除 WAV 头修补
- 会议与引导页麦克风检测改为非阻塞启动
- 模型调试录音与正式录音走同一采集路径
- 用"所选设备启动失败时用系统默认输入重试一次"替换三处"1.2 秒无回调
  即重启"的看门狗(会误伤启动较慢的蓝牙设备)
- 删除 captureTapFormat、isAvailableInputDevice 等只服务旧引擎的代码及测试
- 录音中切换麦克风统一由采集会话在后台完成,不再重建引擎

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
打开蓝牙耳机的麦克风会把耳机从媒体模式切到通话模式,正在播放的开始
提示音被截断且音量变小。当输入与当前输出属于同一副蓝牙耳机时,在提示音
结束后再启动采集;其他设备仍立即启动。提示音期间停止录音则以空结果结束。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- AUHAL 每个 I/O 周期约 10ms,旧的 AVAudioEngine tap 约 100ms 一块;
  本地 VAD 帧、远程 ASR 推流与主线程任务都依赖原有节奏。交付层按
  chunkDurationSeconds(默认 0.1s)重新分块,停止时交付最后不足一块的尾部
- 会议麦克风改为异步启动后,最终启动失败通过 safetyMessage 提示并记录,
  不再静默继续

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AVAudioPCMBuffer.mutableAudioBufferList 每次访问都按 frameLength 重新计算
mDataByteSize。原实现先通过一次访问改写字节数,再传入新的访问结果,且
frameLength 在渲染成功后才设置,于是每次 AudioUnitRender 都拿到 0 字节的
缓冲区并返回 kAudio_ParamError(-50),整段录音收不到任何音频。

同时补上"无音频"可见性:设备启动 2 秒内没有交付第一帧时记录警告并上报
noAudio 事件;MLX 录音在 noAudio、renderFailed 时与数字静音一样提示用户,
不再静默录出空结果。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@hehehai hehehai closed this Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant