音频采集与播放:麦克风与扬声器管理、采样率与位深、设备切换与异常处理

音频采集和播放,是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 降级策略

我的做法是分层降级:

  1. 首选: 用户指定的设备 + 48kHz + 单声道
  2. 次选: 默认设备 + 48kHz + 单声道
  3. 兜底: 默认设备 + 浏览器默认采样率 + 立体声
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 音频设备管理知识体系

下面这张图总结了本章的核心流程和关键节点。

音频设备管理知识体系 navigator.mediaDevices.enumerateDevices() audioinput(麦克风) audiooutput(扬声器) 采样率(sampleRate) 推荐 48000Hz,匹配 OPUS 编码器 位深(sampleSize) 浏览器强制 16bit,无法修改 设备切换 麦克风:replaceTrack() | 扬声器:setSinkId() 异常处理与降级策略 NotAllowed → NotFound → NotReadable → Overconstrained 核心原则:采集采样率与编码器采样率一致,避免重采样 设备切换不中断通话,异常降级保证可用性

2.6 实战建议总结

  • 设备枚举: 页面加载时做一次,监听 devicechange 事件刷新,但别自动切换。
  • 采样率: 固定用48000Hz,和OPUS编码器对齐。别用44100Hz,浏览器会多一次重采样。
  • 设备切换: 麦克风用 replaceTrack,扬声器用 setSinkId。记得停掉旧流释放资源。
  • 异常处理: 分层降级,从精确约束逐步放宽到默认配置。每个错误都给用户明确的提示。
最后说一句: 设备管理这块,看起来简单,但坑特别多。我建议你在开发阶段就把所有异常路径都走一遍,模拟用户插拔设备、拒绝权限、打开其他应用占用麦克风。这些场景在线上迟早会遇到,提前处理比事后救火强得多。