跳到主要内容

音频3A处理

实时音频体验的好坏,70% 取决于"3A"——AEC(Acoustic Echo Cancellation 回声消除)、NS(Noise Suppression 噪声抑制)、AGC(Auto Gain Control 自动增益)。WebRTC 把 3A 内嵌在 getUserMedia 流水线里,但默认参数不一定适合所有场景。本文讲清楚 原理、浏览器实现差异、自定义处理路径(AudioWorklet)

1. 为什么需要 3A

1.1 三类典型问题

问题现象解决
回声对方说话,自己扬声器播放后被麦克风重新拾取,对方听到自己回声AEC
噪声风扇、键盘敲击、空调底噪NS
音量不一致一个人离麦克风近爆音,另一个离远听不到AGC

1.2 失败时的样子

  • 关 AEC:对端听到自己的尾音(300~500ms 延迟回声),通话基本无法进行。
  • 关 NS:背景噪声盖过人声,弱网时编码器把噪声编进低码率,更糊。
  • 关 AGC:一会儿爆音一会儿过小,听众反复调音量。

1.3 何时不需要 3A

  • 录音/直播:需要原始音质,3A 会损失高频和动态。
  • 音乐演奏:3A 把音乐当噪声压。WebRTC 提供 latencyHintgoogEchoCancellation: false 关闭。
  • 仅文件转写(无回放):可关 AEC。

2. AEC:回声消除

2.1 回声从哪来

回声有两种:

  • 声学回声:扬声器声波被同一设备的麦克风拾取(蓝牙耳机、外放最严重)。
  • 线路回声:模拟电路串扰(移动 4G 通话常见,WebRTC 一般不涉及)。

2.2 AEC 算法心智模型

1) 拿到"参考信号" x(t) = 即将通过扬声器播放的远端音频
2) 麦克风采到 y(t) = 本端说话 s(t) + 回声 e(t) + 噪声 n(t)
3) AEC 估计回声路径 h(t):自适应滤波器 (NLMS / Kalman) 学习 x 经房间到 y 的传播函数
4) 估计 ê(t) = h(t) * x(t)
5) 输出 y(t) - ê(t) ≈ s(t) + n(t)
6) 残留回声用非线性处理器 (NLP) 抑制

关键挑战:

  • 双讲检测:双方同时说话时,自适应滤波器不能学错。
  • 延迟对齐:x 和 y 必须时序对齐到几毫秒以内,否则学不到。
  • 非线性扭曲:手机扬声器在大音量时会失真,纯线性滤波抓不住,必须 NLP 兜底。

2.3 WebRTC AEC 的几代实现

版本简称特征
AEC-mobileAECM早期移动端,定点运算,效果一般
AEC2 (legacy)桌面端历史方案
AEC3当前默认频域 + Kalman 滤波,2018+ libwebrtc 默认
ML-based AECRNNoise / Krisp-like部分浏览器(实验)和第三方库

Chrome 现代版本默认 AEC3,效果稳定。

3. NS:噪声抑制

3.1 算法分类

类型原理代价
谱减法(spectral subtraction)估计噪声能量谱,从输入谱里减低 CPU,效果普通
维纳滤波按 SNR 加权抑制中,传统 WebRTC 默认
子空间法SVD 分解
RNN(如 RNNoise)神经网络估计语音概率高 CPU,效果显著

WebRTC 内置是 维纳滤波 + 噪声估计,加上等级 0~3 控制激进程度。

3.2 NS 副作用

  • 音质失真:高频损失,"水声"。
  • 语音漏掉:低能量音节被当噪声压掉(如气声辅音)。
  • 音乐失真:把稳定音调当作平稳噪声。

3.3 噪声抑制等级

const stream = await navigator.mediaDevices.getUserMedia({
audio: {
noiseSuppression: true,
// Chrome 非标准实验字段
googNoiseSuppression2: true,
googHighpassFilter: true,
},
});

W3C 标准只有 noiseSuppression: boolean,激进程度由实现决定。要更细粒度,需用 Insertable Streams + 自训练模型。

4. AGC:自动增益

4.1 工作流程

1) 估计当前帧能量(RMS)
2) 与目标电平(约 -18 dBFS)对比
3) 计算增益 g = target_rms / current_rms
4) 平滑过渡(避免一帧爆音、下一帧静音)
5) 防止削波:g 不能让信号超过 0 dBFS

WebRTC 有两种 AGC 模式:

  • 数字 AGC:在采样后做软件增益。
  • 模拟 AGC:调用 OS 控制麦克风硬件增益(Chrome 桌面可用)。

4.2 AGC 失败模式

现象原因
静音时背景噪声被放大到很响AGC 在没有人声时仍试图拉电平
说话后 0.5s 突然增益平滑窗口太长
多人会议中 A 离麦近、B 离远,AGC 全开后 A 爆音数字 AGC 对硬件输入端救不了削波

4.3 关闭 AGC

const stream = await navigator.mediaDevices.getUserMedia({
audio: { autoGainControl: false },
});

对录音、直播主播这种"人手动调音量"的场景,关闭 AGC 是更专业的做法。

5. 浏览器实现差异

5.1 标准约束(W3C MediaTrackConstraints)

const constraints = {
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
sampleRate: 48000,
channelCount: 1,
latency: 0.02, // 20ms 期望延迟
},
};

5.2 各浏览器现状

浏览器AECNSAGC自定义
Chrome / Edge 桌面AEC3WebRTC NS双 AGC标准 + Chrome 私有 goog*
Chrome AndroidAEC3 / 部分用 OSNS数字 AGC标准
Firefox自研 AECNSAGC标准
Safari macOS / iOS使用 iOS/macOS 系统 AudioUnit AEC系统 NS系统 AGC较封闭,约束部分会被忽略
WebView (Android/iOS)跟随系统系统系统受限

:Safari 即使你设 echoCancellation: false,iOS 在 receiver mode(听筒)仍会强制开 AEC。要彻底关只能用 playsinline + 不同 AudioContext 输出路径。

5.3 查询当前应用的约束

const track = stream.getAudioTracks()[0];
console.log('settings', track.getSettings());
console.log('capabilities', track.getCapabilities());
console.log('constraints', track.getConstraints());

settings.echoCancellation 返回真实生效的值(浏览器可能忽略约束)。

6. 选择策略矩阵

场景echoCancellationnoiseSuppressionautoGainControl
普通会议truetruetrue
主播直播(带话筒)falsefalsefalse
音乐演奏 / K 歌falsefalsefalse
在线钢琴课falsefalsetrue
客服外呼truetruetrue
ASR 转写(无回放)falsetruetrue
高质量录音falsefalsefalse

7. 自定义音频处理:AudioWorklet 路径

当默认 3A 不够,或要叠加自训练模型(RNNoise/DeepFilterNet/Krisp)时,用 AudioWorklet + WebRTC Insertable Streams

7.1 整体架构

7.2 关 3A 拿原始音频

const rawStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
sampleRate: 48000,
channelCount: 1,
},
});

7.3 AudioWorklet 处理器

// processor.js
class NoiseGate extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'threshold', defaultValue: 0.02 }];
}

process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const th = parameters.threshold[0];
for (let ch = 0; ch < input.length; ch++) {
const inCh = input[ch];
const outCh = output[ch];
for (let i = 0; i < inCh.length; i++) {
outCh[i] = Math.abs(inCh[i]) < th ? 0 : inCh[i];
}
}
return true;
}
}
registerProcessor('noise-gate', NoiseGate);

7.4 串联到 PeerConnection

async function buildProcessedStream(rawStream) {
const ctx = new AudioContext({ sampleRate: 48000, latencyHint: 'interactive' });
await ctx.audioWorklet.addModule('/processor.js');

const source = ctx.createMediaStreamSource(rawStream);
const node = new AudioWorkletNode(ctx, 'noise-gate', {
parameterData: { threshold: 0.03 },
});
const destination = ctx.createMediaStreamDestination();

source.connect(node).connect(destination);
return destination.stream;
}

const raw = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
});
const processed = await buildProcessedStream(raw);

const pc = new RTCPeerConnection(config);
processed.getAudioTracks().forEach(t => pc.addTrack(t, processed));

7.5 注意事项

  • 采样率:AudioContext 默认 48kHz 与 WebRTC 一致;如果设备是 44.1kHz,会自动重采样。
  • 延迟:AudioWorklet 一帧 128 样本(约 2.6ms @ 48kHz),处理逻辑必须实时。
  • AEC 失效:关 3A 后,自己得搞 AEC,否则会有回声。RNNoise 不带 AEC,叠加用法很有限。
  • CPU:移动端跑 RNNoise 单声道约 2~5% CPU,AEC 自实现更贵。

8. WebRTC Insertable Streams(更底层)

如果要在 RTP 编码后做修改(如端到端加密、音频混音),用 RTCRtpScriptTransform

const sender = pc.getSenders().find(s => s.track?.kind === 'audio');
const worker = new Worker('/audio-transform.js');
sender.transform = new RTCRtpScriptTransform(worker, { kind: 'audio' });
// audio-transform.js
onrtctransform = (event) => {
const { readable, writable } = event.transformer;
readable
.pipeThrough(new TransformStream({
transform(chunk, controller) {
// chunk 是 RTCEncodedAudioFrame
const data = new Uint8Array(chunk.data);
// 例:对 data 做加密、混音、统计
chunk.data = data.buffer;
controller.enqueue(chunk);
},
}))
.pipeTo(writable);
};

适合:端到端加密(E2EE)、自定义 RTP 包标签、跨 PC 的音频中继。

9. 完整代码:可切换 3A 的音频管线

class AudioPipeline {
constructor() {
this.rawStream = null;
this.outStream = null;
this.ctx = null;
this.gainNode = null;
}

async start({ aec = true, ns = true, agc = true, customGate = false } = {}) {
this.rawStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aec,
noiseSuppression: ns,
autoGainControl: agc,
sampleRate: 48000,
channelCount: 1,
},
});

if (!customGate) {
this.outStream = this.rawStream;
return this.outStream;
}

this.ctx = new AudioContext({ sampleRate: 48000, latencyHint: 'interactive' });
await this.ctx.audioWorklet.addModule('/noise-gate.js');
const src = this.ctx.createMediaStreamSource(this.rawStream);
const gate = new AudioWorkletNode(this.ctx, 'noise-gate');
this.gainNode = this.ctx.createGain();
this.gainNode.gain.value = 1.0;
const dest = this.ctx.createMediaStreamDestination();
src.connect(gate).connect(this.gainNode).connect(dest);
this.outStream = dest.stream;
return this.outStream;
}

setGain(value) {
if (this.gainNode) this.gainNode.gain.value = value;
}

async stop() {
this.rawStream?.getTracks().forEach(t => t.stop());
await this.ctx?.close();
this.rawStream = null;
this.outStream = null;
this.ctx = null;
}
}

const pipe = new AudioPipeline();
const stream = await pipe.start({ aec: true, ns: true, agc: false, customGate: true });
stream.getAudioTracks().forEach(t => pc.addTrack(t, stream));

10. 测试与排障

10.1 自检流程

1) 戴耳机听自己的音频(loopback):
- 是否有回声?→ 检查 AEC
- 是否有底噪?→ 检查 NS / 麦克风物理质量
- 大小声音量是否平稳?→ 检查 AGC

2) 远端测试:
- 自己说话,远端是否听到自己?→ 回声(AEC 失效)
- 远端是否听到自己的键盘声?→ NS 不够
- 远端反馈音量忽大忽小?→ AGC 关了或 AGC 跟不上

3) 数据验证:
- getStats.audioLevel 看波形
- getStats.totalAudioEnergy 看累计能量

10.2 getStats 关键字段

async function inspectAudio(pc) {
const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type === 'media-source' && r.kind === 'audio') {
console.log('audioLevel', r.audioLevel); // 0~1
console.log('totalAudioEnergy', r.totalAudioEnergy);
console.log('echoReturnLoss', r.echoReturnLoss);
console.log('echoReturnLossEnhancement', r.echoReturnLossEnhancement);
}
if (r.type === 'inbound-rtp' && r.kind === 'audio') {
console.log('jitter', r.jitter);
console.log('packetsLost', r.packetsLost);
console.log('audioLevel', r.audioLevel);
console.log('concealedSamples', r.concealedSamples);
}
}
}

echoReturnLoss 越大(dB),AEC 越有效。<10dB 通常意味着有可听见回声。

10.3 chrome://media-internals

桌面 Chrome 提供 audio capture/render 详细日志,能看到 AEC 是否在跑、是否检测到双讲。

11. 反模式

反模式后果正确做法
全程关 AEC 让"音质好"对端听到自己回声默认开
移动端硬开 AEC + 用扬声器远场拾音差,回声残留引导用户戴耳机
多设备同时进会议任一设备的扬声器都会让其它设备产生回声服务器端禁止同房间多端音频或 mute 旧端
在 AudioWorklet 里阻塞超过 2ms实时性断裂,出现爆音严格保持轻量
用 ScriptProcessorNode已废弃,性能差用 AudioWorkletNode
关 NS 后不处理噪声编码器把噪声编进比特流关 NS 前要么硬件好要么自训 RNNoise
AGC 关了又不引导用户调音量远场声音听不到关 AGC 必须 UI 显示音量条
设置 sampleRate: 8000 想"省带宽"Opus 仍按 48k 编码,且采样率不匹配引起重采样杂音让 Opus 自适应内部带宽
在 SDP 删 stereo / DTX 字段体验下降,且不省多少不动 SDP,让 Opus 自适应

12. Opus 编码参数(附加)

3A 之后还有 Opus 的参数:

a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1;stereo=1;maxaveragebitrate=64000;usedtx=1
参数含义建议
useinbandfec=1启用 Opus FEC弱网必开
usedtx=1静音时停发包多人会议必开
stereo=1双声道音乐场景开,会议关
maxaveragebitrate最大平均码率会议 32k,音乐 96k+
minptime最小包时长 ms10ms 默认

13. 权威资料