sdp(Session Description Protocol)是一种非常古老的协议,最早见于rfc2327(1998年),被用于会议系统中通话双端的网络属性及媒体能力属性。2010年Google收购了VoIP软件开发商Global IP Solutions的GIPS引擎,改名为WebRTC,WebRTC选择了sdp协议为通讯双端媒体能力协商协议。
本文并不会重复造轮子去解释协议的内容,而是在了解sdp基础上去学习下相关API,错误处理,解析Google Meet项目与webrtc-samples项目,看看在浏览器端通过修改sdp(munged sdp)可以实现哪些功能。WebRTC项目提供能力非常多,但浏览器暴露的API又特别少,经过多年大家的经验(主要是看Google Meet是怎么用的),总结出修改sdp就可以打开WebRTC的底层能力。
一、sdp解析
sdp协议本身并不复杂,全部内容由<type>=<value>
组成,最新协议描述见于rfc8866,网络上已经有非常多大神做了sdp协议的详细解析,如:
二、sdp相关的API与状态机
和sdp相关的API基本只有 createOffer、createAnswer、setLocalDescription、setRemoteDescription,虽然比较少,但加上本端、远端,类型为offer、answer这些因素开始接触的时候可能感觉比较绕。简单来说createOffer和createAnswer都是在各自本地生成sdp,然后通过setLocalDescription设置,接收到远端的sdp后通过setRemoteDescription设置。
pc每调用setLocalDescription、setRemoteDescription,本地的pc.signalingState就会产生对应的变化,完整的状态机如下,但have-local-pranswer
和have-remote-pranswer
是基本不用的,所以可以看下面简化的图。
捕获错误及处理策略
设置sdp错误大概可以分为两类:状态机错误和sdp信息不匹配,下面举例
case1: 如果不按状态机时序设置的话,调用setLocal/setRemote API会直接抛错,错误信息一般会指明错误信息。比如A/B用户同时创建offer给对方发送,那在A的视角来看,先setLocal -> have-local-offer -> setRemote -> throw error。
rust
// 错误信息
DOMException: Failed to execute 'setLocalDescription' on 'RTCPeerConnection':
The SDP does not match the previously generated SDP for this type
case2: 又例如A/B进行媒体协商,协商结果为空时也会报错:
csharp
// 错误信息
Failed to execute 'setRemoteDescription' on 'RTCPeerConnection':
Failed to parse SessionDescription. m=video 9 UDP/TLS/RTP/SAVPF Invalid value: .
case3: 又或者在unified-plan格式下,offer/answer双方的mid要对齐:
vbnet
// 错误信息
Failed to execute 'setRemoteDescription' on 'RTCPeerConnection': Failed to set remote answer sdp:
The order of m-lines in answer doesn't match order in offer. Rejecting answer
设置sdp报错一般发生在调试阶段,看到错误信息后结合offer及answer可以分析出错误在哪里,如何处理,前提还是需要熟悉sdp的内容。
如果你仍然不知道浏览器的报错信息指向了什么,可以查一下报错信息的代码,分析下源码引用的位置,比如上面case3的报错的函数注释与实现:source.chromium.org/chromium/ch...
完美协商
完美协商其实就是为了解决上面的case1,即两端同时在have-local-offer
的状态收到了对方的offer,此时就会形成一个死结,当然我们可以从业务流程上避免此类情况发生,其实WebRTC也提供了解决方案,需要一端(称为polite)将have-local-offer
的状态回滚到stable,另一端(称为impolite)拒绝掉/丢弃掉对方的offer,等polite完成协商后会产生一个新的answer给到impolite端去设置。
这里说到的polite与impolite也是由业务状态控制的,然后再看下setLocalDescription API,它还有2个作用:
- 回滚到stable状态:
pc.setLocalDescription({ type: 'rollback' })
,这样signalingState就可以回滚到stable - 自动生成本地sdp与设置:
pc.setLocalDescription()
不传参就可以认为是下面行代码的结合:这样写的好处是本地不需要关注自己应该去生成offer或者是answer,可以由状态机自己去判断
ini
const oa = pc.createOffer(); // or pc.createAnswer();
await pc.setLocalDescription(oa);
当然这里只是简单介绍了下完善协商,扩展阅读可以看这里:
三、munged sdp
munged sdp就是在浏览器生成后,我们手动修改,加工一下,目的就是开启一些隐藏能力
JS生成使用sdp的过程(伪代码):
scss
const pc = new RTCPeerConnection();
// 采集
pc.addTrack(videoTrack);
// 生成offer类型的sdp
const offer = await pc.createOffer(); // { type: 'offer', sdp: 'xx' }
// --> munging sdp
// 将生成的sdp设置到pc中
pc.setLocalDescription(offer);
可以看到sdp生成后可以拿到sdp,这样给了我们修改的空间,munging sdp也就是发生在这个阶段。
那么如何知道其他WebRTC应用修改了sdp,并快速找到修改了哪些sdp内容呢?可以借助一下Chrome浏览器提供的WebRTC调试工具:chrome://webrtc-internals(调试工具的玩法见:WebRTC Internals工具在项目中的实践 - 掘金),在其中可以找到createOfferOnSuccess和setLocalDescription(munged),如果有munged标记,那就说明sdp被手动修改了。
修改的内容,通过复制createOffer的sdp与setLocalDescription的sdp,使用\r\n换行符分割,再利用git等版本比较工具轻松地找到。
下面会举几个通过修改sdp实现的功能,有些是提供了API修改,有些则是需要直接修改sdp的字符串。
除这几个例子外,修改sdp可以解锁更多的功能,这个需要长时间积累和学习下优秀的项目
simulcast
simulcast是非常实用的功能,也是机智的小伙伴最早在Google Hangouts(Google Meet的前身)中利用给自己留的后门打开simulcast。具体改动为:先正常扩展1到2组video的ssrcs(ssrc、rtx、FID),然后使用a=ssrc-group:SIM ssrc1 ssrc2 ssrc3
关联起来,设置成功后,浏览器会按输入分辨率的1/2/4比例发送simulcast,同时可以在webrtc-internals里看到有多层码流在发送:
注意,这种打开simulcast的方式为Chrome自行实现的方案,WebRTC已经有了标准API打开simulcast,所以并不是推荐使用这种方式,仅作为一种了解即可。API方式如下:
php
pc.addTransceiver(stream.getVideoTracks()[0], {
direction: "sendonly",
streams: [stream],
sendEncodings: [
{ rid: "h", maxBitrate: 1200 * 1024 },
{ rid: "m", maxBitrate: 600 * 1024, scaleResolutionDownBy: 2 },
{ rid: "l", maxBitrate: 300 * 1024, scaleResolutionDownBy: 4 }
]
});
// 它生成的sdp长这样
a=rid:h send
a=rid:m send
a=rid:l send
a=simulcast:send :h;m;l
带宽相关
参考Peer connection: adjust bandwidth可以看到(实际在rfc里也有写)在sdp中给对应m section下面写入b=as
就可以限制带宽,但在代码上面也能看到通过API的方式,所以实际b=as不会用到。
API方式如下:
ini
const sender = pc1.getSenders()[0];
const parameters = sender.getParameters();
if (!parameters.encodings) {
parameters.encodings = [{}];
}
if (bandwidthInKbps === 'unlimited') {
delete parameters.encodings[0].maxBitrate;
} else {
parameters.encodings[0].maxBitrate = bandwidthInKbps * 1000;
}
return sender.setParameters(parameters);
除此之外,还在3个google自己实现的和带宽相关的控制x-google-min-bitrate/x-google-max-bitrate/x-google-start-bitrate
在开源项目mediasoup中可以看到其应用:控制初始发送码率
ini
m=video 7 UDP/TLS/RTP/SAVPF 96 97 (31 more lines) mid=1 c=IN IP4 127.0.0.1
a=rtpmap:96 VP8/90000
a=rtpmap:97 rtx/90000
a=fmtp:96 x-google-start-bitrate=1000
视频编码
参考change-codecs,可以看到在选择视频编码时,可以通过修改answer里的codec的顺序来控制上行端的编码。
用到的办法是兼容性不太好的setCodecPreferences API,如果没有这个API的话,也可以手动改sdp的内容,主要是m line里codec id的顺序,想要使用的codec放到最前面,下面的rtpmap也放到前面(忘了是不是必须的了)。
三、plan-b与unified-plan
plan-b与unified-plan是sdp的两种描述语义格式,plan-b是Google自己的标准,WebRTC的标准就是unified-plan,Chrome浏览器也在M96正式废弃了plan-b的语义。
unified-plan的优势在于每个M section都可以有单独的配置,甚至不同的编码,但是带来的副作用就是导致sdp过大,在传输上会变慢,不过不过,现在各个WebRTC应用也不会传全量的sdp信息,比如开源项目media-soup,会在刚进房时协商好客户端和服务端的媒体能力,在后面发布或者订阅时只传必要的信息,减少传输数据量(突然想到三体里的一句:只送大脑)。
配合unified-plan的话,我们就可以使用Transceiver API了,这个在plan-b下是无法使用的,Transceiver可以认为对应一个m section,上面有一些丰富的接口,虽然这些接口都比较"高级"(高级并不是一个褒义词,高级就意味着只能粗粒度地设置,不能更精细化),但后面肯定会开放更多的底层能力。