音频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 提供
latencyHint和googEchoCancellation: 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-mobile | AECM | 早期移动端,定点运算,效果一般 |
| AEC2 (legacy) | — | 桌面端历史方案 |
| AEC3 | 当前默认 | 频域 + Kalman 滤波,2018+ libwebrtc 默认 |
| ML-based AEC | RNNoise / 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 各浏览器现状
| 浏览器 | AEC | NS | AGC | 自定义 |
|---|---|---|---|---|
| Chrome / Edge 桌面 | AEC3 | WebRTC NS | 双 AGC | 标准 + Chrome 私有 goog* |
| Chrome Android | AEC3 / 部分用 OS | NS | 数字 AGC | 标准 |
| Firefox | 自研 AEC | NS | AGC | 标准 |
| 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. 选择策略矩阵
| 场景 | echoCancellation | noiseSuppression | autoGainControl |
|---|---|---|---|
| 普通会议 | true | true | true |
| 主播直播(带话筒) | false | false | false |
| 音乐演奏 / K 歌 | false | false | false |
| 在线钢琴课 | false | false | true |
| 客服外呼 | true | true | true |
| ASR 转写(无回放) | false | true | true |
| 高质量录音 | false | false | false |
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 | 最小包时长 ms | 10ms 默认 |
13. 权威资料
- W3C MediaTrackConstraints(音频部分):https://www.w3.org/TR/mediacapture-streams/
- W3C WebRTC Insertable Streams:https://w3c.github.io/webrtc-encoded-transform/
- W3C AudioWorklet:https://www.w3.org/TR/webaudio/#audioworklet
- libwebrtc AEC3 设计:https://webrtc.googlesource.com/src/+/refs/heads/main/modules/audio_processing/aec3/
- RNNoise:https://github.com/xiph/rnnoise
- Opus RFC 7587:https://www.rfc-editor.org/rfc/rfc7587
- Opus FEC:https://www.rfc-editor.org/rfc/rfc6716
- 核对日期:2026-06-22