4. 前端音频链路
音频延迟的最后一公里,就在输出节点这里。
前面我们聊了采集、处理、编解码,信号终于到了 AudioDestinationNode。按理说该出声了吧?但你会发现,从调用 start() 到扬声器真正发出声音,中间还卡着一道坎。这道坎,就是输出缓冲机制和硬件缓冲区大小。
我个人习惯把 AudioDestinationNode 想象成一个「水龙头」。你这边拧开了,但水管里还有一段空腔,得先让水流填满空腔,水才能从龙头口出来。这段空腔,就是输出缓冲区。
4.1 AudioDestinationNode 到底在干什么?
AudioDestinationNode 是音频上下文里唯一一个能直接跟硬件扬声器打交道的节点。它做的事情很简单:从上游节点拿音频数据,塞进自己的内部缓冲区,然后等硬件来取。
但这里有个关键点——硬件不是随时来取的。它按照固定的节奏,比如每 10 毫秒来取一次。这个节奏,就是硬件缓冲区大小决定的。
核心概念:AudioDestinationNode 的输出缓冲是一个环形缓冲区(ring buffer)。上游写入数据,硬件读取数据,两者异步进行。缓冲区大小决定了「写入」和「读取」之间的时间差。
说白了,缓冲区越大,硬件等得越久,延迟就越高。但缓冲区太小,硬件可能还没等到数据,就会产生「欠载运行」(underrun),也就是爆音。
4.2 硬件缓冲区大小:一个你没法直接改的参数
嗯,这里要注意。在 Web Audio API 的标准里,你没法直接设置 AudioDestinationNode 的缓冲区大小。这个值由浏览器和底层音频驱动共同决定。
我在项目中遇到过一个问题:同一段代码,在 Chrome 上延迟 15ms,在 Safari 上延迟 40ms。查了半天,发现是 Safari 的硬件缓冲区大小被设成了 1024 帧,而 Chrome 是 256 帧。
| 缓冲区大小(帧) | 采样率 44100Hz 下的延迟 | 典型场景 |
|---|---|---|
| 64 | 约 1.45 ms | 专业音频应用(极少见) |
| 128 | 约 2.9 ms | 低延迟游戏、实时效果 |
| 256 | 约 5.8 ms | Chrome 默认、大多数 Web 应用 |
| 512 | 约 11.6 ms | 兼容模式、移动端 |
| 1024 | 约 23.2 ms | Safari 默认、老旧设备 |
你想想看,23ms 的延迟,再加上采集和处理的延迟,总延迟轻松超过 50ms。做实时通话或者乐器演奏,基本没法用。
注意:硬件缓冲区大小不是开发者能控制的。你只能通过 AudioContext 的 baseLatency 属性来读取当前值。Chrome 的 baseLatency 通常是 0.0058 秒(5.8ms),Safari 可能是 0.023 秒。
4.3 输出缓冲的内部机制
AudioDestinationNode 内部维护了一个双缓冲(double buffering)机制。为什么是双缓冲?因为单缓冲的话,硬件在读的时候,上游不能写,否则数据会被覆盖。双缓冲让读写可以同时进行。
双缓冲的切换频率,就是硬件缓冲区大小决定的。缓冲区越小,切换越频繁,延迟越低,但 CPU 开销也越大。
4.4 如何测量输出延迟?
你没法直接拿到硬件缓冲区大小,但可以通过 AudioContext 的 outputLatency 属性来估算。不过这个属性目前只有部分浏览器支持。
// 获取输出延迟(部分浏览器支持)
const audioCtx = new AudioContext();
console.log('输出延迟(秒):', audioCtx.outputLatency);
// Chrome 上可能返回 0.0058(5.8ms)
// Safari 上可能返回 undefined
// 更通用的方法:用 baseLatency + 经验值
const totalOutputLatency = audioCtx.baseLatency + (audioCtx.outputLatency || 0);
console.log('总输出延迟:', totalOutputLatency * 1000, 'ms');
我的经验:如果 outputLatency 返回 undefined,可以用 baseLatency 乘以 2 来估算。因为双缓冲机制下,总延迟大约是缓冲区大小的两倍。比如 Chrome 的 baseLatency 是 5.8ms,实际输出延迟大约在 10-12ms 左右。
4.5 避坑指南:输出延迟的常见陷阱
我曾经踩过一个坑:在移动端 Safari 上做实时音频处理,怎么调延迟都降不下来。后来发现,Safari 在省电模式下会把硬件缓冲区强制设为 1024 帧,延迟直接飙到 40ms+。
- 陷阱一:移动端省电模式——浏览器为了省电,会增大缓冲区。用户插上电源或者关闭省电模式,延迟会降下来。
- 陷阱二:蓝牙耳机——蓝牙音频的缓冲区大小通常比有线耳机大 2-3 倍。我测过,AirPods 的延迟比有线耳机多 20ms 左右。
- 陷阱三:多标签页干扰——如果浏览器有多个标签页同时使用音频,系统可能会增大缓冲区来保证稳定性。
- 陷阱四:AudioContext 未激活——在用户交互之前创建的 AudioContext,会被浏览器限制在「暂停」状态,输出延迟会异常高。
重要:输出延迟是「硬延迟」,你没法通过优化代码来消除。唯一能做的,是在创建 AudioContext 时尝试请求低延迟模式:
const audioCtx = new AudioContext({ latencyHint: 'interactive' });
// 'interactive' 表示低延迟(约 5-10ms)
// 'balanced' 表示平衡(约 10-20ms)
// 'playback' 表示高延迟(约 20-50ms)
但注意,这只是「请求」,浏览器不一定采纳。最终延迟还是由硬件和驱动决定。
4.6 输出延迟的实测方法
我一般用「回环法」来测总延迟。原理很简单:让音频从输出到输入走一圈,测量时间差。
// 回环法测延迟(简化版)
async function measureLoopbackLatency() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioCtx = new AudioContext({ latencyHint: 'interactive' });
// 创建一个振荡器,输出一个短脉冲
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
gain.gain.value = 0; // 先静音
osc.connect(gain);
gain.connect(audioCtx.destination);
// 同时监听麦克风输入
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
source.connect(analyser);
// 记录发送时间
const sendTime = performance.now();
// 发出一个短脉冲(100ms 后)
setTimeout(() => {
gain.gain.value = 1;
setTimeout(() => {
gain.gain.value = 0;
}, 50);
}, 100);
// 监听麦克风,检测脉冲到达时间
// ...(实际实现需要更复杂的信号检测逻辑)
// 这里只是示意
}
这个方法虽然粗糙,但能让你直观感受到输出延迟的存在。我测过,在 Chrome 桌面上,从发出脉冲到麦克风检测到,大约 15-20ms。其中输出延迟占了 10ms 左右。
4.7 小结
输出延迟是音频链路里最「硬」的一环。你没法绕过它,只能理解它、测量它、并在设计系统时把它考虑进去。
我个人建议:在做实时音频应用时,优先用 latencyHint: 'interactive',然后通过 baseLatency 和 outputLatency 来评估实际表现。如果发现延迟异常高,先检查是不是蓝牙耳机或者省电模式在作怪。
记住一句话:输出延迟不是 bug,是物理定律。你只能跟它共存,不能消灭它。