WebRTC之实现1v1音视频通话


title: WebRTC之实现1v1音视频通话
date: 2019-12-05 21:06:38
tags:
- WebRTC
- 音视频
categories:
- WebRTC


原文: https://ahoj.cc/

打通一下 1v1 音视频通话的流程。

基本知识

端到端连接基本流程:

客户端信令消息设计:

  • join 加入房间
  • leave 离开房间
  • message 端到端消息
    • offer 消息
    • answer 消息
    • candidate 消息

服务端信令消息设计:

  • joined 已成功加入房间
  • otherjoin 其他用户加入此房间
  • full 要加入的房间已满
  • leave 已成功离开房间
  • bye 对方离开房间

消息处理流程:

客户端状态机:

客户端加入相关流程:

客户端离开流程图:

代码逻辑

连接

  1. 网页加载完毕,用户初始状态为init
  1. 点击连接按钮,进入 start() 函数,获取权限并开启本地音视频,然后进入 conn() 函数和信令服务器建立连接,并向信令服务器发送一个 join 信号,当收到服务器返回的 joined 后,客户端的状态变为 joined 。然后执行 createPeerConnection() 函数,创建 pc 并绑定媒体流,等待第二个人加入。注意:addTrack 将采集的音视频轨道添加并发送给远端。

    image.png
  2. 当有第二个人点击连接按钮,执行完上述步骤后,第一个人的客户端会收到信令服务器发来的一个 otherjoin 信号,这时会触发 call() 函数。在 call() 中执行 createOffer() ,在创建 offer 成功后 setLocalDescription,并将创建好的 desc 信息发给信令 message 给信令服务器。

    setLocalDescription 调用后底层会悄悄地向 sturn/turn 服务器发送一个 bind request,这个时候就开始收集所有能和对方连接的候选者了。

    当服务器收到 message 后,会将其原封不动的转发给房间内除发送者外的其他成员:

    socket.to(room).emit('message', room, data); // 给房间出自己外所有人回消息

  3. 然后第二个人会收到信令服务器发来的 message 信息,信息中的 data.type==='offer'

    下来第二个人的客户端会执行 setRemoteDescription,然后 createAnswer,并 setLocalDescription,最后讲创建的 answer 以 message 信息。同样,信令服务器收到这个 answer 后还是原封不动的转发给了第一个连接的人。

  4. 第一个人在收到 answer 后 setRemoteDescription,这样两个人的 SDP 就交换好了。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P2JB3Ezk-1575561809056)(https://i.loli.net/2019/12/05/gnqZImlSVCbB4HY.png)]

  5. 已经根据SDP信息创建好本地的相关 Channel 后会开启Candidate数据的收集,接收由 TURN 服务器收集好的 candidate 信息。

  6. 当 candidate 到达第一个连接的人那后会触发 onicecandidate,第一个人的客户端会将这个 candidate 发送给信令服务器,信令服务器发给第二个进来的人,第二个进来的人会 addIceCandidate,然后会触发自己的 onicecandidate,再给信令服务器,信令服务器再给第一个进来的人。

  7. 这样两个人就建立了音视频传输的P2P通道,接收对方传送过来的 MediaStream 对象并渲染出来。

  8. onTrack 监听音视频数据的到达,到达后执行 getRemoteStream。

  9. 当其他人再加入时,信令服务器发现此房间已满,会发送一个 full 信号,提示当前房间已满,并关闭 pc 和本地音视频流的 Track。

离开

离开的逻辑就简单一些了。

先向服务器发送 leave 信令,收到 leaved 后变更状态然后关闭pc,关闭媒体流。

注意

网络连接要在音视频流数据获取之后,否则有可能绑定音视频流失败。

当一端退出房间后,另一端的 PeerConnection 要关闭重建,否则与新用户互通的时候媒体协商失败。

异步事件处理。

完整代码

Client.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Room</title>
  <link href="main.css" rel="stylesheet">
</head>
<body>
<div>
  <div id="preview">
    <table align="center">
      <tr>
        <td>
          <h2>Local:</h2>
          <video id="localvideo" autoplay playsinline muted></video>

          <label>Offer SDP:
            <textarea id="textarea_offer"></textarea>
          </label>
        </td>
        <td>
          <button id="connserver">连接</button><br>
          <button id="leave" disabled>离开</button>
        </td>
        <td>
          <h2>Remote:</h2>
          <video id="remotevideo" autoplay playsinline></video>

          <label>Answer SDP:
            <textarea id="textarea_answer"></textarea>
          </label>
        </td>
      </tr>
    </table>
  </div>
</div>

<script src="https://cdn.bootcss.com/socket.io/2.3.0/socket.io.js"></script>
<script src="https://cdn.bootcss.com/webrtc-adapter/zv4.1.1/adapter.min.js"></script>
<script src="room.js"></script>
</body>
</html>
Client.js
'use strict';

var btnConn = document.querySelector('button#connserver');
var btnLeave = document.querySelector('button#leave');

var localVideo = document.querySelector('video#localvideo');
var remoteVideo = document.querySelector('video#remotevideo');

var localStream = null;
var remoteStream = null;

var roomid = '111111';
var socket = null;
var state = 'init';

var pc = null;

var textarea_offer = document.querySelector('textarea#textarea_offer');
var textarea_answer = document.querySelector('textarea#textarea_answer');


var pcConfig = {
  'iceServers': [{
    'urls': 'turn:ahoj.luoshaoqi.cn:3478',
    'credential': 'xxxxxx',
    'username': 'xxxxxx'
  }]
};


btnConn.onclick = connSignalServer;
btnLeave.onclick = leave;

function connSignalServer() {
  // 开启本地音视频设备
  start();

  return true;
}

function start() {
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    alert('不支持');
  } else {
    var constraints = {
      video: true,
      audio: true
    };
    navigator.mediaDevices.getUserMedia(constraints)
        .then(getMediaStream)
        .catch((e) => {
          console.error(e);
        });
  }
}

function getMediaStream(stream) {
  if (localStream) {
    stream.getAudioTracks().forEach((track) => {
      localStream.addTrack(track);
      stream.removeTrack(track);
    });
  } else {
    localStream = stream;
  }

  localVideo.srcObject = localStream;

  conn();
}

/* 信令部分 */
function conn() {
  socket = io.connect();  // 与信令服务器连接

  socket.on('joined', (roomid, id) => {
    state = 'joined';
    console.log('receive msg: joined', roomid, id, 'state = ', state);
    createPeerConnection();

    btnConn.disabled = true;
    btnLeave.disabled = false;
  });

  socket.on('otherjoin', (roomid, id) => {
    if (state === 'joined_unbind') {
      createPeerConnection();
    }

    state = 'joined_conn';
    console.log('receive msg: otherjoin', roomid, id, 'state = ', state);

    call();
  });

  socket.on('full', (roomid, id) => {
    state = 'leaved';
    console.log('receive msg: full', roomid, id, 'state = ', state);

    socket.disconnect();
    closeLocalMedia();

    console.error('房间已满');
    btnConn.disabled = false;
    btnLeave.disabled = true;
  });

  socket.on('leaved', (roomid, id) => {
    state = 'leaved';
    console.log('receive msg: leaved', roomid, id, 'state = ', state);

    socket.disconnect();
    btnConn.disabled = false;
    btnLeave.disabled = true;
  });

  socket.on('bye', (roomid, id) => {
    state = 'joined_unbind';
    closePeerConnection();
    textarea_offer.value = '';
    textarea_answer.value = '';
    console.log('receive msg: bye', roomid, id, 'state = ', state);
  });

  socket.on('disconnect', (socket) => {
    console.log('disconnect message', roomid);
    if (!(state === 'leaved')) {
      hangup();
      closeLocalMedia();
    }
    state = 'leaved'
  });

  socket.on('message', (roomid, data) => {
    console.log('receive msg: message', roomid, data);
    /* 媒体协商 */
    if (data) {
      if (data.type === 'offer') {
        textarea_offer.value = data.sdp;

        pc.setRemoteDescription(new RTCSessionDescription(data));

        pc.createAnswer()
            .then(getAnswer)
            .catch((e) => {
              console.error(e);
            });

      } else if (data.type === 'answer') {
        textarea_answer.value = data.sdp;
        pc.setRemoteDescription(new RTCSessionDescription(data));

      } else if (data.type === 'candidate') {
        var candidate = new RTCIceCandidate({
          sdpMLineIndex: data.label,
          candidate: data.candidate
        });
        pc.addIceCandidate(candidate);

      } else {
        console.error('data type error');
        // console.log(data);
      }
    }
  });

  socket.emit('join', '111111');  // 加入房间 111111
}

function hangup() {
  if (pc) {
    pc.close();
    pc = null;
  }
}

/* 退出时关闭 track 流 */
function closeLocalMedia() {
  if (localStream && localStream.getTracks()) {
    localStream.getTracks().forEach((track) => {
      track.stop();
    });
  }
  localStream = null;
}

function leave() {
  if (socket) {
    socket.emit('leave', '111111');  // 离开房间 111111
  }

  /* 释放资源 */
  closePeerConnection();
  closeLocalMedia();

  textarea_offer.value = '';
  textarea_answer.value = '';

  btnConn.disabled = false;
  btnLeave.disabled = true;
}

function createPeerConnection() {
  console.log('create RTCPeerConnection!');
  if (!pc) {
    pc = new RTCPeerConnection(pcConfig);

    pc.onicecandidate = (e) => {

      if (e.candidate) {
        sendMessage(roomid, {
          type: 'candidate',
          label: event.candidate.sdpMLineIndex,
          id: event.candidate.sdpMid,
          candidate: event.candidate.candidate
        });
      } else {
        console.log('this is the end candidate');
      }

    };

    pc.ontrack = getRemoteStream;

  } else {
    console.log('the pc have be created')
  }

  if ((localStream !== null || localStream !== undefined) && (pc !== null || pc !== undefined)) {
    localStream.getTracks().forEach((track) => {
      pc.addTrack(track, localStream);  // 进行添加, 并发送给远端。
    });
  } else {
    console.log('pc or localStream is null or undefined');
  }

}  /* createPeerConnection */

function closePeerConnection() {
  console.log('close RTCPeerConnection!');
  if (pc) {
    pc.close();
    pc = null;
  }
}

function getRemoteStream(e) {
  remoteStream = e.streams[0];
  remoteVideo.srcObject = e.streams[0];
}

function call() {
  if (state === 'joined_conn') {

    var options = {
      offerToReceiveVideo: 1,
      offerToReceiveAudio: 1,
    };
    pc.createOffer(options)
        .then(getOffer)
        .catch((e) => {
          console.error(e);
        });

  }
}

function getOffer(desc) {
  pc.setLocalDescription(desc);
  textarea_offer.value = desc.sdp;
  sendMessage(roomid, desc);
}

function getAnswer(desc) {
  pc.setLocalDescription(desc);
  textarea_answer.value = desc.sdp;
  sendMessage(roomid, desc);
}

function sendMessage(roomid, data) {
  console.log('send p2p message', roomid, data);
  if (socket) {
    socket.emit('message', roomid, data);
  }
}
Signal Server
'use strict';

var http = require('http');
var https = require('https');
var fs = require('fs');

var express = require('express');
var serveIndex = require('serve-index');

var socketIo = require('socket.io');


var app = express();
app.use(serveIndex('./public'));
app.use(express.static('./public'));

// http server
var http_server = http.createServer(app);
http_server.listen(8087, '0.0.0.0');


// https server
var options = {
  key: fs.readFileSync('./cret/xxx.key'),
  cert: fs.readFileSync('./cret/xxx.pem')
};
var https_server = https.createServer(options, app);

var io = socketIo.listen(https_server);
io.sockets.on('connection', (socket) => {
  socket.on('message', (room, data) => {
    io.in(room).emit('message', room, data);  // socket.to(room).emit('message', room, data);

    console.log('[message] room:', room, 'data:', data);
  });

  socket.on('join', (room) => {
    socket.join(room);
    var myRoom = io.sockets.adapter.rooms[room];
    var users = Object.keys(myRoom.sockets).length;

    if (users <= 2) {
      socket.emit('joined', room, socket.id);  // 发消息给房间里除自己之外的所有人

      console.log('[joined] room:', room, 'user_id:', socket.id);

      if (users > 1) {
        socket.to(room).emit('otherjoin', room, socket.id);

        console.log('[otherjoin] room:', room, 'user_id:', socket.id);
      }
    } else {
      socket.leave(room);
      socket.emit('full', room, socket.id);
      console.log('[otherjoin] room:', room, 'user_id:', socket.id);
    }
  });

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

    console.log(room);
    var myRoom = io.sockets.adapter.rooms[room];
    socket.to(room).emit('bye', room, socket.id);
    socket.emit('leaved', room, socket.id);

    console.log('[otherjoin] room:', room, 'user_id:', socket.id);
  });
});
https_server.listen(443, '0.0.0.0');

其他参考资料:

MOOC: https://coding.imooc.com/class/chapter/329.html#Anchor

tencent cloud: https://cloud.tencent.com/developer/article/1480648

MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/ontrack

MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/onicecandidate

webrtc: https://hpbn.co/webrtc/#establishing-a-peer-to-peer-connection

cnblogs: https://www.cnblogs.com/fangkm/p/4364553.html


EOF

发布了73 篇原创文章 · 获赞 90 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/Hanoi_ahoj/article/details/103414628