【复】基于 WebRTC 的音视频在线监考模块的设计与实现(下)
文章目录
- 前言
- P2P 通话实现
- 媒体设备
- 通讯连接
- 在线监考
- 后记
前言
在上一篇博文 【复】基于 WebRTC 的音视频在线监考模块的设计与实现(上) 中,主要介绍了关于 WebRTC 的基本理论,那么这篇文章我们将进入实战阶段,通过 WebRTC 框架,去实现 P2P 通话,以及延伸到一对多的音视频通话,从而实现在线监考功能;
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');// 旧的浏览器可能没有srcObjectif ("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;// 创建 offerlet 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)// 接收端创建 answerlet 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 设置为本地的远程描述。
在线监考
通过刚才的 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 srcObjectif ('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 offerpeerConn.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 offerpeerConn.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 usersocket.on('message', function(data) {var data = JSON.parse(data);switch (data.event) {// When has new user join incase 'join':user = data.name;console.log('User joined', data.name);// Save users infoallUsers[user] = true; // 'true' means has not call, 'false' means callingallSockets[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 UserBconsole.log('Sending offer to: ', data.connectedUser);//if UserB exists then send him offer detailsvar conn = allSockets[data.connectedUser];// allUsers[user] = false;allUsers[user] = true;if (conn != null) {showUserInfo(allUsers);// Setting that UserA connected with UserBsocket.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 UserAvar 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 connectionif (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);
}
界面自己调整,这里就是为了方便展示;
注意,如果浏览器无法获取到摄像头,并报错Cannot read properties of undefined (reading 'getUserMedia')
,是因为浏览器有安全设置,只需要进行如下操作即可开放摄像头权限:
chrome://flags/#unsafely-treat-insecure-origin-as-secure
不是用谷歌的小伙伴可以自行替换前缀,比如 Edge 浏览器:
edge://flags/#unsafely-treat-insecure-origin-as-secure
最后在旁边的空白处点一下,底部就会出现如下图所示:
点一下 Relauch 就可以了;
后记
总的来说,WebRTC 还是超赞的,node.js 也是,记录每一个脚印!
参考:
- webrtc实现群聊系列文章(一)本地模拟视频通话
- Introduction to WebRTC protocols
- vue+node(socket.io)+webRTC实现一对一通话测试
- 使用浏览器访问远程服务,调用本地摄像头录制音视频报错
【复】基于 WebRTC 的音视频在线监考模块的设计与实现(下)相关推荐
- 【复】基于 WebRTC 的音视频在线监考模块的设计与实现(上)
文章目录 前言 什么是 WebRTC? WebRTC 架构 WebRTC 通讯内容 WebRTC 通讯协议 WebRTC 连接建立过程 后记 前言 最近在做关于考试系统的项目,其中有一项需求分析是要做 ...
- 基于WebRTC实现音视频及数据通信
文章目录 前言 一.WebRTC的组成? 二.信令交换的方式 三.会话描述 四.客户端应用 1.HTML 2.JavaScript 五.效果演示 六.项目地址 总结 前言 刚写了篇基于WebRTC使用 ...
- 技术分享| 如何快速实现音视频在线通话
请问咱们支持像微信一样的音视频呼叫功能吗? 请问呼叫邀请怎么实现? 如果客户端离线了,怎么呼叫到客户? 怎么添加呼叫铃声?以及接收铃声? 经常能听到有用户问上述的问题,今天借此机会向大家讲解下音视频呼 ...
- html5音频剪辑,一种基于HTML5Canvas画布音视频分段剪辑方法与流程
技术特征: 1.一种基于html5canvas画布音视频分段剪辑方法,其特征在于:包括如下步骤: 步骤一:首先使用者预先获取源音视频文件,然后使用者对音视频文件分段剪辑时,进入音视频文件分段剪辑主单元 ...
- 基于FFMPEG的音视频截取(C++Qt 版)
基于FFMPEG的音视频截取(C++Qt 版) 这篇博客是基于上篇博客的: https://blog.csdn.net/liyuanbhu/article/details/121744275 上篇博客 ...
- 基于springboot的短视频网站的开发与设计
通知:代本科论文"润色"<基于视频监控的智慧消防系统>,可私聊. [注]该项目<基于springboot的短视频网站的开发与设计>为本人毕业设计.使用的开发 ...
- 基于B/S架构的在线考试系统的设计与实现
前言 这个是我的Web课程设计,用到的主要是JSP技术并使用了大量JSTL标签,所有代码已经上传到了我的Github仓库里,地址:https://github.com/quanbisen/online ...
- mysql意见反馈表设计_一个基于PHP和MySQL的意见反馈模块的设计和实现
一个基于PHP和MySQL的意见反馈模块的设计和实现杜大刚 [期刊名称]<计算机与现代化> [年(卷),期]2005(000)003 [摘要]本文涉及的意见反馈模块是一个基于PHP和MyS ...
- 网站在线客服系统实时语音视频聊天实战开发,利用peerjs vue.js实现webRTC网页音视频客服系统...
webRTC机制和peerjs库的介绍在其他博客中已经有了很多介绍,这里我直接搬运过来 一.webrtc回顾 WebRTC(Web Real-Time Communication)即:网页即时通信. ...
- WebRTC实时音视频技术基础:基本架构和协议栈
概述 本文主要介绍WebRTC的架构和协议栈. 最基本的三角形WebRTC架构 为了便于理解,我们来看一个最基本的三角形WebRTC架构(见下图): 在这个架构中,移动电话用"浏览器M&qu ...
最新文章
- django-后台sms管理系统的css框架
- 从“诺奖级”成果到“非主观造假”,时隔6年,韩春雨带着原一作,再发高分文章!...
- IBM 3650 M3阵列卡配置
- Tomcat学习总结(6)——Tomca常用配置详解
- 常用的java虚拟机_带你了解 JAVA虚拟机 面试必备
- MySQL为表添加外键约束
- 在html里面动画变颜色,html – 在悬停时填充文本颜色动画,带有动画颜色
- Linux内核LED子系统、请务必看
- 字符串匹配之PabinKarp(模式匹配)
- 迅捷CAD编辑器剪切框架工具具体使用方法
- RS485芯片UN485E的特点及其应用
- Microsoft office 2016在win10上的安装
- 多因子选股模型 —— 因子历史收益率(因子与股票收益率回归后的收益率)加权法
- 【AI语音】华为EC6110M、Q21AQ、Q21C部分EC6110T、EC6110U_海思3798MV310_通刷_卡刷固件
- 基于电流型磁链观测器的异步电机矢量控制学习
- 大数据窥探微信表情背后的含义,结论可能就是,你老了……
- python玩转android_Python Xplorer
- C语言实现STL静态链表,先进后出的数据结构-栈 一
- Java 线程安全问题及解决
- 湖北专升本-湖师计科
热门文章
- 未来教育计算机题库三合一,未来教育-全国计算机等级考试真考题库、高频考点、模拟考场三合一(二级MS Office高级应用)...
- 传送带计数器c语言程序,脉搏计数器的程序(用C语言编写程序)
- thinkphp5实战系列(二)前台模板的引入
- python3 pdf转图片_Python 将pdf转成图片的方法
- python解析xml文件为pdf_用Python解析XML文件的软件实现
- 弘辽科技:电商壹周大事
- Maven:repositories、distributionManagement、pluginRepositories中repository的区别(轻松搞明白)
- 聊聊最近的几件小事儿
- 戴尔电脑装ubuntu报ACPI错误解决过程
- seo与sem的区别