深入浅出mediasoup—媒体处理

mediasoup 的媒体处理基于一个设计良好的转发框架。mediasoup 转发框架层次分明,结构清晰,高性能,且易扩展,支持包括 WebRTC 在内的多种协议接入。本文主要分析 mediasoup 的转发框架及框架之上的媒体流相关处理。

1. 转发框架

1.1. 静态结构

mediasoup 的转发架构设计精巧,整个框架由几个关键组件构成,要理解其设计精要,需要理解架构中几个重要组件的功能及它们之间的关系:

1)Router

Router 负责媒体流的路由。Router 维护了 producer 和 consumer 之间的订阅关系,并基于订阅关系在 Producer 和 Consumer 之间转发报文。

2)Transport

Transport 负责报文的收发,建立在 Router 之上。Transport 是上层转发框架与底层网络之间的接口。当前支持 WebRtcTransport、PlainTransport、PipeTransport 和 DirectTransport 四种类型。

3)Producer

Producer 代表发送端,建立在 Transport 之上。Producer 从 Transport 收取媒体流并发布到 Router。Producer 不区分类型。

4)Consumer

Consumer 代表接收端,建立在 Transport 之上。Consumer 从 Router 定于媒体流并转发到网络。 当前支持 PipeConsumer、SimpleConsumer、SimulcastConsumer 和 SvcConsumer 四种类型。

下图展示了组件之间的关系。Worker 代表 C++ 进程,进程中可以创建一个或多个 WebRtcServer 用来聚合媒体转发端口。Router 对应一个逻辑房间,可以根据业务需求把相关的媒体流承载到指定的 Router 进行转发。Transport 可以创建在 WebRtcServer 之上也可以使用独立端口,但必定要关联到一个 Router。在 Transport 上创建 Producer 和 Consumer 对接客户端媒体。

1.2. 调用流程

媒体报文在各个类之间的流转如下图所示,逻辑非常清晰。报文从 XXXTransport 进入最终也从 XXX Transport 发出。Transport 是一个基类,它封装了框架层的转发逻辑,是 Producer 与 Router 之间的桥梁。Transport 的设计值得玩味,如果 Transport 更加纯粹,Producer 到 Router 之间的流转不经过 Transport 似乎更加合理,没有再深入分析这里面的缘由,感兴趣的可以尝试下。

1.3. 逻辑架构

不考虑转发细节和 Consumer 类型,mediasoup 的转发框架可以用下图表示。

如果加上部分转发细节和 Consumer 类型,SimpleConsumer/SvcComsumer 可以用下图表示。Producer 内部使用 RtpRecvStream 处理收到的报文,Consumer 对 RtpRecvStream 处理后的报文进行过滤(丢弃DTX/SVC选层转发)经 RtpSendStream 处理后发送到网络。

Simulcast 的处理稍微复杂些,Producer 会收到多路流,每路流对应一个 RtpRecvStream。Consumer 会选择特定的分辨率(空域层)和特定的帧率(时域层)经 RtpSendStream 处理后发送到网络。

2. 转换处理

2.1. Producer

Producer 会修改报文的 payload type、ssrc 和 RTP 扩展头,如下面代码所示。

inline bool Producer::MangleRtpPacket(RTC::RtpPacket* packet, RTC::RtpStreamRecv* rtpStream) const
{
  // payload type 转换
  {
    const uint8_t payloadType = packet->GetPayloadType();
    auto it                   = this->rtpMapping.codecs.find(payloadType);

    if (it == this->rtpMapping.codecs.end()) {
      return false;
    }

    const uint8_t mappedPayloadType = it->second;
    packet->SetPayloadType(mappedPayloadType);
  }

  // SSRC 转换
  {
    const uint32_t mappedSsrc = this->mapRtpStreamMappedSsrc.at(rtpStream);
    packet->SetSsrc(mappedSsrc);
  }

  // 扩展头转换
  {
    thread_local static uint8_t buffer[4096];
    thread_local static std::vector<RTC::RtpPacket::GenericExtension> extensions;

    ...
  }
}

发送端的 payload type 是由客户端决定的(先 setLocalDescritipion),接收端的 payload type 是由服务器决定(先 setRemoteDescritpion),因此,在服务器端需要对 payload type 进行转换。

mediasoup 在创建 router 时,会从配置文件中读取 router 的 codec 配置,并使用 supportedRtpCapabilities 进行过滤。如果 codec 没有设置 preferredPayloadType,则从 DynamicPayloadTypes 中按顺序选取。

const DynamicPayloadTypes = [
	100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114,
	115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 96, 97, 98,
	99,
];

一般情况,SDP 协商时,一路媒体对应一个 ssrc。simulcast 的协商结果可能是一个 ssrc 和多个 rid,但这种协商结果存在兼容性问题。为了统一处理,mediasoup 对每一路流都分配一个 ssrc,并统一在服务器端对 ssrc 进行转换,接收端看到的永远是多个 ssrc(如果有的话)。

export function getProducerRtpParametersMapping(
  params: RtpParameters,
  caps: RtpCapabilities
): RtpMapping {
  ...

  // 遍历客户端协商的 encodings
  for (const encoding of params.encodings!) {
    const mappedEncoding: any = {};

    // 客户端协商的每个 encoding 都对应一个服务器 SSRC
    mappedEncoding.mappedSsrc = mappedSsrc++;

    if (encoding.rid) {
      mappedEncoding.rid = encoding.rid;
    }
    if (encoding.ssrc) {
      mappedEncoding.ssrc = encoding.ssrc;
    }
    if (encoding.scalabilityMode) {
      mappedEncoding.scalabilityMode = encoding.scalabilityMode;
    }

    rtpMapping.encodings.push(mappedEncoding);
  }

  return rtpMapping;
}

Producer 更新了报文的 RTP 扩展头,重新申请内存,将报文中可复用的扩展头拷贝过来,部分扩展头需要更新。当然,扩展头 ID 要统一进行切换。

bool Producer::MangleRtpPacket(RTC::RtpPacket* packet, RTC::RtpStreamRecv* rtpStream) const
{
  ...

  // 拷贝
  extenValue = packet->GetExtension(this->rtpHeaderExtensionIds.absCaptureTime, extenLen);
  if (extenValue) {
    std::memcpy(bufferPtr, extenValue, extenLen);
    extensions.emplace_back(
      static_cast<uint8_t>(RTC::RtpHeaderExtensionUri::Type::ABS_CAPTURE_TIME),
      extenLen,
      bufferPtr);
    bufferPtr += extenLen;
  }
  
  // 更新
  {
    extenLen = 3u;
    // NOTE: Add value 0. The sending Transport will update it.
    const uint32_t absSendTime{ 0u };
    Utils::Byte::Set3Bytes(bufferPtr, 0, absSendTime);
    extensions.emplace_back(
      static_cast<uint8_t>(RTC::RtpHeaderExtensionUri::Type::ABS_SEND_TIME), extenLen, bufferPtr);
    bufferPtr += extenLen;
  }

  ...
}  

2.2. Router

Router 只做简单的报文分发,但会在转发之前会更新 MID。这个更新处理好像放在 consumer 内部也是可以的,放在 router 处理可能是从设计角度考虑。

inline void Router::OnTransportProducerRtpPacketReceived(
  RTC::Transport* /*transport*/, RTC::Producer* producer, RTC::RtpPacket* packet)
{
  auto& consumers = this->mapProducerConsumers.at(producer);
  if (!consumers.empty()) {
    std::shared_ptr<RTC::RtpPacket> sharedPacket;
    for (auto* consumer : consumers) {
      // 转换 mid
      const auto& mid = consumer->GetRtpParameters().mid;
      if (!mid.empty()) {
        packet->UpdateMid(mid);
      }
      consumer->SendRtpPacket(packet, sharedPacket);
    }
  }
  ...
}

2.3. Consumer

Consumer 会对报文的 ssrc、sequnce number 和 timestamp 进行转换。ssrc 转换比较好理解。sequence number 转换是因为服务端存在主动丢弃报文的行为,比如 simulcast 和 SVC 场景,Opus 的 DTS 报文也可能被丢弃。为了保证接收端 sequence number 的连续性,服务端要对序列号进行处理并转换。timestamp 转换与 simulcast 有关,因为 simulcast 存在多流,每个流的 RTP 时间戳不是绝对同步的,当切换空域分层时,需要进行时间戳的校准,保证接收端时间戳的连续性,不会产生跳帧现象。

以下是 SimpleConsumer 的处理,转换了 ssrc 和 sequence number。

void SimpleConsumer::SendRtpPacket(RTC::RtpPacket* packet, std::shared_ptr<RTC::RtpPacket>& sharedPacket)
{
  if (!IsActive()) {
    return;
  }

  // 检查 payload type 的合法性
  auto payloadType = packet->GetPayloadType();
  if (!this->supportedCodecPayloadTypes[payloadType]) {
    return;
  }

  // 这里只处理音频报文,根据配置丢弃 DTX 报文。
  bool marker;
  if (this->encodingContext && !packet->ProcessPayload(this->encodingContext.get(), marker)) {
    this->rtpSeqManager.Drop(packet->GetSequenceNumber());
    return;
  }

  // 刚连接上时要进行同步,等待关键帧
  if (this->syncRequired && this->keyFrameSupported && !packet->IsKeyFrame()) {
    return;
  }

  // Whether this is the first packet after re-sync.
  const bool isSyncPacket = this->syncRequired;

  // 序列号管理器同步
  if (isSyncPacket) {
    this->rtpSeqManager.Sync(packet->GetSequenceNumber() - 1);
    this->syncRequired = false;
  }

  // 更新序列号管理器
  uint16_t seq;
  this->rtpSeqManager.Input(packet->GetSequenceNumber(), seq);

  // 保存原始 ssrc 和 seq 以备恢复
  auto origSsrc = packet->GetSsrc();
  auto origSeq  = packet->GetSequenceNumber();

  // 切换 ssrc
  packet->SetSsrc(this->rtpParameters.encodings[0].ssrc);

  // 切换 seq
  packet->SetSequenceNumber(seq);

  // 处理并发送报文
  if (this->rtpStream->ReceivePacket(packet, sharedPacket)) {
    this->listener->OnConsumerSendRtpPacket(this, packet);
    EmitTraceEventRtpAndKeyFrameTypes(packet);
  }

  // 恢复原始 ssrc 和 seq
  packet->SetSsrc(origSsrc);
  packet->SetSequenceNumber(origSeq);
}

以下是 SimulcastConsumer 的处理,除 ssrc 和 sequnece number 外,timestamp 也进行了处理。timestamp 使用 NTP 时间进行校准,计算比较复杂,这里暂不展开。

void SimulcastConsumer::SendRtpPacket(
  RTC::RtpPacket* packet, std::shared_ptr<RTC::RtpPacket>& sharedPacket)
{
  ...
    
  // Rewrite packet.
  packet->SetSsrc(this->rtpParameters.encodings[0].ssrc);
  packet->SetSequenceNumber(seq);
  packet->SetTimestamp(timestamp);

  ...
}

SvcConsumer 和 SimpleConsumer 一样,也只是转换了 ssrc 和 sequence number。

void SvcConsumer::SendRtpPacket(RTC::RtpPacket* packet, std::shared_ptr<RTC::RtpPacket>& sharedPacket)
{
	...
		
	// Rewrite packet.
	packet->SetSsrc(this->rtpParameters.encodings[0].ssrc);
	packet->SetSequenceNumber(seq);
	...
}

3. 负载解析

一般理解,服务器通过 producer 从发送端收到多少报文,就通过 consumer 向接收端转发多少报文。对于 AVC 来说是这样,但对于 simulcast 和 SVC 情况变了,服务器需要选择特定的空域层和时域层报文进行转发,并丢弃其他报文(mediasoup 支持手动指定并动态切换接收的分层)。

要能够进行选择性转发和丢弃,前提是能够别报文属于哪一个空域层和时域层。WebRTC 使用 frame-marking 扩展头来标识视频报文的分层信息。

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  ID=? |  L=2  |S|E|I|D|B| TID |   LID         |    TL0PICIDX  |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
		  or
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  ID=? |  L=1  |S|E|I|D|B| TID |   LID         | (TL0PICIDX omitted)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
		  or
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  ID=? |  L=0  |S|E|I|D|B| TID | (LID and TL0PICIDX omitted)
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • ID (4 bits): 头扩展的 ID。
  • L (4 bits): 头扩展的长度,这里固定为 3,表示长度为 4 个 32-bit words。
  • S (1 bit): Start of Frame,标识当前包是一个视频帧的开始。
  • E (1 bit): End of Frame,标识当前包是一个视频帧的结束。
  • I (1 bit): Independent,标识当前包属于一个独立帧(关键帧)。
  • D (1 bit): Discardable,标识当前包所属的帧可以被丢弃。
  • B (1 bit): Base Layer Sync,标识当前包所属的帧可以用于同步基本层。
  • TID (3 bits): Temporal Layer ID,标识时间层 ID。
  • LID (8 bits): Layer ID,标识空间层 ID。
  • TL0PICIDX (8 bits): Temporal Layer 0 Picture Index,标识基础层的帧索引。

mediasoup 在 producer 处会进行解析处理,获取 codec 信息供 consumer 使用。

bool RtpStreamRecv::ReceivePacket(RTC::RtpPacket* packet)
{
  ...

  // 从 payload 解析 codec 信息
  if (packet->GetPayloadType() == GetPayloadType())
  {
    RTC::Codecs::Tools::ProcessRtpPacket(packet, GetMimeType());
  }

  ...
}

H264 解析出来的信息如下,最重要的是分层信息和关键帧标识,这些都是进行分层切换和帧同步的重要参考。

struct PayloadDescriptor : public RTC::Codecs::PayloadDescriptor
{
public:
  // Fields in frame-marking extension.
  uint8_t s : 1;          // Start of Frame.
  uint8_t e : 1;          // End of Frame.
  uint8_t i : 1;          // Independent Frame.
  uint8_t d : 1;          // Discardable Frame.
  uint8_t b : 1;          // Base Layer Sync.
  uint8_t tid{ 0 };       // Temporal layer id.
  uint8_t lid{ 0 };       // Spatial layer id.
  uint8_t tl0picidx{ 0 }; // TL0PICIDX

  // Parsed values.
  bool hasLid{ false };
  bool hasTid{ false };
  bool hasTl0picidx{ false };
  bool isKeyFrame{ false };
};

H264-SVC 相比 H264 多了一个 NALU SVC 扩展头,会比 H264 额外解析一些信息。

class H264_SVC
{
public:
  // Fields in frame-marking extension.
  uint8_t s : 1;          // Start of Frame.
  uint8_t e : 1;          // End of Frame.
  uint8_t i : 1;          // Independent Frame.
  uint8_t d : 1;          // Discardable Frame.
  uint8_t b : 1;          // Base Layer Sync.
  uint8_t slIndex{ 0 };   // Temporal layer id.
  uint8_t tlIndex{ 0 };   // Spatial layer id.
  uint8_t tl0picidx{ 0 }; // TL0PICIDX

  // Parsed values.
  bool hasSlIndex{ false };
  bool hasTlIndex{ false };
  bool hasTl0picidx{ false };
  bool isKeyFrame{ false };

  // Extension fields.
  uint8_t idr{ 0 };
  uint8_t priorityId{ 0 };
  uint8_t noIntLayerPredFlag{ true };
};

4. 分层切换

对于 simulcast 和 SVC 来说,可以设置偏好分层(preferred layer)。服务器会根据带宽评估情况,确定目标分层(target layer),如果带宽足够,目标分层等于偏好分层;如果带宽不够目标分层会小于偏好分层。由于带宽是动态变化的,服务器会根据带宽情况动态调整分层。

4.1. Simulcast

由于 simulcast 多个空域层的码流是独立编码的,在没有分层切换的情况下,simulcast 只转发当前空域层的报文,其他空域层报文直接丢弃。

void SimulcastConsumer::SendRtpPacket(
  RTC::RtpPacket* packet, std::shared_ptr<RTC::RtpPacket>& sharedPacket)
{
  ...

  // 根据 ssrc 找到报文属于哪个分辨率的流
  auto spatialLayer = this->mapMappedSsrcSpatialLayer.at(packet->GetSsrc());

  bool shouldSwitchCurrentSpatialLayer{ false };

  // 收到目标空域层第一个报文,开始切换分层
  if (this->currentSpatialLayer != this->targetSpatialLayer 
    && spatialLayer == this->targetSpatialLayer) {
    if (!packet->IsKeyFrame()) { // 必须从关键帧开始切,否则会产生黑屏
      return;
    }
    shouldSwitchCurrentSpatialLayer = true; // 标记切换
    this->syncRequired       = true; // 标记同步
    this->spatialLayerToSync = spatialLayer; // 标记同步空域层
  }
  else if (spatialLayer != this->currentSpatialLayer) {
    return; // 非目标空域层的报文直接丢弃
  }

  ...

  if (shouldSwitchCurrentSpatialLayer) {
    // 更新当前空域分层
    this->currentSpatialLayer = this->targetSpatialLayer;

    this->snReferenceSpatialLayer             = packet->GetSequenceNumber();
    this->checkingForOldPacketsInSpatialLayer = true;

    // 更新目标时域分层和当前时域分层
    this->encodingContext->SetTargetTemporalLayer(this->targetTemporalLayer);
    this->encodingContext->SetCurrentTemporalLayer(packet->GetTemporalLayer());

    // Reset the score of our RtpStream to 10.
    this->rtpStream->ResetScore(10u, /*notify*/ false);

    // 处理 payload
    packet->ProcessPayload(this->encodingContext.get(), marker);
  }
  else {
    // 处理 payload
    if (!packet->ProcessPayload(this->encodingContext.get(), marker)) {
      this->rtpSeqManager.Drop(packet->GetSequenceNumber());
      return;
    }
  }

  ...
}

同一个空域层下,大于目标时域层的报文直接丢弃,否则更新当前时域层。另外,这里兼容了 AVC 的处理,也就是 H264 AVC 和 simulcast 都是使用同一个处理类。

bool H264::PayloadDescriptorHandler::Process(
  RTC::Codecs::EncodingContext* encodingContext, uint8_t* /*data*/, bool& /*marker*/)
{
  auto* context = static_cast<RTC::Codecs::H264::EncodingContext*>(encodingContext);

  // TID 大于目标时域层,丢弃
  if (this->payloadDescriptor->hasTid &&
    this->payloadDescriptor->tid > context->GetTargetTemporalLayer()) {
    return false;
  }
  // TID 丹玉当前时域层,且不是基础同步报文,丢弃(待研究)
  else if (this->payloadDescriptor->hasTid &&
    this->payloadDescriptor->tid > context->GetCurrentTemporalLayer() &&
    !this->payloadDescriptor->b) {
    return false;
  }

  // 走到这里,说明 TID 小于等于目标时域层

  if (this->payloadDescriptor->hasTid &&
    this->payloadDescriptor->tid > context->GetCurrentTemporalLayer()) {
    // 更新当前时域层
    context->SetCurrentTemporalLayer(this->payloadDescriptor->tid);
  }
  else if (!this->payloadDescriptor->hasTid) {
    // 兼容 AVC 处理
    context->SetCurrentTemporalLayer(0);
  }

  // 当前时域层限制(不能超过目标时域层)
  if (context->GetCurrentTemporalLayer() > context->GetTargetTemporalLayer()) {
    context->SetCurrentTemporalLayer(context->GetTargetTemporalLayer());
  }

  return true;
}

4.2. SVC

SVC 只有一路流,分层的切换和报文的过滤都是放在 PayloadDescriptorHandler 处理。

void SvcConsumer::SendRtpPacket(RTC::RtpPacket* packet, std::shared_ptr<RTC::RtpPacket>& sharedPacket)
{
  ...

  // 处理切层,根据返回值丢弃报文
  if (!packet->ProcessPayload(this->encodingContext.get(), marker)) {
    this->rtpSeqManager.Drop(packet->GetSequenceNumber());
    return;
  }

  ...
}

同样,如果报文的分层高于设置的分层,直接丢弃。相比 simulcast,SVC 需要处理空域和时域两个维度,而且 SVC 时域和空域分层切换的条件更加苛刻,这部分有待深入研究。

bool H264_SVC::PayloadDescriptorHandler::Process(
  RTC::Codecs::EncodingContext* encodingContext, uint8_t* /*data*/, bool& marker)
{
  auto* context = static_cast<RTC::Codecs::H264_SVC::EncodingContext*>(encodingContext);

  // 报文的空间层和时域层
  auto packetSpatialLayer  = GetSpatialLayer();
  auto packetTemporalLayer = GetTemporalLayer();

  // 当前的空间层和时域层
  auto tmpSpatialLayer     = context->GetCurrentSpatialLayer();
  auto tmpTemporalLayer    = context->GetCurrentTemporalLayer();

  // 如果报文的空间层或时域层高于已设置的最大值,则丢弃报文。
  if (packetSpatialLayer >= context->GetSpatialLayers() ||
    packetTemporalLayer >= context->GetTemporalLayers()) {
    return false;
  }

  //=== 空域层处理
  
  // 空域升层
  if (context->GetTargetSpatialLayer() > context->GetCurrentSpatialLayer()) {
    // 等待关键帧才能升层
    if (this->payloadDescriptor->isKeyFrame) {
      tmpSpatialLayer  = context->GetTargetSpatialLayer();
      tmpTemporalLayer = 0; // Just in case.
    }
  }
  // 空域降层
  else if (context->GetTargetSpatialLayer() < context->GetCurrentSpatialLayer()) {
    // K-SVC 必须等待关键帧才能降层
    if (context->IsKSvc()) {
      if (this->payloadDescriptor->isKeyFrame) {
        tmpSpatialLayer  = context->GetTargetSpatialLayer();
        tmpTemporalLayer = 0; // Just in case.
      }
    }
    // full SVC 不需要关键帧
    else {
      if (packetSpatialLayer == context->GetTargetSpatialLayer() &&
        this->payloadDescriptor->e) {
        tmpSpatialLayer  = context->GetTargetSpatialLayer();
        tmpTemporalLayer = 0; // Just in case.
      }
    }
  }

  // 报文的空域层高于待设置的空域层,丢弃
  if (packetSpatialLayer > tmpSpatialLayer) {
    return false;
  }

  //=== 时域层处理

  // 时域升层
  if (context->GetTargetTemporalLayer() > context->GetCurrentTemporalLayer()) {
    // 等待帧的第一个包
    if (packetTemporalLayer >= context->GetCurrentTemporalLayer() + 1 &&
      this->payloadDescriptor->s) {
      tmpTemporalLayer = packetTemporalLayer;
    }
  }
  // 时域降层
  else if (context->GetTargetTemporalLayer() < context->GetCurrentTemporalLayer()) {
    // 等待帧的最后一个包
    if (packetTemporalLayer == context->GetTargetTemporalLayer() &&
      this->payloadDescriptor->e) {
      tmpTemporalLayer = context->GetTargetTemporalLayer();
    }
  }

  // 报文的时域层高于待设置的时域层,丢弃
  if (packetTemporalLayer > tmpTemporalLayer) {
    return false;
  }

  // 设置帧结束标志
  if (packetSpatialLayer == tmpSpatialLayer && this->payloadDescriptor->e) {
    marker = true;
  }

  // 更新当前空域层
  if (tmpSpatialLayer != context->GetCurrentSpatialLayer()) {
    context->SetCurrentSpatialLayer(tmpSpatialLayer);
  }

  // 更新当前时域层
  if (tmpTemporalLayer != context->GetCurrentTemporalLayer()) {
    context->SetCurrentTemporalLayer(tmpTemporalLayer);
  }

  return true;
}

5. 总结

本文分析了 mediasoup 报文转发框架,以及报文流转过程中的相关处理逻辑。其中,simulcast 和 SVC 的分层切换逻辑稍微复杂一些,切换条件涉及到伸缩编码的背景知识。最复杂的是 simulcast 时间戳转换,除了计算基于 NTP 时间戳的基础偏移,还需要计算额外偏移,然后再进行调整,本文没有展开分析。

  • 8
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值