一、WebRTC 与 WebAssembly:为什么要把它们放在一起?

说实话,我第一次在浏览器里跑起一个 WASM 编解码器时,还挺激动的。你想想看,以前我们做实时通信,编解码这块基本被浏览器原生实现锁死了——H.264 硬编硬解、Opus 软编软解,开发者能做的选择其实很少。

但 WebAssembly 的出现,改变了这个局面。它让我们能在浏览器里跑 C/C++/Rust 写的音视频处理库,性能接近原生。说白了,就是把服务端那套能力,搬到了浏览器端。

我个人习惯把 WebRTC + WASM 的组合称为「浏览器原生能力的扩展包」。它主要解决三个问题:

  • 编解码器扩展:浏览器不支持的编码格式,比如 AV1、SILK,可以用 WASM 实现
  • 信号处理定制:回声消除、降噪、自动增益控制,这些可以用 WASM 做更精细的控制
  • 性能优化:把计算密集的任务从 JS 搬到 WASM,减少卡顿

核心观点:WASM 不是要替代浏览器原生编解码器,而是填补空白。浏览器能做的,交给浏览器;浏览器做不了的,交给 WASM。

二、WASM 编解码器:实战经验分享

2.1 为什么需要 WASM 编解码器?

我在项目中遇到过这样一个场景:客户要求支持 AV1 编码,但当时 Chrome 和 Firefox 都还没内置 AV1 编码器。怎么办?

方案其实就两个:要么等浏览器更新,要么自己用 WASM 编译一个。我们选了后者。用 libaom 编译成 WASM,在浏览器里跑 AV1 编码。虽然速度比原生慢一些,但至少能用了。

常见的 WASM 编解码器场景:

  • AV1 编解码:libaom 编译成 WASM,用于低码率场景
  • SILK 编解码:Skype 的语音编码器,WebRTC 原生不直接暴露
  • G.711/G.722:传统电话编码,某些企业场景需要
  • 自定义编码:比如你公司自研的私有编码格式

2.2 一个简单的 WASM 编解码器示例

下面这个例子,我用 C 写了一个简单的 PCM 转 G.711 μ-law 编码器,然后编译成 WASM 在浏览器里调用。

// encoder.c - G.711 μ-law 编码器
#include <stdint.h>

int16_t linear2ulaw(int16_t pcm_val) {
    int16_t mask;
    int16_t seg;
    int16_t uval;
    
    pcm_val = pcm_val >> 2;  // 14位精度
    
    if (pcm_val < 0) {
        pcm_val = -pcm_val;
        mask = 0x7F;
    } else {
        mask = 0xFF;
    }
    
    if (pcm_val > 8159) pcm_val = 8159;
    
    // 计算段号
    seg = 0;
    while (pcm_val > 63) {
        pcm_val >>= 1;
        seg++;
    }
    
    uval = (seg << 4) | ((pcm_val >> 1) & 0x0F);
    uval ^= mask;
    
    return uval;
}

编译命令:

emcc encoder.c -O3 -s WASM=1 -s EXPORTED_FUNCTIONS='["_linear2ulaw"]' -o encoder.js

然后在 JS 里调用:

Module.onRuntimeInitialized = function() {
    const pcmData = new Int16Array([0, 100, 200, 500, 1000, 2000]);
    const ulawData = new Int16Array(pcmData.length);
    
    for (let i = 0; i < pcmData.length; i++) {
        ulawData[i] = Module._linear2ulaw(pcmData[i]);
    }
    
    console.log('编码结果:', ulawData);
};

小技巧:编译 WASM 时记得加 -O3 优化。我刚开始没加,编码速度慢了将近 3 倍。另外,导出函数名前面要加下划线,这是 Emscripten 的约定。

三、WASM 信号处理:降噪与回声消除

3.1 为什么自己写信号处理?

WebRTC 内置的 AEC(回声消除)和 NS(降噪)其实已经不错了。但问题是,你没法调参数。比如在某些会议场景下,你需要更激进的降噪策略,或者针对特定麦克风做适配。

我曾经在一个项目里,客户用的是某品牌的会议麦克风阵列,WebRTC 默认的 AEC 效果很差。后来我们用 SpeexDSP 编译成 WASM,自己控制降噪强度,效果明显好了很多。

3.2 用 WASM 做实时降噪

下面是一个用 SpeexDSP 做降噪的简化流程:

// denoise.c - 使用 SpeexDSP 降噪
#include <speex/speex_preprocess.h>

SpeexPreprocessState *st;
int denoise_enabled = 1;
int noise_suppress = -30;  // dB

void init_denoiser(int sample_rate, int channels) {
    st = speex_preprocess_state_init(160, sample_rate);
    speex_preprocess_ctl(st, SPEEX_PREPROCESS_SET_DENOISE, &denoise_enabled);
    speex_preprocess_ctl(st, SPEEX_PREPROCESS_SET_NOISE_SUPPRESS, &noise_suppress);
}

void process_audio(short *audio, int len) {
    speex_preprocess_run(st, audio);
}

编译后,在 WebRTC 的音频处理链中插入 WASM 降噪模块:

// 在 WebRTC 的音频轨道上插入 WASM 处理
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);

processor.onaudioprocess = function(event) {
    const input = event.inputBuffer.getChannelData(0);
    const output = event.outputBuffer.getChannelData(0);
    
    // 调用 WASM 降噪
    Module._process_audio(input, input.length);
    output.set(input);
};

source.connect(processor);
processor.connect(audioContext.destination);

注意:ScriptProcessor 在部分浏览器上已被标记为废弃。建议用 AudioWorklet 替代。我踩过这个坑,AudioWorklet 的延迟更低,而且不会阻塞主线程。

四、性能对比:WASM vs 原生 vs JS

我做了几组性能测试,数据如下。测试环境是 Chrome 120,MacBook Pro M1,16GB 内存。

操作 原生 (C++) WASM 纯 JS
G.711 编码 (1秒音频) 0.3ms 0.8ms 4.2ms
Speex 降噪 (1秒音频) 1.1ms 2.5ms 15.3ms
AV1 编码 (1帧 720p) 12ms 45ms 不支持
FFT 变换 (1024点) 0.05ms 0.12ms 0.8ms

从数据可以看出:

  • WASM 比纯 JS 快 3-5 倍,在计算密集任务上优势明显
  • WASM 比原生慢 2-3 倍,主要是内存访问和函数调用的开销
  • 对于简单操作(比如 G.711 编码),WASM 的开销占比大,优势不明显
  • 对于复杂操作(比如 AV1 编码),WASM 是唯一能在浏览器里跑的方案

我的建议:如果浏览器原生支持,优先用原生。如果浏览器不支持,或者你需要定制参数,再考虑 WASM。不要为了用 WASM 而用 WASM。

五、知识体系结构图

下面这张图展示了 WebRTC + WASM 的核心知识体系:

WebRTC + WASM 知识体系 WebRTC 核心 编解码器扩展 信号处理定制 性能优化 AV1 / SILK G.711 / G.722 AEC / NS / AGC VAD / 波束成形 FFT / 滤波 内存优化 WASM 编译工具链 (Emscripten / wasm-pack) 浏览器运行时 (Chrome / Firefox / Safari)

六、避坑指南与最佳实践

6.1 我踩过的几个坑

  • 内存泄漏:WASM 分配的内存不会自动回收。我曾经在循环里不断调用 malloc,结果浏览器内存飙到 2GB。记得每次用完要 free。
  • 线程问题:WASM 默认是单线程的。如果你要做并行处理,需要用 SharedArrayBuffer 和 Atomics。这个配置起来比较麻烦,而且需要设置 COOP/COEP 头。
  • 调试困难:WASM 的调试信息不如 JS 丰富。我建议先用 C/C++ 在本地调试好,再编译成 WASM。

6.2 最佳实践总结

  1. 按需加载:WASM 文件可能比较大(几 MB),建议在需要时才加载,不要阻塞页面初始化
  2. 数据拷贝最小化:JS 和 WASM 之间传数据有开销。尽量批量传输,不要一次传一个样本点
  3. 使用 SIMD:如果浏览器支持 WASM SIMD,性能能再提升 2-3 倍。编译时加 -msimd128 即可
  4. 降级方案:如果 WASM 加载失败,要有 JS 版本的降级实现

个人习惯:我一般会在 WASM 模块加载完成后,先跑一个小的基准测试,看看当前设备的性能。如果太慢,就自动切换到低复杂度模式。这样用户体验会好很多。

好了,关于 WebRTC 和 WebAssembly 的结合,今天就聊到这里。记住一句话:WASM 是工具,不是目的。用对地方,它就是利器;用错地方,它就是累赘。


公众号:蓝海资料掘金营,微信deep3321