webrtc代码走读(十七)-音频QOS-NetEQ

十七、音频QOS-NetEQ

音频的QoS可以分:音频前处理3A算法、NetEQ两大类。

这张图从音频传输的全链路视角 ,展示了 WebRTC 中 NetEQ(网络均衡器)在接收端的核心作用 ------通过抖动消除(JB)丢包补偿(PLC),解决网络波动导致的音频卡顿、失真问题,同时与发送端的 3A 算法形成闭环。以下从 NetEQ 的角度拆解每一环:

NetEQ 是 WebRTC 接收端的核心模块,负责处理网络传输带来的不确定性 (抖动、丢包),确保音频播放流畅。在图中,它对应红色虚线框内的 "抖动消除(JB)""丢包补偿(PLC)""压缩解码(Decoder)" 模块,是连接"网络传输"与"本地播放"的关键枢纽。

1. 发送端:3A 算法预处理(为 NetEQ 减负)

图中左上的 "AEC(回声消除)→ ANS(噪声抑制)→ AGC(自动增益控制)→ Encoder(压缩编码)" 是发送端的音频处理流程:

  • 3A 算法先把原始音频"提纯"(消回声、降噪、稳音量),再通过编码器压缩。
  • 这一步的意义是:让传输的音频本身质量足够高,减少 NetEQ 后续处理的压力(比如噪声少了,丢包补偿时生成的伪信号更自然)。

2. 网络传输:Internet 中的"不确定因素"

音频包在互联网传输时,会因网络拥堵、路由波动出现抖动 (包到达时间忽快忽慢)或丢包(包直接丢失)。这些问题由图中"Internet"模块体现,也是 NetEQ 要解决的核心痛点。

3. 接收端:NetEQ 的"三大武器"(红色虚线框)

这部分是 NetEQ 的主战场,通过三个模块依次处理,把"不稳定的网络包"变回"流畅的音频":

  • ① 抖动消除(JB)

    • 作用:解决"包到达时间不一致"的问题。
    • 原理:NetEQ 会维护一个自适应抖动缓冲区(Jitter Buffer),把到达时间混乱的包先存起来,再按"匀速、有序"的节奏取出。
    • 类比:就像把"时快时慢的流水"先放进水库,再以稳定的速度放出,避免播放时忽快忽慢。
    • 源码关联:neteq/delay_manager.cc 负责统计网络抖动,动态调整缓冲区大小;neteq/buffer_level_filter.cc 平滑计算抖动延迟。
  • ② 丢包补偿(PLC)

    • 作用:解决"包丢失"的问题。
    • 原理:当检测到丢包时,NetEQ 会基于"历史收到的音频特征"生成伪音频帧(比如用前一帧的音频做插值、或生成舒适噪声),填补丢失的部分。
    • 类比:就像拼图缺了一块,用旁边的图案推测出缺失的部分,让人看不出破绽。
    • 源码关联:neteq/decision_logic.cc 判定丢包场景,neteq/expand.cc 执行丢包补偿算法(如 WSOLA 算法实现"变速不变调"的伪帧生成)。
  • ③ 压缩解码(Decoder)

    • 作用:把发送端压缩的音频包还原成原始 PCM 数据。
    • 原理:和发送端的编码器对应,是"压缩 - 解压缩"的逆过程。
    • 补充:解码后的数据会进入 NetEQ 的算法缓冲区(Algorithm Buffer),供后续 DSP 处理(如加减速、融合)。

4. 本地播放:最终的"音频还原"

处理后的音频经过 "ANS(噪声抑制)→ AGC(自动增益控制)→ DAC(声音播放)" 输出:

  • 这里的 ANS 和 AGC 是接收端的"二次优化"(可选),进一步确保播放时的音量稳定、噪声少。
  • 最后通过 DAC(数模转换)把数字信号变回模拟声音,从扬声器播放出来。

NetEQ 的"协同逻辑":让音频"又稳又好"

从图中可以看出,NetEQ 不是孤立工作的:

  • 发送端的 3A 算法"提纯"音频,减少 NetEQ 处理难度;
  • NetEQ 处理网络波动,确保音频"不断、不卡、不跳";
  • 接收端的 3A 算法"收尾",让最终播放的声音质量拉满。

这种"发送端预处理 + 网络端稳传输 + 接收端后处理"的闭环,是 WebRTC 音频体验的核心保障。

1、NACK丢包重传协议

详细原理

NACK(Negative Acknowledgment,否定确认)是TCP中"确认重传"机制在实时音频中的轻量化实现:当接收端检测到音频包丢失时,通过RTCP协议向发送端发送包含丢失包序号的NACK报文,请求重传。为避免重传导致延迟累积,仅对"近期丢失"的包(通常不超过1个RTT+100ms)发起请求,超时未收到则放弃。

核心作用

  • 弥补少量丢包(1-2个连续包),避免因局部丢包导致音频卡顿;
  • 平衡重传收益与延迟代价(只重传"值得重传"的包)。

WebRTC源码实现(neteq/nack.cc

cpp 复制代码
// 生成NACK请求列表(仅包含未超时的丢失包)
std::vector<uint16_t> Nack::GetNackList(int64_t round_trip_time_ms) {
  std::vector<uint16_t> nack_list;
  rtc::CritScope cs(&crit_);  // 线程安全锁

  for (auto it = missing_packets_.begin(); it != missing_packets_.end();) {
    uint16_t seq = it->first;
    int64_t loss_time_ms = it->second;
    int64_t age_ms = clock_->TimeInMilliseconds() - loss_time_ms;

    // 超时判断:仅保留RTT+100ms内的丢失包(kMaxNackRetransmissionAgeMs=100ms)
    if (age_ms > round_trip_time_ms + kMaxNackRetransmissionAgeMs) {
      it = missing_packets_.erase(it);  // 超时包不再请求
    } else {
      nack_list.push_back(seq);  // 加入请求列表
      ++it;
    }
  }
  return nack_list;
}

// 接收新包时更新丢失包记录(通过序号连续性检测丢失)
void Nack::Update(uint16_t sequence_number) {
  rtc::CritScope cs(&crit_);
  if (last_sequence_number_ != -1) {
    uint16_t start = static_cast<uint16_t>(last_sequence_number_ + 1);
    uint16_t end = sequence_number;
    // 记录[start, end)范围内的所有丢失包
    for (uint16_t seq = start; seq != end; seq++) {
      missing_packets_[seq] = clock_->TimeInMilliseconds();
    }
  }
  last_sequence_number_ = sequence_number;
  missing_packets_.erase(sequence_number);  // 移除已接收的包
}

2、FEC冗余协议(Opus带内FEC)

详细原理

FEC(Forward Error Correction,前向纠错)通过在发送端添加冗余数据,使接收端无需重传即可恢复丢失的包。WebRTC中音频FEC基于Opus编码器的"带内FEC"(RFC 7845):编码器在当前音频帧中嵌入前一帧的压缩冗余数据(不增加额外包),接收端解码器若检测到当前帧丢失,可使用前一帧的冗余数据重建。

核心作用

  • 解决瞬时丢包(1-2帧),避免NACK重传带来的延迟;
  • 轻量化实现(无额外包开销),适合带宽受限场景。

WebRTC源码实现(codecs/opus/opus_encoder_impl.cc

cpp 复制代码
// 初始化Opus编码器并开启带内FEC
int OpusEncoderImpl::Init() {
  // 创建Opus编码器(48kHz采样率,单声道,VOIP优化)
  encoder_ = opus_encoder_create(48000, 1, OPUS_APPLICATION_VOIP, &error_);
  if (error_ != OPUS_OK) return -1;

  // 开启带内FEC(通过Opus控制接口)
  if (enable_fec_) {
    int fec_enabled = 1;  // 1=开启
    error_ = opus_encoder_ctl(encoder_, OPUS_SET_INBAND_FEC, fec_enabled);
    if (error_ != OPUS_OK) return -1;
  }

  // 配置预期丢包率(影响冗余量,丢包率高则冗余数据多)
  error_ = opus_encoder_ctl(encoder_, OPUS_SET_PACKET_LOSS_PERC, packet_loss_perc_);
  return error_ == OPUS_OK ? 0 : -1;
}

// 编码时嵌入冗余数据(当前帧+前一帧冗余)
int OpusEncoderImpl::EncodeImpl(const int16_t* audio,
                                size_t input_len,
                                rtc::Buffer* encoded) {
  std::vector<float> audio_float(input_len);  // 转为浮点输入
  for (size_t i = 0; i < input_len; ++i) {
    audio_float[i] = static_cast<float>(audio[i]);
  }

  // 编码输出包含当前帧数据+前一帧冗余(若FEC开启)
  encoded->Resize(kMaxEncodedBytes);
  int len = opus_encode_float(encoder_, audio_float.data(), input_len,
                             encoded->data(), encoded->size());
  if (len < 0) return -1;
  encoded->Resize(len);
  return len;
}

3、交织编码

详细原理

交织编码通过"拆分-重组"打破音频帧的连续性:将连续N个音频帧(如2帧)各拆分为M个小块(如2块),按"块索引"重组后传输(帧1块0→帧2块0→帧1块1→帧2块1)。接收端解交织时恢复原始顺序,即使丢失连续块,仍能从剩余块中恢复部分帧数据,避免长段音频丢失。

核心作用

  • 对抗连续丢包(如3-4个包丢失),将"长段卡顿"分解为"分散杂音";
  • 不增加数据量,仅改变传输顺序,适合突发丢包场景。

WebRTC源码实现(neteq/interleaver.cc

cpp 复制代码
// 交织:拆分帧并按块索引重组
void Interleaver::Interleave(const std::vector<rtc::Buffer>& input_frames,
                             std::vector<rtc::Buffer>* output_frames) {
  output_frames->clear();
  if (input_frames.empty()) return;

  const size_t kInterleaveDepth = 2;  // 交织深度(每帧拆2块)
  size_t block_size = input_frames[0].size() / kInterleaveDepth;

  // 按块索引重组:先所有帧的第0块,再第1块
  for (size_t block_idx = 0; block_idx < kInterleaveDepth; ++block_idx) {
    rtc::Buffer interleaved_block;
    for (const auto& frame : input_frames) {
      const uint8_t* block_start = frame.data() + block_idx * block_size;
      interleaved_block.AppendData(block_start, block_size);
    }
    output_frames->push_back(interleaved_block);
  }
}

// 解交织:恢复原始帧顺序
void Interleaver::Deinterleave(const std::vector<rtc::Buffer>& input_blocks,
                               std::vector<rtc::Buffer>* output_frames) {
  output_frames->clear();
  if (input_blocks.empty()) return;

  const size_t kInterleaveDepth = input_blocks.size();
  size_t num_frames = input_blocks[0].size() / (input_blocks[0].size() / kInterleaveDepth);
  size_t frame_size = (input_blocks[0].size() * kInterleaveDepth) / num_frames;

  // 拼接各块的对应部分为原始帧
  for (size_t frame_idx = 0; frame_idx < num_frames; ++frame_idx) {
    rtc::Buffer frame;
    for (size_t block_idx = 0; block_idx < kInterleaveDepth; ++block_idx) {
      const uint8_t* block_start = input_blocks[block_idx].data() + frame_idx * (frame_size / kInterleaveDepth);
      frame.AppendData(block_start, frame_size / kInterleaveDepth);
    }
    output_frames->push_back(frame);
  }
}

4、JitterBuff(抗网络抖动)

网络延时统计缓冲BUF延迟统计控制命令决策判定三个子模块组成,协同实现抗抖动。

4.1. 网络延时统计算法

delay_manager.cc

原理

通过统计历史音频包的传输延迟(到达时间-发送时间),计算延迟分布的"95分位值"作为目标缓冲延迟------确保95%的包能在缓冲内等待至平稳输出,覆盖大部分网络抖动。

作用

  • 动态估计网络抖动强度,为缓冲大小提供参考;
  • 避免缓冲过小(导致卡顿)或过大(导致延迟过高)。

源码实现

cpp 复制代码
// 更新网络延迟样本并计算目标缓冲
void DelayManager::Update(int64_t arrival_time_ms, uint32_t rtp_timestamp) {
  int64_t send_time_ms = RtpTimestampToMs(rtp_timestamp);  // RTP时间戳转发送时间
  int64_t one_way_delay_ms = arrival_time_ms - send_time_ms;  // 传输延迟
  if (one_way_delay_ms < 0) one_way_delay_ms = 0;  // 过滤异常值

  // 保留最近kMaxDelaySamples个延迟样本(如100个)
  delay_samples_.push_back(one_way_delay_ms);
  if (delay_samples_.size() > kMaxDelaySamples) {
    delay_samples_.pop_front();
  }

  // 目标延迟=95分位值(覆盖95%的抖动)
  target_delay_ms_ = ComputePercentile(delay_samples_, 0.95);
}
4.2. 缓冲BUF延迟统计算法

buffer_level_filter.cc

原理

对当前缓冲的"水位"(已缓存音频的时长)进行低通滤波,平滑瞬时波动(如突发的包到达),避免因短期波动导致缓冲调整过于频繁(如频繁加速/减速)。

作用

  • 稳定缓冲状态,减少不必要的平滑处理;
  • 为决策模块提供可靠的缓冲水位数据。

源码实现

cpp 复制代码
// 平滑缓冲水位(低通滤波)
void BufferLevelFilter::Update(int buffer_level_ms) {
  const float alpha = 0.8f;  // 平滑系数(经验值)
  // 公式:当前平滑值 = 0.8*上一次平滑值 + 0.2*当前原始值
  filtered_level_ms_ = alpha * filtered_level_ms_ + (1 - alpha) * buffer_level_ms;
}
4.3. 控制命令决策判定

decision_logic.cc

原理

综合网络目标延迟(DelayManager)、当前缓冲水位(BufferLevelFilter)、上一帧处理方式,输出五大处理命令(kNormal/kAccelerate/kExpand/kAlternativePlc/kMerge),决定音频帧的播放策略。

作用

  • 动态平衡缓冲大小与播放延迟,确保音频流畅;
  • 触发丢包补偿或平滑过渡,掩盖网络异常。

源码实现

cpp 复制代码
// 生成处理命令
Operation DecisionLogic::GetDecision(int buffer_level_ms,
                                     int target_delay_ms,
                                     Operation last_operation) {
  // 缓冲过高→加速播放(减少缓冲)
  if (buffer_level_ms > target_delay_ms + kHighThresholdMs) {
    return kAccelerate;
  }
  // 缓冲过低→减速播放(增加缓冲)
  else if (buffer_level_ms < target_delay_ms - kLowThresholdMs) {
    return kExpand;
  }
  // 检测到丢包→丢包补偿
  else if (IsPacketLost()) {
    return kAlternativePlc;
  }
  // 上一帧是融合→继续融合(平滑过渡)
  else if (last_operation == kMerge) {
    return kMerge;
  }
  // 正常状态→正常播放
  else {
    return kNormal;
  }
}
5、音频平滑处理方法

根据JitterBuff的命令,对音频帧进行实时处理,确保播放流畅。

5.1. kNormal(正常播放)

原理 :直接输出解码后的音频帧,无额外处理。
作用 :缓冲状态正常时,保证音频原始质量。
源码NetEqImpl::ProcessNormal 直接复制解码数据到输出。

cpp 复制代码
// 正常播放:直接输出解码后的音频帧
int NetEqImpl::ProcessNormal(const DecodedFrame& frame,
                             AudioFrame* output) {
  // 复制解码后的PCM数据到输出帧
  output->CopyFrom(frame.data(), frame.length(), frame.sample_rate_hz());
  output->set_absolute_capture_timestamp_ms(frame.timestamp_ms());
  return frame.length();  // 返回样本数
}
5.2. kAccelerate(加速播放)

原理 :采用WSOLA(波形相似叠加)算法,在不改变音调的前提下缩短音频时长(如10ms帧压缩至8ms),减少缓冲占用。
作用 :当缓冲过高时,快速消耗缓冲,避免延迟累积。
源码TimeStretch::Process 通过寻找相似波形片段并重叠相加,实现"变速不变调"。

cpp 复制代码
// 加速播放处理
// input:输入音频(解码后)
// input_len:输入样本数
// output_len:目标输出样本数(小于input_len,实现加速)
// output:加速后的音频
int TimeStretch::Process(const int16_t* input,
                         size_t input_len,
                         size_t output_len,
                         int16_t* output) {
  // 1. 对输入信号加汉宁窗(减少频谱泄漏)
  std::vector<float> input_win(input_len);
  ApplyHannWindow(input, input_len, input_win.data());

  // 2. 寻找相似波形片段(确保拼接后平滑无跳跃)
  size_t best_match_pos = FindBestMatch(input_win, input_len, output_len);

  // 3. 重叠相加:将输入的前半段与相似片段的后半段叠加
  OverlapAdd(input_win.data(), best_match_pos, output, output_len);

  return output_len;  // 返回加速后的样本数
}
5.3. kExpand(减速播放)

原理 :延长音频时长(如10ms帧扩展至12ms),通过线性预测(LPC)生成扩展部分,增加缓冲。
作用 :当缓冲过低时,缓慢消耗缓冲,避免后续包到达前缓冲为空(卡顿)。
源码Expand::ProcessSlow 基于LPC系数预测语音趋势,生成扩展片段。

cpp 复制代码
// 减速播放(扩展帧)
// input:输入音频
// input_len:输入样本数
// output_len:目标输出样本数(大于input_len,实现减速)
// output:减速后的音频
int Expand::ProcessSlow(const int16_t* input,
                        size_t input_len,
                        size_t output_len,
                        int16_t* output) {
  // 1. 分帧并提取语音特征(线性预测系数LPC)
  std::vector<float> lpc_coeffs;
  ComputeLpc(input, input_len, &lpc_coeffs);

  // 2. 基于LPC系数生成扩展部分(预测语音趋势)
  std::vector<float> extended(output_len);
  ExtendUsingLpc(lpc_coeffs, input, input_len, extended.data(), output_len);

  // 3. 重叠相加原始帧和扩展部分,保证平滑过渡
  OverlapAddOriginalAndExtended(input, input_len, extended.data(), output_len, output);

  return output_len;
}
5.4. kAlternativePlc(丢包补偿)

原理 :利用上一帧的语音特征(LPC系数)生成伪音频帧,填补丢失的帧,避免播放中断。
作用 :掩盖丢包导致的音频空洞,保证听感连续。
源码Expand::Process 用LPC预测生成伪帧,加入少量噪声提升自然度。

cpp 复制代码
// 丢包补偿:生成伪音频帧
// last_received_frame:上一个正常接收的帧
// last_frame_len:上一帧样本数
// output_len:补偿帧的样本数
// output:生成的伪音频帧
int Expand::Process(const int16_t* last_received_frame,
                    size_t last_frame_len,
                    size_t output_len,
                    int16_t* output) {
  // 1. 用LPC提取上一帧的语音特征(如共振峰频率)
  std::vector<float> lpc_coeffs;
  ComputeLpc(last_received_frame, last_frame_len, &lpc_coeffs);

  // 2. 基于LPC系数预测丢失帧的波形(模仿上一帧的语音趋势)
  std::vector<float> predicted(output_len);
  PredictUsingLpc(lpc_coeffs, last_received_frame, last_frame_len, predicted.data(), output_len);

  // 3. 加入少量舒适噪声,避免生成信号过于单调(提升听感)
  AddComfortNoise(predicted.data(), output_len, 0.01f);  // 噪声强度0.01

  // 4. 转换为16位PCM格式
  for (size_t i = 0; i < output_len; ++i) {
    output[i] = static_cast<int16_t>(predicted[i]);
  }
  return output_len;
}
5.5. kMerge(融合处理)

原理 :将前一帧(如补偿帧)与当前帧(正常帧)的重叠区域加权融合,平滑过渡。
作用 :避免从补偿帧切换到正常帧时的听感跳变。
源码Merge::Process 对重叠区域加窗(前帧衰减、后帧增强)后叠加。

cpp 复制代码
// 融合处理:平滑过渡两帧
// prev_frame:历史帧(如补偿帧)
// curr_frame:当前帧(正常解码帧)
// output_len:融合后样本数
// output:融合后的音频
int Merge::Process(const int16_t* prev_frame,
                   size_t prev_len,
                   const int16_t* curr_frame,
                   size_t curr_len,
                   size_t output_len,
                   int16_t* output) {
  // 1. 计算重叠区域(取两帧的后半段和前半段)
  size_t overlap_len = std::min(prev_len, curr_len) / 2;

  // 2. 对重叠区域加窗(前帧用余弦窗衰减,后帧用余弦窗增强)
  std::vector<float> prev_win(overlap_len);
  std::vector<float> curr_win(overlap_len);
  ApplyOverlapWindows(prev_win.data(), curr_win.data(), overlap_len);

  // 3. 重叠区域融合:前帧*衰减窗 + 后帧*增强窗
  std::vector<float> merged(overlap_len);
  for (size_t i = 0; i < overlap_len; ++i) {
    merged[i] = prev_frame[prev_len - overlap_len + i] * prev_win[i] +
                curr_frame[i] * curr_win[i];
  }

  // 4. 拼接非重叠部分和融合部分
  memcpy(output, prev_frame, (prev_len - overlap_len) * sizeof(int16_t));
  memcpy(output + (prev_len - overlap_len), merged.data(), overlap_len * sizeof(int16_t));
  memcpy(output + prev_len, curr_frame + overlap_len, (curr_len - overlap_len) * sizeof(int16_t));

  return output_len;
}

6、音频NetEQ与视频NetEQ的细化异同

维度 音频NetEQ 视频NetEQ
核心目标 优先保证低延迟 (<500ms)和流畅性,容忍轻微音质损失 优先保证画质完整流畅性,可接受较高延迟(1-3s)
数据特性 帧小(10-60ms/帧,<1KB)、时序敏感(断帧即卡顿) 帧大(33ms/帧,10-100KB)、有冗余信息(如I帧可独立解码)
NACK机制 重传窗口极小(<200ms),超时立即放弃(避免延迟); 源码:neteq/nack.cc 重传窗口大(<1s),允许多次重传(优先保画质); 源码:video_coding/jitter_buffer/nack_module.cc
FEC实现 仅支持带内FEC (Opus帧内嵌入冗余),开销<10%; 无额外包,适合带宽受限 支持带外FEC (单独FEC包),开销20%-50%; 可配置冗余度(如1:1保护),适合重要帧(I帧)
交织策略 细粒度拆分(每帧拆2-4块),延迟影响可忽略; 源码:neteq/interleaver.cc 粗粒度拆分(每GOP拆2块),增加50-100ms延迟; 源码:video_coding/interleaving.cc
JitterBuff 缓冲小(<500ms),调整激进(频繁加速/减速); 目标延迟=95分位抖动 缓冲大(1-3s),调整保守(避免频繁画质波动); 目标延迟=固定值+动态补偿
平滑处理 基于音频波形特征(WSOLA、LPC),注重听感自然 ; 计算量小(毫秒级) 基于图像运动特征(帧复制、运动补偿),注重视觉连贯 ; 计算量大(几十毫秒级)
丢包影响 丢1帧即感知卡顿,需快速补偿(PLC) 丢1帧可通过前后帧掩盖(如P帧依赖前帧),I帧丢失影响大

总结

音频NetEQ是WebRTC针对实时音频设计的"抗网络干扰引擎",通过NACK、FEC、交织编码抵御丢包,JitterBuff动态平衡延迟与缓冲,结合五种平滑处理命令保证播放流畅,核心是"低延迟优先"。与视频NetEQ相比,因音频对延迟更敏感、数据量更小,策略上更轻量化,实现上更注重实时性和听感自然,两者共同构成WebRTC实时媒体传输的质量保障体系。

相关推荐
Elias不吃糖4 小时前
LeetCode每日一练(209, 167)
数据结构·c++·算法·leetcode
Want5954 小时前
C/C++跳动的爱心②
c语言·开发语言·c++
初晴や4 小时前
指针函数:从入门到精通
开发语言·c++
特种加菲猫4 小时前
用户数据报协议(UDP)详解
网络·网络协议·udp
百***86464 小时前
服务器部署,用 nginx 部署后页面刷新 404 问题,宝塔面板修改(修改 nginx.conf 配置文件)
运维·服务器·nginx
铁手飞鹰4 小时前
单链表(C语言,手撕)
数据结构·c++·算法·c·单链表
无限进步_4 小时前
C语言动态内存管理:掌握malloc、calloc、realloc和free的实战应用
c语言·开发语言·c++·git·算法·github·visual studio
苏小瀚5 小时前
[JavaSE] 网络编程
网络
渡我白衣5 小时前
五种IO模型与非阻塞IO
运维·服务器·网络·c++·网络协议·tcp/ip·信息与通信
豐儀麟阁贵5 小时前
7.2内部类
java·开发语言·c++