做桌面 IM 的音视频通话功能时,我们遇到过这样一个诡异的反馈: “用笔记本内置麦克风通话完全正
避坑指南:桌面端 WebRTC 音频兼容性:为什么 AirPods 接上就"没声音"?
发布时间: 2026-06-20 (a month ago)
Vue

做桌面 IM 的音视频通话功能时,我们遇到过这样一个诡异的反馈:

“用笔记本内置麦克风通话完全正常,但戴上 AirPods 之后,对方听不到我的声音。”

这不是偶尔听不到,是每次都听不到。更奇怪的是:本地预览有波形、设备枚举能找到 AirPods、getUserMedia 也没报错——一切看起来完全正常,但推流出去就是静音的。

排查了一上午,最终定位到原因:浏览器的软件降噪和蓝牙耳机的硬件降噪在“打架”


一、 先搞清楚音频链路长什么样

在桌面端发起一个 WebRTC 音频流,信号要经过这样一条链路:

\text{物理麦克风} \longrightarrow \text{驱动层} \longrightarrow \text{操作系统音频子系统} \longrightarrow \text{浏览器 AudioContext} \longrightarrow \text{WebRTC 编码} \longrightarrow \text{网络传输}

其中在浏览器这一层,WebRTC 提供了几个可配置的 DSP(数字信号处理) 开关:

参数 作用
echoCancellation 回声消除(AEC):抑制自己说话被扬声器播放后又被麦克风录到的回声
noiseSuppression 噪声抑制(ANS):滤除背景噪音
autoGainControl 自动增益控制(AGC):把音量拉到统一水平

这些参数在 getUserMedia 的约束对象里配置:

javascript 复制代码
const constraints = {
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  }
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)

对于内置麦克风来说,开启这三个选项几乎是标准做法——内置麦克风的信噪比低、离扬声器近,确实需要这些辅助算法。但问题出在:这套逻辑对蓝牙耳机并不适用。


二、 AirPods 不是“普通麦克风”

2.1 蓝牙耳机自带硬件 DSP

以 AirPods 为代表的现代蓝牙 TWS 耳机,内部集成了完整的音频处理芯片:

  • ENC(环境噪声抑制):通过双麦阵列做波束成形,在硬件层面过滤背景噪声。
  • AEC 回声消除:耳机同时处理输入和输出通道,在芯片内部完成回声对消。
  • AGC 自动增益:固件级别动态调整输入增益。

也就是说,降噪这件事,已经在耳机硬件内部完成了。

2.2 双重降噪 = 静音

当你在代码里开启了软件降噪,而用户戴的是 AirPods 时,音频链路变成了:

\text{AirPods 硬件 AEC/ANS} \longrightarrow \text{浏览器软件 AEC/ANS} \longrightarrow \text{WebRTC 编码}

两层降噪算法处理的都是同一份音频信号,且它们之间没有协调机制。结果就是:

  1. 硬件降噪已经把“非语音”部分压得很低了。
  2. 浏览器软件降噪看到的是一个“几乎没声音”的原始信号,误以为是噪音,于是进一步把它当成噪声压制
  3. 最终输出接近静音。

[!WARNING]
这就像两个人同时在调同一个音量旋钮——一个人往上拧,一个人往下拧,最终结果是声音彻底没了。

2.3 为什么移动端没有这个问题?

iOS 和 Android 系统会自动识别蓝牙耳机的类型并调整音频路由策略。例如,iOS 在检测到 HFP/HSP 协议连接时,会自动关闭系统底层的软件 AEC。

桌面端浏览器没有这个能力。 Chrome/Chromium(以及基于它的 Electron)在调用 getUserMedia 时,不会去判断当前设备的硬件能力,它只看你传了什么 constraints。你开了降噪,它就开;你不开,它就不开。这也是为什么同一个 AirPods,手机端 App 通话正常,Electron 桌面端却静音的原因。


三、 解决方案:根据设备类型动态切换策略

核心思路很简单:先探测用户当前用的是不是蓝牙耳机。如果是,就把浏览器的降噪全部关掉,把音频处理权全权交给耳机本身。

3.1 设备探测

通过枚举 MediaDevices 来识别设备标签(Label):

typescript 复制代码
function isBluetoothHeadset(label?: string): boolean {
  if (!label) return false
  const lower = label.toLowerCase()

  const keywords = [
    'airpods', 'bluetooth', 'hands-free',
    'headset', 'headphone', 'earbuds', 'beats', 'freebuds'
  ]

  return keywords.some(k => lower.includes(k))
}

// 获取所有的音频输入和输出设备
const inputs = await navigator.mediaDevices.enumerateDevices()
  .then(devices => devices.filter(d => d.kind === 'audioinput'))

const outputs = await navigator.mediaDevices.enumerateDevices()
  .then(devices => devices.filter(d => d.kind === 'audiooutput'))

[!IMPORTANT]
关键点:不仅要看输入设备,还要看输出设备。用户的实际使用场景通常是:“我用 AirPods 听声音(输出),我也希望用它收音(输入)。”
因此,如果默认输出设备是蓝牙耳机,我们应当优先选择对应的蓝牙输入设备。

javascript 复制代码
async function resolvePreferredInput() {
  const [inputs, outputs] = await Promise.all([
    getAudioInputs(),
    getAudioOutputs()
  ])

  // 判断当前默认输出是否为蓝牙设备
  const defaultOutput = outputs.find(
    d => d.deviceId === 'default' || d.deviceId === 'communications'
  )
  const preferBluetooth = isBluetoothHeadset(defaultOutput?.label)

  // 查找蓝牙输入设备
  const bluetoothInput = inputs.find(d => isBluetoothHeadset(d.label))
  const defaultInput = inputs.find(
    d => d.deviceId === 'default' || d.deviceId === 'communications'
  )

  // 路由策略:输出是蓝牙 -> 优先用蓝牙输入;否则用系统默认输入
  const preferred =
    (preferBluetooth ? bluetoothInput : null) ||
    defaultInput ||
    bluetoothInput ||
    null

  return { isBluetooth: !!preferBluetooth, device: preferred }
}

3.2 动态配置约束(Constraints)

拿到设备信息后,根据是否为蓝牙设备生成不同的 getUserMedia 约束:

javascript 复制代码
const { isBluetooth, device } = await resolvePreferredInput()

const captureOptions = {
  deviceId: { exact: device.deviceId },
  // 关键差异在这里 👇
  autoGainControl: !isBluetooth,
  echoCancellation: !isBluetooth,
  noiseSuppression: !isBluetooth,
  voiceIsolation: !isBluetooth // 针对支持语音隔离的新系统
}
  • 蓝牙耳机:四项降噪控制全关,让硬件 DSP 全权处理。
  • 内置/普通麦克风:四项全开,靠浏览器软件算法补足。

3.3 Probe 探针:激活硬件通道

还有一个容易被忽略的细节:macOS 对蓝牙音频设备的激活是延迟的。有时候设备虽然出现在列表里,但实际上音频物理通道还没真正唤醒。

在正式发布音轨前,我们可以先做一个短暂的“探针”请求:

javascript 复制代码
async function probeMicrophone(constraints) {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: constraints,
      video: false
    })
    // 立即停止所有音轨,目的是“唤醒”设备通道,不产生实际音频流
    stream.getTracks().forEach(track => track.stop())
    return true
  } catch (error) {
    console.warn('探针失败,设备可能不可用:', error)
    throw error
  }
}

// 实际使用流程
await probeMicrophone(captureOptions) // 先唤醒并探测
const publication = await room.localParticipant.setMicrophoneEnabled(true, captureOptions)
  • 如果权限被拒或设备占用,在进入通话前就能捕获报错,而不是在 SDK 内部抛出难以定位的异常。
  • 强制触发 macOS 的音频路由切换,确保蓝牙通道真正激活。
  • 探针通常在几十毫秒内完成,用户感知不到任何延迟。

四、 完整实现架构

把以上环节串起来,整个音频初始化流程如下:

graph TD A[用户点击“开始通话”] --> B[1. 设备探测<br/>枚举输入/输出设备<br/>识别是否为蓝牙耳机] B --> C[2. 约束适配<br/>生成 captureOptions<br/>蓝牙 -> 关降噪<br/>其他 -> 开降噪] C --> D[3. 激活探针<br/>getUserMedia 唤醒设备通道<br/>失败则抛出友好提示] D --> E[4. 轨道发布<br/>LiveKit/WebRTC 正式发布音频<br/>进入通话房间]

五、 踩过的坑

坑 1:deviceId: 'default' 不等于当前“系统默认”

在 Chrome 中,default 是一个虚拟设备 ID。当你直接把 { deviceId: { exact: 'default' } } 传给 getUserMedia 时,它可能解析到的是上一次缓存的设备,而不是系统当前实际的默认设备。

  • 正确做法
    javascript 复制代码
    deviceId: preferredDevice?.deviceId !== 'default'
      ? { exact: preferredDevice.deviceId }
      : { ideal: 'default' }

坑 2:enumerateDevices() 需要先授权

在用户授予麦克风权限之前,enumerateDevices() 返回的设备列表中,label 字段全是空字符串。因此,设备探测必须放在权限获取之后(可以先用默认约束 getUserMedia 获取权限,再重新枚举设备并做第二次精确采集)。

坑 3:不同品牌的蓝牙关键词不同

AirPods 的 label 可能是 "AirPods Pro""AirPods-Left";华为 FreeBuds 叫 "HUAWEI FreeBuds 4";索尼则带有 "WH-1000XM"

  • 解决思路:关键词库需要持续维护,或者添加兜底逻辑:如果设备名称包含 "headset""ear""buds""hands-free" 等通用词,一律按蓝牙设备处理。

六、 总结

桌面端 WebRTC 的音频兼容性是个经常被忽视但极其影响体验的话题。

问题的本质在于:浏览器把所有音频输入设备当成了“裸麦克风”对待,试图套用同一套软件降噪(DSP)策略。但现代蓝牙耳机早就不只是“麦克风”,而是自带完整音频处理管线的小型计算机。

解决思路其实并不复杂,只需三步:

  1. 识别:通过设备枚举和标签匹配,判断是否使用了蓝牙耳机。
  2. 适配:如果是蓝牙设备则关掉浏览器的降噪,由耳机硬件全权打理。
  3. 探针:发布前进行一次极短的采集唤醒,提前暴露权限和通道未激活问题。

这三步加起来不到一百行代码,但能把 AirPods 等主流蓝牙耳机在桌面端的通话体验从“完全不能用”提升到“和手机端一样好用”。