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 时间戳的基础偏移,还需要计算额外偏移,然后再进行调整,本文没有展开分析。