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)机制。为什么是双缓冲?因为单缓冲的话,硬件在读的时候,上游不能写,否则数据会被覆盖。双缓冲让读写可以同时进行。

AudioDestinationNode 输出缓冲机制 上游节点 缓冲区 A 写入中 缓冲区 B 读取中 硬件扬声器 写入 读取 切换 双缓冲机制:上游写入缓冲区A时,硬件读取缓冲区B。 当缓冲区A写满,两者交换角色。这样读写互不阻塞。

双缓冲的切换频率,就是硬件缓冲区大小决定的。缓冲区越小,切换越频繁,延迟越低,但 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,是物理定律。你只能跟它共存,不能消灭它。