流媒体弱网优化之路(FEC+mediasoup)——mediasoup的Nack优化以及FEC引入
——
我正在的github给大家开发一个用于做实验的项目 —— github.com/qw225967/Bifrost
目标:可以让大家熟悉各类Qos能力、带宽估计能力,提供每个环节关键参数调节接口并实现一个json全配置,提供全面的可视化算法观察能力。
欢迎大家使用
——
文章目录
一、mediasoup上行情况
最近对mediasoup进行了一部分的上行弱网优化工作,在这里对之前的优化工作做一些总结和分析。按照惯例,在开始讲关键内容前先把mediasoup上行数据传输以及控制手段进行简单的介绍。
例如上述的图中展示的数据流向。发送端中完整地使用了webrtc中的tcc带宽估计以及Nack模块。当数据包传输时,可以识别网络的带宽突变,同时也具备一定的丢包重传能力。
二、纯Nack优化的效果
2.1 方案
事实上,这远远达不到我们生产开发中的要求。在实际的测试中,我们拉流的播放端在不扩大缓存(增大延迟)、慢放的情况下,10%的丢包就已经出现比较明显的卡顿情况。这类卡顿造成的原因主要来自于下行数据包的延迟到达或者丢失。因此,我们前段时间做的Nack优化提出了——在保证数据包完整到达的同时还要保证足够快地补偿给对端 弱网优化之路(NACK)——纯NACK方案的优化探索。
根据我们的Nack优化思路,我们把 RTC/NackGenerator.cpp 部分函数的代码修改成了这样:
/* Static. */
constexpr size_t MaxPacketAge{ 10000u };
constexpr size_t MaxNackPackets{ 1000u };
constexpr uint32_t DefaultRtt{ 100u };
// constexpr uint8_t MaxNackRetries{ 10u };
// constexpr uint64_t TimerInterval{ 40u };
// =================== nack test ===================
constexpr uint8_t MaxNackRetries{ 20u };
constexpr uint64_t TimerInterval{ 20u };
// =================== nack test ===================
...
// =================== nack test ===================
double NackGenerator::CalculateRttLimit2SendNack(int tryTimes) {
return tryTimes < 3 ? (double)(tryTimes*0.4) + 1 : 2;
}
// =================== nack test ===================
std::vector<uint16_t> NackGenerator::GetNackBatch(NackFilter filter)
{
MS_TRACE();
uint64_t nowMs = DepLibUV::GetTimeMs();
std::vector<uint16_t> nackBatch;
auto it = this->nackList.begin();
while (it != this->nackList.end())
{
NackInfo& nackInfo = it->second;
uint16_t seq = nackInfo.seq;
// clang-format off
if (
filter == NackFilter::SEQ &&
nackInfo.sentAtMs == 0 &&
(
nackInfo.sendAtSeq == this->lastSeq ||
SeqManager<uint16_t>::IsSeqHigherThan(this->lastSeq, nackInfo.sendAtSeq)
)
)
// clang-format on
{
nackBatch.emplace_back(seq);
nackInfo.retries++;
auto oldMs = nackInfo.sentAtMs;
nackInfo.sentAtMs = nowMs;
if (oldMs == 0) {
oldMs = nowMs;
}
if (nackInfo.retries >= MaxNackRetries)
{
MS_WARN_TAG(
rtx,
"sequence number removed from the NACK list due to max retries [filter:seq, seq:%" PRIu16
"]",
seq);
it = this->nackList.erase(it);
}
else
{
++it;
}
continue;
}
// =================== nack test ===================
auto limit_var = uint64_t(this->rtt/CalculateRttLimit2SendNack(nackInfo.retries));
if (filter == NackFilter::TIME && nowMs - nackInfo.sentAtMs >= limit_var)
// =================== nack test ===================
// if (filter == NackFilter::TIME && nowMs - nackInfo.sentAtMs >= this->rtt)
{
nackBatch.emplace_back(seq);
nackInfo.retries++;
auto oldMs = nackInfo.sentAtMs;
nackInfo.sentAtMs = nowMs;
if (oldMs == 0) {
oldMs = nowMs;
}
if (nackInfo.retries >= MaxNackRetries)
{
MS_WARN_TAG(
rtx,
"sequence number removed from the NACK list due to max retries [filter:time, seq:%" PRIu16
"]",
seq);
it = this->nackList.erase(it);
}
else
{
++it;
}
continue;
}
++it;
}
...
也就是:
1.将定时器间隔从 40ms/次 下调到 20ms/次;
2.将最大重传次数从 10次 上调至 20次(还可以增大到40次);
3.最后是根据 rtt 我们重传次数越多的包要的越频繁。
2.2 结果
整体测试方案:
1.同一局域网中的两台安卓进行推拉流测试;
2.弱网限制由 Windows 机器中的 Network Emulator Client 软件进行模拟;
3.将拉流画面按 15 帧切割后逐帧对比变化程度(使用OpenCV编写的对比demo);
4.两张图片相同则认为发生130ms的卡顿(15帧每秒——每帧 66.6ms ),后续依次类推计算出卡顿率(具体对比的demo和方法可以参考我的 github);
5.得出结论包括:
客观(卡顿率变化、卡顿时长占比、卡顿次数占比、带宽变化);
主观(观看感受、画面质感)。
客观结果展示:
卡顿率统计: 20%丢包+100ms延迟场景
wireshark抓包信息显示:
旧方案:无法抵抗20%丢包,没有全补上包导致丢包带宽估计算法降码率又重新上升探测的过程,影响观看体验。
新方案:可以抵抗20%丢包,完全补上包没有触发丢包带宽估计,同时nack导致码率上升,从1.6m ~ 1.8m左右(12.5%)
由上面的内容变化来看,实测卡顿率有效降低13.25%(取多次测试中间值)。同时,nack补偿会带来12.5%的带宽上涨。
主观体验:
事实上,整体画面的质感变化不大,但是Nack新方案缩短了重传间隔,因此中间画面跳跃时长更短。以上介绍的内容只是纯nack方案优化带来的,但实际上10%丢包的环境也无法做到无感知抵抗(在不增加延迟弱网的情况,如果加上延迟卡顿会更严重)。如果需要继续缩短补包间隔来达到降低卡顿率的目的,需要考虑增加FEC。
三、mediasoup中引入FEC
3.1 FEC方案
UlpFEC方案:
目前WebRTC的FEC方案有两个——UlpFEC、FlexFEC。
UlpFEC在WebRTC中使用了RED进行封装(客户端使用原生的WebRTC打开,UlpFEC必须使用RED封装),具体的就不展开讨论了,而FlexFEC是可以单独进行封装的。这两种方式的实现难度会有所区别。
UlpFEC中,FEC包会和普通的RTP包进行统一打包,也就意味着数据包的序列号是一起统计的。例如,现在有三个视频包和一个FEC包,那么这三个视频包的序列号和FEC包的序列号会合在一起——1(RTP)——2(RTP)——3(FEC)——4(RTP)。很显然,这样会影响我们mediasoup中的Nack队列的统计(因为我们的Nack队列是序号相减计算出来的)。
而且mediasoup本身就不支持RED,那么实现起来我们还需要调整很多的东西,因此传统的UlpFEC的实现方式暂时不考虑。
FlexFEC方案:
FlexFEC其实底层的算法支持也还是在使用UlpFEC封装的,在这我们只是把他新的实现方式与旧的做一个区分,因此称为FlexFEC。
客户端
我们使用FlexFEC则比UlpFEC要方便的多。因为在上层不强制使用RED封装,而是提供了一个开关,例如客户端以下代码展示:
fieldtrials=”WebRTC-FlexFEC-03/Enabled/WebRTC-FlexFEC-03-Advertised/Enabled”
// 1.假设你的sdk在call上层进行封装的
// 那么你只能使用call中提供的设置流codec的接口,将flexfec的保护ssrc设置进去,例如:
...
cricket::StreamParams stream;
stream.ssrcs.push_back(ssrc);
stream.ssrcs.push_back(ssrc);
stream.AddFecFrSsrc(ssrc, ssrc); // 设置flexfec的保护ssrc和自己的ssrc,可以设置成一样的。
...
VideoChannel->->media_channel()->AddSendStream(stream);
...
// 2.假设你的sdk在call下层进行封装的
// 那么你只需要在创建 VideoSendStream 时将flexfec一起设置进去即可,例如:
...
config.suspend_below_min_bitrate = video_config_.suspend_below_min_bitrate;
config.periodic_alr_bandwidth_probing =
video_config_.periodic_alr_bandwidth_probing;
config.encoder_settings.experiment_cpu_load_estimator =
video_config_.experiment_cpu_load_estimator;
config.encoder_settings.encoder_factory = encoder_factory_;
config.encoder_settings.bitrate_allocator_factory =
bitrate_allocator_factory_;
config.encoder_settings.encoder_switch_request_callback = this;
config.crypto_options = crypto_options_;
config.rtp.extmap_allow_mixed = ExtmapAllowMixed();
config.rtcp_report_interval_ms = video_config_.rtcp_report_interval_ms;
std::vector<uint32_t> protected_ssrcs;
protected_ssrcs.push_back(sp.ssrcs[0]);
config.rtp.flexfec.ssrc = ssrc;
config.rtp.flexfec.protected_media_ssrcs = protected_ssrcs;
...
服务端
服务端部分需要添加FEC解码模块,并且应该在NACK模块之前,这样可以减少不必要的重传。mediasoup的WebRTC代码还是m72版本的,因此我们将m72的fec解码部分全部迁移到mediasoup的目录中。
随后在 RtpStreamRecv 接收rtp包的部分进行解码,解出来的数据上抛回producer。
// RTC/RtpStreamRecv.hpp
// 接收流部分添加flexfec解码对象,同时仿照 ReceivePacket 函数新增一个 FecReceivePacket 函数。
class RtpStreamRecv : public RTC::RtpStream,
public RTC::NackGenerator::Listener,
public Timer::Listener
{
...
/* ---------- flexfec ---------- */
bool FecReceivePacket(RTC::RtpPacket* packet, bool isRecover);
bool IsFlexFecPacket(RTC::RtpPacket* packet);
std::unique_ptr<webrtc::FlexfecReceiver> flexfecReceiver;
/* ---------- flexfec ---------- */
...
}
/* -------------------------------- 分割线 -------------------------------- */
// RTC/RtpStreamRecv.cpp
/* ---------- flexfec ---------- */
bool RtpStreamRecv::IsFlexFecPacket(RTC::RtpPacket* packet) {
if (packet == nullptr) return false;
return packet->GetPayloadType() == params.fecPayloadType ? true : false;
}
bool RtpStreamRecv::FecReceivePacket(RTC::RtpPacket* packet, bool isRecover){
if (this->params.useFec) {
MS_WARN_TAG(rtp,
"fec packet receive [ssrc:%" PRIu32 ", payloadType:%" PRIu8 ", seq:%" PRIu16,
packet->GetSsrc(),
packet->GetPayloadType(),
packet->GetSequenceNumber());
// 所有数据都入 fec 解码模块
if (flexfecReceiver) {
webrtc::RtpPacketReceived parsed_packet(nullptr);
if (!parsed_packet.Parse(packet->GetData(), packet->GetSize())) {
MS_WARN_TAG(rtp, "receive fec packet but parsed_packet failed!");
return false;
}
// 如果是恢复包打上恢复记号
parsed_packet.set_recovered(isRecover);
flexfecReceiver->OnRtpPacket(parsed_packet);
} else {
MS_WARN_TAG(rtp, "receive fec packet but receiver is not exit");
// do not things
}
}
if (isRecover)
this->AddFecRepaired();
// fec包跳过后续流程
if (IsFlexFecPacket(packet)) {
packetFecCount++;
return false;
}
return true;
}
/* ---------- flexfec ---------- */
};
在producer部分,我们需要继承flexfec的回调类,来承接恢复的数据。
// RTC/Producer.hpp
class Producer : public RTC::RtpStreamRecv::Listener,
public RTC::KeyFrameRequestManager::Listener,
public Timer::Listener,
public webrtc::RecoveredPacketReceiver
{
...
// 新增传入参数 isRecover
ReceiveRtpPacketResult ReceiveRtpPacket(RTC::RtpPacket* packet, bool isRecover);
private:
/* ---------- flexfec ---------- */
void OnRecoveredPacket(const uint8_t *packet, size_t length) override;
/* ---------- flexfec ---------- */
...
}
/* -------------------------------- 分割线 -------------------------------- */
// RTC/Producer.cpp
Producer::ReceiveRtpPacketResult Producer::ReceiveRtpPacket(RTC::RtpPacket* packet, bool isRecover)
{
...
// Media packet.
if (packet->GetSsrc() == rtpStream->GetSsrc())
{
result = ReceiveRtpPacketResult::MEDIA;
// FEC 或 media 都包进入模块进行处理
if(!rtpStream->FecReceivePacket(packet, isRecover)) {
return result;
}
// Process the packet.
if (!rtpStream->ReceivePacket(packet))
{
// May have to announce a new RTP stream to the listener.
if (this->mapSsrcRtpStream.size() > numRtpStreamsBefore)
NotifyNewRtpStream(rtpStream);
return result;}
}
// RTX packet.
else if (packet->GetSsrc() == rtpStream->GetRtxSsrc())
{
result = ReceiveRtpPacketResult::RETRANSMISSION;
isRtx = true;
// Process the packet.
if (!rtpStream->ReceiveRtxPacket(packet))
return result;
}
// Should not happen.
else
{
MS_ABORT("found stream does not match received packet");
}
...
}
...
/* ---------- flexfec ---------- */
void Producer::OnRecoveredPacket(const uint8_t* packet, size_t length) {
RTC::RtpPacket* recover_packet = RTC::RtpPacket::Parse(packet, length);
if (!recover_packet){
MS_WARN_TAG(rtp, "fec recover packet data is not a valid RTP packet");
return;
}
this->ReceiveRtpPacket(recover_packet, true);
MS_WARN_TAG(rtp,
"fec packet recover [ssrc:%" PRIu32 ", payloadType:%" PRIu8 ", seq:%" PRIu16,
recover_packet->GetSsrc(),
recover_packet->GetPayloadType(),
recover_packet->GetSequenceNumber());
}
/* ---------- flexfec ---------- */
在Transport中还需要添加调用Producer的接收函数时传入isRecover值为true。
// RTC/Transport.cpp
void Transport::ReceiveRtpPacket(RTC::RtpPacket* packet)
{
...
// Pass the RTP packet to the corresponding Producer.
auto result = producer->ReceiveRtpPacket(packet, false);
...
}
3.2 结果讨论
按照上述的方式完成后,我们进行了丢包+100ms延迟的测试。
可见整体的卡顿率都有大幅的下降。
最重要的是,在10%丢包的场景下,已经基本实现了无感知的优化(接收端不做快慢放,不增加jitter)。