
简单理解 WebRTC Simulcast 的流处理机制:从多路输入到单路输出的完整流程
Producer 保留多路 Simulcast 输入;每个 Consumer 独立选择其中一路,并转换成一条连续的输出媒体流。RTX 和 FlexFEC 都是逐跳终止、逐跳重新生成,不会原样穿透 Router。
Producer 如何识别和管理多路流
Transport 收到 RTP 后,先通过 RtpListener 找到对应 Producer。注册 Producer 时,以下 SSRC 都会关联到同一个 Producer:
| 编码层 | 媒体 SSRC | RTX SSRC | FlexFEC SSRC |
|---|---|---|---|
| LOW | 1111 | 2111 | 3111 |
| MEDIUM | 1222 | 2222 | 3222 |
| HIGH | 1333 | 2333 | 3333 |
WebRTC Transport 找到 Producer 后调用:
cpp
producer->ReceiveRtpPacket(packet);
入口位于 WebRtcTransport.cpp:906,SSRC 注册逻辑位于 RtpListener.cpp:62。
Producer 内部主要维护两张表:
text
mapSsrcRtpStreamInfo
media SSRC -> RtpStreamRecv + profile + RTX SSRC
mapFlexFecSsrcRtpStream
FlexFEC SSRC -> 它保护的 RtpStreamRecv
例如:
text
1111 -> RtpStreamRecv LOW, RTX=2111
1222 -> RtpStreamRecv MEDIUM, RTX=2222
1333 -> RtpStreamRecv HIGH, RTX=2333
3111 -> LOW RtpStreamRecv
3222 -> MEDIUM RtpStreamRecv
3333 -> HIGH RtpStreamRecv
创建逻辑在 Producer.cpp:841。
三类RTP包在Producer中的处理流程
#mermaid-svg-uSNNhlgFZY5Q78Do{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uSNNhlgFZY5Q78Do .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uSNNhlgFZY5Q78Do .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uSNNhlgFZY5Q78Do .error-icon{fill:#552222;}#mermaid-svg-uSNNhlgFZY5Q78Do .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uSNNhlgFZY5Q78Do .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uSNNhlgFZY5Q78Do .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uSNNhlgFZY5Q78Do .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uSNNhlgFZY5Q78Do .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uSNNhlgFZY5Q78Do .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uSNNhlgFZY5Q78Do .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uSNNhlgFZY5Q78Do .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uSNNhlgFZY5Q78Do .marker.cross{stroke:#333333;}#mermaid-svg-uSNNhlgFZY5Q78Do svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uSNNhlgFZY5Q78Do p{margin:0;}#mermaid-svg-uSNNhlgFZY5Q78Do .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uSNNhlgFZY5Q78Do .cluster-label text{fill:#333;}#mermaid-svg-uSNNhlgFZY5Q78Do .cluster-label span{color:#333;}#mermaid-svg-uSNNhlgFZY5Q78Do .cluster-label span p{background-color:transparent;}#mermaid-svg-uSNNhlgFZY5Q78Do .label text,#mermaid-svg-uSNNhlgFZY5Q78Do span{fill:#333;color:#333;}#mermaid-svg-uSNNhlgFZY5Q78Do .node rect,#mermaid-svg-uSNNhlgFZY5Q78Do .node circle,#mermaid-svg-uSNNhlgFZY5Q78Do .node ellipse,#mermaid-svg-uSNNhlgFZY5Q78Do .node polygon,#mermaid-svg-uSNNhlgFZY5Q78Do .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uSNNhlgFZY5Q78Do .rough-node .label text,#mermaid-svg-uSNNhlgFZY5Q78Do .node .label text,#mermaid-svg-uSNNhlgFZY5Q78Do .image-shape .label,#mermaid-svg-uSNNhlgFZY5Q78Do .icon-shape .label{text-anchor:middle;}#mermaid-svg-uSNNhlgFZY5Q78Do .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uSNNhlgFZY5Q78Do .rough-node .label,#mermaid-svg-uSNNhlgFZY5Q78Do .node .label,#mermaid-svg-uSNNhlgFZY5Q78Do .image-shape .label,#mermaid-svg-uSNNhlgFZY5Q78Do .icon-shape .label{text-align:center;}#mermaid-svg-uSNNhlgFZY5Q78Do .node.clickable{cursor:pointer;}#mermaid-svg-uSNNhlgFZY5Q78Do .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uSNNhlgFZY5Q78Do .arrowheadPath{fill:#333333;}#mermaid-svg-uSNNhlgFZY5Q78Do .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uSNNhlgFZY5Q78Do .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uSNNhlgFZY5Q78Do .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uSNNhlgFZY5Q78Do .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uSNNhlgFZY5Q78Do .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uSNNhlgFZY5Q78Do .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uSNNhlgFZY5Q78Do .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uSNNhlgFZY5Q78Do .cluster text{fill:#333;}#mermaid-svg-uSNNhlgFZY5Q78Do .cluster span{color:#333;}#mermaid-svg-uSNNhlgFZY5Q78Do div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uSNNhlgFZY5Q78Do .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uSNNhlgFZY5Q78Do rect.text{fill:none;stroke-width:0;}#mermaid-svg-uSNNhlgFZY5Q78Do .icon-shape,#mermaid-svg-uSNNhlgFZY5Q78Do .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uSNNhlgFZY5Q78Do .icon-shape p,#mermaid-svg-uSNNhlgFZY5Q78Do .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uSNNhlgFZY5Q78Do .icon-shape .label rect,#mermaid-svg-uSNNhlgFZY5Q78Do .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uSNNhlgFZY5Q78Do .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uSNNhlgFZY5Q78Do .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uSNNhlgFZY5Q78Do :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-uSNNhlgFZY5Q78Do .decision>*{fill:#fff4d8!important;stroke:#99711d!important;color:#493400!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .decision span{fill:#fff4d8!important;stroke:#99711d!important;color:#493400!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .decision tspan{fill:#493400!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .process>*{fill:#e5f0ff!important;stroke:#336b9e!important;color:#17354f!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .process span{fill:#e5f0ff!important;stroke:#336b9e!important;color:#17354f!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .process tspan{fill:#17354f!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .terminal>*{fill:#f3e4e4!important;stroke:#914646!important;color:#4c2323!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .terminal span{fill:#f3e4e4!important;stroke:#914646!important;color:#4c2323!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .terminal tspan{fill:#4c2323!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .success>*{fill:#e1f3e5!important;stroke:#347848!important;color:#183d24!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .success span{fill:#e1f3e5!important;stroke:#347848!important;color:#183d24!important;}#mermaid-svg-uSNNhlgFZY5Q78Do .success tspan{fill:#183d24!important;} FlexFEC SSRC
否
是
媒体 SSRC
RTX SSRC
否
是
是
否
收到一个 RTP 包
SSRC 属于哪一类?
交给对应层的
FlexFecReceiveHandler
恢复出媒体包?
FEC 包到此结束
不直接转发
以 isRecover=true
重新进入 ReceiveRtpPacket
找到对应 RtpStreamRecv
将媒体包送入 FEC 接收器
作为恢复参考
ReceivePacket
乱序、重复、丢包统计
找到对应媒体流
ReceiveRtxPacket
去掉 RTX OSN
恢复媒体 PT/SSRC/Seq
包有效?
丢弃
Producer 暂停?
映射 PayloadType
映射 RTP 扩展 ID
通知 Router
携带 packet + profile
普通媒体包的处理
- 根据媒体 SSRC 找到
RtpStreamRecv。 - 先把包交给 FlexFEC 模块,作为后续恢复其他丢包的参考。
- 调用
RtpStreamRecv::ReceivePacket()做序列号、丢包、抖动和 NACK 检测。 - 携带该流的
profile转给 Router。
对应 Producer.cpp:309。
RTX重传包的处理
RTX 包不会直接转发给 Consumer。
- 根据 RTX SSRC 找到它对应的媒体
RtpStreamRecv。 ReceiveRtxPacket()把 RTX 包还原为原媒体包:- RTX SSRC 改回媒体 SSRC
- RTX PT 改回媒体 PT
- 从 RTX Payload 取出原始序列号 OSN
- 去掉 RTX 额外头
- 后续把它当作普通媒体包转发。
对应 Producer.cpp:350。
发布端发来的 FlexFEC 包
FlexFEC 包同样不会直接转发。
- 根据 FEC SSRC 找到它保护的媒体流。
- 交给 FlexFEC 解码器。
- 如果恢复出媒体 RTP,触发
OnRecoveredPacket()。 - 恢复包以
isRecover=true重新进入正常 Producer 流程。 - 原始 FlexFEC 包处理完成后直接结束。
对应 Producer.cpp:270 和 Producer.cpp:429。
因此进入 Router 的只有:
text
正常媒体包
+ RTX 恢复出来的媒体包
+ FlexFEC 恢复出来的媒体包
不会进入 Router 的是:
text
原始 RTX 包
原始 FlexFEC 包
3. Router 如何转发
Router 不负责选层。它只是遍历该 Producer 关联的所有 Consumer:
cpp
for (auto* consumer : consumers)
{
if (consumer->IsEnabled())
consumer->SendRtpPacket(packet, profile);
}
见 Router.cpp:2501。
同一个高层包可能会依次送给 A、B、C 三个 Consumer,但每个 Consumer 会根据自己的 effectiveProfile 决定接收还是丢弃。
例如:
| 输入包 profile | Consumer A | Consumer B | Consumer C |
|---|---|---|---|
| LOW | 转发 | 丢弃 | 丢弃 |
| MEDIUM | 丢弃 | 转发 | 丢弃 |
| HIGH | 丢弃 | 丢弃 | 转发 |
所以一个 Producer 可以同时让:
text
弱网用户看 LOW
普通用户看 MEDIUM
大屏用户看 HIGH
4. Consumer 如何把多路输入变成单路输出
Consumer 只创建一个 RtpStreamSend,并要求至少存在一个输出 encoding,见 Consumer.cpp:152。
对选中的输入包,Consumer 会依次执行:
text
输入媒体 SSRC/Seq/Timestamp
↓
检查 profile 是否等于 effectiveProfile
↓
必要时处理编码层/时间层
↓
重写 SSRC 为 Consumer 的媒体 SSRC
↓
重写 Sequence Number,保持连续
↓
重写 Timestamp,保持连续
↓
存入 RtpStreamSend 重传缓存
↓
发送媒体包
↓
根据重写后的媒体包生成 Consumer FlexFEC
关键代码位于 Consumer.cpp:514。
这里非常重要:
接收端不会看到 Producer 的 1111、1222、1333 三个媒体 SSRC。它只看到 Consumer 自己协商的一条媒体 SSRC,例如 5001。
5. Simulcast 切层怎么保证连续
假设 Consumer 当前发送 LOW,准备切换到 HIGH:
发布端 Producer Router Consumer 发布端 Producer Router Consumer #mermaid-svg-LwVRWRttzVNTI2lQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LwVRWRttzVNTI2lQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LwVRWRttzVNTI2lQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LwVRWRttzVNTI2lQ .error-icon{fill:#552222;}#mermaid-svg-LwVRWRttzVNTI2lQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LwVRWRttzVNTI2lQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LwVRWRttzVNTI2lQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LwVRWRttzVNTI2lQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LwVRWRttzVNTI2lQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LwVRWRttzVNTI2lQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LwVRWRttzVNTI2lQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LwVRWRttzVNTI2lQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LwVRWRttzVNTI2lQ .marker.cross{stroke:#333333;}#mermaid-svg-LwVRWRttzVNTI2lQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LwVRWRttzVNTI2lQ p{margin:0;}#mermaid-svg-LwVRWRttzVNTI2lQ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LwVRWRttzVNTI2lQ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-LwVRWRttzVNTI2lQ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LwVRWRttzVNTI2lQ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-LwVRWRttzVNTI2lQ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-LwVRWRttzVNTI2lQ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-LwVRWRttzVNTI2lQ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-LwVRWRttzVNTI2lQ .sequenceNumber{fill:white;}#mermaid-svg-LwVRWRttzVNTI2lQ #sequencenumber{fill:#333;}#mermaid-svg-LwVRWRttzVNTI2lQ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-LwVRWRttzVNTI2lQ .messageText{fill:#333;stroke:none;}#mermaid-svg-LwVRWRttzVNTI2lQ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LwVRWRttzVNTI2lQ .labelText,#mermaid-svg-LwVRWRttzVNTI2lQ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-LwVRWRttzVNTI2lQ .loopText,#mermaid-svg-LwVRWRttzVNTI2lQ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-LwVRWRttzVNTI2lQ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-LwVRWRttzVNTI2lQ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-LwVRWRttzVNTI2lQ .noteText,#mermaid-svg-LwVRWRttzVNTI2lQ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-LwVRWRttzVNTI2lQ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LwVRWRttzVNTI2lQ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LwVRWRttzVNTI2lQ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-LwVRWRttzVNTI2lQ .actorPopupMenu{position:absolute;}#mermaid-svg-LwVRWRttzVNTI2lQ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-LwVRWRttzVNTI2lQ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-LwVRWRttzVNTI2lQ .actor-man circle,#mermaid-svg-LwVRWRttzVNTI2lQ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-LwVRWRttzVNTI2lQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} targetProfile = HIGH请求关键帧发送 PLIHIGH 普通帧profile=HIGH尚未收到关键帧,不能切换HIGH 关键帧profile=HIGHeffectiveProfile = HIGH重同步 Seq 和 Timestamp输出仍使用同一个媒体 SSRC
Consumer 使用三个状态:
preferredProfile:用户希望的层。targetProfile:正在尝试切换到的层。effectiveProfile:当前真正向接收端发送的层。
升层通常先做带宽探测;网络不健康时降层。确定目标层后,需要等目标层关键帧才真正切换,见 Consumer.cpp:1277。
切换后:
- 输出 SSRC 不变。
- 输出序列号连续。
- 输出时间戳连续。
- 清空旧的重传缓存。
- 后续非目标 profile 的包全部丢弃。
因此接收端感觉是一条流发生了清晰度变化,而不是换了三条 RTP 流。
6. 下游 RTX 和 FlexFEC 如何产生
下游 RTX
Consumer 发送媒体包时,RtpStreamSend 会缓存已经重写后的输出包。
接收端发来 NACK 后:
- Transport 根据反馈中的媒体 SSRC 找到 Consumer。
- Consumer 从自己的发送缓存找到丢失包。
- 如果协商了 RTX,调用
RtxEncode()。 - 使用 Consumer 自己的 RTX SSRC 和 RTX PT 发送。
见 Consumer.cpp:941 和 Consumer.cpp:1235。
text
Producer RTX 2111
↓ 解包
内部普通媒体包
↓ Consumer 重写
Consumer 媒体 SSRC 5001
↓ 下游 NACK
重新封装
↓
Consumer RTX SSRC 6001
下游 FlexFEC
Consumer 启用时,根据自己的 RTP 参数创建 FlexFEC Sender,见 Consumer.cpp:1158。
发送顺序是:
text
选中的媒体包
↓
重写为 Consumer SSRC/Seq/Timestamp
↓
FlexFEC 编码器生成保护包并暂存
↓
先发送媒体包
↓
再发送 FlexFEC 包
因此下游 FlexFEC 保护的是 Consumer 重写后的输出流,不是 Producer 原始 SSRC。
最终总结
text
发布端三层 Simulcast
每层 = media + RTX + FlexFEC
│
▼
Producer 分别维护三个 RtpStreamRecv
RTX 在这里解包
FlexFEC 在这里恢复媒体包
│
▼
Router 将恢复后的普通媒体包广播给所有 Consumer
│
▼
每个 Consumer 独立选择 LOW / MEDIUM / HIGH
│
▼
重写成一条连续输出媒体流
│
├── 下游丢包时重新生成 RTX
└── 根据输出媒体包重新生成 FlexFEC
所以它不是"把 Producer 的多条流和修复流直接搬到 Consumer",而是:
Producer 侧终止上游传输保护,Router 分发规范化媒体包,Consumer 侧完成选层、连续化,并重新建立下游 RTX/FlexFEC 保护。