如何实现一个基于WebRTC的音视频通信系统

推荐阅读时长25分钟

前言

目前市场上音视频技术方案大致分为以下几类,WebRTC因其超低延时、集成音视频采集传输等优点,是在线教育、远程会议等领域首选技术。

方案 优势 劣势 应用场景
基于浏览器插件的flash播放RTMP 即将淘汰 即将淘汰 传统直播
跨平台的HLS/DASH 播放方案 – 跨端广泛支持:苹果浏览器原生支持

– hls.js

– 延时高

– 碎片化

传统直播,如赛事直播、大型会议直播
基于HTML5 MSE 能力的flv播放技术 – 格式简单

– 无需插件

– 移动端MSE支持性差

– 一定延时

传统直播,同上
WebRTC实时通讯技术 – 毫秒级的低延时

– 音视频采集上行传输

– 相对复杂

– 支持度低

– 价格高

– 容量有限

在线教育、远程会议

WebRTC是 Google 在 2010 年收购 VoIP 软件开发商 GlobalIPSolutions 的 GIPS 引擎后,基于 GIPS 引擎实现的浏览器音视频和数据通信技术,在 2012 年集成到 chrome 浏览器,到目前为止,大部分主流现代浏览器都已经支持。

image.png

image.png

WebRTC架构

一个简单的音视频架构大致如下:

image.png

  • 音视频采集模块:调用系统API,从系统麦克风和摄像头读取设备并采集音视频数据。音频是PCM数据,视频是YUV数据

  • 音视频编码模块:根据不同类型数据使用不同编码方式,将原始PCM、YUV数据压缩编码

  • 网络传输模块:将压缩编码后的数据封装成RTP包,通过网络传输至对端,同时对端接收RTP数据

  • 音视频解码模块:将接收到的压缩编码数据还原成原始的PCM、YUV/RGB数据

  • 音视频渲染模块:拿到原始数据后,音频数据输出到扬声器,视频数据输出到显示器

如果我们按照上面的架构实现一个音视频通信系统,相当于至少需要开发7个小模块,想想就费时费力。此时WebRTC就可以闪亮登场了,它内部标准化的实现上述架构,并在此基础上进行拓展,对外只暴露了相关的API,其架构图如下( 官网 的有点旧,重新画的):

image.png

WebRTC大体可以分为四层:接口层、Session层、引擎层、设备层:

  • 接口层:暴露给业务侧,业务侧可以使用原生的 C++ API 接口或者 Web API 开发音视频实时通信。核心是 RTCPeerConnection

  • Session层:用于控制业务逻辑,比如媒体协商、收集 Candidate 等

  • 引擎层:包括音频引擎、视频引擎和网络传输

  • 设备层:主要和硬件交互,负责音频的采集和播放,视频的采集,物理网络等

WebRTC音视频通信过程

image.png

一个正常音视频通信架构如上图所示,通信双方分别是 caller(主叫) 与 callee(被叫),两边的内部逻辑相似,下面以caller端为例,了解内部流程:

  1. 调用音视频检测模块,检测终端是否有可用的音视频设备

  2. 调用音视频采集模块,采集用户音视频数据

  3. 根据用户选择,是否开启录制(授权)

  4. 通过信令模块交换SDP

  5. 创建WebRTC的核心对象RTCPeerConnection,之后添加采集到的音视频数据

  6. RTCPeerConnection向STUN(SessionTraversal Utilities forNAT)/TURN(Traversal Using Relays aroundNAT)服务器发送请求,返回caller的外网ip地址和端口号

  7. 通过信令服务器,caller和callee互相传递对方的外网ip地址和端口(媒体协商)

  8. 最终P2P链接建立完成,后面就会源源不断的发送音视频数据到对端

下面就是该过程对应的泳道图:

image.png

信令服务器

信令是实现音视频通信的重要一环,比如创建房间、离开房间、交换双端offer/answer以及candidate信息等。但WebRTC规范文档中并未定义信令相关的内容,因为不同业务,逻辑不同,信令也会千差万别,所以需要各个业务自己实现一套信令服务。
下面以socket.io为例,实现一套信令服务:

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const USER_LIMITS = 3;

const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer);

const getRoomUsers = room => {
  const myRoom = io.sockets.adapter.rooms[room];
  return myRoom || [];
};

const getRoomUsersCount = room => {
  return getRoomUsers(room).length;
};

// 连接事件
io.sockets.on('connection', socket => {
  // 转发消息
  socket.on('message', (room, data) => {
    console.log(`message, room: ${room}, data type: ${data.type}`);
    socket.to(room).emit('message', room, data);
  });

  // 加入房间
  socket.on('join', room => {
    socket.join(room);
    const userCount = getRoomUsersCount(room);
    console.log(`user join, room number ${userCount}`);

    // 房间未满员
    if (userCount < USER_LIMITS) {
      // 广播用户加入房间
      socket.emit('joined', room, socket.id);

      if (userCount > 1) {
        // 广播其他用户加入房间
        socket.to(room).emit('otherJoin', room, socket.id);
      }
    }
    // 房间满员
    else {
      socket.leave(room);
      socket.emit('full', room, socket.id);
    }
  });

  socket.on('leave', room => {
    socket.leave(room);

    const userCount = getRoomUsersCount(room);
    console.log(`user leave, room number ${userCount}`);

    // 广播有用户退出房间
    socket.to(room).emit('exit', room, socket.id);

    socket.emit('leaved', room, socket.id);
  });
});

httpServer.listen('80');

常用API

💡WebRTC的api返回值基本上都是Promise。

获取设备 enumerateDevices

navigator.mediaDevices.enumerateDevices()

该API的返回值是一个 Promise<MediaDeviceInfo[]>

interface MediaDeviceInfo {
  deviceId: string; // 设备的唯一编号
  kind: MediaDeviceKind; // 设备的类型
  label: string; // 设备的名字
  groupId: string; // 设备组编号,如果两个设备在同一个硬件上,则值是一致的
}

enum MediaDeviceKind {
   AudioInput = 'audioinput', // 麦克风
   AudioOutput = 'audiooutput', // 扬声器
   VideoInput = 'videoinput', // 摄像头
}

举个🌰,在控制台输入下面的命令:

navigator.mediaDevices.enumerateDevices().then(deviceInfos => console.table(deviceInfos))

image.png

采集音视频 getUserMedia

navigator.mediaDevices.getUserMedia(constrains?: MediaStreamConstrains):Promise<MediaStream>

通过 enumerateDevices 方法获取音视频设备后,就可以调用 getUserMedia 方法指定设备采集音视频数据了。 constrains 详情参考 MediaTrackConstraints – Web APIs | MDN

interface MediaStreamConstrains {
  video?: MediaTrackConstrains | boolean;
  audio?: MediaTrackConstrains | boolean;
}

interface MediaTrackConstrains {
  // 视频相关
  width?: ConstrainULong; // 宽度
  height?: ConstrainULong; // 高度
  aspectRatio?: ConstrainDouble; // 宽高比
  frameRate?: ConstrainDouble; // 帧率
  facingMode?: ConstrainDOMString; // 前置/后置摄像头
  resizeMode?: ConstrainDOMString; // 缩放或裁剪

  // 音频相关
  sampleRate?: ConstrainULong; // 采样率
  sampleSize?: ConstrainULong; // 采样大小
  echoCancellation?: ConstrainBoolean; // 是否开启回音消除
  autoGainControl?: ConstrainBoolean; // 是否开启自动增益控制
  noiseSuppression?: ConstrainBoolean; // 是否开启降噪
  latency?: ConstrainDouble; // 目标延迟
  channelCount?: ConstrainULong; // 声道数量

  // 设备相关
  deviceId?: ConstrainDOMString; // 设备编号
  groupId?: ConstrainDOMString; // 设备组编号
}

举个🌰:

<body>
  <video id="video" autoplay></video>
</body>
<script>
  const setLocalMediaStream = mediaStream => {
    const video = document.getElementById('video');
    video.srcObject = mediaStream;
  };
  navigator.mediaDevices
      .getUserMedia({
        video: true,
        audio: false,
      })
      .then(setLocalMediaStream)
</script>

核心对象 RTCPeerConnection

new RTCPeerConnection(config: RTCConfiguration)

RTCPeerConnection 对象是WebRTC的核心,同时也是暴露给用户的统一接口,内部包含了网络处理模块、服务质量模块、音视频引擎模块等,可以把它理解为一个socket,能够快速稳定的实现端到端的数据传输。
创建 RTCPeerConnection 对象时,需要传入STUN/TURN服务器等相关信息。

interface RTCConfiguration {
    bundlePolicy?: RTCBundlePolicy;
    certificates?: RTCCertificate[];
    iceCandidatePoolSize?: number;
    iceServers?: RTCIceServer[];
    iceTransportPolicy?: RTCIceTransportPolicy;
    rtcpMuxPolicy?: RTCRtcpMuxPolicy;
}

type RTCBundlePolicy = "balanced" | "max-bundle" | "max-compat";
type RTCIceCredentialType = "password";
type RTCIceTransportPolicy = "all" | "relay";
type RTCRtcpMuxPolicy = "require";

interface RTCCertificate {
    readonly expires: number;
    getFingerprints(): RTCDtlsFingerprint[];
}

interface RTCDtlsFingerprint {
    algorithm?: string;
    value?: string;
}

interface RTCIceServer {
    credential?: string;
    credentialType?: RTCIceCredentialType;
    urls: string | string[];
    username?: string;
}

举个🌰:

const config = {
  iceServers: [
    {
      urls: '[stun:xxx.exmaple.com](http://stun:xxx.exmaple.com/)'
    }
  ]
};

const pc = new RTCPeerConnection(config);

属性

属性名 描述
canTrickleIceCandidates 如果远端支持UDP打洞或支持通过中继服务器连接,则该属性值为true。否则,为false。该属性的值依赖于远端设置且仅在本地的 RTCPeerConnection.setRemoteDescription() 方法被调用时有效,如果该方法没被调用,则其值为null.
connectionState 返回由枚举RTCPeerConnectionState指定的字符串值之一来指示对等连接的当前状态。
currentLocalDescription 返回一个描述连接本地端的RTCSessionDescription对象。
currentRemoteDescription 返回一个描述连接远程端的RTCSessionDescription对象。
iceConnectionState 返回与RTCPeerConnection关联的ICE代理的状态类型为RTCIceConnectionState的枚举。
iceGatheringState 返回一个RTCIceGatheringState类型的结构体,它描述了连接的ICE收集状态
localDescription 返回一个 RTCSessionDescription ,它描述了这条连接的本地端的会话控制(用户会话所需的属性以及配置信息)。如果本地的会话控制还没有被设置,它的值就会是null。
peerIdentity 返回一个RTCIdentityAssertion,它由一组信息构成,包括一个域名(idp)以及一个名称(name),它们代表了这条连接的远端机器的身份识别信息。如果远端机器还没有被设置以及校验,这个属性会返回一个null值。一旦被设置,它不能被一般方法改变。
remoteDescription 返回一个 RTCSessionDescription ,它描述了和远程对端之间的会话(包括配置和媒体信息) ,如果还没有被设置过的话,它会是 null.
signalingState 返回一个RTC通信状态的结构体,这个结构体描述了本地连接的通信状态。这个 状态描述了一个定义连接配置的SDPoffer。它包含了下列信息,与 MediaStream 类型本地相关的对象的描述,媒体流编码方式或RTP和 RTCP协议的选项 ,以及被ICE服务器收集到的candidates(连接候选者)。当 RTCPeerConnection.signalingState 的值改变时,对象上的 signalingstatechange 事件会被触发。

方法

方法名 描述
createOffer 生成一个offer,它是一个带有特定的配置信息寻找远端匹配机器(peer)的请求。这个方法的前两个参数分别是方法调用成功以及失败的回调函数,可选的第三个参数是用户对视频流以及音频流的定制选项(一个对象)。
createAnswer 在协调一条连接中的两端offer/answers时,根据从远端发来的offer生成一个answer。这个方法的前两个参数分别是方法调用成功以及失败时的回调函数,可选的第三个参数是生成的answer的可供选项。
setLocalDescription 改变与连接相关的本地描述。这个描述定义了连接的属性,例如:连接的编码方式。连接会受到它的改变的影响,而且连接必须能同时支持新的以及旧的描述。这个方法可以接收三个参数,一个 RTCSessionDescription 对象包含设置信息,还有两个回调函数,它们分别是方法调用成功以及失败的回调函数。
setRemoteDescription 改变与连接相关的远端描述。这个描述定义了连接的属性,例如:连接的编码方式。连接会受到它的改变的影响,而且连接必须能同时支持新的以及旧的描述。这个方法可以接收三个参数,一个 RTCSessionDescription 对象包含设置信息,还有两个回调函数,它们分别是方法调用成功以及失败的回调函数。
addIceCandidate 添加iceCandidate时调用的方法
getConfiguration 获取配置信息时调用的方法
getLocalStreams 返回连接的本地媒体流数组。这个数组可能是空数组
getRemoteStreams 返回连接的远端媒体流数组。这个数组可能是空数组
getStreamById 返回连接中与所给id匹配的媒体流 MediaStream ,如果没有匹配项,返回null
addStream 添加一个媒体流 MediaStream 作为本地音频或视频源。如果本地端与远端协调已经发生了,那么需要一个新的媒体流,这样远端才可以使用它
removeStream 将一个作为本地音频或视频源的媒体流 MediaStream 移除。如果本地端与远端协调已经发生了,那么需要一个新的媒体流,这样远端才可以停止使用它
addTrack 将一个新的媒体轨道添加到一组轨道中,这些轨道将被传输给另一个对等点。
removeTrack 移除轨道中的某个轨道,停止发送到对等点。
close 关闭一个RTCPeerConnection实例所调用的方法
createDataChannel 在一条连接上建立一个新的 RTCDataChannel (用于数据发送)。这个方法把一个数据对象作为参数,数据对象中包含必要的配置信息
getStats 生成一个新的 RTCStatsReport ,它包含连接相关的统计信息
setIdentityProvider 根据所给的三个参数设置身份提供者(IdP),这三个参数是它的名称,通信所使用的协议(可选),以及一个可选的用户名。只有当一个断言被需要时,这个IdP才会被使用。
getIdentityAssertion 初始化身份断言的收集,只有当 signalingState 的值不为”closed”时,它才有效。它自动完成,在需求发生前调用它是最好的选择。

事件

事件名 描述
onaddstream MediaStream 被远端机器添加到这条连接时,该事件会被触发。 当调用 RTCPeerConnection.setRemoteDescription() 方法时,这个事件就会被立即触发,它不会等待SDP协商的结果。
ondatachannel 当一个 RTCDataChannel 被添加到连接时,这个事件被触发。
onicecandidate 当一个 RTCICECandidate 对象被添加时,这个事件被触发。
oniceconnectionstatechange iceConnectionState 改变时,这个事件被触发。
onnegotiationneeded 浏览器发送该事件以告知在将来某一时刻需要协商。
onremovestream 当一条 MediaStream 从连接上移除时,该事件被触发。
onsignalingstatechange signalingState 的值发生改变时,该事件被触发。
ontrack 当新轨道加入时,该事件被触发。

下面以绑定本地音视频数据为例,说明api的使用方法。
目前 RTCPeerConnection 提供了两种方法用来绑定音视频数据:addTrack() 和 addSteam() ,其中 addStream 已经被官方标记为废弃,推荐使用 addTrack() 方法,这两个方法可以转换:

peerConnection.addStream(mediaStream);
// 等价于
mediaStream.getTracks().forEach(track => {
  peerConnection.addTrack(track, mediaStream);
});

下面以 addTrack 为例:

let localMediaStream = null;

const setLocalMediaStream = mediaStream => {
  localMediaStream = mediaStream;
};

navigator.mediaDevices
  .getUserMedia({
    video: true,
    audio: false,
  })
  .then(setLocalMediaStream);

const bindTracks = () => {
  localMediaStream
    .getTracks()
    .forEach(track => {
      peerConnection.addTrack(track, localMediaStream);
    })
};

媒体协商

媒体协商就是在双端通信之前,了解双方具备哪些能力。其协商过程中交换的内容就是SDP协议定义的。

会话描述协议SDP

SDP(SessionDescription Protocol)是一个2006年发布的老协议,以 <type>=<value> 的格式描述会话内容,其中 <type> 表示描述的目标,由单个字符构成; <value> 是对 <type> 的描述和约束,包括音视频编解码器类型、传输协议等,详情可以查看 RFC4566 。WebRTC引入SDP来描述媒体信息,用于媒体协商,决定双端使用何种方式通信。
SDP协议的具体格式如下,分为两部分:会话描述和媒体描述。其中带星号(*)的表示可选。

Session description
    v=  (protocol version)
    o=  (originator and session identifier)
    s=  (session name)
    i=* (session information)
    u=* (URI of description)
    e=* (email address)
    p=* (phone number)
    c=* (connection information -- not required if included in all media)
    b=* (zero or more bandwidth information lines)
    [...One or more time descriptions ("t=" and "r=" lines)]
    z=* (time zone adjustments)
    k=* (encryption key)
    a=* (zero or more session attribute lines)
    [...Zero or more media descriptions]

Time description
    t=  (time the session is active)
    r=* (zero or more repeat times)

Media description, if present
    m=  (media name and transport address)
    i=* (media title)
    c=* (connection information -- optional if included at session level)
    b=* (zero or more bandwidth information lines)
    k=* (encryption key)
    a=* (zero or more media attribute lines)

举个🌰:

v=0
o=jdoe 2890844526 2890842807 IN IP4 10.47.16.5
s=SDP Seminar
i=A Seminar on the session description protocol
u=[http://www.example.com/seminars/sdp.pdf](http://www.example.com/seminars/sdp.pdf)
[e](mailto:e=j.doe@example.com)[=j.doe@example.com](mailto:e=j.doe@example.com) (Jane Doe)
c=IN IP4 224.2.17.12/127
t=2873397496 2873404696
a=recvonly
m=audio 49170 RTP/AVP 0
m=video 51372 RTP/AVP 99
a=rtpmap:99 h263-1998/90000

协商流程

image.png

  1. caller生成本地描述信息
const offer = await peerConnection.createOffer()
  1. caller设置本地描述信息
await peerConnection.setLocalDescription(offer);
  1. caller发送本地描述信息至远端
const signalServer = new WebSocket('ws://[xxx.signal.com](http://xxx.signal.com/)');

signalServer.send({
  type: 'offer',
  data: offer,
});
  1. callee设置远端描述信息
await peerConnection.setRemoteDescription(offer);
  1. callee生成本地应答描述信息
const answer = await peerConnection.createAnswer();
  1. callee设置本地描述信息
await peerConnection.setLocalDescription(answer);
  1. callee发送answer描述信息至远端
const signalServer = new WebSocket('ws://[xxx.signal.com](http://xxx.signal.com/)');

signalServer.send({
  type: 'answer',
  data: answer,
});
  1. callee设置远端描述信息
peerConnection.setRemoteDescription(answer);

交互式连接建立 ICE

当各端调用 setLocalDescription 后,WebRTC就开始建立网络连接,主要包括收集candidate、交换candidate和按优先级尝试连接,该过程被称为ICE(Interactive Connectivity Establishment,交互式连接建立)。其中每个 candidate 都包含IP地址、端口、传输协议、类型等信息。
根据 RFC5245 协议,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和端口生成

网络地址转换NAT

NAT在真实网络环境中随处可见,主要由两个用处:

  • 解决IPv4地址不够用的问题,可以让多台主机共用一个公网IP

  • 安全问题,将主机隐藏在内网中,外网就比较难访问到真实主机

NAT类型

根据 RFC3489 协议,NAT总共分成4种类型:完全锥型(Full ConeNAT)、IP限制锥型(Address Restricted ConeNAT)、端口限制锥型(Port Restricted ConeNAT)、对称型(SymmetrictNAT),依次检测越来越严格。

💡所谓“打洞”,其实就是在 NAT 建立一个内外网的映射表。包括内网IP和端口,以及映射的外网IP和端口。

完全锥型

NAT打洞成功后,所有知道该洞的主机都可以通过它与内网主机进行通信。映射表内容如下:

{
  内网ip,
  内网端口,
  映射的外网ip,
  映射的外网端口
}

举个栗子:从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000 ,192.168.0.8可以收到任意外部主机发到1.2.3.4:62000的数据报。

IP限制锥型

NAT打洞成功后,只有打洞成功的外网主机才能通过该洞与内网主机通信,其他外网主机即使知道洞口也不能内网主机通信。映射表内容如下:

{
  内网ip,
  内网端口,
  映射的外网ip,
  映射的外网端口,
  [被访问主机的ip,....]
}

举个栗子:从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000,只有当内部主机192.168.0.8先给服务器C 6.7.8.9发送一个数据报后,192.168.0.8才能收到6.7.8.9发送到1.2.3.4:62000的数据报。

端口限制锥型

除了像IP限制锥型一样对IP进行检测以外,还需要检测端口。映射表内容如下:

{
  内网ip,
  内网端口,
  映射的外网ip,
  映射的外网端口,
  [
    {被访问主机的ip,被访问主机的端口},
    ...
  ]
}

举个栗子:从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000,只有当内部主机192.168.0.8先向外部主机地址端口6.7.8.9:8000发送一个数据报后,192.168.0.8才能收到6.7.8.9:8000发送到1.2.3.4:62000的数据报。

对称型

内网主机每次访问不同的外网时,都需要打一个新洞,而不像前面三种NAT类型使用的是同一个“洞”,即只有收到过一个数据包的外部主机才能够向该内部主机发送数据包,映射表内容如下:

{
  内网ip,
  内网端口,
  // 不仅访问地址变化,映射ip也要发生变化
  映射的外网ip,
  // 不仅访问端口变化,映射端口也要发生变化
  映射的外网端口,
  被访问主机的ip,
  被访问主机的端口
}

NAT类型检测

💡下述算法在 RFC 3489 被提出,但在 RFC 5389 中被删除。因为随着发展,NAT类型比协议中描述的更多种多样,检测过程变得比较脆弱。更详细的原因可以到 RFC 5389 的Page 45中‘ 19.Changes since RFC 3489 ’查看。

下面( 原图 )就是内网主机进行NAT类型检测的算法流程,总共需要2台STUN服务器,每台STUN服务器又需要两块网卡,每块网卡都需要配置公网ip地址。
如果双端都进入红色部分,则表示无法通信,进入黄色或者绿色就有打洞通信的可能性。

image.png

检测是否具备通信能力(Test1)

  • 客户端建立UDPsocket,然后用这个socket向服务器 Server#1 的(IP-1,Port-1)发送数据包,要求服务器从(IP-1,Port-1)返回客户端的IP和Port,客户端发送请求后立即开始接收数据包。

    • 如果超时收不到服务器的响应,则说明客户端无法进行UDP通信,表明:防火墙阻止UDP通信;

    • 如果能收到回应,则比较服务器返回的客户端(ip:port)与本地的(ip:port)是否一致;

  • 如果完全相同则表明:客户端具有公网IP,然后进行防火墙检测;

  • 如果不同,则表明:客户端在NAT后,要做进一步的NAT类型检测(继续)。

检测是否具有防火墙(Test2)

  • 客户端向服务器 Server#1 的(IP-1,PORT-1)发送请求,要求服务器从(IP-2,PORT-2)向客户端发送数据包:

    • 如果客户端能够收到数据包,则认为客户端处在一个开放的网络上,网络类型为公开的互联网IP

    • 否则客户端被前置防火墙拦截,判断为对称型网络;

检测是否为完全锥型网络(Test2)

  • 客户端向服务器的(IP-1,Port-1)发送数据包,并要求服务器从(IP-2,Port-2)向客户端发回一个响应数据包,客户端发送请求后立即开始接受数据包。

    • 如果能够接受到服务器从(IP-2,Port-2)返回的应答UDP包,则说明客户端是一个完全锥型网络。

    • 否则进行下一步检测(继续);

检测是否为对称型(Test1#2)

  • 客户端向另一台STUN服务器 Server#2 的 (IP-3,Port-3)发送请求,要求服务器从(IP-3,Port-3)返回客户端的ip和端口。

    • 如果服务端返回的客户端ip与本地ip不一致,则表明是对称型网络;

    • 否则,表明是限制型网络,进行下一步检测(继续);

检测为IP限制锥型 or 端口限制锥型(Test3)

  • 客户端向另一台STUN服务器 Server#2 的 (IP-3,Port-3)发送请求,要求服务器从(IP-3,Port-4)返回客户端的ip和端口。

    • 如果收到数据,则表明是:IP限制锥形网络;

    • 否则表明是:端口限制锥形网络。

实战

接下来开发一个本地1v1通信的简单demo以及附加的拍照功能.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WebRTC Demo</title>
    <style>
      video {
        width: 400px;
      }
    </style>
  </head>
  <body>
    <video id="localVideo" autoplay></video>
    <video id="remoteVideo" autoplay></video>

    <div>
      <button id="startBtn">打开摄像头</button>
      <button id="callBtn">建立远程连接</button>
      <button id="hangupBtn">断开远程连接</button>
      <button id="photoBtn" disabled>拍照</button>
    </div>

    <canvas id="photoContainer"></canvas>
  </body>
  <script>
    const startBtn = document.getElementById("startBtn");
    const callBtn = document.getElementById("callBtn");
    const hangupBtn = document.getElementById("hangupBtn");
    const photoBtn = document.getElementById("photoBtn");
    const photoContainer = document.getElementById("photoContainer");
    const photoCtx = photoContainer.getContext("2d");

    startBtn.addEventListener("click", startHandle);
    callBtn.addEventListener("click", callHandle);
    hangupBtn.addEventListener("click", hangupHandle);
    photoBtn.addEventListener("click", photoHandle);

    // 本地流
    let localStream;
    // 远端流
    let remoteStream;

    // 本地连接对象
    let localPeerConnection;
    // 远端连接对象
    let remotePeerConnection;

    // 本地视频
    const localVideo = document.getElementById("localVideo");
    // 远端视频
    const remoteVideo = document.getElementById("remoteVideo");

    // 设置约束
    const mediaStreamConstraints = {
      video: true,
    };

    // 仅交换视频
    const offerOptions = {
      offerToReceiveVideo: 1,
    };

    function startHandle() {
      console.log("开启本地摄像头");
      startBtn.disabled = true;
      navigator.mediaDevices
        .getUserMedia(mediaStreamConstraints)
        .then(setLocalMediaStream)
        .catch((err) => {
          console.error("getUserMedia", err);
        });
    }

    async function callHandle() {
      console.log("建立远端连接");
      callBtn.disabled = true;
      hangupBtn.disabled = false;
      photoBtn.disabled = false;

      // 本地直连,没有STUN服务器
      const rtcConfig = null;

      // 1. 创建 RTCPeerConnection
      createLocalPeerConnection(rtcConfig);
      createRemotePeerConnection(rtcConfig);

      // 2.添加本地音视频流
      addLocalStream();

      /** 媒体协商 */
      // 2.创建SDP offer
      const offer = await createOffer(offerOptions);
      // 3.设置本地SDP offer
      setLocalDescription(localPeerConnection, offer);
      // 4.远端设置远端SDP offer
      setRemoteDescription(remotePeerConnection, offer);
      // 5.远端创建SDP answer
      const answer = await createAnswer();
      // 6.远端设置本地SDP answer
      setLocalDescription(remotePeerConnection, answer);
      // 7.本地设置SDP answer
      setRemoteDescription(localPeerConnection, answer);
    }

    function hangupHandle() {
      console.log("断开远端连接");
      // 关闭连接并设置为空
      localPeerConnection.close();
      remotePeerConnection.close();
      localPeerConnection = null;
      remotePeerConnection = null;
      hangupBtn.disabled = true;
      callBtn.disabled = false;
      photoBtn.disabled = true;
    }

    function photoHandle() {
      photoContainer.setAttribute("width", localVideo.videoWidth);
      photoContainer.setAttribute("height", localVideo.videoHeight);
      photoCtx.drawImage(localVideo, 0, 0);
    }

    function createLocalPeerConnection(rtcConfig) {
      // 创建本地 RTCPeerConnection 对象
      localPeerConnection = new RTCPeerConnection(rtcConfig);
      // 监听本地返回的 Candidate
      localPeerConnection.addEventListener("icecandidate", handleICEConnection);
      // 监听本地 ICE 状态变化
      localPeerConnection.addEventListener(
        "iceconnectionstatechange",
        handleICEConnectionChange
      );
    }

    function createRemotePeerConnection(rtcConfig) {
      // 创建远端 RTCPeerConnection 对象
      remotePeerConnection = new RTCPeerConnection(rtcConfig);
      // 监听远端返回的 Candidate
      remotePeerConnection.addEventListener(
        "icecandidate",
        handleICEConnection
      );
      // 监听远端 ICE 状态变化
      remotePeerConnection.addEventListener(
        "iceconnectionstatechange",
        handleICEConnectionChange
      );
      // 监听远端轨道添加
      remotePeerConnection.addEventListener("track", setRemoteMediaStream);
    }

    function addLocalStream() {
      localStream.getTracks().forEach((track) => {
        localPeerConnection.addTrack(track, localStream);
      });
    }

    // 设置本地媒体流
    function setLocalMediaStream(mediaStream) {
      localVideo.srcObject = mediaStream;
      localStream = mediaStream;
      callBtn.disabled = false;
    }

    // 设置本地SDP
    function setLocalDescription(peerConnection, description) {
      return peerConnection.setLocalDescription(description);
    }

    // 生成SDP offer
    function createOffer(options) {
      return localPeerConnection.createOffer(options);
    }

    // 生成SDP answer
    function createAnswer() {
      return remotePeerConnection.createAnswer();
    }

    // 设置远端SDP
    function setRemoteDescription(peerConnection, description) {
      return peerConnection.setRemoteDescription(description);
    }

    // 端与端建立连接
    function handleICEConnection(event) {
      // 获取到触发 icecandidate 事件的 RTCPeerConnection 对象
      // 获取到具体的Candidate
      const peerConnection = event.target;
      const iceCandidate = event.candidate;

      if (iceCandidate) {
        // 创建 RTCIceCandidate 对象
        const newIceCandidate = new RTCIceCandidate(iceCandidate);
        // 得到对端的 RTCPeerConnection
        const otherPeer = getOtherPeer(peerConnection);

        // 将本地获得的 Candidate 添加到远端的 RTCPeerConnection 对象中
        otherPeer.addIceCandidate(newIceCandidate);
      }
    }

    // 显示远端媒体流
    function setRemoteMediaStream(event) {
      if (remoteVideo.srcObject !== event.streams[0]) {
        remoteVideo.srcObject = event.streams[0];
        remoteStream = event.streams[0];
        console.log("开始接收远端音视频流");
      }
    }

    function handleICEConnectionChange(event) {
      console.log("ICE连接状态改变: ", event);
    }

    function getOtherPeer(peerConnection) {
      return peerConnection === localPeerConnection
        ? remotePeerConnection
        : localPeerConnection;
    }
  </script>
</html>

image.png

参考文档

浅聊WebRTC视频通话
从0到1打造一个 WebRTC 应用
前端音视频WebRTC实时通讯的核心
音视频开发基础概述 – PCM、YUV、H264、常用软件介绍
WebRTC音视频实时互动技术——李超
官网 WebRTC 架构
STUN(RFC3489)的NAT类型检测方法
webRTC连接过程详细剖析,及阶段总结 – github

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容