2.67ms的生死线:为什么浏览器的实时混响几乎不可能

13 阅读3分钟

你在一个48kHz的DAW里开128 buffer,操作系统给你承诺:每2.67ms你必须交出128个采样。交不出来?爆音。

这不是建议值,是物理死线。128 samples ÷ 48000Hz = 2.667ms。音频线程和OS之间有一份实时契约——你的回调函数必须在2.67ms内算完一帧,否则DAC缓冲区欠载,扬声器里就是一声爆响。

这个数字对所有平台都成立:C++、Rust、JUCE、浏览器。不存在"浏览器宽松一点"这种事。Chrome的AudioWorklet跑在独立高优先级线程上,OS调度给它的就是2.67ms。

但问题是——这2.67ms里你到底要算多少?

O(N²)的物理诅咒

卷积混响的核心操作:把干声信号和房间的脉冲响应(IR)做卷积。

一个3秒尾音的"史诗大教堂",IR有144,000个采样点(48000 × 3)。AudioWorklet每帧给你128个采样。时域卷积的运算量:

FLOPs = 2 × 128 × 144,000 = 36,864,000

36.9M次浮点运算。在浏览器V8引擎单线程、无SIMD加持的纯标量环境下,真实的浮点吞吐量往往被卡在8 GFLOPS左右:

36.9M ÷ 8G = 4.6ms

4.6ms > 2.67ms。死刑。

不是代码写得烂,不是V8慢——是O(N²)的数学上限。IR长度翻倍,计算量翻四倍。3秒大教堂的卷积,无论你怎么微优化,时域暴力算法在帧预算内就是算不完。

小录音室0.5s IR勉强苟活(0.77ms),大教堂直接崩盘。

O(N log N)的破局

卷积定理:时域的卷积等于频域的乘法。

把信号和IR都扔进FFT,频域里做一次极其廉价的逐元素相乘,再IFFT回来。复杂度从O(N²)降到O(N log N)。

但工程上有个问题——3秒的IR意味着FFT要覆盖144,127个点,一次性做不现实。答案是基于分块的重叠相加法(Partitioned Overlap-Add):将长IR切割成与Buffer Size匹配的小块,每块独立FFT→频域乘法→IFFT,结果重叠相加拼出完整卷积。

分块卷积是实时混响的业界标配——JUCE的DSP模块、WDL的fftconvolver都是这个思路。它保证了第一块的计算极小,实现零算法延迟。不像纯Overlap-Add那样必须等够一个FFT块的时间才能输出。

同样的大教堂IR,分块频域卷积加上SIMD加速:

FFT FLOPs ≈ 3 × 5 × N × log₂(N) + 2N(含3次FFT + 频域乘法) WASM-SIMD v128寄存器,一条指令4个float并行,有效吞吐量再翻4倍

最终延迟:0.65ms。稳稳压在2.67ms帧预算内。

三个空间对比:

  • 小录音室(0.5s IR):时域0.77ms → 频域0.18ms
  • 大音乐厅(1.5s IR):时域2.3ms → 频域0.35ms
  • 史诗大教堂(3.0s IR):时域4.6ms → 频域0.65ms

无论空间多大,频域卷积延迟始终压在1ms以内。这不是微优化,是量级跃迁。

为什么这跟你有关

如果你做原生DAW,128 buffer + 3秒混响在C++里也得算4.6ms——O(N²)不挑语言。区别是C++有SIMD intrinsics,浏览器有WASM-SIMD。v128寄存器,一条指令4个float并行,频域乘法的吞吐量直接翻4倍。

算法选对了,语言不重要。算法选错了,C++也救不了你。

亲手试

上面的数字全部由FLOPs公式推导,不是硬编码。切到"史诗大教堂",看O(N²)如何刺穿2.67ms死线;切到SIMD-FFT,看0.65ms稳如磐石:

diffserv.xyz/blog/wasm-c…