1对1 音视频通话
多对多 视频通话
实现两个终端或多个终端的音视频通话,原理是每个终端有一个唯一的用户id,通过webrtc直接连接来实现1对1的音视频通话。或者通过媒体服务器,如Kurento,licode,mediasoup等,来处理中转webrtc的数据流,实现多对多的音视频通话。
一、组件介绍
音视频通话的主要工作在于前端,后台主要提供两个终端的信息来建立连接。
二、方案说明
webrtc如何建立连接
通信发起方A,根据接受方B的标识符,向服务器发送WS请求 —— 我要和B通信
服务器通过WS推送信息给B,A想和你通信,你愿意吗?
如果B愿意,服务器通过WS推送消息给A、B,你们可以通信了
A、B分别创建连接对象(WebRtcPeer)
WebRtcPeer会自动收集Candidate,你应该通过WS把Candidate发回服务器,服务器再中转给Peer
一单A、B都收集到Candidate,它们就有可能进行点对点通信了(如果是局域网内)
A发起(Offer)一个会话描述(SDP),B接收到后,给出Answer
根据双方的SDP,建立媒体流交换
1对1 音视频通话
单机版本的参考下面的代码,只是介绍原理,调用浏览器的webrtc接口,实现数据流的采集,通过RTCPeerConnection来建立两个客户端之间的连接。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <title>webrtc_base</title> <style> #main{ overflow: hidden;} #main>div{ width: 100px; height: 100px; margin: 10px; border:1px solid #ccc; float: left; } video{width: 100%; height: 100%; filter: grayscale(90%);} </style> </head> <body> <div id="main"> <div> <video id="local" autoplay></video> </div> <div> <video id="remote" autoplay></video> </div> </div> <a href="javascript:;" onclick="initMedia()">开始</a> <script> //单机版的方案 //本地和远程个创建一个RTCPeerConnection,用来建立连接 var local = new RTCPeerConnection(null); var remote = new RTCPeerConnection(null); //webrtc初始化 async function initMedia() { //webrtc获取视频流 let stream = await navigator.mediaDevices.getUserMedia({ video: true }); //将流渲染到video标签上,就能看到视频流的数据了 let video = document.querySelector('#local'); video.srcObject = stream; //本地添加监听事件 //当 RTCPeerConnection通过RTCPeerConnection.setLocalDescription()方法更改本地描述之后,该RTCPeerConnection会抛出icecandidate事件。该事件的监听器需要将更改后的描述信息传送给远端RTCPeerConnection,以更新远端的备选源。 local.addEventListener('icecandidate', handleLocalConnection,false) local.addStream(stream); //远端添加监听事件 remote.addEventListener("addstream",function(e){ let video = document.querySelector('#remote') video.srcObject = e.stream; console.log('onaddstream', e); }, false) remote.addEventListener('icecandidate', handleRemoteConnection,false) //本地开始呼叫 createOffer, 远端并侦听事件 let offer = await local.createOffer({offerOptions: 1}); console.log('createOffer', offer); local.setLocalDescription(offer); //远端创建createAnswer进行应答 remote.setRemoteDescription(offer); let answer = await remote.createAnswer(); console.log('createAnswer', answer); //本地和远端设置应答 remote.setLocalDescription(answer); local.setRemoteDescription(answer); } function handleLocalConnection(e){ const iceCandidate = e.candidate; if (iceCandidate) { remote.addIceCandidate(new RTCIceCandidate(iceCandidate)) } } function handleRemoteConnection(e){ const iceCandidate = e.candidate; if (iceCandidate) { local.addIceCandidate(new RTCIceCandidate(iceCandidate)) } } </script> </body>
实际上,每个用户都有一个本地原视频图像和远端图像,方案流程如下:
1、A用户 createOffer
2、A用户 setLocalDescription(offer) 并发送信令 给B
3、B用户设置 setRemoteDescription(offer)
4、B用户 createAnswer 设置 setLocalDescription(answer) 并发送信令
5、A用户 setLocalDescription(answer)
具体的整个项目代码参考备注的第二个
vue版本的通过网络进行1V1通讯
<template> <div class="remote1" v-loading="loading" :element-loading-text="loadingText" element-loading-spinner="el-icon-loading" element-loading-background="rgba(0, 0, 0, 0.8)" > <div class="shade" v-if="!isJoin"> <div class="input-container"> <input type="text" v-model="account" placeholder="请输入你的昵称" @keyup.enter="join"> <button @click="join">确定</button> </div> </div> <div class="userList"> <h5>在线用户:{{userList.length}}</h5> <p v-for="v in userList" :key="v.account"> {{v.account}} <i v-if="v.account === account || v.account === isCall"> {{v.account === account ? 'me' : ''}} {{v.account === isCall ? 'calling' : ''}} </i> <span @click="apply(v.account)" v-if="v.account !== account && v.account !== isCall">呼叫 {{v.account}}</span> </p> </div> <div class="video-container" v-show="isToPeer"> <div> <video src="" id="rtcA" controls autoplay></video> <h5>{{account}}</h5> <button @click="hangup">hangup</button> </div> <div> <video src="" id="rtcB" controls autoplay></video> <h5>{{isCall}}</h5> </div> </div> </div> </template> <script> import socket from '../../utils/socket'; export default { name: 'remote1', data() { return { account: window.sessionStorage.account || '', isJoin: false, userList: [], roomid: 'webrtc_1v1', // 指定房间ID isCall: false, // 正在通话的对象 loading: false, loadingText: '呼叫中', isToPeer: false, // 是否建立了 P2P 连接 peer: null, offerOption: { offerToReceiveAudio: 1, offerToReceiveVideo: 1 } }; }, methods: { join() { if (!this.account) return; this.isJoin = true; window.sessionStorage.account = this.account; socket.emit('join', {roomid: this.roomid, account: this.account}); }, initSocket() { socket.on('joined', (data) => { this.userList = data; }); socket.on('reply', async data => { // 收到回复 this.loading = false; console.log(data); switch (data.type) { case '1': // 同意 this.isCall = data.self; // 对方同意之后创建自己的 peer await this.createP2P(data); // 并给对方发送 offer this.createOffer(data); break; case '2': //拒绝 this.$message({ message: '对方拒绝了你的请求!', type: 'warning' }); break; case '3': // 正在通话中 this.$message({ message: '对方正在通话中!', type: 'warning' }); break; } }); socket.on('apply', data => { // 收到请求 if (this.isCall) { this.reply(data.self, '3'); return; } this.$confirm(data.self + ' 向你请求视频通话, 是否同意?', '提示', { confirmButtonText: '同意', cancelButtonText: '拒绝', type: 'warning' }).then(async () => { await this.createP2P(data); // 同意之后创建自己的 peer 等待对方的 offer this.isCall = data.self; this.reply(data.self, '1'); }).catch(() => { this.reply(data.self, '2'); }); }); socket.on('1v1answer', (data) => { // 接收到 answer this.onAnswer(data); }); socket.on('1v1ICE', (data) => { // 接收到 ICE this.onIce(data); }); socket.on('1v1offer', (data) => { // 接收到 offer this.onOffer(data); }); socket.on('1v1hangup', _ => { // 通话挂断 this.$message({ message: '对方已断开连接!', type: 'warning' }); this.peer.close(); this.peer = null; this.isToPeer = false; this.isCall = false; }); }, hangup() { // 挂断通话 socket.emit('1v1hangup', {account: this.isCall, self: this.account}); this.peer.close(); this.peer = null; this.isToPeer = false; this.isCall = false; }, apply(account) { // account 对方account self 是自己的account this.loading = true; this.loadingText = '呼叫中'; socket.emit('apply', {account: account, self: this.account}); }, reply(account, type) { socket.emit('reply', {account: account, self: this.account, type: type}); }, async createP2P(data) { this.loading = true; this.loadingText = '正在建立通话连接'; await this.createMedia(data); }, async createMedia(data) { // 保存本地流到全局 try { this.localstream = await navigator.mediaDevices.getUserMedia({audio: true, video: true}); let video = document.querySelector('#rtcA'); video.srcObject = this.localstream; } catch (e) { console.log('getUserMedia: ', e) } this.initPeer(data); // 获取到媒体流后,调用函数初始化 RTCPeerConnection }, //自定义turn的方式 createConn(stream) { let localStream = stream // 显示本地视频流 localVideo.srcObject = stream; //谷歌公共stun服务器 let serverConfig = { "iceServers": [ { "urls": ["turn:192.168.1.133:3478"], "username": "webrtc", "credential": "webrtc" } ] }; // 呼叫者 let localPeer = new RTCPeerConnection(serverConfig) // 被呼叫者 let remotePeer = new RTCPeerConnection(serverConfig) // 设置媒体流监听,将本地流添加到RTCPeerConnection对象 localStream.getTracks().forEach((track) => { localPeer.addTrack(track, localStream); }); localPeer.addStream(stream) }, initPeer(data) { // 创建输出端 PeerConnection let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection; this.peer = new PeerConnection(); this.peer.addStream(this.localstream); // 添加本地流 // 监听ICE候选信息 如果收集到,就发送给对方 this.peer.onicecandidate = (event) => { if (event.candidate) { socket.emit('1v1ICE', {account: data.self, self: this.account, sdp: event.candidate}); } }; this.peer.onaddstream = (event) => { // 监听是否有媒体流接入,如果有就赋值给 rtcB 的 src this.isToPeer = true; this.loading = false; let video = document.querySelector('#rtcB'); video.srcObject = event.stream; }; }, async createOffer(data) { // 创建并发送 offer try { // 创建offer let offer = await this.peer.createOffer(this.offerOption); // 呼叫端设置本地 offer 描述 await this.peer.setLocalDescription(offer); // 给对方发送 offer socket.emit('1v1offer', {account: data.self, self: this.account, sdp: offer}); } catch (e) { console.log('createOffer: ', e); } }, async onOffer(data) { // 接收offer并发送 answer try { // 接收端设置远程 offer 描述 await this.peer.setRemoteDescription(data.sdp); // 接收端创建 answer let answer = await this.peer.createAnswer(); // 接收端设置本地 answer 描述 await this.peer.setLocalDescription(answer); // 给对方发送 answer socket.emit('1v1answer', {account: data.self, self: this.account, sdp: answer}); } catch (e) { console.log('onOffer: ', e); } }, async onAnswer(data) { // 接收answer try { await this.peer.setRemoteDescription(data.sdp); // 呼叫端设置远程 answer 描述 } catch (e) { console.log('onAnswer: ', e); } }, async onIce(data) { // 接收 ICE 候选 try { await this.peer.addIceCandidate(data.sdp); // 设置远程 ICE } catch (e) { console.log('onAnswer: ', e); } } }, mounted() { this.initSocket(); if (this.account) { this.join(); } } } </script>
信令方案:
https://github.com/strophe/strophejs
Socket.io
其他可以做长连接的都可以,websocket,mqtt都可以
中间传输信令用的代码如下server.js:
使用node搭建,也可以用其他的方式
运行方法:
npm i koa
node server.js
const Koa = require('koa'); const path = require('path'); const koaSend = require('koa-send'); const static = require('koa-static'); const socket = require('koa-socket'); const users = {}; // 保存用户 const sockS = {}; // 保存客户端对应的socket const io = new socket({ ioOptions: { pingTimeout: 10000, pingInterval: 5000, } }); // 创建一个Koa对象表示web app本身: const app = new Koa(); // socket注入应用 io.attach(app); app.use(static( path.join( __dirname, './public') )); // 对于任何请求,app将调用该异步函数处理请求: app.use(async (ctx, next) => { if (!/\./.test(ctx.request.url)) { await koaSend( ctx, 'index.html', { root: path.join(__dirname, './'), maxage: 1000 * 60 * 60 * 24 * 7, gzip: true, } // eslint-disable-line ); } else { await next(); } }); // io.on('join', ctx=>{ // event data socket.id // }); app._io.on( 'connection', sock => { sock.on('join', data=>{ sock.join(data.roomid, () => { if (!users[data.roomid]) { users[data.roomid] = []; } let obj = { account: data.account, id: sock.id }; let arr = users[data.roomid].filter(v => v.account === data.account); if (!arr.length) { users[data.roomid].push(obj); } sockS[data.account] = sock; app._io.in(data.roomid).emit('joined', users[data.roomid], data.account, sock.id); // 发给房间内所有人 // sock.to(data.roomid).emit('joined',data.account); }); }); sock.on('offer', data=>{ console.log('offer', data); sock.to(data.roomid).emit('offer',data); }); sock.on('answer', data=>{ console.log('answer', data); sock.to(data.roomid).emit('answer',data); }); sock.on('__ice_candidate', data=>{ console.log('__ice_candidate', data); sock.to(data.roomid).emit('__ice_candidate',data); }); // 1 v 1 sock.on('apply', data=>{ // 转发申请 console.log('转发申请'); sockS[data.account].emit('apply', data); }); sock.on('reply', data=>{ // 转发回复 console.log('转发回复'); sockS[data.account].emit('reply', data); }); sock.on('1v1answer', data=>{ // 转发 answer console.log('转发 answer'); sockS[data.account].emit('1v1answer', data); }); sock.on('1v1ICE', data=>{ // 转发 ICE console.log('转发 ICE'); sockS[data.account].emit('1v1ICE', data); }); sock.on('1v1offer', data=>{ // 转发 Offer console.log('转发 Offer'); sockS[data.account].emit('1v1offer', data); }); sock.on('1v1hangup', data=>{ // 转发 hangup console.log('转发 hangup'); sockS[data.account].emit('1v1hangup', data); }); }); app._io.on('disconnect', (sock) => { for (let k in users) { users[k] = users[k].filter(v => v.id !== sock.id); } console.log(`disconnect id => ${users}`); }); let port = 8090; app.listen(port, _ => { console.log('app started at port ...' + port); }); // https.createServer(app.callback()).listen(3001);
多对多 视频通话
基于webrtc的方案,每个用户都有一个本地视频图像和远端视频图像,如果扩展到多用户,则意味着每一个用户都需要与其他的用户建立连接,无疑会极大的浪费带宽,所以引入了媒体服务器来做中间处理。
简单点说就是每个用户跟媒体服务器连接,媒体服务器进行视频流的转发处理。
了解不少的开源项目,最后决定使用Kurento作为媒体服务器。
教程:
https://doc-kurento.readthedocs.io/en/latest/user/tutorials.html
参考:https://hub.docker.com/r/kurento/kurento-media-server
docker pull kurento/kurento-media-server:latest
linux的环境,可以运行这个命令
docker run -d --name kms --network host
kurento/kurento-media-server:latest
window的docker环境,运行这个命令
docker run -name kms -d
-p 8888:8888/tcp
-p 5000-5050:5000-5050/udp
-e KMS_MIN_PORT=5000
-e KMS_MAX_PORT=5050
kurento/kurento-media-server:latest
执行完成,如果docker ps里面显示运行了,则服务端可以暂时使用这个。
2.客户端
目前有三个客户端
js端
java端
node端
暂时用不到,先不了解
解构js相关代码
这块后面如果用,前端来完善
解构java相关代码
待完善
备注:
运行
npm install
node index.js
运行的方案:
首先webrtc-stream下面运行npm install 这个是用来运行node项目的,然后运行node server.js,就相当于有个信令服务器
进入webrtc-main 下面运行npm install 这个是用来运行前端项目,执行npm run server。
通过webrtc的createDataChannel()可以实现图像、音频、文件等传送。
https://webrtc.github.io/samples/