webrtc代码走读(六)-QOS-FEC冗余度配置

1、FEC冗余度配置的核心逻辑

  1. 接收端算丢包率,发丢包报告"

    接收端统计收到的数据包,算出"丢了多少比例"(比如10个包丢1个,丢包率10%),然后通过RTCP_RR(一种专门的反馈报文)把这个"丢包率"告诉发送端。

  2. 发送端接"报告",预判未来丢包"

    发送端收到丢包率后,不是直接用这个数值,而是用算法"猜"未来一小段时间(比如接下来1秒)的丢包率。比如现在丢包率10%,但最近3次反馈分别是8%、9%、10%,趋势在上升,就可能预判未来丢包率是11%。这一步是为了"提前准备",避免等丢包严重了再调整,反应太慢。

  3. 按预判丢包率,查表

    发送端有个预设的表,里面记录了"不同丢包率对应多少冗余度"。比如预判丢包率10%,查表格知道I帧(关键帧,重要性高)需要30%冗余,P帧(普通帧,依赖关键帧)需要15%冗余。就像预判中雨,选一把中等大小的伞。

  4. 按冗余度打包FEC,发送

    FEC模块根据确定的冗余度,生成对应的冗余包,和原始媒体包一起发送。比如15%冗余度意味着每发100个媒体包,额外发15个冗余包。这样即使丢了15个以内的包,接收端也能靠冗余包恢复。

2、冗余度从计算到生效的完整链路

上面4步是核心逻辑,实际在WebRTC代码里,这个过程是通过一系列函数"接力"完成的,分为两大链路:"丢包率→冗余度"的计算链路"冗余度→FEC包"的封装链路

2.1 链路1:从丢包率到冗余度的计算

这条链路的核心是"定时触发+反馈处理",简单说就是"接收端发丢包率→发送端定时收、算、调"。用"流程图+通俗解释"拆解:

函数调用流程(从上到下是执行顺序) 通俗解释
线程定时启动:PlatformThread::StartThread → Run WebRTC有个专门的"工作线程",定时(比如每200ms)触发一次"检查网络状况"的任务
ProcessThreadImpl::Process → SendSideCongestionController::Process 线程触发后,先到"发送端拥塞控制器",这里会接收从接收端发来的RTCP_RR报文,提取里面的丢包率数据(比如"最近丢包率10%")。
SendSideCongestionController::MaybeTriggerOnNetworkChanged 拥塞控制器判断:"丢包率变化够大了,需要调整冗余度",就触发"网络变化"事件,把丢包率传给下一个模块。
BitrateAllocator::OnNetworkChanged → VideoSendStreamImpl::OnBitrateUpdated "比特率分配器"收到丢包率后,告诉"视频发送流"模块:"网络丢包变了,你得调整FEC了"。
ProtectionBitrateCalculator::SetTargetRates 专门负责"保护策略计算"的模块接收到消息,开始准备计算冗余度。
media_optimization::VCMLossProtectionLogic::FilteredLoss 这里是"预判丢包率"的核心!用三种算法(后面讲)算出未来的丢包率,比如把当前10%丢包率预判成11%。
VCMFecMethod::ProtectionFactor / VCMNackFecMethod::ProtectionFactor 用预判的丢包率"查表",算出I帧和P帧各自的冗余度(比如I帧30%,P帧15%)。
VideoSendStreamImpl::ProtectionRequest → ModuleRtpRtcpImpl::SetFecParameters 把算好的冗余度(分成key_fec_params关键帧参数、delta_fec_params普通帧参数)传给"RTP/RTCP模块",告诉它"接下来按这个冗余度打包FEC"。

补充说明:除了"定时触发",还有很多场景会触发这个流程,比如突然收到"丢包率从10%涨到30%"的反馈,会立刻触发调整,不用等定时,避免"反应滞后"。

2.2 链路2:从冗余度到FEC包的封装

这条链路的核心是"编码完帧→按冗余度加FEC",简单说就是"视频帧编码好→按之前算的冗余度加冗余包→一起发送"。同样用"流程图+通俗解释"拆解:

函数调用流程(从上到下是执行顺序) 通俗解释
VideoStreamEncoder::EncodeTask::Run → EncodeVideoFrame 视频编码器(比如H264、VP8)把原始视频帧编码成"压缩后的帧"(比如I帧、P帧),就像把大照片压缩成小文件。
H264EncoderImpl::Encode → CMEncodedFrameCallback::OnEncodedImage 编码完成后,触发"编码完成回调",告诉后续模块"帧准备好了,可以加FEC了"。
VideoSendStreamImpl::OnEncodedImage → PayloadRouter::OnEncodedImage "视频发送流"模块把编码帧传给"负载路由器",这里会判断"这是I帧还是P帧",然后选对应的冗余度参数(I帧用key_fec_params,P帧用delta_fec_params)。
ModuleRtpRtcpImpl::SendOutgoingData → RTPSenderVideo::SendVideo 把选好的冗余度参数传给"RTP发送模块",告诉它"按这个参数生成FEC包"。
RTPSenderVideo::SendVideoPacketAsRedMaybeWithUlpfec 这里是"Red+Ulpfec"的核心逻辑,把原始RTP包和FEC冗余包打包在一起(Red协议负责封装,Ulpfec负责生成冗余包)。
UlpfecGenerator::AddRtpPacketAndGenerateFec → ForwardErrorCorrection::NumFecPackets "Ulpfec生成器"根据冗余度(比如15%)计算需要生成多少个冗余包,比如100个媒体包对应15个冗余包。
ForwardErrorCorrection::EncodeFec → GenerateFecPayloads → XorPayloads 最终生成FEC冗余包的"内容"(用异或运算把媒体包数据混合生成冗余数据),然后和原始包一起发送。

3、WebRTC如何"预判未来丢包率"

前面提到,发送端会"预判未来丢包率",WebRTC提供了3种算法,分别对应不同场景,各有优缺点。

3.1 算法1:直接用当前丢包率(最简单,适合稳定网络)

  • 逻辑:不做任何预判,直接把接收端最新反馈的丢包率当"未来丢包率"。比如接收端刚说"现在丢包率10%",就认为未来也会是10%。
  • 适用场景:网络非常稳定,丢包率长时间不变(比如一直5%左右),不需要复杂计算。
  • 缺点:网络波动时反应慢,比如突然丢包率从5%涨到20%,还按5%准备冗余,会导致大量包丢了无法恢复。

3.2 算法2:一阶指数平滑算法(最常用,适合波动网络)

  • 逻辑:把"过去的预判值"和"当前的实际值"按比例混合,新数据权重高,旧数据权重低。就像"今天的天气预报=0.3×昨天的预报+0.7×今天的实际天气",让预报更贴近最新情况,又不会忽高忽低。
  • 公式St+1 = α×xt + (1-α)×St
    其中:
    • St+1:下一次的预判丢包率(比如未来1秒的);
    • α:平滑系数(WebRTC里通常设为0.1~0.3,值越大,越依赖当前数据,反应越快);
    • xt:当前收到的实际丢包率(比如接收端刚反馈的10%);
    • St:上一次的预判丢包率(比如上次算的9%)。
  • 例子:α=0.2,St=9%,xt=10% → St+1=0.2×10% + 0.8×9% = 9.2%,比直接用10%更平缓,避免"一波动就乱调"。
  • 适用场景:网络有小波动(比如5%→7%→10%),需要平滑过渡,避免冗余度频繁调整导致带宽浪费。

3.3 算法3:窗口期最大丢包率(最保守,适合突发丢包)

  • 逻辑:记录最近一段时间(比如最近5秒)内的所有丢包率,取其中最大的值作为"未来丢包率"。比如最近5次反馈是8%、10%、12%、9%、11%,最大是12%,就按12%准备冗余。
  • 逻辑:就像"看到最近一周下过一次大雨,就按大雨的情况准备雨伞",属于"宁可多准备,也不冒险"的保守策略。
  • 适用场景:网络容易突发丢包(比如WiFi突然断一下、4G信号跳变),需要提前按最坏情况准备,避免突发丢包导致画面卡顿。

4、核心函数注释与解读

前面讲了原理和流程,现在结合博文中的源码,把关键函数拆解开,加上详细注释,让你能看懂"代码里到底怎么实现这些逻辑"。

4.1 预判丢包率

(VCMLossProtectionLogic::FilteredLoss)这个函数是"预判未来丢包率"的入口,根据选择的"过滤模式"(3种算法),返回最终的预判丢包率。

cpp 复制代码
/**
 * @brief 计算预判的丢包率(核心函数)
 * @param nowMs 当前时间(毫秒),用于判断历史数据是否过期
 * @param filter_mode 过滤模式(对应3种预判算法:kNoFilter=直接用当前值,kAvgFilter=指数平滑,kMaxFilter=窗口期最大)
 * @param lossPr255 当前收到的实际丢包率(注意:单位是"以255为分母的比例",比如10%丢包率对应25(25/255≈10%))
 * @return uint8_t 预判后的丢包率(同样以255为分母)
 */
uint8_t VCMLossProtectionLogic::FilteredLoss(int64_t nowMs, 
                                             FilterPacketLossMode filter_mode, 
                                             uint8_t lossPr255) {
    // 1. 更新"窗口期最大丢包率"的历史记录(不管用哪种算法,都先记录历史数据,以备后续使用)
    UpdateMaxLossHistory(lossPr255, nowMs);

    // 2. 更新"指数平滑算法"的历史值(为kAvgFilter模式做准备)
    // _lastPrUpdateT:上一次更新指数平滑值的时间
    // 计算"距离上次更新的时间差",用于调整平滑系数(时间越久,当前数据权重越高)
    _lossPr255.Apply(rtc::saturated_cast<float>(nowMs - _lastPrUpdateT), 
                     rtc::saturated_cast<float>(lossPr255));
    _lastPrUpdateT = nowMs; // 更新"上次更新时间"为当前时间

    // 3. 根据选择的过滤模式,计算最终的预判丢包率
    uint8_t filtered_loss = lossPr255; // 默认值:直接用当前丢包率(kNoFilter模式)
    switch (filter_mode) {
        case kNoFilter:
            // 模式1:直接用当前丢包率,不做任何处理
            break;
        case kAvgFilter:
            // 模式2:用指数平滑算法的结果(取整时+0.5是为了四舍五入,比如9.2→9,9.6→10)
            filtered_loss = rtc::saturated_cast<uint8_t>(_lossPr255.filtered() + 0.5);
            break;
        case kMaxFilter:
            // 模式3:用窗口期内的最大丢包率
            filtered_loss = MaxFilteredLossPr(nowMs);
            break;
    }

    return filtered_loss; // 返回预判后的丢包率
}

4.2 指数平滑算法

(ExpFilter::Apply)这个函数是"算法2"的具体实现,负责计算"平滑后的丢包率",对应公式St+1 = α×xt + (1-α)×St

cpp 复制代码
/**
 * @brief 一阶指数平滑算法的核心实现
 * @param exp 时间因子(距离上次更新的时间差,用于调整平滑系数α)
 * @param sample 当前的实际丢包率(即公式中的xt)
 * @return float 平滑后的丢包率(即公式中的St+1)
 */
float ExpFilter::Apply(float exp, float sample) {
    if (filtered_ == kValueUndefined) {
        // 第.一次调用:还没有历史平滑值,直接把当前样本作为初始平滑值
        // 比如第一次收到丢包率10%,平滑值就设为10%
        filtered_ = sample;
    } else if (exp == 1.0f) {
        // 时间因子为1.0:表示距离上次更新刚好是"一个平滑周期",直接用标准公式计算
        // alpha_是预设的平滑系数(比如0.2)
        filtered_ = alpha_ * filtered_ + (1 - alpha_) * sample;
    } else {
        // 时间因子不是1.0:根据时间差调整平滑系数(时间越久,alpha越大,当前样本权重越高)
        // 公式:alpha = alpha_^exp(比如alpha_=0.2,exp=0.5,alpha≈0.447,当前样本权重更高)
        float alpha = std::pow(alpha_, exp);
        filtered_ = alpha * filtered_ + (1 - alpha) * sample;
    }

    // 如果设置了"最大平滑值",确保平滑后的结果不超过这个最大值(避免预判值过高)
    if (max_ != kValueUndefined && filtered_ > max_) {
        filtered_ = max_;
    }

    return filtered_; // 返回平滑后的丢包率
}

4.3 更新窗口期最大丢包率的历史

(UpdateMaxLossHistory)这个函数负责"记录最近一段时间的丢包率",为"算法3(窗口期最大)"提供数据支持,比如记录最近5次的丢包率。

cpp 复制代码
/**
 * @brief 更新"窗口期最大丢包率"的历史记录
 * @param lossPr255 当前的实际丢包率(以255为分母)
 * @param now 当前时间(毫秒),用于判断历史数据是否过期
 */
void VCMLossProtectionLogic::UpdateMaxLossHistory(uint8_t lossPr255, int64_t now) {
    // kLossPrShortFilterWinMs:窗口期长度(比如1000毫秒,即1秒)
    // 检查"最早的历史记录"是否还在窗口期内:如果没过期,且当前丢包率比之前的最大值大,就更新最大值
    if (_lossPrHistory[0].timeMs >= 0 && now - _lossPrHistory[0].timeMs < kLossPrShortFilterWinMs) {
        if (lossPr255 > _shortMaxLossPr255) {
            _shortMaxLossPr255 = lossPr255; // 更新窗口期内的最大值
        }
    } else {
        // 最早的历史记录已过期,需要"移除旧数据,加入新数据"(类似队列的"先进先出")
        // 1. 先判断是否是第一次添加数据
        if (_lossPrHistory[0].timeMs == -1) {
            // 第一次添加:直接把当前丢包率作为初始最大值
            _shortMaxLossPr255 = lossPr255;
        } else {
            // 不是第一次:把历史数据"往后挪一位"(移除最旧的,空出第一位)
            for (int32_t i = (kLossPrHistorySize - 2); i >= 0; i--) {
                _lossPrHistory[i + 1].lossPr255 = _lossPrHistory[i].lossPr255; // 丢包率数据后移
                _lossPrHistory[i + 1].timeMs = _lossPrHistory[i].timeMs;       // 时间戳后移
            }
            // 重置最大值:如果之前的最大值是"被移除的旧数据",需要重新计算最大值
            if (_shortMaxLossPr255 == 0) {
                _shortMaxLossPr255 = lossPr255;
            }
        }
        // 2. 在第一位加入当前的新数据(最新的丢包率和时间戳)
        _lossPrHistory[0].lossPr255 = lossPr255;
        _lossPrHistory[0].timeMs = now;
    }
}

4.4 获取窗口期内的最大丢包率

(MaxFilteredLossPr)这个函数是"算法3"的核心,从历史记录中找出"窗口期内最大的丢包率",作为预判值。

cpp 复制代码
/**
 * @brief 从历史记录中获取"窗口期内的最大丢包率"
 * @param nowMs 当前时间(毫秒),用于过滤过期的历史数据
 * @return uint8_t 窗口期内的最大丢包率(以255为分母)
 */
uint8_t VCMLossProtectionLogic::MaxFilteredLossPr(int64_t nowMs) const {
    uint8_t maxFound = _shortMaxLossPr255; // 初始值:当前记录的最大值

    // 如果没有任何历史数据(第一次调用),直接返回初始最大值
    if (_lossPrHistory[0].timeMs == -1) {
        return maxFound;
    }

    // 遍历所有历史记录,找出"在窗口期内"的最大丢包率
    for (int32_t i = 0; i < kLossPrHistorySize; i++) {
        // 1. 如果当前历史记录未初始化(timeMs=-1),跳出循环(后面的都是空的)
        if (_lossPrHistory[i].timeMs == -1) {
            break;
        }

        // 2. 过滤过期数据:如果历史记录的时间距离现在超过"窗口期总长度"(比如5秒),跳出循环
        // kLossPrHistorySize:历史记录的最大数量(比如5条)
        // kLossPrShortFilterWinMs:单条记录的窗口期(比如1秒),总窗口期=5×1=5秒
        if (nowMs - _lossPrHistory[i].timeMs > kLossPrHistorySize * kLossPrShortFilterWinMs) {
            break;
        }

        // 3. 如果当前历史记录的丢包率比"已找到的最大值"大,更新最大值
        if (_lossPrHistory[i].lossPr255 > maxFound) {
            maxFound = _lossPrHistory[i].lossPr255;
        }
    }

    return maxFound; // 返回窗口期内的最大丢包率
}

5、总结:WebRTC FEC冗余度配置的核心要点

  1. 核心思想:"按需调整,不浪费不不足"------根据网络丢包率动态改冗余度,平衡抗丢包能力和带宽消耗。
  2. 关键流程:接收端算丢包率→发送端预判丢包率→查表格定冗余度→按冗余度封FEC包,四步闭环。
  3. 算法选择
    • 稳定网络用"直接用当前丢包率"(简单);
    • 波动网络用"指数平滑"(平衡反应速度和平滑度);
    • 突发丢包用"窗口期最大"(保守,避免风险)。
  4. 代码核心 :关键函数是FilteredLoss(选算法算预判丢包率)、ExpFilter::Apply(指数平滑)、MaxFilteredLossPr(窗口期最大),理解这几个函数就能掌握冗余度计算的核心。

通过这套机制,WebRTC能在复杂的网络环境中,自动调整FEC的"力度",既保证音视频不卡顿,又不浪费过多带宽,这也是它能成为主流实时音视频框架的重要原因之一。

相关推荐
飞飞是甜咖啡4 小时前
网络渗流:爆炸渗流
网络
Aevget4 小时前
从复杂到高效:QtitanNavigation助力金融系统界面优化升级
c++·qt·金融·界面控件·ui开发
jf加菲猫5 小时前
条款20:对于类似std::shared_ptr但有可能空悬的指针使用std::weak_ptr
开发语言·c++
tft36405 小时前
An attempt was made to access a socket in a way forbidden by its access
服务器·网络·tcp/ip
tan180°5 小时前
Linux网络HTTP(下)(9)
linux·网络·http
jf加菲猫5 小时前
条款21:优先选用std::make_unique、std::make_shared,而非直接new
开发语言·c++
scx201310045 小时前
20251019状压DP总结
c++
baynk6 小时前
传输层协议分析
网络·协议分析
m0_748240256 小时前
C++ 游戏开发示例:简单的贪吃蛇游戏
开发语言·c++·游戏