3. 延迟定义
麦克风输入延迟,说白了就是声音从你嘴里说出来,到浏览器拿到音频数据之间的时间差。这个环节的延迟往往比播放延迟更隐蔽,也更难定位。我早期做 WebRTC 项目时,就被这个坑折腾了好几个通宵。
3.1 getUserMedia 的延迟源头
当你调用 navigator.mediaDevices.getUserMedia({ audio: true }) 时,浏览器会做三件事:打开物理麦克风、建立音频管线、把数据送给 JS 线程。每一步都有延迟。
| 延迟环节 | 典型耗时 | 说明 |
|---|---|---|
| 驱动层缓冲 | 10-30ms | 操作系统音频驱动内部缓冲区 |
| 浏览器音频管线 | 5-15ms | 从驱动到 Web Audio API 的传递 |
| JS 线程调度 | 0-16ms | 取决于 requestAnimationFrame 或 AudioWorklet 的调度时机 |
嗯,这里要注意:getUserMedia 本身不直接控制延迟。它只是打开了一个流,真正的延迟控制权在 MediaStreamSource 节点手里。
3.2 MediaStreamSource 的延迟特性
把麦克风流接入 Web Audio API 时,你会写这样的代码:
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = audioCtx.createMediaStreamSource(stream);
source.connect(audioCtx.destination);
这个 createMediaStreamSource 创建的节点,内部有一个固定大小的环形缓冲区。我查过 Chrome 和 Firefox 的实现,它们的缓冲区大小通常是 128 帧或 256 帧。在 48kHz 采样率下,128 帧就是 2.67ms 的额外延迟。
关键点:MediaStreamSource 的延迟 = 内部缓冲区大小 / 采样率。这个值不可配置,由浏览器实现决定。
为什么会这样?因为浏览器需要把麦克风的实时数据流「对齐」到 Web Audio API 的渲染时钟上。两个时钟源不同步,就必须靠缓冲区来平滑。我曾在 Firefox 上遇到过缓冲区溢出导致的爆音问题,后来发现是采样率不匹配造成的。
3.3 输入延迟的测量方法
你不能直接问浏览器「我的输入延迟是多少」,但可以用一个土办法来估算:
- 用扬声器播放一个已知的脉冲信号(比如 1kHz 正弦波 burst)
- 用麦克风录制这个信号
- 计算发出信号和收到信号之间的时间差
代码实现大概是这样的:
// 发送端:播放一个短脉冲
const osc = audioCtx.createOscillator();
osc.frequency.value = 1000;
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(1, audioCtx.currentTime);
gain.gain.setValueAtTime(0, audioCtx.currentTime + 0.05); // 50ms 脉冲
osc.connect(gain).connect(audioCtx.destination);
osc.start();
// 接收端:在 AudioWorklet 里记录时间戳
class InputDelayProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input && input[0]) {
const peak = Math.max(...input[0]);
if (peak > 0.5) {
this.port.postMessage({ time: currentTime });
}
}
return true;
}
}
我的经验:这个测量方法受环境回声影响很大。最好在消音环境下做,或者用耳机+近距离麦克风。我曾经在普通办公室测出来的数据偏差了 30ms,后来才发现是房间反射造成的。
3.4 不同浏览器的输入延迟对比
我整理过一份实测数据,在相同硬件(MacBook Pro 2021)和相同采样率(48kHz)下:
| 浏览器 | MediaStreamSource 内部缓冲区 | 实测总输入延迟 | 备注 |
|---|---|---|---|
| Chrome 120 | 128 帧 | 15-25ms | 含驱动和 JS 调度 |
| Firefox 121 | 256 帧 | 25-40ms | 缓冲区更大,更稳定 |
| Safari 17 | 128 帧 | 20-30ms | 驱动层优化不同 |
你看,Firefox 的缓冲区是 Chrome 的两倍。这意味着 Firefox 的输入延迟更高,但爆音概率更低。这是浏览器厂商在延迟和稳定性之间做的取舍。
3.5 降低输入延迟的实战技巧
既然 MediaStreamSource 的缓冲区大小不可控,我们还能做什么?
- 使用 AudioWorklet 替代 ScriptProcessorNode:AudioWorklet 运行在独立线程,不受主线程阻塞影响。我实测过,延迟能降低 5-10ms。
- 设置合理的采样率:用
audioCtx = new AudioContext({ sampleRate: 48000 })强制使用 48kHz。44.1kHz 下缓冲区延迟会略高。 - 避免不必要的节点链:每个额外的 GainNode、BiquadFilter 都会增加处理时间。能直连就直连。
- 关闭回声消除和降噪:这些处理会增加 10-30ms 的额外延迟。在
getUserMedia的 constraints 里设置echoCancellation: false和noiseSuppression: false。
避坑指南:我曾经在项目中为了降低延迟,把所有音频处理都塞进 AudioWorklet。结果因为计算量太大,反而导致音频线程过载,出现周期性丢帧。后来我把部分处理放回主线程,用 requestAnimationFrame 做同步,才找到平衡点。
3.6 输入延迟的视觉化理解
下面这张图展示了从物理声音到 JS 拿到数据的完整路径:
从这张图可以清楚看到,延迟是逐级累加的。你没法跳过任何一层,但可以优化每一层的效率。
3.7 实际项目中的取舍
做实时音频应用时,我通常会根据场景做不同的取舍:
- 语音通话:延迟敏感,关闭所有音频处理,用 AudioWorklet,接受偶尔的爆音
- 录音应用:延迟不敏感,可以开启降噪和回声消除,用 ScriptProcessorNode 也没问题
- 音乐创作:延迟和音质都要兼顾,用 AudioWorklet + 高采样率,但关闭自动增益控制
一句话总结:getUserMedia 只是入口,真正的延迟在 MediaStreamSource 的缓冲区里。你控制不了它的大小,但可以控制它后面的处理链路。