一、什么是webRTC
WebRTC(Web RealTime Communication)旨在建立一个互联网浏览器间的实时通信的平台,让 WebRTC技术成为 H5标准之一。(webrtc.org) 是一个规范
1.1 WebRTC框架
1、紫色部分是Web应用开发者API层
2、蓝色实线部分是面向浏览器厂商的API层
3、蓝色虚线部分浏览器厂商可以自定义实现
1.2 WebRTC 通话原理
1. 媒体协商
彼此了解对方支持的媒体格式
VP8 视频图像编解码器,是WebRTC视频引擎的默认的编解码器
VP8适合实时通信应用场景,因为它主要是针对低延时而设计的编解码器。
比如:PeerA端可支持VP8、H264多种编码格式,而PeerB端支持VP9、H264,要保证二端都正确的编解码,最简 单的办法就是取它们的交集H264
有一个专门的协议 ,称为Session Description Protocol (SDP),可用于描述上述这类信息,在WebRTC中,参与 视频通讯的双方必须先交换SDP信息,这样双方才能知根知底,而交换SDP的过程,也称为”媒体协商”
2. 网络协商
彼此了解对方的网络情况,才有可能找到一个相互通信的链路
结论
- 获取外网IP地址映射(因为本机都是在局域网内除非本机IP是公网IP);
- 通过信令服务器交换网络信息
1. 如何拿到外网IP地址映射
通过STUN的全称为(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络
协议,拿到公网IP地址+端口
3. 媒体协商+网络协商 数据的交换通道
知道了2个客户端协商媒体信息(SDP)和网络信息(candidate),如何交换各自的信息?
所以我们需要一个信令服务器 (Signal server)(房间服务器)转发彼此的媒体信息和网络信息。一般搭建到公网除非在局域网中也能保证两端都能同时访问到信令服务器
1. 信令服务器(第三方服务器做交换)
二、webRTC APIs
1. MediaStream
MediaStream用来表示一个媒体数据流(通过getUserMedia接口获取),允许你访问输入设
备,如麦克风和 Web摄像机,该 API 允许从其中任意一个获取媒体流。
1.1 常用方法
1.1.1 getUserMedia
HTML5的getUserMedia API为用户提供访问硬件设备媒体(摄像头、视频、音频、地理位置等)的接口
// 设置约束条件, 同时打开音频流和视频流
var constraints = {
audio: true,
video: true
}
var promise = navigator.mediaDevices.getUserMedia(constraints);
// 处理打开摄像头+麦克风成功
function handleSuccess(stream) {
const video = document.querySelector("#local‐video");
video.srcObject = stream;
}
promise.then(handleSuccess).catch(handleError);
1.1.2 enumerateDevices
webrtc获取电脑所有音视频设备的API:enumerateDevices。获取成功后走then的方法,获取失败走catch的方法。
var ePromise = navigator.mediaDevices.enumerateDevices();
//获取设备信息,参数是设备信息的数组
function successFunction(deviceInfos) {
//遍历设备信息数组,第一个是默认的
deviceInfos.forEach(
//拿到每一项的deviceInfo作为参数
function (deviceinfo) {
//select 中的每一项是一个option,根据不同的种类添加到不同的select中去
var option = document.createElement('option');
console.log("deviceinfo.kind:", deviceinfo.kind);
console.log("deviceinfo.groupId:", deviceinfo.groupId);
if (deviceinfo.kind === 'audioinput') {
audioSource.appendChild(option); // 重新加入下拉框
} else if (deviceinfo.kind === 'audiooutput') {
audioOutput.appendChild(option);
} else if (deviceinfo.kind === 'videoinput') {
videoSource.appendChild(option);
}
}
)
}
ePromise.then(successFunction);
ePromise.catch(failureFunction);
获取到的音视频设备信息包括:
属性 | 说明 |
---|---|
deviceId | 设备ID |
label | 设备的名字 |
kind | 设备的种类 |
groupId | 设备的groupId,如果2个设备的groupId相同,说明是同一个物理设备 |
2.1 综合例子
html
<html lang="en">
<head>
<meta charset="UTF‐8" />
<title>WebRTC caoture video and audio</title>
</head>
<body>
<!‐‐音频源,音频是输出,视频源‐‐>
<div>
<label>audio Source:</label>
<select id="audioSource"></select>
</div>
<div>
<label>audio Output:</label>
<select id="audioOutput"></select>
</div>
<div>
<label>video Source</label>
<select id="videoSource"></select>
</div>
<!‐‐video元素里可以显示我们捕获的音视频数据‐‐>
<!‐‐autoplay属性表示我们拿到视频源的时候直接将它播放出来,playsinline 表示在浏览器的页面中播放‐‐ >
<video autoplay playsinline id="player"></video>
</body>
</html>
javaScript
//获取select这个元素中id为audioSource的元素
var audioSource = document.querySelector("select#audioSource");
var audioOutput = document.querySelector("select#audioOutput");
var videoSource = document.querySelector("select#videoSource");
var filtersSelect = document.querySelector("select#filter");
//首先获取到在html中定义的vedio标签
var videoplay = document.querySelector('video#player');
//获取设备信息,参数是设备信息的数组
function gotDevices(deviceInfos) {
audioSource.innerHTML = ''; // 先清空下拉框
audioOutput.innerHTML = '';
videoSource.innerHTML = '';
//遍历设备信息数组,第一个是默认的
deviceInfos.forEach(
//拿到每一项的deviceInfo作为参数
function (deviceinfo) {
//select 中的每一项是一个option,根据不同的种类添加到不同的select中去
var option = document.createElement('option');
option.text = deviceinfo.label; //设备名称
option.value = deviceinfo.deviceId //值是deviceid
console.log("deviceinfo.kind:", deviceinfo.kind);
console.log("deviceinfo.groupId:", deviceinfo.groupId);
if (deviceinfo.kind === 'audioinput') {
audioSource.appendChild(option); // 重新加入下拉框
} else if (deviceinfo.kind === 'audiooutput') {
audioOutput.appendChild(option);
} else if (deviceinfo.kind === 'videoinput') {
videoSource.appendChild(option);
}
}
)
}
//实现获取流之后的方法,将获取到的流赋值给我们在html中定义的vedio标签
function gotMediaStream(stream) {
//指定标签获取视频流的数据源
videoplay.srcObject = stream;
//拿到流之后,说明用户已经同意访问音视频设备了,此时可以返回一个promise,获取
//所有的音视频设备
return navigator.mediaDevices.enumerateDevices();
}
function start() {
//如果这个方法不存在,则打印
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.log('getUserMedia is not supported!')
return;
} else {
var deviceId = videoSource.value;
//设置参数,采集音视频,并设置视频参数和音频参数
var constraints = {
video: {
width: 320,
height: 240,
frameRate: 30,
facingMode: 'enviroment',
//判断deviceId的值是否为空,如果不为空就设置它的值为deviceId,如果为空就设置为 undefined
deviceId: deviceId ? deviceId : undefined
},
audio: {
noiseSuppression: true,
echoCancellation: true
},
}
//如果存在,就调用这个方法,成功进入成功方法,失败进入失败方法
//成功之后,由于promise是可以串联的,then之后可以继续then
navigator.mediaDevices.getUserMedia(constraints)
.then(gotMediaStream)
.then(gotDevices)
.catch(handleError);
}
}
start();
//增加事件,选择摄像头的时候重新调用start函数,实现设备切换
videoSource.onchange = start;
2. RTCPeerConnection
RTCPeerConnection 对象允许用户在两个浏览器之间直接通讯 ,你可以通过网络将捕
获的音频和视频流实时发送到另一个 WebRTC 端点。使用这些 Api,你可以在本地机器和远程对等点之间创建
连接。它提供了连接到远程对等点、维护和监视连接以及在不再需要连接时关闭连接的方法。
2.1 配置RTCPeerConnection构造函数
- bundlePolicy一般用maxbundle
banlanced:音频与视频轨使用各自的传输通道
maxcompat:每个轨使用自己的传输通道
maxbundle:都绑定到同一个传输通道
- iceTransportPolicy一般用all 指定ICE的传输策略
relay:只使用中继候选者
all:可以使用任何类型的候选者
- iceServers
其由RTCIceServer组成,每个RTCIceServer都是一个ICE代理的服务器
属性 | 含义 |
---|---|
credential | 凭据,只有TURN服务使用 |
credentialType | 凭据类型,可以password或oauth |
urls | 用于连接服中的ur数组 |
username | 用户名,只有TURN服务使用 |
- rtcpMuxPolicy一般用require
rtcp的复用策略,该选项在收集ICE候选者时使用
选项 | 说明 |
---|---|
negotiat | 收集RTCP与RTP复用的ICE候选者,如果RTCP能复用就与RTP复用,如果不能复用,就将他们单独使 |
e | 用 |
require | 只能收集RTCP与RTP复用的ICE候选者,如果RTCP不能复用,则失败 |
// 音视频通话的核心类
function createPeerConnection() {
var defaultConfiguration = {
bundlePolicy: "max-bundle",
rtcpMuxPolicy: "require",
iceTransportPolicy:"all",//relay 或者
// 修改ice数组测试效果,需要进行封装
iceServers: [
{
"urls": [
"turn:192.168.0.143:3478?transport=udp",
"turn:192.168.0.143:3478?transport=tcp" // 可以插入多个进行备选
],
"username": "lqf",
"credential": "123456"
},
{
"urls": [
"stun:192.168.0.143:3478"
]
}
]
};
pc = new RTCPeerConnection(defaultConfiguration); // 音视频通话的核心类
// 把本地流设置给RTCPeerConnection
localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));
}
2.1.1 重要事件
– onicecandidate
在WebRTC中,在STUN和TURN服务器完成网络连接建立后,PeerConnection对象会自动触发icecandidate事件,并提供事件的相关信息,如候选地址candidate等。icecandidate事件的触发时机非常重要,因为我们需要在这个事件触发之后立即将候选地址发送给远程的PeerConnection,以便二者能够建立连接。
function handleIceCandidate(event) {
console.info("handleIceCandidate");
if (event.candidate) {
var candidateJson = {
'label': event.candidate.sdpMLineIndex,
'id': event.candidate.sdpMid,
'candidate': event.candidate.candidate
};
var jsonMsg = {
'cmd': SIGNAL_TYPE_CANDIDATE,
'roomId': roomId,
'uid': localUserId,
'remoteUid':remoteUserId,
'msg': JSON.stringify(candidateJson)
};
var message = JSON.stringify(jsonMsg);
new WebSocket(this.wsUrl).send(message);
console.info("handleIceCandidate message: " + message);
console.info("send candidate message");
} else {
console.warn("End of candidates");
}
}
pc.addEventListener('icecandidate', handleIceCandidate);
– ontrack
获取远端的视频流
pc.ontrack = function(event) {
document.getElementById("received_video").srcObject = event.streams[0];
};
2.1.2 重要方法
– createOffer
WebRTC 主要用于 peer 之间音视频通讯,而通讯前需要协商一些参数,比如编解码器、传输协议等。
所以CreareOffer 的目的就在于搜集本地相关参数,用于初始化一次 session.
const pc1 = new RTCPeerConnection();
const offer = await pc1.createOffer();
– setLocalDescription
方法setLocalDescription()更改与连接关联的本地描述。此描述指定连接的本地端的属性,包括媒体格式。该方法只接受一个参数,即会话描述,并返回一个Promise,一旦描述被异步更改,Promise就会被实现
pc1.setLocalDescription(offer);
– addTrack
RTCPeerConnection.addTrack()将新的媒体轨道添加到轨道集,该轨道将被传输到另一对等方
navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(async (stream: any) => {
for (const track of stream.getTracks()) {
pc.addTrack(track);
}
}
三、webRTC 实现 案例
一、client端
HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta id="theme-color" name="theme-color" content="#ffffff">
<base target="_blank">
<title>WebRTC</title>
<link rel="stylesheet" href="main.css"/>
</head>
<body>
<div id="container">
<video id="localVideo" playsinline autoplay muted></video>
<video id="remoteVideo" playsinline autoplay></video>
<div class="box">
<button id="startButton">Start</button>
<button id="callButton">Call</button>
</div>
</div>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script src="main.js" async></script>
</body>
</html>
javaScript
'use strict';
const startButton = document.getElementById('startButton');
const callButton = document.getElementById('callButton');
callButton.disabled = true;
startButton.addEventListener('click', start);
callButton.addEventListener('click', call);
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
let localStream;
let pc1;
let pc2;
const offerOptions = {
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
};
async function start() {
/**
* 获取本地媒体流
*/
startButton.disabled = true;
const stream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
localVideo.srcObject = stream;
localStream = stream;
callButton.disabled = false;
}
function gotRemoteStream(e) {
if (remoteVideo.srcObject !== e.streams[0]) {
remoteVideo.srcObject = e.streams[0];
console.log('pc2 received remote stream');
setTimeout(() => {
pc1.getStats(null).then(stats => console.log(stats));
}, 2000)
}
}
function getName(pc) {
return (pc === pc1) ? 'pc1' : 'pc2';
}
function getOtherPc(pc) {
return (pc === pc1) ? pc2 : pc1;
}
async function call() {
callButton.disabled = true;
/**
* 创建呼叫连接
*/
pc1 = new RTCPeerConnection({
sdpSemantics: 'unified-plan', // 指定使用 unified plan
iceServers: [
{ "url": "stun:stun.l.google.com:19302" },
{ "url": "turn:user@turnserver.com", "credential": "pass" }
] // 配置ICE服务器
});
pc1.addEventListener('icecandidate', e => onIceCandidate(pc1, e)); // 监听ice候选项事件
/**
* 创建应答连接
*/
pc2 = new RTCPeerConnection();
pc2.addEventListener('icecandidate', e => onIceCandidate(pc2, e));
pc2.addEventListener('track', gotRemoteStream);
/**
* 添加本地媒体流
*/
localStream.getTracks().forEach(track => pc1.addTrack(track, localStream));
/**
* pc1 createOffer
*/
const offer = await pc1.createOffer(offerOptions); // 创建offer
await onCreateOfferSuccess(offer);
}
async function onCreateOfferSuccess(desc) {
/**
* pc1 设置本地sdp
*/
await pc1.setLocalDescription(desc);
/******* 以下以pc2为对方,来模拟收到offer的场景 *******/
/**
* pc2 设置远程sdp
*/
await pc2.setRemoteDescription(desc);
/**
* pc2 createAnswer
*/
const answer = await pc2.createAnswer(); // 创建answer
await onCreateAnswerSuccess(answer);
}
async function onCreateAnswerSuccess(desc) {
/**
* pc2 设置本地sdp
*/
await pc2.setLocalDescription(desc);
/**
* pc1 设置远程sdp
*/
await pc1.setRemoteDescription(desc);
}
async function onIceCandidate(pc, event) {
try {
await (getOtherPc(pc).addIceCandidate(event.candidate)); // 设置ice候选项
onAddIceCandidateSuccess(pc);
} catch (e) {
onAddIceCandidateError(pc, e);
}
console.log(`${getName(pc)} ICE candidate:\n${event.candidate ? event.candidate.candidate : '(null)'}`);
}
function onAddIceCandidateSuccess(pc) {
console.log(`${getName(pc)} addIceCandidate success`);
}
function onAddIceCandidateError(pc, error) {
console.log(`${getName(pc)} failed to add ICE Candidate: ${error.toString()}`);
}