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

news2024/11/26 16:55:59

 

 

文章有点长,推荐先收藏

前言

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

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

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

- hls.js
- 延时高

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

- 无需插件
- 移动端MSE支持性差

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

- 音视频采集上行传输
- 相对复杂

- 支持度低

- 价格高

- 容量有限
在线教育、远程会议


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

WebRTC架构

音视频流媒体技术:【入门或者转行音视频】如何快速学习音视频开发?

音视频流媒体技术:WebRTC入门与提高1:WebRTC基础入门

音视频流媒体技术:音视频开发为什么要学SRS流媒体服务器

文末名片免费领取WebRTC学习资料 

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

  • 音视频采集模块:调用系统API,从系统麦克风和摄像头读取设备并采集音视频数据。音频是PCM数据,视频是YUV数据
  • 音视频编码模块:根据不同类型数据使用不同编码方式,将原始PCM、YUV数据压缩编码
  • 网络传输模块:将压缩编码后的数据封装成RTP包,通过网络传输至对端,同时对端接收RTP数据
  • 音视频解码模块:将接收到的压缩编码数据还原成原始的PCM、YUV/RGB数据
  • 音视频渲染模块:拿到原始数据后,音频数据输出到扬声器,视频数据输出到显示器

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

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

  • 接口层:暴露给业务侧,业务侧可以使用原生的 C++ API 接口或者 Web API 开发音视频实时通信。核心是 RTCPeerConnection
  • Session层:用于控制业务逻辑,比如媒体协商、收集 Candidate 等
  • 引擎层:包括音频引擎、视频引擎和网络传输
  • 设备层:主要和硬件交互,负责音频的采集和播放,视频的采集,物理网络等

WebRTC音视频通信过程

一个正常音视频通信架构如上图所示,通信双方分别是 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链接建立完成,后面就会源源不断的发送音视频数据到对端

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

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

采集音视频 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

协商流程

1.caller生成本地描述信息

 const offer = await peerConnection.createOffer() 

2.caller设置本地描述信息

 await peerConnection.setLocalDescription(offer); 

3.caller发送本地描述信息至远端

const signalServer = new WebSocket('ws://[xxx.signal.com](http://xxx.signal.com/)');

signalServer.send({
  type: 'offer',
  data: offer,
});
 

4.callee生成本地应答描述信息

 const answer = await peerConnection.createAnswer();

5.callee设置本地描述信息

 await peerConnection.setLocalDescription(answer);

6.callee发送answer描述信息至远端

const signalServer = new WebSocket('ws://[xxx.signal.com](http://xxx.signal.com/)');

signalServer.send({
  type: 'answer',
  data: answer,
});

7.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地址。 如果双端都进入红色部分,则表示无法通信,进入黄色或者绿色就有打洞通信的可能性。
 

检测是否具备通信能力(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>

参考文档
浅聊WebRTC视频通话
从0到1打造一个 WebRTC 应用
前端音视频WebRTC实时通讯的核心
《  WebRTC音视频实时互动技术——李超 》  官网 WebRTC 架构
STUN(RFC3489)的NAT类型检测方法
webRTC连接过程详细剖析,及阶段总结 - github
作者:随风丶逆风

★文末名片可以免费领取音视频开发学习资料,内容包括(C/C++,Linux 服务器开发,FFmpeg ,webRTC ,rtmp ,hls ,rtsp ,ffplay ,srs)以及音视频学习路线图等等。

见下方↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/92424.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

10年网安经验分享:一般人别瞎入网络安全行业

小白入门网络安全&#xff0c;如何选择方向&#xff1f; 如果你是一个新手小白&#xff0c;那么在最开始方向选择上面这一步是至关重要的&#xff0c;一旦你选错了那很可能就要和安全“saygoodbye”了。 很多小白刚开始的时候还没开始学会走就想着飞了&#xff0c;计算机功底…

四六级口语|考研复试口语|满满干货

目录 1.Which do you prefer to use, credit cards or cash?/Do you prefer the credit card or cash? 2.When you shop, do you prefer to go by yourself or with someone?

指针进阶(3)--玩转指针

指针进阶 内容不多&#xff0c;但面面俱到&#xff0c;都是精华 1.回调函数&#xff1a; 2.详解qsort函数参数&#xff1a; 回调函数就是&#xff0c;把一个函数的地址&#xff0c;放在函数指针中&#xff0c;然后将该指针作为一个参数&#xff0c;传到 另一个函数中&#x…

04.南瓜树低代码平台平台 分析后的感想

随着企业产品的不断完善&#xff0c;后续将有时间来推进产品转向低代码平台化。 低代码平台不是无代码平台&#xff0c;采用配置的方式完成UI/流程/报表的处理&#xff0c;有业务人员在完成基本的产品框架后&#xff0c;由研发人员完成业务规则代码固化&#xff0c;最终达到产…

MobSDK 封装MobSDK基础包

平台兼容性 Android Android CPU类型 iOS 适用版本区间&#xff1a;4.4 - 12.0 armeabi-v7a&#xff1a;支持&#xff0c;arm64-v8a&#xff1a;支持&#xff0c;x86&#xff1a;支持 原生插件通用使用流程&#xff1a; 购买插件&#xff0c;选择该插件绑定的项目。在HB…

3. 实例化Bean的三种方式

实例化Bean的三种方式 一、构造方法方式 1.1 BookDaoImpl package com.lin.dao.daoimpl;import com.lin.dao.BookDao;public class BookDaoImpl implements BookDao {public BookDaoImpl() {System.out.println("BookDao的无参构造器");}/*** 数据层实现*/public …

了解Linux 操作系统!开篇!!!

【推荐阅读】 Linux内核CPU调度域内容讲解 关于如何快速学好&#xff0c;学懂Linux内核。内含学习路线 一文了解Linux上TCP的几个内核参数调优 Linux 接口 Linux 系统是一种金字塔模型的系统&#xff0c;如下所示 应用程序发起系统调用把参数放在寄存器中(有时候放在栈中)…

Python解题 - CSDN周赛第15期 - 客栈的咖啡

本期遇上官方大放水&#xff0c;四道题里有三道都在每日一练里做过&#xff0c;再加上比赛时间不太友好&#xff0c;参与人数不多&#xff0c;问哥竟然混了个第一名&#xff0c;真是惭愧。。。就当是官方在奖励那些平时多多参加每日一练的童鞋们了。 第一题&#xff1a;求并集 …

Vue3响应式原理设计和实现

Vue3响应式原理设计和实现响应式什么是响应式手动响应式proxy代理对象响应式系统一个属性注册一个副作用函数一个属性注册多个副作用函数多个属性注册不同的副作用函数多个数据不同属性注册不同的副作用函数响应式 什么是响应式 响应式是一个过程&#xff0c;这个过程存在两个…

【MaixPy】:K210识别简例(简单二维码检测和双二维码检测)

实物图 俩二维码识别实物图 前言 这段时间接触了一下基于MaixPy的开发K210的摄像头设备,的确很有趣,运行速度很快,编程难度不大。很适合咱们视觉开发的同学们学习,以下是我玩设备的一些感悟,如有不妥之处,希望大家雅正,也希望能帮助初学者了解和学习,也可加bulidupup(…

java毕业设计——基于java+Socket+sqlserver的网络通讯系统设计与实现(毕业论文+程序源码)——网络通讯系统

基于javaSocketsqlserver的网络通讯系统设计与实现&#xff08;毕业论文程序源码&#xff09; 大家好&#xff0c;今天给大家介绍基于javaSocketsqlserver的网络通讯系统设计与实现&#xff0c;文章末尾附有本毕业设计的论文和源码下载地址哦。 文章目录&#xff1a; 基于jav…

LwIP源码分析(3):内存堆和内存池代码详解

文章目录1 内存堆1.1 mem_init1.2 mem_malloc1.3 mem_free2 内存池2.1 memp_init2.2 memp_malloc2.3 memp_free3 内存管理宏定义在嵌入式系统中&#xff0c;内存池有助于快速有效地分配内存。LwIP提供了两个灵活的方式来管理和组织内存池的大小&#xff1a;内存堆和内存池。当然…

电脑维修记录

记于2022年12月15日 今天把电脑修好了&#xff0c;总结这次经验&#xff1a; &#xff08;1&#xff09;无知者无畏&#xff0c;对任何事情都要抱有探索的精神&#xff0c;最遗憾的事情不是做错了&#xff0c;而是想做没去做 &#xff08;2&#xff09;将每次走错路的经历都…

Orcale数据表去重创建联合主键

分享一下最近遇到的一个问题&#xff0c;我们从一个数据表中将数据表中的数据同步到另一个数据库的表中&#xff0c;由于要同步的数据表中没有建主键&#xff0c;所以数据同步后发现同步的数据比原始数据表中的数据要多&#xff0c;有不少重复的数据。因此需要对数据表进行去重…

事业编招聘:市委社会工委北京市民政局事业单位公开招聘

市委社会工委市民政局所属事业单位 根据《北京市事业单位公开招聘工作人员实施办法》&#xff08;京人社专技发﹝2010﹞102号&#xff09;等文件精神&#xff0c;北京市委社会工委北京市民政局所属21家事业单位面向社会及应届毕业生公开招聘事业单位工作人员88名。现将具体情况…

【SpringBoot 2.x】定时任务 之- @Scheduled注解

一、概述 Scheduled注解是Spring Boot提供的用于定时任务控制的注解&#xff0c;主要用于控制任务在某个指定时间执行&#xff0c;或者每隔一段时间执行。注意需要 启动类加EnableScheduling实现类加Component方法上加ScheduledScheduled主要有以下几种配置执行时间的方式&…

Neural Discrete Representation Learning (VQ-VAE) 简介

目录VQ-VAE参考VQ-VAE VAE是一种生成模型。 Vector QuantisedVariational AutoEncoder (VQ-VAE)是VAE的变种&#xff0c;其隐含变量是离散的。离散的隐含变量对于自然语言&#xff0c;推理都比较有帮助。著名的DALL-E就使用了类似VQ-VAE的离散隐含变量来从文本生成图像&#x…

【Python】同一网络下,手机和电脑进行socket通信

同一网络下&#xff0c;手机和电脑进行socket通信 最近在学python网络编程&#xff0c;发现socket可以进行跨主机的进程通信&#xff0c;于是尝试用电脑作为服务端&#xff0c;手机作为客户端&#xff0c;来进行socket通信。 电脑端准备 1.电脑开启热点&#xff08;非必须&a…

[附源码]Python计算机毕业设计SSM基于vue的图书管理系统2022(程序+LW)

项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM mybatis Maven Vue 等等组成&#xff0c;B/S模式 M…

java递归实现多级Map集合合并(结合实际场景)

合并Map集合 合并Map集合有很多方法&#xff0c;例如Map自生提供的putAll()方法。但是这种方法只能合并一层的集合&#xff0c;如果是多层的呢&#xff1f; 场景 现在有一个yml配置文件&#xff0c;由于项目部署在多台服务器上&#xff0c;而且每台服务器上的配置有些许差异…