什么是webrtc
WebRTC(Web Real-Time Communication)是 Google于2010以6829万美元从 Global IP Solutions 公司购买,并于2011年将其开源,旨在建立一个互联网浏览器间的实时通信的平台,让 WebRTC技术成为 H5标准之一。感兴趣的小伙伴可以看官网(https://webrtc.org)的介绍。WebRTC是一个免费的开放项目,它通过简单的API为浏览器和移动应用程序提供实时通信(RTC)功能。并不受限于传统互联网应用或浏览器的终端运行环境。实际上无论终端运行环境是浏览器、桌面应用、移动设备(Android或iOS)还是IoT设备,只要IP连接可到达且符合WebRTC规范就可以互通。这一点大大提升了智能终端的实时通信能力,打开了许多对于实时交互性要求较高的应用场景的空间,被广泛应用于语音通话、视频聊天、在线会议、远程医疗、在线教育和互联网安防监控等领域。
webrtc框架
webrtc主要包含,Web API、webRTC C++ API、session管理、音频引擎、视频引擎、网络传输模块。
- Web API:这一层 API 是提供给不同平台应用软件开发者使用的 API,这些API 都会提供三个功能接口,分别是MediaStream、RTCPeerConnection和RTCDataChannel。MediaStream接口用于捕获和存储客户端的实时音视频流,便于客户端的进行音视频采集和渲染。RTCPeerConnection接口是WebRTC 的核心接口,它封装了WebRTC连接的管理,是承载着 WebRTC 连接机制的接口。RTCDataChannel接口是进行WebRTC 连接数据传输的数据通道接口。
- WebRTC C++ API:这一层的API是提供给浏览器厂商、平台SDK开发者使用的 C++ API,不同的平台可以通过各自的C++接口调用能力,对其进行上层封装,满足跨平台的需求。
- Session management/Abstract signaling:该层是WebRTC的会话层,主要用于进行信令交互和管理 RTCPeerConnection的连接状态。
- Voice Engine:音频引擎模块是一系列音频多媒体处理框架,包括Audio Codecs、NetEQ for voice、Acoustic Echo Canceller(AEC)和Noise Reduction(NR)。其中,Audio Codecs是音频编解码器,当前 WebRTC 支持ilbc、isac、G711、G722和opus等等;NetEQ for voice 是自适应抖动控制算法以及语音包丢失隐藏算法,用于适应不断变化的网络环境;AEC 是回声消除器,用于实时消除麦克风采集到的回声;NR是噪声抑制器,用于消除与相关 VoIP的某些类型的背景噪音(嘶嘶声、风扇噪声等等)。以上多项音频处理技术集成在一起,使得WebRTC在确保音质优美的同时还减小了缓冲延迟。
- Video Engine:视频引擎模块是一系列视频多媒体处理框架,包括Video Codec、Video Jitter Buffer和Image Enhancement。其中,Video Codec是视频编解码器,当前WebRTC 支持VP8、VP9和H.264编解码;Video Jitter Buffer是视频抖动缓冲器,用于降低由于视频抖动和视频信息包丢失带来的不良影响;Image Enhancement 是图像质量增强模块,用于对摄像头采集回来的图像进行处理,包括明暗度检测、颜色增强、降噪处理等。以上多项视频处理技术集成在一起,使得WebRTC在确保画面优美的同时还提高了流畅度。
- Transport:数据传输模块是WebRTC对音视频进行P2P传输的核心模块,包括SRTP、Multiplexing和P2P。其中,SRTP是基于UDP的安全实时传输协议,为WebRTC中音视频数据提供安全单播和多播功能;Multiplexing 是多路复用技术,采用多路复用技术能把多个信号组合在一条物理信道上进行传输,减少对传输线路的数量消耗;P2P是端对端传输技术,WebRTC 的P2P技术集成了STUN、TURN和ICE,这些都是针对 UDP 的NAT的防火墙穿越方法,是连接有效性的保障。
webrtc通信架构
WebRTC 本身提供的是 1 对 1 的通信模型,在 STUN/TURN 的辅助下,如果能实现 NAT 穿越,那么两个浏览器是可以直接进行媒体数据交换的;如果不能实现 NAT 穿越,那么只能通过 TURN 服务器进行数据转发的方式实现通信。
要通过 WebRTC 实现多对多通信,有三种方案:
1、Mesh 方案
即多个终端之间两两进行连接,形成一个网状结构。比如 A、B、C 三个终端进行多对多通信,当 A 想要共享媒体(比如音频、视频)时,它需要分别向 B 和 C 发送数据。同样的道理,B 想要共享媒体,就需要分别向 A、C 发送数据,依次类推。这种方案对各终端的带宽要求比较高。
2、MCU(Multipoint Conferencing Unit)方案
该方案由一个服务器和多个终端组成一个星形结构。各终端将自己要共享的音视频流发送给服务器,服务器端会将在同一个房间中的所有终端的音视频流进行混合,最终生成一个混合后的音视频流再发给各个终端,这样各终端就可以看到 / 听到其他终端的音视频了。实际上服务器端就是一个音视频混合器,这种方案服务器的压力会非常大。
3、SFU(Selective Forwarding Unit)方案
该方案也是由一个服务器和多个终端组成,但与 MCU 不同的是,SFU 不对音视频进行混流,收到某个终端共享的音视频流后,就直接将该音视频流转发给房间内的其他终端。它实际上就是一个音视频路由转发器。
webrtc使用流程
1、媒体协商
比如:PeerA端可支持VP8、H264多种编码格式,而PeerB端支持VP9、H264,要保证二端都正确的编解码,最简单的办法就是取它们的交集H264。有一个专门的协议叫做Session Description Protocol (SDP),可用于描述上述这类信息,在WebRTC中,参与视频通讯的双方必须先交换SDP信息,这样双方才能知根知底,而交换SDP的过程,也称为"媒体协商"。
SDP(Session Description Protocol)是一种通用的会话描述协议,也就是一种用来描述信息格式的标准,一般常用在实时音视频中用来交换信息,比如在 WebRTC 中,需要通信双方在连接阶段通过已有的信令服务使用 SDP 来协商后续传输过程中使用的音视频编解码器(codec)、ICE Candida、网络传输协议等;
通信双方可以使用 HTTP、WebSocket、socketio 等传输协议来交换各自的SDP 内容,在webrtc中这个过程称叫作 offer/answer 交换,也就是发起方通过webrtc的接口生成offer sdp,然后发送 offer,接收方收到 offer通过接口设置给webrtc后调用接口生成自己的answer SDP,发送给offer端;
SDP 描述分为两部分,分别是会话描述(session level)和媒体描述(media level),具体的组成可参考RFC4566
2、网络协商
要想实现通话需要彼此要了解对方的网络情况,这样才有可能找到一条相互通讯的链路。需要获取外网IP地址映射,通过信令服务器交换网络信息。
理想的网络情况是每个浏览器的电脑都是公网IP,可以直接进行点对点连接。实际情况是我们的电脑和电脑之间都是在某个局域网中并且有防火墙,需要NAT(Network Address Translation,网络地址转换)。
NAT技术会保护内网地址的安全性,所以这就会引发个问题,就是当我采用P2P之中连接方式的时候,NAT会阻止外网地址的访问,这时我们就得采用NAT穿透了。WebRTC的NAT/防火墙穿越技术,就是基于上述的一个思路来实现的。在WebRTC中采用ICE框架的NAT/防火墙穿越技术来保证RTCPeerConnection能实现NAT穿越。
ICE是用于UDP媒体传输的NAT穿透协议(适当扩展也可以支持TCP),它需要利用STUN和TURN协议来完成工作。
STUN协议提供了获取一个内网地址对应的公网地址映射关系(NAT Binding)的机制,并且提供了它们之间的保活机制。
TURN协议是STUN协议的一个扩展,允许一个peer只使用一个转发地址就可以和多个peer实现通信。其本质是一个中继协议。
可以简单的理解为如果STUN不通,那就走TURN,TURN可以理解为一个中继代理转发。
在WebRTC中,ICE会在SDP中增加传输地址信息,利用这个信息进行NAT穿透及确定媒体流传输地址。
WebRTC开始建立网络连接,主要包括收集candidate、交换candidate和按优先级尝试连接的过程,该过程被称为ICE(Interactive Connectivity Establishment,交互式连接建立)。其中每个 candidate 都包含IP地址、端口、传输协议、类型等信息。
WebRTC将 candidate分为了四个类型:host、srflx、prflx、relay,它们的优先级依次降低。
- host:Host Candidate,根据主机的网卡数量决定,一般一个网卡对应一个ip地址,然后给每个ip随机分配一个端口生成。
- srflx:Server Reflexive Candidate,根据STUN服务器获得的ip和端口生成。
- prflx:Peer Reflexive Candidate,根据对端的ip和端口生成。
- relay:Relayed Candidate,根据TURN服务器获得的ip和端口生成。
整体连接使用流程如下:
使用流程
一般来说WebRTC需要三个服务器,房间服务器、信令服务器、打洞服务器(STUN/TURN),房间服务器用来管理用户状态加入与离开房间等等,信令服务器用来交换媒体信息
MediaStream API
MediaStream API中有两个重要组成:MediaStreamTrack
以及MediaStream
。MediaStreamTrack
对象代表单一类型的媒体流,产生自客户端的media source
,可以是音频或者视频,但只能是其中一种,是音频称作audio track
,视频的话称作video track
,这其实就是我们平时所说的音轨与视频轨。
一个track由source与sink组成。source给track提供数据。
MeidiaStream
用于将多个MediaStreamTrack
对象打包到一起。一个MediaStream
可包含audio track
与video track
。类似我们平时的多媒体文件,可包含音频与视频。
一个MediaStream
对象包含0或多个MediaStreamTrack
对象。MediaStream
中的所有MediaStreamTrack
对象在渲染时必须同步。就像我们平时播放媒体文件时,音视频的同步。
简单点说,source 与sink构成一个track,多个track构成MediaStram。
source 与 sink
在MediaTrack的源码中,MediaTrack都是由对应的source和sink组成的。
浏览器中存在从source到sink的媒体管道,其中source负责生产媒体资源,包括多媒体文件,web资源等静态资源以及麦克风采集的音频,摄像头采集的视频等动态资源。而sink则负责消费source生产媒体资源,也就是通过,video等媒体标签进行展示,或者是通过RTCPeerConnection将source通过网络传递到远端。RTCPeerConnection可同时扮演source与sink的角色,作为sink,可以将获取的source降低码率,缩放,调整帧率等,然后传递到远端,作为source,将获取的远端码流传递到本地渲染。
检测获取音视频设备
function getUserMedia() {
return new Promise((resolve, reject) => {
navigator.mediaDevices.enumerateDevices().then((devices) => {
devices.forEach((devInfo) => {
if (devInfo.kind === 'audioinput') {
//音频输入设备
} else if (devInfo.kind === 'audiooutput') {
//音频输出设备
} else if (devInfo.kind === 'videoinput') {
//摄像头
}
});
resolve(devices);
});
});
}
采集本地音视频数据
var video = document.querySelector('#showview');
//流约束
const constraints = {
audio: true,
video: { width: 1280, height: 720 },
};
//成功回调
function successCallback(mediaStream) {
localStream = mediaStream;
// 获取视频的track
const videoTrack = mediaStream.getVideoTracks()[0];
//拿到video的所有约束
const videoConstraints = videoTrack.getSettings();
// 获取音频的track
const audioTrack = mediaStream.getAudioTracks()[0];
video.src = mediaStream;
}
//失败回调
function errorCallback(error){
console.log('navigator.getUserMedia error: ', error);
}
navigator.getUserMedia(constraints, successCallback, errorCallback);
constraints约束条件有如下参数:
dictionary MediaTrackConstraintSet {
ConstrainULong width;
ConstrainULong height;
ConstrainDouble aspectRatio;
ConstrainDouble frameRate;
ConstrainDOMString facingMode;
ConstrainDOMString resizeMode;
ConstrainULong sampleRate;
ConstrainULong sampleSize;
ConstrainBoolean echoCancellation;
ConstrainBoolean autoGainControl;
ConstrainBoolean noiseSuppression;
ConstrainDouble latency;
ConstrainULong channelCount;
ConstrainDOMString deviceId;
ConstrainDOMString groupId;
};
屏幕共享
var config = {
video: {frameRate:15,width:1920,height:1080},
audio: true
};
navigator.mediaDevices.getUserMedia(config)
.then(stream => {
videoElement.srcObject = stream;
}, error => {
console.log("Unable to acquire screen capture", error);
});
RTCPeerConnection
RTCPeerConnection接口代表一个由本地计算机到远端的WebRTC连接。该接口提供了创建,保持,监控,关闭连接的方法的实现。 其接口的定义如下:
declare var RTCPeerConnection: {
prototype: RTCPeerConnection;
new(configuration?: RTCConfiguration): RTCPeerConnection;
generateCertificate(keygenAlgorithm: AlgorithmIdentifier): Promise<RTCCertificate>;
};
其中有一个可选参数RTCConfiguration, 在文档中定义如下;
interface RTCConfiguration {
bundlePolicy?: RTCBundlePolicy;
certificates?: RTCCertificate[];
iceCandidatePoolSize?: number;
iceServers?: RTCIceServer[];
iceTransportPolicy?: RTCIceTransportPolicy;
rtcpMuxPolicy?: RTCRtcpMuxPolicy;
}
iceServers,由多个RTCIceServer组成需要填入stun货turn服务的地址;
iceTransportPolicy :ice的传输策略,默认值是all允许考虑所有候选者,值有"all",“public” 已弃用 ,“relay” 只收集中继候选者;
rtcpMuxPolicy:收集 ICE 候选时是否使用的 RTCP 多路复用策略。值有 'negotiate’和 ‘require’;
bundlePolicy: ‘balanced’、‘max-compat’和’max-bundle’;默认是balanced。各个含义如下:
Balanced实际就是音频轨、视频轨各自各自使用一个传输通道,是分开的。其中多路音频轨是共用同一个传输通道、多路视频轨也是使用同一个通道。 max-compat是最大兼容性,怎样才能达到最大的兼容性呢?就是每一个轨都有自己的通道,如果我有这个两个音频,一个视频,就有三个通道,就是每个音频走自己的,那视频也是一样的。等到Balanced策略不成功的时候,就使用max-compat这种方式。 max-bundle就是最大化的使用一个绑定,那就是将所有的这些这个媒体的这个流都用一个这个通道进行传输。这是webrtc建议的方式,这样的话对于底层来说就比较简单了,而且你建立这个连接之后,只需要一个证书,而不需要一堆证书,否则的话,你每一个连接都需要一个证书,就会非常的耗费时间。
一般使用方式如下:
const config = {
bundlePolicy: 'balanced',
iceTransportPolicy: "all",
rtcpMuxPolicy : 'negotiate',
iceServers: [
{
urls: "stun:stun.l.google.com:19302",
username: "aaa",
credential: "123456"
}
]
};
主要方法:
-
addIceCandidate 向ICE代理提供远程候选人。
-
addStream 添加MediaStream作为视频或音频的本地源。
-
addTrack 添加MediaStreamTrack
-
close 关闭连接。
-
createAnswer 创建answer。此方法的两个第一个参数是成功回调和错误回调。可选的第三个参数是要创建的选项。
-
createDataChannel 创建一个新的数据通道RTCDataChannel
-
createOffer 用来创建offer,创建成功后调用setLocalDescription方法将localDescription设置为offer,localDescription即为我们需要发送给应答方的sdp
-
getConfiguration 获取配置信息
-
getStats 创建一个新的RTCStatsReport,其中包含有关连接的统计信息。
-
removeStream 删除MediaStream作为视频或音频的本地源。
-
removeTrack 删除MediaStreamTrack作为视频或音频的本地源。
-
setLocalDescription 设置本地连接描述信息。该方法采用三个参数,RTCSessionDescription对象,成功回调,失败回调。
-
setRemoteDescription 设置远程连接描述信息
主要事件:
onaddstream 事件用来监听通道中新加入的流,当addstream事件被触发时,会调用此处理程序。当远程方将MediaStream添加到此连接时,会发送此事件。
ondatachannel 当数据通道事件被触发时,会调用此处理程序。将RTCDataChannel添加到此连接时发送此事件。
onicecandidate 当RTCIceCcandidate对象添加时,会发送此事件。
oniceconnectionstatechange 当iceConnectionState的值发生更改时,会发送此事件。
onnegotiationneeded 当触发需要协商的事件时,会调用此处理程序。此事件由浏览器发送,以通知将来某个时候需要进行协商。
onremovestream 当信号状态更改事件被触发时,会调用此处理程序。当signalingState的值发生变化时,会发送此事件。
onsignalingstatechange 当removestream事件被激发时,会调用此处理程序。此事件是在从该连接中删除MediaStream时发送的。
ontrack 当远程方将MediaStream添加到此连接时,会发送此事件。
-
创建一个新的
RTCPeerConnection
对象并使用getUserMedia()
添加流.pc = new RTCPeerConnection(config); //.. localStream.getTracks().forEach((track) => { pc.addTrack(track,localStream); }
-
创建
offer
设置为 pc1的本地描述,通过信令服务器发送到远端,作为 pc2的远端描述.pc2收到后创建answer设置成自己的本地描述pc.createOffer(offerOptions).then(function (offer) { pc.setLocalDescription(offer); // 设置本地 Offer 描述,(设置描述之后会触发ice事件) //... 发送offer }); pc2.setRemoteDescription(new RTCSessionDescription(offer)); pc2.createAnswer().then(function (answer) { pc2.setLocalDescription(answer); // 设置本地 Answer 描述 //... 发送 Answer 请求信令 });
-
应答方收到发起方发送的ICE数据时,调用pc的addIceCandidate方法。pc2.addIceCandidate(new RTCIceCandidate(ice))复制代码发起方收到应答方发送的ICE数据时,同样调用RTCPeerConnection对象的addIceCandidate方法。
pc.addEventListener('icecandidate', function (event) { const iceCandidate = event.candidate; if (iceCandidate) { // 发送 ice到远端 } }); pc.addIceCandidate(new RTCIceCandidate(iceCandidate));
至此,一个最简单的WebRTC连接就建立完成了。
完整代码如下:
const userName = document.getElementById('userName'); // 用户名输入框
const roomName = document.getElementById('roomName'); // 房间号输入框
const startConn = document.getElementById('startConn'); // 连接按钮
const joinRoom = document.getElementById('joinRoom'); // 加入房间按钮
const hangUp = document.getElementById('hangUp'); // 挂断按钮
const videoContainer = document.getElementById('videoContainer'); // 通话列表
roomName.disabled = true;
joinRoom.disabled = true;
hangUp.disabled = true;
var pcList = []; // rtc连接列表
var localStream; // 本地视频流
var ws; // WebSocket 连接
// ice stun服务器地址
var config = {
'iceServers': [{
'urls': 'stun:stun.l.google.com:19302'
}]
};
// offer 配置
const offerOptions = {
offerToReceiveVideo: 1,
offerToReceiveAudio: 1
};
// 开始
startConn.onclick = function () {
ws = new WebSocket('wss://' + location.host);
ws.onopen = evt => {
console.log('connent WebSocket is ok');
const sendJson = JSON.stringify({
type: 'conn',
userName: userName.value,
});
ws.send(sendJson); // 注册用户名
}
ws.onmessage = msg => {
const str = msg.data.toString();
const json = JSON.parse(str);
switch (json.type) {
case 'conn':
console.log('连接成功');
userName.disabled = true;
startConn.disabled = true;
roomName.disabled = false;
joinRoom.disabled = false;
hangUp.disabled = false;
break;
case 'room':
// 返回房间内所有用户
sendRoomUser(json.roomUserList, 0);
break;
case 'signalOffer':
// 收到信令Offer
signalOffer(json);
break;
case 'signalAnswer':
// 收到信令Answer
signalAnswer(json);
break;
case 'iceOffer':
// 收到iceOffer
addIceCandidates(json);
break;
case 'close':
// 收到房间内用户离开
closeRoomUser(json);
default:
break;
}
}
}
// 加入或创建房间
joinRoom.onclick = function () {
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(function (mediastream) {
localStream = mediastream; // 本地视频流
addUserItem(userName.value, localStream.id, localStream);
const str = JSON.stringify({
type: 'room',
roomName: roomName.value,
streamId: localStream.id
});
ws.send(str);
roomName.disabled = true;
joinRoom.disabled = true;
}).catch(function (e) {
console.log(JSON.stringify(e));
});
}
// 创建WebRTC
function createWebRTC (userName, isOffer) {
const pc = new RTCPeerConnection(config); // 创建 RTC 连接
pcList.push({ userName, pc });
localStream.getTracks().forEach(track => pc.addTrack(track, localStream)); // 添加本地视频流 track
if (isOffer) {
// 创建 Offer 请求
pc.createOffer(offerOptions).then(function (offer) {
pc.setLocalDescription(offer); // 设置本地 Offer 描述,(设置描述之后会触发ice事件)
const str = JSON.stringify({ type: 'signalOffer', offer, userName });
ws.send(str); // 发送 Offer 请求信令
});
// 监听 ice
pc.addEventListener('icecandidate', function (event) {
const iceCandidate = event.candidate;
if (iceCandidate) {
// 发送 iceOffer 请求
const str = JSON.stringify({ type: 'iceOffer', iceCandidate, userName });
ws.send(str);
}
});
}
return pc;
}
// 为每个房间用户创建RTCPeerConnection
function sendRoomUser (list, index) {
createWebRTC(list[index], true);
index++;
if (list.length > index) {
sendRoomUser(list, index);
}
}
// 接收 Offer 请求信令
function signalOffer (json) {
const { offer, sourceName, streamId } = json;
addUserItem(sourceName, streamId);
const pc = createWebRTC(sourceName);
pc.setRemoteDescription(new RTCSessionDescription(offer)); // 设置远端描述
// 创建 Answer 请求
pc.createAnswer().then(function (answer) {
pc.setLocalDescription(answer); // 设置本地 Answer 描述
const str = JSON.stringify({ type: 'signalAnswer', answer, userName: sourceName });
ws.send(str); // 发送 Answer 请求信令
});
// 监听远端视频流
pc.addEventListener('addstream', function (event) {
document.getElementById(event.stream.id).srcObject = event.stream; // 播放远端视频流
});
}
// 接收 Answer 请求信令
function signalAnswer (json) {
const { answer, sourceName, streamId } = json;
addUserItem(sourceName, streamId);
const item = pcList.find(i => i.userName === sourceName);
if (item) {
const { pc } = item;
pc.setRemoteDescription(new RTCSessionDescription(answer)); // 设置远端描述
// 监听远端视频流
pc.addEventListener('addstream', function (event) {
document.getElementById(event.stream.id).srcObject = event.stream;
});
}
}
// 接收ice并添加
function addIceCandidates (json) {
const { iceCandidate, sourceName } = json;
const item = pcList.find(i => i.userName === sourceName);
if (item) {
const { pc } = item;
pc.addIceCandidate(new RTCIceCandidate(iceCandidate));
}
}
// 房间内用户离开
function closeRoomUser (json) {
const { sourceName, streamId } = json;
const index = pcList.findIndex(i => i.userName === sourceName);
if (index > -1) {
pcList.splice(index, 1);
}
removeUserItem(streamId);
}
// 挂断
hangUp.onclick = function () {
userName.disabled = false;
startConn.disabled = false;
roomName.disabled = true;
joinRoom.disabled = true;
hangUp.disabled = true;
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
pcList.forEach(element => {
element.pc.close();
element.pc = null;
});
pcList.length = 0;
if (ws) {
ws.close();
ws = null;
}
videoContainer.innerHTML = '';
}
// 添加用户
function addUserItem (userName, mediaStreamId, src) {
const div = document.createElement('div');
div.id = mediaStreamId + '_item';
div.className = 'video-item';
const span = document.createElement('span');
span.className = 'video-title';
span.innerHTML = userName;
div.appendChild(span);
const video = document.createElement('video');
video.id = mediaStreamId;
video.className = 'video-play';
video.controls = true;
video.autoplay = true;
video.muted = true;
video.webkitPlaysinline = true;
src && (video.srcObject = src);
div.appendChild(video);
videoContainer.appendChild(div);
}
// 移除用户
function removeUserItem (streamId) {
videoContainer.removeChild(document.getElementById(streamId + '_item'));
}