音视频通话的方案记录

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

  1. 服务端
    搭建服务器,使用docker方式

参考: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相关代码

待完善

备注:

  1. socket.io的demo

运行

npm install

node index.js

  1. 参考开源项目,已经加入流程的注释

运行的方案:

首先webrtc-stream下面运行npm install 这个是用来运行node项目的,然后运行node server.js,就相当于有个信令服务器
进入webrtc-main 下面运行npm install 这个是用来运行前端项目,执行npm run server。
通过webrtc的createDataChannel()可以实现图像、音频、文件等传送。

  1. webrtc的一些很棒的例子

https://webrtc.github.io/samples/

上一篇:W5500


下一篇:【hiho一下第二周 】Trie树