两个人同时说话,扬声器最终只能收到一路播放数据。最直观的做法是把两个波形逐样本相加;问题是相加后的峰值可能超过 16 位 PCM 的表示范围。直接截断会失真,简单地除以人数又可能让只说话的那个人变小声。

WebRTC 的默认选择是:接收端逐路取出 10 ms 音频,统一格式后用浮点数求和,再用 Limiter 控制合成音频的峰值。 本文以本地源码提交 c8bca1867e(2026-10-22)为基线,先看播放链路,再比较三种混音方法。

一、WebRTC 多路音频流的混音流程

混音发生在播放端的哪一处

一个远端音频流对应一个接收源。它自己的 NetEQ 负责抖动缓冲、解码与丢包隐藏;混音器拿到的是各路 PCM 帧,不是把 Opus 或 RTP 包直接相加。AudioState 在接收流加入、退出时把源注册到混音器;音频设备需要下一段播放数据时,AudioTransportImpl 才调用 Mix() 拉取并合成。接收源注册 · 播放回调

flowchart LR A["远端流 A<br/>RTP → NetEQ → PCM"] --> M["AudioMixerImpl<br/>每次拉取 10 ms"] B["远端流 B<br/>RTP → NetEQ → PCM"] --> M C["远端流 C<br/>RTP → NetEQ → PCM"] --> M M --> F["统一采样率与声道<br/>求和 → Limiter"] F --> R["播放反向音频处理<br/>供回声消除参考"] R --> D["重采样为设备格式<br/>扬声器播放"]

这条链路由播放时钟驱动:每次播放回调从每个源要下一段 10 ms 音频。NetEQ 各自处理网络抖动与时钟差异;混音器并不把不同流的 RTP 时间戳逐样本配对。混合后的声音进入音频处理的反向路径,供回声消除使用,随后按播放设备的采样率重采样。Chromium 的 PullRenderData() 路径会跳过这里的反向音频处理,因为 Chromium 自己管理对应的 APM 实例。ChannelReceive::GetAudioFrameWithInfo · AudioTransportImpl

一次 Mix() 做了什么

flowchart TD A["收集已注册源的 PreferredSampleRate"] --> B["选混音采样率<br/>8 / 16 / 32 / 48 kHz 中满足最高需求的最低档"] B --> C["逐路请求 10 ms AudioFrame<br/>源内部按要求重采样"] C --> D{"帧状态"} D -->|"正常"| E["加入混音列表"] D -->|"静音或错误"| X["不加入本帧求和"] E --> F["转换到调用方要求的声道数"] X --> G F --> G["0 路:静音<br/>1 个已注册源:直通<br/>多源:浮点求和与可选 Limiter"] G --> H["输出 16 位 PCM 的 AudioFrame"]

默认采样率不是把所有流降到最低:它取各源首选采样率的最大值,再向上选到支持的原生采样率,最高 48 kHz;没有源时使用 48 kHz。输出声道数由播放端指定,常见设备路径是单声道或双声道。源的采样率转换发生在各自的 ChannelReceive 中,声道转换发生在求和之前。DefaultOutputRateCalculator · FrameCombiner

这里的“多源”依据已注册源数,并非这一帧实际发声的人数。两个源仍在房间里、其中一个静音时,静音帧不参与求和,但默认流程仍经过 Limiter。音量较低时其增益通常为 1;若前一刻出现过高峰,增益恢复过程仍可能影响这段声音。这一点从 AudioMixerImpl::Mix() 传入的 number_of_streams 和 FrameCombiner::Combine() 的分支可以直接看到。AudioMixerImpl · FrameCombiner

二、混音实现:先相加,再限制峰值

为什么“相加”是混音的基础

当各路帧已经处于相同采样率、相同声道布局和同一播放时段时,每个输出声道的样本可以写成:

s[t, c] = x₁[t, c] + x₂[t, c] + … + xₖ[t, c]

其中 t 是采样点,c 是声道,k 是参与混音的流数。WebRTC 在 FrameCombiner 中把 16 位输入转换到 FloatS16 浮点缓冲区逐样本累加,再转换回 16 位 PCM。浮点缓冲区允许中间和超过 32767,避免在累加途中就溢出;它本身没有解决最终输出的削顶问题。求和实现 · FloatS16ToS16

Limiter 怎样压住叠加后的峰值

flowchart LR A["已求和的浮点音频<br/>可超出 ±32768"] --> B["10 ms 切成 20 段<br/>每段 0.5 ms"] B --> C["估计所有声道的最大绝对值<br/>上升快、下降慢"] C --> D["查增益曲线<br/>低电平保持 1,高电平降低增益"] D --> E["逐样本插值增益<br/>所有声道共用"] E --> F["硬钳制到 16 位范围<br/>输出 PCM"]

Limiter 使用的是峰值估计,不是说话人数,也不是平均音量。估计器将 10 ms 帧分成 20 个子帧,在所有声道中取最大绝对值;峰值升高时迅速响应,降低时约按 20 ms 衰减 1 dB。增益曲线在约 −0.75 dBFS 以下保持原样,接着通过软拐点逐渐压低;高电平区使用约 5:1 的压缩关系。目标增益在子帧间逐样本插值,所有声道共用同一增益,避免声道间电平比例突然变化。FixedDigitalLevelEstimator · InterpolatedGainCurve · Limiter

Attack / decay:让峰值估计快升慢降

Attack / decay 是控制状态量变化速度的常用方法:根据新观测值相对旧状态的变化方向,选择不同的平滑系数。具体哪一方向要快,取决于控制目标。 Limiter 要及时抑制突发峰值,所以峰值估计采用“快速上升、缓慢下降”;高峰过去后,目标增益才逐渐恢复,避免声音忽大忽小。

FixedDigitalLevelEstimator::ComputeLevel() 先在每个 0.5 ms 子帧、所有声道中取最大绝对样本值 x,再用上一子帧的估计值 y_prev 更新包络:

1
2
x > y_prev(attack): y = x                          // 系数为 0,立即跟随峰值
x ≤ y_prev(decay) : y = 0.0028741 × x + 0.9971259 × y_prev

下降系数 0.9971259 每 0.5 ms 应用一次;当输入降为零,估计值约每 20 ms 下降 1 dB。若输入只是变小而非归零,估计值会向新的电平靠近,下降速度不能简单等同于 1 dB / 20 ms。filter_state_level_ 会把估计状态延续到下一子帧、下一次 Process() 调用。电平估计与系数

Limiter 的峰值估计在 attack 阶段迅速上升、decay 阶段缓慢下降

图中橙线是输入子帧的峰值,蓝线是平滑后的估计值。代码还会把相邻下一子帧更高的峰值提前一个子帧,让后续增益插值更早开始下降;图中的 0.5 ms 提前量在整张时间图上几乎不可见。这不是跨 10 ms 帧的预读,也不能保证突发峰值完全没有削顶。注意图示的是电平估计:估计值上升时目标增益下降,估计值缓慢回落时目标增益才缓慢上升;真正施加到音频样本上的增益还会在 ComputePerSampleSubframeFactors() 中逐样本插值。

同样的非对称平滑也用于 WebRTC 其他音频控制环节,例如 AGC2 的饱和保护器根据峰值余量的变化选用不同系数;噪声底估计器则是慢升、快降。它们都采用按方向调整响应速度的思路,但系数和目标不同。

两路音频相加后的波形示例

假设 A、B 是同相的正弦波,各自峰值为 20000。浮点求和后,峰值变为 40000,已越过 16 位 PCM 的 +32767 上限。下图四条轨道使用相同的纵向比例,便于直接比较波形高度。

两路同相音频先求和再限幅的波形示例

在这种持续高电平、增益已稳定的例子中,40000 超过增益曲线约 +1 dBFS 的最高输入区间,因此目标增益约为 32768 / 40000 ≈ 0.819。Limiter 把这个增益施加到整个混音波形,再将结果钳制为 16 位范围;图中绿色波形的峰值约为 32767。红色虚线表示若直接把 40000 的波形硬截断,超出范围的部分会被削成平顶。实际实现会按 0.5 ms 子帧估计电平,并在子帧间逐样本平滑增益,因此输入刚变大时的输出不会立刻等同于图中的稳定状态。增益曲线超上限分支 · 增益插值与钳制

Limiter 不能保证完全没有削顶:它没有整帧预读的 look-ahead,突发峰值的最初几个样本可能在增益降下来前被最终的硬钳制截断。它解决的是多路音频相加时的常见电平过高问题,不是对每路说话人做响度均衡。Limiter::ScaleSamples

三种实现放在同一个例子里

假设有两路单声道音频,输出是 16 位 PCM。先看一个采样时刻:A 与 B 同相位,各为 20000;再看只有 A 发声、B 静音的时刻。下表中的 Limiter 数值指稳定高电平后的近似结果,不代表突发峰值第一样本的保证值。

flowchart LR X["同声:A=20000,B=20000<br/>原始和=40000"] --> D["直接求和<br/>最终截断为 32767"] X --> N["固定按 2 路平均<br/>40000 / 2 = 20000"] X --> L["求和 + Limiter<br/>峰值附近降低增益,约 32767"] Y["单声:A=20000,B=0"] --> D2["直接求和<br/>20000"] Y --> N2["固定按 2 路平均<br/>10000"] Y --> L2["求和 + Limiter<br/>通常保持约 20000"]
方法两路同时较响只有一路发声主要取舍
直接求和,输出时硬截断超过 16 位范围的波峰被削平,出现谐波失真保留原音量实现最简单,但多路同声时听感风险最高
固定按人数归一化:(A+B)/N峰值容易控制若 N=2,单人也被压低 6 dB 左右电平界限直观,却牺牲单人音量;按活跃人数调整又需要平滑切换
WebRTC 默认:浮点求和 + Limiter按实际混音峰值降低增益,减少持续削顶低电平通常不衰减保留单人响度,同时应对同声峰值;高电平时有动态压缩和少量处理开销

固定按人数平均对“所有输入都在同一时刻取最大同号样本”的情况确实稳妥,但人数越多,只有一个人说话时就越轻。若改为按活跃人数平均,加入、静音、退出会改变分母:两人说话时 A 的增益为 1/2,B 刚停止后 A 的增益变为 1,如果不做渐变就会突然增大。直接求和没有这个音量跳变,却要承受峰值硬截断。

WebRTC 因而把控制量放在实际叠加后的峰值上:低电平保留,峰值逼近上限时逐渐压低,并通过插值减少增益突变。这是从源码行为作出的工程解释,不是官方关于历史选型的原话。它也不排斥业务层先给某一路设置音量:例如主持人声音高于背景音乐时,仍应先调每路增益,再让 Limiter 处理最终峰值。接收流增益入口

总结: WebRTC 默认混音器为实时播放提供了一条清晰的基线:逐路 NetEQ 取音频、统一格式、浮点相加、限制峰值、交给设备播放。它优先保持单人讲话的自然音量,并控制多人同时发声时的失真;多人持续高声、音乐混音或空间音频等场景,仍需结合逐路增益、声道布局和主观听感进一步设计。