WebRTC 全景实战 (4):SDP 解剖与媒体协商
"Offer/Answer 里那一大段文本,就是整个 WebRTC 会话的「合同」。"
Ch3 信令 负责传递 SDP,但 SDP 本身是什么?Ch2 中 createOffer() 产出的字符串,遵循 RFC 8866 SDP 格式。它描述了:用什么编解码器、用什么 ICE 凭证、用什么 DTLS 指纹、媒体方向是什么。
本章逐行解剖 WebRTC 中的 SDP,理解 Unified Plan、BUNDLE、rtcp-mux,以及 RTCRtpTransceiver 与 m-line 的对应关系。
本篇术语表
| 术语 | 英文 | 解释 |
|---|---|---|
| SDP | Session Description Protocol | 文本格式的会话描述协议,不是传输协议,只描述「会话参数」 |
| Offer | — | 发起方生成的 SDP,声明「我能提供什么」 |
| Answer | — | 应答方生成的 SDP,声明「我接受什么」 |
| m-line | Media Description Line | m= 开头的行,描述一条媒体流(audio/video/application) |
| mid | Media Identification | m-line 的唯一标识符,Unified Plan 下每个 track 一个 mid |
| Payload Type (PT) | — | RTP 包头中的 7bit 字段,映射到具体编解码器 |
| rtpmap | — | SDP 属性,定义 PT 与编解码器的映射关系 |
| fmtp | Format Parameters | 编解码器的额外参数,如 H.264 的 profile-level-id |
| rtcp-fb | RTCP Feedback | 声明支持的 RTCP 反馈机制(NACK/PLI/TWCC 等) |
| BUNDLE | — | 将多条 m-line 的多路 RTP 复用到同一个 UDP 五元组 |
| rtcp-mux | RTCP Multiplexing | RTP 与 RTCP 共用同一端口,而非各用独立端口 |
| Unified Plan | — | 现行 SDP 语义:每个 m-line 对应一个 Transceiver |
| Plan B | — | 已废弃:多个 track 共用一个 m-line |
一、SDP 在 WebRTC 中的角色
SDP 是 声明式 的:它不建立连接,只描述能力。真正的连通由 ICE 完成,加密由 DTLS 完成,媒体由 RTP 承载。
二、完整 SDP 示例与逐行解读
以下是一段真实的 WebRTC Offer SDP(简化版):
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
a=extmap-allow-mixed
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:FPlu
a=ice-pwd:2/1muCWoOi3J5Wfu+86J7GqJ
a=ice-options:trickle
a=fingerprint:sha-256 4A:AD:BA:62:79:...
a=setup:actpass
a=mid:0
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=sendrecv
a=msid:stream-id track-audio-id
a=rtcp-mux
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:63 red/48000/2
a=rtcp-fb:111 transport-cc
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 103 104 107 108
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:FPlu
a=ice-pwd:2/1muCWoOi3J5Wfu+86J7GqJ
a=fingerprint:sha-256 4A:AD:BA:62:79:...
a=setup:actpass
a=mid:1
a=sendrecv
a=msid:stream-id track-video-id
a=rtcp-mux
a=rtcp-rsize
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 goog-remb
a=rtcp-fb:96 transport-cc
a=rtcp-fb:96 ccm fir
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
2.1 会话级字段
| 行 | 字段名 | 含义 |
|---|---|---|
v=0 | Version | SDP 版本,固定为 0 |
o=- 4611731400430051336 2 IN IP4 127.0.0.1 | Origin | 会话创建者 ID、版本号、地址 |
s=- | Session Name | 会话名,WebRTC 中通常为 - |
t=0 0 | Timing | 会话时间,0 0 表示永久会话 |
a=group:BUNDLE 0 1 | BUNDLE Group | mid 0 和 1 共用同一传输通道 |
a=extmap-allow-mixed | — | 允许不同 m-line 使用不同 RTP 扩展 |
2.2 m-line 结构
m=<media> <port> <proto> <fmt列表>
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 ...
| 部分 | 示例 | 含义 |
|---|---|---|
| media | audio / video | 媒体类型 |
| port | 9 | 占位端口(ICE 实际决定端口,9 为 discard port) |
| proto | UDP/TLS/RTP/SAVPF | 协议栈:UDP + DTLS + SRTP + AVPF(反馈 profile) |
| fmt | 111 63 9 ... | 支持的 Payload Type 列表,按优先级排序 |
2.3 ICE 与 DTLS 属性
| 属性 | 示例 | 作用 |
|---|---|---|
a=ice-ufrag | FPlu | ICE 用户名片段,连通性检查凭证 |
a=ice-pwd | 2/1muCWo... | ICE 密码 |
a=ice-options:trickle | — | 支持 Trickle ICE |
a=fingerprint:sha-256 ... | — | DTLS 证书 SHA-256 指纹,Ch7 |
a=setup:actpass | — | DTLS 角色:可主动可被动 |
2.4 媒体方向与 Track 标识
| 属性 | 含义 |
|---|---|
a=mid:0 | 此 m-line 的 ID,BUNDLE 组内引用 |
a=sendrecv | 双向收发(还有 sendonly/recvonly/inactive) |
a=msid:stream-id track-id | MediaStream ID + Track ID,关联 addTrack() |
a=rtcp-mux | RTP 与 RTCP 同端口 |
a=rtcp-rsize | 使用 Reduced-Size RTCP |
2.5 编解码器映射
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1
- PT 111 → Opus,48kHz,2 声道
- fmtp → 最小时长 10ms,启用带内 FEC
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=rtcp-fb:96 transport-cc
- PT 96 → VP8,90kHz 时钟
- 支持 NACK 重传、PLI 关键帧请求、TWCC 拥塞控制
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
- PT 97 → RTX(重传)流,关联主 PT 96
三、BUNDLE 与 rtcp-mux 深度解析
3.1 为什么需要 BUNDLE?
没有 BUNDLE 时,audio 和 video 各占用独立 UDP 端口 → 更多 ICE Candidate → 更慢连通、更多 NAT 打洞失败。
a=group:BUNDLE 0 1 表示 mid 0 和 1 的 RTP/RTCP 都走第一个 m-line 协商出的传输通道。
3.2 rtcp-mux
传统 RTP:RTP 端口 N,RTCP 端口 N+1。WebRTC 强制 rtcp-mux——RTP 和 RTCP 在同一端口,通过包类型区分。这减少了一半的 Candidate 数量。
四、Unified Plan vs Plan B
| 特性 | Plan B | Unified Plan |
|---|---|---|
| m-line 与 track | 多 track 共一 m-line | 一 track 一 m-line |
| API | addStream() | addTransceiver() / addTrack() |
| Simulcast | 支持差 | 原生 rid 支持 |
| 浏览器 | 已移除 | 唯一支持 |
Fippo — Exploring RTCRtpTransceiver 详细记录了 Unified Plan 成为唯一标准的设计决策过程。
五、RTCRtpTransceiver 详解
// 添加只发送视频的 Transceiver
const transceiver = pc.addTransceiver("video", {
direction: "sendonly",
sendEncodings: [
{ rid: "h", maxBitrate: 1_500_000, scaleResolutionDownBy: 1 },
{ rid: "m", maxBitrate: 500_000, scaleResolutionDownBy: 2 },
{ rid: "l", maxBitrate: 150_000, scaleResolutionDownBy: 4 },
],
});
// direction 可选值
// "sendrecv" | "sendonly" | "recvonly" | "inactive"
| direction | 含义 | SDP 中 |
|---|---|---|
sendrecv | 双向 | a=sendrecv |
sendonly | 只发送 | a=sendonly |
recvonly | 只接收 | a=recvonly |
inactive | 暂停 | a=inactive |
六、编解码器协商过程
Answer 方不能添加 Offer 中未出现的 Codec。协商结果 = 双方 fmt 列表的交集,按 Offer 中的顺序取最优。
// 查看浏览器支持的所有 Codec
const audioCodecs = RTCRtpSender.getCapabilities("audio").codecs;
const videoCodecs = RTCRtpSender.getCapabilities("video").codecs;
console.log(videoCodecs.map((c) => `${c.mimeType} pt=${c.preferredPayloadType}`));
七、手工调试 SDP
7.1 打印与分析
const offer = await pc.createOffer();
console.log(offer.sdp);
// 按 m-line 分段
const sections = offer.sdp.split(/^m=/m);
sections.forEach((s) => console.log("--- m=" + s.slice(0, 20) + "..."));
7.2 限制带宽(调试用)
// 不推荐生产使用,优先用 setParameters
offer.sdp = offer.sdp.replace(
/(a=mid:1\r\n)/,
"$1b=AS:500\r\n" // 视频 mid=1 限制 500 kbps
);
await pc.setLocalDescription(offer);
7.3 强制 H.264(调试用)
const transceiver = pc.addTransceiver("video", { direction: "sendrecv" });
const caps = RTCRtpSender.getCapabilities("video");
const h264 = caps.codecs.filter((c) => c.mimeType === "video/H264");
transceiver.setCodecPreferences(h264);
八、常见问题
| 问题 | 原因 | 解决 |
|---|---|---|
| 有 Offer 无 Answer | 对端 Codec 无交集 | 检查 fmt 列表 |
| 单向视频 | direction 设为 sendonly | 改 sendrecv |
| Simulcast 不生效 | SDP 无 rid | 用 Unified Plan + addTransceiver |
| BUNDLE 失败 | 一端不支持 BUNDLE | 检查 a=group:BUNDLE |
| fingerprint 不匹配 | SDP 被中间人修改 | 信令必须 WSS |
九、实战 Lab
createOffer()后完整打印 SDP,标出每个a=行的含义- 对比 Offer 与 Answer 的 fmt 列表差异
- 添加第二个 video Transceiver(屏幕共享),观察新 m-line
- 用
setCodecPreferences强制 Opus + VP8
下一篇(Ch5):ICE、STUN、TURN——SDP 中 ice-ufrag 如何驱动 NAT 穿透。
系列导航
章节 主题 状态 0 架构全景与协议栈地图 ✅ 已发布 1 浏览器媒体 API 与设备管理 ✅ 已发布 2 第一个 P2P 视频通话 ✅ 已发布 3 信令服务器设计与会话状态机 ✅ 已发布 4 SDP 解剖与媒体协商 ✅ 已发布 5 ICE、STUN、TURN 与 NAT 穿透 ✅ 已发布 6 Data Channel 与 SCTP over DTLS ✅ 已发布 7 DTLS 握手与 SRTP 加密体系 ✅ 已发布 8 RTP/RTCP 媒体传输与 QoS ✅ 已发布 9 音视频编解码与 Simulcast 入门 ✅ 已发布 10 带宽估计与拥塞控制 GCC ✅ 已发布 11 Simulcast、SVC 与选择性订阅 ✅ 已发布 12 SFU/MCU/Mesh 架构与 Pion 实战 ✅ 已发布 13 调试工具链与可观测性 ✅ 已发布 14 TURN 集群部署与多区域扩展 ✅ 已发布 15 Capstone 生产级视频会议系统 ✅ 已发布