webrtc实现1v1视频通话从0到1

前言

现在远程办公的场景多的话,WebRtc有时候在一些公司还是有应用场景的,以直播为例,一起看下webrtc涉及哪些流程

WebRtc处理过程

上图是实现1v1的通话有4个部分,WebRtc终端(这里理解为浏览器端)、Signal(信令)服务器、STUN/TURN服务器

  • WebRtc终端,负责音视频的采集、编解码、NAT穿越、音视频数据传输 (这里终端暂时看做浏览器,webRtc不止应用在浏览器)
  • Signal服务器,负责信令处理,如有人加入房间、离开房间、媒体协商消息传递等。(类似聊天室的xx加入房间),一般采用WebSocket连接
  • STUN/TURN服务,负责获取WebRtc终端在公网的ip地址,以及NAT穿越失败后的数据中转。()

用户A和用户B要语音通过大致过程:

1.用户A和用户B作为WebRtc终端(浏览器)检测你的设备是否支持音视频数据采集

2.获取音视频数据后加入到信令信令服务器,这样2个用户都加入到一个房间

3.用户A会创建RTCPeerConnection对象,该对象将采集到的音视频数据进行编码和通过P2P传送给对方,P2P穿越失败,就使用TURN进行数据中转,有的公司架构是直接用后者进行传输

音视频采集

浏览器的getUserMedia方法

navigator.mediaDevices.getUserMedia(constraints);

constraints参数视频设置采集分辨率、帧率参数,音频可以设置开启降噪等参数;如设备具备音视频采集能力,它返回的成功的promise里,可以获取到MediaStream对象

1.本地操作视频流:MediaStream对象存放着采集到的音视频轨,直接赋值给video标签的srcObject属性,就可以本地实现看到摄像头和听到声音。

2.拍照:通过canvas的drawImage,将video标签传入

3.保存照片:通过canvas.toDataURL生成本地地址,通过a标签下载图片

// 视频预览
var constraints = { audio: true, video: true };
let media
navigator.mediaDevices
    .getUserMedia(constraints)
    .then((MediaStream) => {
      media = MediaStream;
      const video = document.querySelector("video");
      video.srcObject = MediaStream;
      video.onloadedmetadata = function (e) {
        video.play();
      };
    })
    .catch((e) => {
      console.warn(e, "e");
    });

// 拍照:调用canvas的api,将video标签
const video = document.querySelector("video");
document.querySelector("canvas").getContext('2d').drawImage(video, 0, 0,400,300)

// 保存照片
const url = canvas.toDataURL("image/jpeg");
document.createElement('a').href = url;

媒体协商

作用:让双方找到共同支持的媒体能力,过程有点像TCP的三次握手

1.创建连接,创建RTCPeerConnection,它负责端与端之间建立P2P连接

2.信令,客户端通过信令服务器交换SDP(包含编解码方式、传输协议、ip地址和端口等信息)

媒体协商过程

  • Offer, 在双发通讯时,呼叫方发送的SDP消息称Offer
  • Answer,在双发通讯时,被呼叫方发送的SDP消息称Answer

  • 呼叫方(图左边)创建Offer类型的SDP消息后,调setLocalDescription方法保存到本地Local域,再通过信令将Offer发给被呼叫方
  • 被呼叫方收到Offer类型的SDP消息后,调setRemoteDescription保存到它的Remote域。它再创建Answer类型SDP消息,调setLocalDescription保存到本地域,再将消息发给呼叫方。
  • 呼叫方收到Answer类型的SDP消息后,调setRemoteDescription保存到它的Remote域。

总结:媒体协商完成后,WebRtc底层会收集Candidate(WebRtc与远端通信时使用的协议、ip地址和端口),进行连通性测试,最终建立一条链路。
 

// 呼叫方A
// 创建Offer,本地设置,发给对方
const pcA = new RTCPeerConnection();
pcA.createOffer().then((offerSDP)=>{
    pcA.setLocalDescription(offerSDP);
    sendMessage(offerSDP);
})

// 接受到answer,保存起来
socket.on('message', (message) => {
    if(message.type === 'answer') {
        pcA.setRemoteDescription(new RTCSessionDescription(message))
    }
})


// 被呼叫方B
const pcB = new RTCPeerConnection();
socket.on('message', (message) => {
    if(message.type === 'offer')
    pcB.setRemoteDescription(new RTCSessionDescription(message))
    
    // 创建Answer,本地设置,发给对方
    pcB.createAnswer().then((answerSDP)=>{
        pcB.setLocalDescription(answerSDP);
        sendMessage(answerSDP)
    })
})

连接建立

红色部分-连接的创建、STUN/TURN以及NAT穿越

连接的基本原则

如果A和B连接,C作为服务器

  • 场景一:双方处于同一个网段内(内网)
  • 场景二:双方处于不同点

ICE Candidate(ICE候选者),它表示WebRtc与远端通信使用的协议、ip地址和端口,一般3种方式

  • host表示本机候选者,内网之间的联通性测试,优先级最高
  • srflx表示内网主机映射的外网地址和端口,让双发通过P2P进行连接,次优化级
  • relay表示中继候选者,低优先级


 

{
    address: 'xxx.xxx.xxx.xxx',
    port: 'xxxx',
    type: 'host/srflx/relay',
    protocol: 'UDP/TCP',
    // ...
}

STUN协议

NAT是指网络地址转换,作用就是进行内外网的地址转换。

STUN(session traversal utilities for NAT),一种处理NAT传输的协议,它允许位于NAT后的客户端找出自己的公网地址。

srflx类型的Candidate实际上就是用的经NAT映射后的外网地址,进行P2P连接通信。

TURN协议 relay服务通过TURN协议实现。TURN协议描述了如何获取relay服务器(即TUNR协议)的Candidate过程。通过TURN服务器发送Allocation指令,relay服务就会在服务端分配一个新的relay端口,用于中转UDP数据报。

因为P2P场景有限,其实大部分还是采用relay方式来传输数据。

实现1v1音视频实时直播系统

用户A和用户B之间视频通话过程:

  • 后端nodejs做信令服务器
const ws = require('nodejs-websocket');
const roomTableMap = new Map(); // 根据roomId,记录每个房间的成员信息
ws.createServer((socket) => {
    socket.on('text', (str) => {
        // 接收来自客户端的消息
    })

    socket.on('close', (data) => {
        console.log(data);
    })

    socket.on('error', (data) => {
        console.log(data);
    })
}).listen(3000)
  • 用户A和用户B,通过WebSocket,连接ws服务,监听回到函数
function createWebScoket(url) {
  ws = new WebSocket(url);
  ws.onopen = (e) => {};
  ws.onmessage = (e) => {
    // 接收来自信令服务器的消息
  };
  ws.onclose = (e) => {};
  ws.onerror = (e) => {};
}

用户A和用户B先后点击加入房间,根据navigator.mediaDevices.getUserMedia获取本地的音视频流,再通过ws.send将roomId、userId等信息传递给信令服务器

roomId = document.getElementById('roomBox').value // 输入框取房间号
// 拿到媒体设备流,显示在本地
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then((stream) => {
    ws.send(JSON.stringify({
      type: 'join',
      roomId,
      uid: localUserId
    }))
    localStream = stream;
    localVideo.srcObject = stream;
})

信令服务器先后收到用户A和用户B消息,将该用户加入房间,当用户B加入的时候,就通知到房间里用户A

socket.on('text', (data) => {
    const { type = '', roomId, uid, remoteUid} = JSON.parse(data);
    if (type === 'join') {
        let roomMap = roomTableMap.get(roomId);
        if (!roomMap) {
            roomMap = new Map();
            roomTableMap.set(roomId, roomMap)
        }
        roomMap.set(uid, {roomId,uid,socket})
        for (const [key, value] of roomMap) {
            if(key !== uid) {
                value.socket.sendText(JSON.stringify({ type: 'peer-join', remoteUid: uid }));
            }
        }
    }
})

用户A发现此时房间有其他人,会收到消息,type=“peer-join”,会先创建RTCPeerConnection对象,再监听onicecandidate(收到网络协商相关消息)和ontrack(收到远端音视频流),将本地音视频流添加到对象中

ws.onmessage = (e) => {
    if (type === 'peer-join') {
        pc = new RTCPeerConnection(null);
        pc.onicecandidate = (e) => {
            if (e.candidate) {
              ws.send(JSON.stringify({
                type: 'candidate',
                roomId,
                uid: localUserId,
                remoteUid: remoteUserId,
                candidate: e.candidate
              }))
            }
        }
        pc.ontrack = (e) => {
            remoteStream = e.streams[0];
            remoteVideo.srcObject = remoteStream;
        }
        localStream.getTracks().forEach(track => {
        pc.addTrack(track, localStream)
        });
    }
})

用户A再通过RTCPeerConnection的实例,创建offer(sdp信息用于协商音视频编码协议),setLocalDescription设置在本地,再发送到信令服务器,转给用户B

ws.onmessage = (e) => {
    if (type === 'peer-join') {
      pc.createOffer().then(sdp => {
          pc.setLocalDescription(sdp).then(() => {
              ws.send(JSON.stringify({
                type: 'offer',
                roomId,
                uid: localUserId,
                remoteUid,
                sdp
              }))
            })
        })
    }

信令服务器收到消息,type=‘offer’时,转发给用户B

socket.on('text', (data) => {
    const { type = '', roomId, uid, sdp = '' } = JSON.parse(str);
    if(type === 'offer') {
        let roomMap = roomTableMap.get(roomId);
        for (const [key, value] of roomMap) {
            if (key !== uid) {
                value.socket.sendText(JSON.stringify({
                  type,
                  roomId,
                  remoteUid: uid,
                  sdp
                }))
              }
        }
    }
})

用户B,收到信令服务器发到type=‘offer’消息,先创建RTCPeerConnection实例和监听再监听onicecandidate和ontrack,将本地音视频流添加到实例中,代码同上面一致
同时用户B,会将用户A的sdp消息,setRemoteDescription到远程,自己创建answer的sdp信息,发送给服务端,转给用户A
 

ws.onmessage = (e) => {
    if (type === 'offer') {
        pc.setRemoteDescription(new RTCSessionDescription(sdp));
        pc.createAnswer().then(sdp => {
            pc.setLocalDescription(sdp).then(() => {
              ws.send(JSON.stringify({
                type: 'answer',
                roomId,
                uid: localUserId,
                remoteUid,
                sdp
              }))
            })
        })
    }
})

用户A收到,type=‘answer’的消息,setRemoteDescription到远程
双方收到ontrack回调事件,获取到对方码流的对象句柄
双方都开始请求打洞,通过onicecandidate获取到打洞信息(candidate)并通过信令服务器发送给对方
如果p2p能成功则进行通话,不成功则进行中继转发通话

完整代码

前端代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div>
    <input id="roomBox" type="text">
    <button id="joinBtn">加入</button>
    <button id="leaveBtn">离开</button>
  </div>
  <div>
    <video id="localVideo" autoplay muted playsinline></video>
    <video id="remoteVideo" autoplay playsinline></video>
  </div>
  <script>
    const localVideo = document.getElementById('localVideo');
    const remoteVideo = document.getElementById('remoteVideo');
    const joinBtn = document.getElementById('joinBtn');
    const leaveBtn = document.getElementById('leaveBtn');
    const localUserId = Math.random().toString(36).slice(2);
    let roomId = -1;
    let remoteUserId = -1;
    let localStream = null;
    let remoteStream = null;
    let ws = null;
    let pc = null;
    function createWebScoket(url) {
      ws = new WebSocket(url);
      ws.onopen = (e) => {
        console.log("onopen", e)
      }
      ws.onmessage = (e) => {
        const { type, remoteUid = '', sdp, candidate } = JSON.parse(e.data);
        if (type === 'peer-join') {
          remoteUserId = remoteUid;
          if (!pc) createConnect();
          pc.createOffer().then(sdp => {
            pc.setLocalDescription(sdp).then(() => {
              ws.send(JSON.stringify({
                type: 'offer',
                roomId,
                uid: localUserId,
                remoteUid,
                sdp
              }))
            })
          })
        } else if (type === 'peer-leave') {
          remoteVideo.srcObject = null;
        } else if (type === 'offer') {
          if (!pc) createConnect();
          pc.setRemoteDescription(new RTCSessionDescription(sdp))
          pc.createAnswer().then(sdp => {
            pc.setLocalDescription(sdp).then(() => {
              ws.send(JSON.stringify({
                type: 'answer',
                roomId,
                uid: localUserId,
                remoteUid,
                sdp
              }))
            })
          })
        } else if (type === 'answer') {
          if (!pc) createConnect();
          pc.setRemoteDescription(new RTCSessionDescription(sdp))
        } else if (type === 'candidate') {
          pc.addIceCandidate(new RTCIceCandidate(candidate))
        }
      }
      ws.onclose = (e) => {
        console.log("onclose", e)
      }
      ws.onerror = (e) => {
        console.log("onerror", e)
      }
    }
    createWebScoket('ws://127.0.0.1:3000');
    // 加入房间
    joinBtn.onclick = () => {
      roomId = document.getElementById('roomBox').value // 输入框取房间号
      // 拿到媒体设备流,显示在本地
      navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then((stream) => {
        ws.send(JSON.stringify({
          type: 'join',
          roomId,
          uid: localUserId
        }))
        localStream = stream;
        localVideo.srcObject = stream;
      })
    }
    function createConnect() {
      pc = new RTCPeerConnection(null);
      pc.onicecandidate = (e) => {
        if (e.candidate) {
          ws.send(JSON.stringify({
            type: 'candidate',
            roomId,
            uid: localUserId,
            remoteUid: remoteUserId,
            candidate: e.candidate
          }))
        }
      }
      pc.ontrack = (e) => {
        remoteStream = e.streams[0];
        remoteVideo.srcObject = remoteStream;
      }
      localStream.getTracks().forEach(track => {
        pc.addTrack(track, localStream)
      });
    }
    // 离开
    leaveBtn.onclick = () => {
      ws.send(JSON.stringify({
        type: 'leave',
        roomId,
        uid: localUserId
      }))
      remoteVideo.srcObject = null;
      localStream && localStream.getTracks().forEach(track => track.stop())
      localVideo.srcObject = null;
      if(pc) {
        pc.close();
        pc = null;
      }
    }
  </script>
</body>
</html>

信令服务器完整代码

const ws = require('nodejs-websocket');
const roomTableMap = new Map();
ws.createServer((socket) => {
    socket.on('text', (str) => {
        const { type = '', roomId, uid, remoteUid, candidate = '', sdp = '' } = JSON.parse(str);
        if (type === 'join') {
            let roomMap = roomTableMap.get(roomId);
            if (!roomMap) {
                roomMap = new Map();
                roomTableMap.set(roomId, roomMap)
            }
            const client = {
                roomId,
                uid,
                socket
            }
            roomMap.set(uid, client)
            for (const [key, value] of roomMap) {
                if (key !== uid) {
                    // 通知其他人,有人进入房间了
                    value.socket.sendText(JSON.stringify({ type: 'peer-join', remoteUid: uid }));
                }
            }
        } else if (type === 'leave') {
            let roomMap = roomTableMap.get(roomId);
            roomMap.delete(uid);
            for (const [key, value] of roomMap) {
                value.socket.sendText(JSON.stringify({ type: 'peer-leave', remoteUid: uid }));
            }
        } else if (['offer', 'answer', 'candidate'].includes(type)) {
            let roomMap = roomTableMap.get(roomId);
            for (const [key, value] of roomMap) {
              if (key !== uid) {
                value.socket.sendText(JSON.stringify({
                  type,
                  roomId,
                  remoteUid: uid,
                  sdp,
                  candidate
                }))
              }
            }
        }
    })
    socket.on('close', (data) => {
        console.log(data);
    })
    socket.on('error', (data) => {
        console.log(data);
    })
}).listen(3000)

最后

本文只是webrtc进行一个初步的了解,参考了<<WebRtc音视频实时互动技术原理>>书籍和b站视频,对于大家如果有所帮助,欢迎点赞~

原文 webrtc实现1v1视频通话从0到1 - 掘金

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

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

猜你喜欢

转载自blog.csdn.net/yinshipin007/article/details/132277656