做桌面 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 编码}
两层降噪算法处理的都是同一份音频信号,且它们之间没有协调机制。结果就是:
- 硬件降噪已经把“非语音”部分压得很低了。
- 浏览器软件降噪看到的是一个“几乎没声音”的原始信号,误以为是噪音,于是进一步把它当成噪声压制。
- 最终输出接近静音。
[!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 的音频路由切换,确保蓝牙通道真正激活。
- 探针通常在几十毫秒内完成,用户感知不到任何延迟。
四、 完整实现架构
把以上环节串起来,整个音频初始化流程如下:
五、 踩过的坑
坑 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)策略。但现代蓝牙耳机早就不只是“麦克风”,而是自带完整音频处理管线的小型计算机。
解决思路其实并不复杂,只需三步:
- 识别:通过设备枚举和标签匹配,判断是否使用了蓝牙耳机。
- 适配:如果是蓝牙设备则关掉浏览器的降噪,由耳机硬件全权打理。
- 探针:发布前进行一次极短的采集唤醒,提前暴露权限和通道未激活问题。
这三步加起来不到一百行代码,但能把 AirPods 等主流蓝牙耳机在桌面端的通话体验从“完全不能用”提升到“和手机端一样好用”。