likes
comments
collection
share

【项目实战】基于 WebRTC 的音视频在线监考模块的设计与实现(下)

作者站长头像
站长
· 阅读数 15

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言

在上一篇博文 【复】基于 WebRTC 的音视频在线监考模块的设计与实现(上) 中,主要介绍了关于 WebRTC 的基本理论,那么这篇文章我们将进入实战阶段,通过 WebRTC 框架,去实现 P2P 通话,以及延伸到一对多的音视频通话,从而实现在线监考功能;

【项目实战】基于 WebRTC 的音视频在线监考模块的设计与实现(下)

 

P2P 通话实现

媒体设备

在开发 Web 时,WebRTC 标准提供了 API,用于访问连接到计算机或智能手机的相机和麦克风,这些设备通常称为媒体设备,可以通过实现 MediaDevices 接口的 navigator.mediaDevices 对象使用 JavaScript 进行访问。通过该对象,我们可以枚举所有已连接的设备,侦听设备更改(连接或断开设备时),并打开设备以检索媒体流。

调用 getUserMedia() 将触发权限请求。如果用户接受许可,则通过包含一个视频和一个音轨的 MediaStream 来解决承诺。如果权限被拒绝,则抛出 PermissionDeniedError。如果没有连接匹配的设备,则会抛出 NotFoundError

  • 创建媒体流
 async createMedia() {
        let streamTep = null;
        // 保存本地流到全局
        streamTep = await navigator.mediaDevices.getUserMedia({
	        audio: true, 
	        video: true
        })
        console.log("streamTep",streamTep)
        return streamTep;
 },
  • 播放媒体流
<div style="float: left">
     <video id="sucA" autoplay></video>
</div>
// 打开本地摄像头
async nativeMedia(){
        const that = this;
        that.localStream = await this.createMedia()
        let video = document.querySelector('#sucA');
        // 旧的浏览器可能没有srcObject
        if ("srcObject" in video) {
          video.srcObject = that.localStream;
        } else {
          video.src = window.URL.createObjectURL(that.localStream);
        }

        that.initPeer(); // 获取到媒体流后,调用函数初始化 RTCPeerConnection
},
  • 媒体设备约束条件
// 设置视频窗口的范围
{
    "video": {
        "width": {
            "min": 640,
            "max": 1024
        },
        "height": {
            "min": 480,
            "max": 768
        }
    }
}
// 获取手机端前置摄像头
{ audio: true, video: { facingMode: "user" } }

// 后置摄像头
{ audio: true, video: { facingMode: { exact: "environment" } } }

// 具有带宽限制的WebRTC传输,可能需要较低的帧速率
{ video: { frameRate: { ideal: 10, max: 15 } } }

 

通讯连接

RTCPeerConnection 接口表示本地计算机和远程对等方之间的 WebRTC 连接。它提供了连接到远程对等方,维护和监视连接以及在不再需要连接时关闭连接的方法。

RTCPeerConnection 建立

  • 本地流获取(上述内容)
  • 全局参数初始化
window.RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;

var iceServers = {
	iceServers: [
		{ url: "stun:stun.l.google.com:19302"},// 谷歌的公共服务
		{
			url: 'turn:numb.viagenie.ca',
			credential: 'muazkh',
			username: 'webrtc@live.com'
		}
	]
};
  • 初始化两个模拟客户端
// 以下 pc1 和 pc2 分别代表两个模拟客户端的链接服务简写
// pc1: 代表 pc1->pc2 链接  pc2: 代表 pc2->pc1 链接

const that = this;
that.pc1 = new PeerConnection(iceServers);
that.pc2 = new PeerConnection(iceServers);

// 将全局视频流赋给 pc1 链接服务
that.pc1.addStream(this.localStream);

// 监听 ice 候选信息
that.pc1.onicecandidate = function(event) {
  console.log("pc1 onicecandidate", event)
  if (event.candidate) {
	// 一般来说这个地方是通过第三方 (socket 后面会将网络端点对点) 发送给另一个客户端,但是现在本地演示直接将候选信息发送到 pc2 链接服务
    that.pc2.addIceCandidate(event.candidate.toJSON());
  }
};

// 监听远程视频 pc1 充当呼叫端,所以只要监听 pc2 有无视频流信息进来
that.pc2.onaddstream = (event) => {
  console.log("pc2 onaddstream",event)
  // 监听到流后将视频流赋给另一个 video 标签
  let video = document.querySelector('#sucB');
  video.srcObject = event.stream;
  video.onloadedmetadata = function(e) {
    console.log(e)
    video.play();
  };
};

onicecandidate: 候选 ICE 描述了 WebRTC 能够与远程设备进行通信所需的协议和路由。在启动 WebRTC 对等连接时,通常在连接的每一端都建议多个候选对象,直到他们相互同意描述他们认为最好的连接的候选对象为止。

  • 呼叫端模拟呼叫(pc1)和应答端模拟应答(pc2)
 async createOffer() {
	const that = this;
	
	// 创建 offer
	let offer_tep = await that.pc1.createOffer(this.offerOption);
	console.log("offer_tep", offer_tep)
	
	// 设置本地描述
	await that.pc1.setLocalDescription(offer_tep)
	
	//接收端设置远程 offer 描述
	await that.pc2.setRemoteDescription(offer_tep)
	
	// 接收端创建 answer
	let answer = await that.pc2.createAnswer();
	
	// 接收端设置本地 answer 描述
	await that.pc2.setLocalDescription(answer);
	
	// 发送端设置远程 answer 描述
	await that.pc1.setRemoteDescription(answer);
},
	
// 呼叫
async call() {
	const that = this;
	
	//创建 offer 并保存本地描述
	await that.createOffer()
},

为何呼叫会有这么麻烦的步骤呢?这就又涉及到 WebRTC 的会话了,具体看下面一条:

“当用户 (上述pc1) 向另一个用户(上述pc2)发起 WebRTC 呼叫时,会创建一个特殊的描述,称为 offer。此描述包括有关呼叫者为呼叫建议的配置的所有信息。然后,接收者用一个答案来回应,这是他们通话结束的描述。以此方式,两个设备彼此共享为了交换媒体数据所需的信息。这种交换是使用交互式连接建立(ICE)处理的,该协议允许两个设备使用中介程序交换要约和答复,即使两个设备之间都被网络地址转换(NAT)隔开。然后,每个对等方都保留两个描述:本地描述(描述自己)和远程描述(描述呼叫的另一端)”

上面的话简单来说就是 A 呼叫 B,A 创建 offer,在本地保留 offer,然后发送给 B,B 创建 answer,之后本地保留 answer,再将 answer 发送给 A,A 拿到后将 B 的 answer 设置为本地的远程描述。

【项目实战】基于 WebRTC 的音视频在线监考模块的设计与实现(下)

 

在线监考

通过刚才的 P2P 学习,想必已经了解了双方之间是如何建立通讯的,那么基于 WebRTC 的在线监考原理也是如此,老师与同学们建立通讯即可,即一对多的关系,这样就能实现在线监考了;

这里使用的是 vue + node 的实现形式,可以根据自己的需要进行改进;

<script>
import * as config from '../../configure';

navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia;
window.RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate || window.webkitRTCIceCandidate;
window.RTCSessionDescription =
  window.RTCSessionDescription || window.mozRTCSessionDescription || window.webkitRTCSessionDescription;

const socket = io.connect(config.API_ROOT);
const configuration = {
  iceServers: [config.DEFAULT_ICE_SERVER],
};

let localStream, peerConn;
let connectedUser = null;

export default {
  data() {
    return {
      user_name: '',
      show: true,
      users: '',
      call_username: '',
      remote_video: '',
      accept_video: false,
      peersList: [],
    };
  },
  mounted() {
    socket.on(
      'message',
      function(data) {
        console.log(data);
        switch (data.event) {
          case 'show':
            this.users = data.allUsers;
            break;
          case 'join':
            this.handleLogin(data);
            break;
          case 'call':
            this.handleCall(data);
            break;
          case 'accept':
            this.handleAccept(data);
            break;
          case 'offer':
            this.handleOffer(data);
            break;
          case 'candidate':
            this.handleCandidate(data);
            break;
          case 'msg':
            this.handleMsg(data);
            break;
          case 'answer':
            this.handleAnswer(data);
            break;
          case 'leave':
            this.handleLeave();
            break;
          default:
            break;
        }
      }.bind(this)
    );
  },
  methods: {
    submit() {
      if (this.user_name !== '') {
        this.send({
          event: 'join',
          name: this.user_name,
        });
      }
    },
    send(message) {
      if (connectedUser !== null) {
        message.connectedUser = connectedUser;
      }
      socket.send(JSON.stringify(message));
    },
    handleLogin(data) {
      if (data.success === false) {
        alert('Ooops...please try a different username');
      } else {
        this.show = false;
        this.users = data.allUsers;
        this.initCreate();
      }
    },
    addVideoURL(elementId, stream) {
      var video = document.getElementById(elementId);
      // Older brower may have no srcObject
      if ('srcObject' in video) {
        video.srcObject = stream;
      } else {
        // 防止在新的浏览器里使用它,应为它已经不再支持了
        video.src = window.URL.createObjectURL(stream);
      }
    },
    initCreate() {
      const self = this;
      navigator.mediaDevices
        .getUserMedia({ audio: true, video: true })
        .then(function(stream) {
          var video = document.getElementById('localVideo');
          self.addVideoURL('localVideo', stream);
          video.muted = true;
          localStream = stream;
        })
        .catch(function(err) {
          console.log(err.name + ': ' + err.message);
        });
    },
    call() {
      if (this.call_username.length > 0) {
        if (this.users[this.call_username] === true) {
          connectedUser = this.call_username;
          this.createConnection(connectedUser);
          this.send({
            event: 'call',
          });
        } else {
          alert('The current user is calling, try another');
        }
      } else {
        alert('Ooops...this username cannot be empty, please try again');
      }
    },
    createConnection(username) {
      peerConn = new RTCPeerConnection(configuration);
      peerConn.addStream(localStream);
      peerConn.onaddstream = e => {
        this.addVideoURL('remoteVideo'+username, e.stream);
      };
      peerConn.onicecandidate = event => {
        setTimeout(() => {
          if (event.candidate) {
            this.send({
              event: 'candidate',
              candidate: event.candidate,
            });
          }
        });
      };
    },
    handleCall(data) {
      this.accept_video = true;
      connectedUser = data.name;
    },
    reject() {
      this.send({
        event: 'accept',
        accept: false,
      });
      this.accept_video = false;
    },
    accept() {
      this.send({
        event: 'accept',
        accept: true,
      });
      this.accept_video = false;
    },
    handleAccept(data) {
      if (data.accept) {
        // Create an offer
        peerConn.createOffer(
          offer => {
            this.send({
              event: 'offer',
              offer: offer,
            });
            peerConn.setLocalDescription(offer);
          },
          error => {
            alert('Error when creating an offer');
          }
        );
      } else {
        alert('对方已拒绝');
      }
    },
    handleOffer(data) {
      connectedUser = data.name;
      this.createConnection(connectedUser);
      peerConn.setRemoteDescription(new RTCSessionDescription(data.offer));
      // Create an answer to an offer
      peerConn.createAnswer(
        answer => {
          peerConn.setLocalDescription(answer);
          this.send({
            event: 'answer',
            answer: answer,
          });
        },
        error => {
          alert('Error when creating an answer');
        }
      );
      this.peersList.push(connectedUser)
    },
    handleMsg(data) {
      console.log(data.message);
    },
    handleAnswer(data) {
      peerConn.setRemoteDescription(new RTCSessionDescription(data.answer));
    },
    handleCandidate(data) {
      // ClientB 通过 PeerConnection 的 AddIceCandidate 方法保存起来
      peerConn.addIceCandidate(new RTCIceCandidate(data.candidate));
    },
    hangUp() {
      this.send({
        event: 'leave',
      });
      this.handleLeave();
    },
    handleLeave() {
      alert('通话已结束');
      connectedUser = null;
      this.remote_video = '';
      peerConn.close();
      peerConn.onicecandidate = null;
      peerConn.onaddstream = null;
      if (peerConn.signalingState === 'closed') {
        this.initCreate();
      }
    },
    closePreview() {
      this.accept_video = false;
    },
  },
};
</script>
var express = require('express');
var app = express();
var http = require('http');
var fs = require('fs');
var IO = require('socket.io');
var { APT_HOST, API_PORT } = require('./configure');

app.use(express.static('dist'));

var server = http.createServer(app);
console.log('The HTTPS server is up and running');

var io = IO(server);
console.log('Socket Secure server is up and running.');

server.listen(API_PORT, APT_HOST, function(){
  console.log('Access Address: http://%s:%s', APT_HOST, API_PORT);
});

// All joined users
var allUsers = {};
// All joined sockets
var allSockets = {};
  
io.on('connect', function(socket) {
  var user = ''; // current joined user

  socket.on('message', function(data) {
    var data = JSON.parse(data);
    switch (data.event) {
      // When has new user join in
      case 'join':
        user = data.name;
        console.log('User joined', data.name);
        // Save users info
        allUsers[user] = true; // 'true' means has not call, 'false' means calling
        allSockets[user] = socket;
        socket.name = user;
        showUserInfo(allUsers);
        sendTo(socket, {
          event: 'join',
          allUsers: allUsers,
          success: true,
        });
        break;

      case 'call':
        var conn = allSockets[data.connectedUser];
        sendTo(conn, {
          event: 'call',
          name: socket.name,
        });
        break;

      case 'offer':
        // i.e. UserA wants to call UserB
        console.log('Sending offer to: ', data.connectedUser);
        //if UserB exists then send him offer details
        var conn = allSockets[data.connectedUser];
        // allUsers[user] = false;
        allUsers[user] = true;
        if (conn != null) {
          showUserInfo(allUsers);
          // Setting that UserA connected with UserB
          socket.otherName = data.connectedUser;
          sendTo(conn, {
            event: 'offer',
            offer: data.offer,
            name: socket.name,
          });
        } else {
          sendTo(socket, {
            event: 'msg',
            message: 'Not found this name',
          });
        }
        break;

      case 'accept':
        var conn = allSockets[data.connectedUser];
        if (conn != null) {
          if (data.accept) {
            sendTo(conn, {
              event: 'accept',
              accept: true,
            });
          } else {
            allUsers[data.connectedUser] = true;
            sendTo(conn, {
              event: 'accept',
              accept: false,
            });
          }
        }
        break;

      case 'answer':
        console.log('Sending answer to: ', data.connectedUser);
        // i.e. UserB answers UserA
        var conn = allSockets[data.connectedUser];
        // allUsers[user] = false;
        allUsers[user] = true;
        if (conn != null) {
          showUserInfo(allUsers);
          socket.otherName = data.connectedUser;
          sendTo(conn, {
            event: 'answer',
            answer: data.answer,
          });
        }
        break;

      case 'candidate':
        console.log('Sending candidate to:', data.connectedUser);
        var conn1 = allSockets[data.connectedUser];
        var conn2 = allSockets[socket.otherName];
        if (conn1 != null) {
          sendTo(conn1, {
            event: 'candidate',
            candidate: data.candidate,
          });
        } else {
          sendTo(conn2, {
            event: 'candidate',
            candidate: data.candidate,
          });
        }
        break;

      case 'leave':
        console.log('Disconnecting from', data.connectedUser);
        var conn = allSockets[data.connectedUser];
        allUsers[socket.name] = true;
        allUsers[data.connectedUser] = true;
        socket.otherName = null;
        // Notify the other user so he can disconnect his peer connection
        if (conn != null) {
          showUserInfo(allUsers);
          sendTo(conn, {
            event: 'leave',
          });
        }
        break;
    }
  });

  socket.on('disconnect', function() {
    if (socket.name) {
      delete allUsers[socket.name];
      delete allSockets[socket.name];
      showUserInfo(allUsers);
      if (socket.otherName) {
        console.log('Disconnecting from ', socket.otherName);
        var conn = allSockets[socket.otherName];
        allUsers[socket.otherName] = true;
        socket.otherName = null;
        if (conn != null) {
          sendTo(conn, {
            type: 'leave',
          });
        }
      }
    }
  });
});

function showUserInfo(allUsers) {
  sendTo(io, {
    event: 'show',
    allUsers: allUsers,
  });
}

function sendTo(connection, message) {
  connection.send(message);
}

界面自己调整,这里就是为了方便展示; 【项目实战】基于 WebRTC 的音视频在线监考模块的设计与实现(下)

注意,如果浏览器无法获取到摄像头,并报错Cannot read properties of undefined (reading 'getUserMedia'),是因为浏览器有安全设置,只需要进行如下操作即可开放摄像头权限:

chrome://flags/#unsafely-treat-insecure-origin-as-secure

不是用谷歌的小伙伴可以自行替换前缀,比如 Edge 浏览器:

edge://flags/#unsafely-treat-insecure-origin-as-secure

【项目实战】基于 WebRTC 的音视频在线监考模块的设计与实现(下)

最后在旁边的空白处点一下,底部就会出现如下图所示: 【项目实战】基于 WebRTC 的音视频在线监考模块的设计与实现(下)

点一下 Relauch 就可以了;

 

后记

总的来说,WebRTC 还是超赞的,node.js 也是,记录每一个脚印!

参考: