2. WebRTC音频架构概览
音频采集和播放,是WebRTC通话的起点和终点。说白了,就是麦克风怎么把声音变成数字信号,扬声器怎么把数字信号变回声音。这一步要是没做好,后面编解码、网络传输再优化也没用。
我刚开始做WebRTC的时候,就踩过一个坑。当时在Windows上测试,麦克风采集出来的声音总是有“沙沙”的底噪。查了半天,发现是采样率设置不对,麦克风硬件支持48kHz,我代码里硬写成了16kHz。嗯,从那以后,我对设备管理这块就特别上心。
2.1 麦克风与扬声器的核心管理
浏览器通过 MediaDevices API 来管理音频设备。核心就两个操作:枚举设备和获取流。
2.1.1 枚举设备
用 navigator.mediaDevices.enumerateDevices() 可以拿到所有设备列表。我个人习惯在页面加载完就调一次,把设备信息缓存起来。
async function getAudioDevices() {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(d => d.kind === 'audioinput');
const audioOutputs = devices.filter(d => d.kind === 'audiooutput');
return { audioInputs, audioOutputs };
}
返回的每个设备都有 deviceId、label、groupId 这几个关键字段。groupId 可以把同一个物理设备的麦克风和扬声器关联起来,这在做设备配对时很有用。
2.1.2 获取音频流
获取流用 getUserMedia,指定 deviceId 就能选设备。
const constraints = {
audio: {
deviceId: { exact: selectedMicId },
sampleRate: 48000,
channelCount: 1
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
deviceId 在浏览器重启后会变。别把它硬编码存数据库,每次启动重新枚举一遍。
2.2 采样率与位深:数字音频的基石
这两个参数决定了音频的“分辨率”。采样率决定频率范围,位深决定动态范围。
| 参数 | 常见值 | 影响 | WebRTC 推荐 |
|---|---|---|---|
| 采样率 | 8kHz / 16kHz / 48kHz | 频率上限 = 采样率 / 2 | 48kHz(OPUS 默认) |
| 位深 | 16bit / 24bit / 32bit | 动态范围 ≈ 6dB × 位深 | 16bit(浏览器强制) |
为什么WebRTC推荐48kHz?因为OPUS编码器内部工作在48kHz,如果输入是16kHz,浏览器会做重采样,多一次处理就多一次延迟和失真。我在项目中测试过,48kHz输入比16kHz输入,OPUS编码质量能提升10%左右。
至于位深,浏览器目前只支持16bit。你就算在 constraints 里写 sampleSize: 24,浏览器也会忽略。这是底层驱动和浏览器实现的限制,暂时没办法。
2.3 设备切换:动态换麦不中断通话
用户可能在通话中插拔耳机,或者想从听筒切换到扬声器。这时候不能重新 getUserMedia,否则会打断通话。
2.3.1 替换音频轨道
正确做法是用 RTCRtpSender.replaceTrack()。
async function switchMicrophone(newDeviceId) {
const newStream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: newDeviceId } }
});
const newTrack = newStream.getAudioTracks()[0];
const sender = peerConnection.getSenders().find(s => s.track.kind === 'audio');
await sender.replaceTrack(newTrack);
// 别忘了关掉旧流
oldTrack.stop();
}
这里有个细节:replaceTrack 是瞬间完成的,不会产生音频中断。但新设备可能和旧设备的采样率不同,浏览器会自动做重采样适配。
2.3.2 扬声器切换
扬声器切换更简单,用 HTMLAudioElement.setSinkId()。
const audioElement = document.getElementById('remoteAudio');
await audioElement.setSinkId(newSpeakerId);
不过这个API在Safari上不支持。我在项目中遇到过一次,用户用iPhone插拔耳机,声音还是从听筒出来。最后只能加了个降级方案:检测到不支持 setSinkId 时,提示用户手动调系统音量。
devicechange 事件,当用户插拔设备时自动刷新列表。但别自动切换,让用户自己选。
2.4 异常处理:设备不可用怎么办
设备管理这块,异常情况特别多。用户可能没插麦克风,可能权限被拒绝,可能设备被其他应用占用。
2.4.1 常见错误码
| 错误名 | 原因 | 处理方式 |
|---|---|---|
| NotAllowedError | 用户拒绝授权 | 引导用户去浏览器设置里开启 |
| NotFoundError | 没有找到匹配的设备 | 提示用户连接设备,或使用默认设备 |
| NotReadableError | 设备被其他应用占用 | 提示用户关闭其他使用麦克风的软件 |
| OverconstrainedError | 约束条件无法满足 | 降级约束,去掉 exact 或降低采样率 |
我曾经遇到一个用户,在Chrome上一直报 NotReadableError。查了半天,发现他开了两个浏览器标签页都在用同一个麦克风。Chrome不允许共享设备,除非用 getUserMedia 的 audio: true 不加具体设备ID。
2.4.2 降级策略
我的做法是分层降级:
- 首选: 用户指定的设备 + 48kHz + 单声道
- 次选: 默认设备 + 48kHz + 单声道
- 兜底: 默认设备 + 浏览器默认采样率 + 立体声
async function getAudioStreamWithFallback(preferredDeviceId) {
try {
return await getUserMedia({
audio: {
deviceId: { exact: preferredDeviceId },
sampleRate: 48000,
channelCount: 1
}
});
} catch (e) {
console.warn('首选设备失败,降级到默认设备', e);
try {
return await getUserMedia({
audio: {
sampleRate: 48000,
channelCount: 1
}
});
} catch (e2) {
console.warn('默认设备也失败,使用浏览器默认配置', e2);
return await getUserMedia({ audio: true });
}
}
}
OverconstrainedError,导致用户设备明明可用,但因为采样率不匹配一直报错。后来我改成先去掉 exact,再去掉采样率约束,问题就解决了。
2.5 音频设备管理知识体系
下面这张图总结了本章的核心流程和关键节点。
2.6 实战建议总结
- 设备枚举: 页面加载时做一次,监听
devicechange事件刷新,但别自动切换。 - 采样率: 固定用48000Hz,和OPUS编码器对齐。别用44100Hz,浏览器会多一次重采样。
- 设备切换: 麦克风用
replaceTrack,扬声器用setSinkId。记得停掉旧流释放资源。 - 异常处理: 分层降级,从精确约束逐步放宽到默认配置。每个错误都给用户明确的提示。