18-基于WebRTC实现音视频通话
18 基于WebRTC实现音视频通话
基于WebRTC实现音视频通话
基于WebRTC实现音视频通话
背景
随着互联网技术的飞速发展,实时音视频通话已经成为在线教育、远程办公、社交媒体等领域的核心且常用的功能。WebRTC(Web Real-Time Communication)作为一项开放的实时通信标准,为开发者提供了快速构建实时音视频通话系统的能力。在本中,我们将从0到1使用 WebRTC 构建一个基于 P2P 架构的音视频通话的应用案例。
应用场景
- 点对点视频聊天:如 微信视频 等实时视频通话应用。
- 多人视频会议:企业级多人视频会议系统,如飞书、钉钉、腾讯会议等。
- 在线教育:如腾讯课堂、网易云课堂等。
- 直播:游戏直播、直播等。
P2P通信原理
P2P 通信即点对点通信。
要实现两个客户端的实时音视频通信,并且这两个客户端可能处于不同网络环境,使用不同的设备,都需要解决哪些问题?
主要是下面这 3 个问题:
- 如何发现对方?
- 不同的音视频编解码能力如何沟通?
- 如何联系上对方?
下面我们将逐个讨论这 3 个问题。
如何发现对方?
在 P2P 通信的过程中,双方需要交换一些元数据比如媒体信息、网络数据等等信息,我们通常称这一过程叫做“信令(signaling)”。
对应的服务器即“信令服务器 (signaling server)”,通常也有人将之称为“房间服务器”,因为它不仅可以交换彼此的媒体信息和网络信息,同样也可以管理房间信息。
比如:
1)通知彼此 who 加入了房间;2)who 离开了房间 3)告诉第三方房间人数是否已满是否可以加入房间。
为了避免出现冗余,并最大限度地提高与已有技术的兼容性,WebRTC 标准并没有规定信令方法和协议。使用websocket来搭建一个信令服务器
不同的音视频编解码能力如何沟通?
不同浏览器对于音视频的编解码能力是不同的。
比如: 以日常生活中的例子来讲,小李会讲汉语和英语,而小王会讲汉语和法语。为了保证双方都可以正确的理解对方的意思,最简单的办法即取他们都会的语言,也就是汉语来沟通。
在 WebRTC 中:有一个专门的协议,称为 Session Description Protocol(SDP),可以用于描述上述这类信息。
因此:参与音视频通讯的双方想要了解对方支持的媒体格式,必须要交换 SDP 信息。而交换 SDP 的过程,通常称之为媒体协商。
如何联系上对方?
其实就是网络协商的过程,即参与音视频实时通信的双方要了解彼此的网络情况,这样才有可能找到一条相互通讯的链路。
理想的网络情况是每个客户端都有自己的私有公网 IP 地址,这样的话就可以直接进行点对点连接。实际上呢,出于网络安全和其他原因的考虑,大多数客户端之间都是在某个局域网内,需要网络地址转换(NAT)。
在 WebRTC 中我们使用 ICE 机制建立网络连接。ICE 协议通过一系列的技术(如 STUN、TURN 服务器)帮助通信双方发现和协商可用的公共网络地址,从而实现 NAT 穿越。
ICE 的工作原理如下:
- 首先,通信双方收集本地网络地址(包括私有地址和公共地址)以及通过 STUN 和 TURN 服务器获取的候选地址。
- 接下来,双方通过信令服务器交换这些候选地址。
- 通信双方使用这些候选地址进行连接测试,确定最佳的可用地址。
- 一旦找到可用的地址,通信双方就可以开始实时音视频通话。
在 WebRTC 中网络信息通常用 candidate 来描述
针对上面三个问题的总结:就是通过 WebRTC 提供的 API 获取各端的媒体信息 SDP 以及 网络信息 candidate ,并通过 信令服务器 交换,进而建立了两端的连接通道完成实时视频语音通话。
常用的API
音视频采集 getUserMedia
const localStream = ref<MediaStream>() // 本地流
// 获取本地音视频流
const getLocalStream = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ // 获取音视频流
audio: true,
video: true
})
localVideo.value!.srcObject = stream
localVideo.value!.play()
return stream
}
核心对象 RTCPeerConnection
作为创建点对点连接的 API,是我们实现音视频实时通信的关键。
const peer = new RTCPeerConnection({
// iceServers: [
// { url: "stun:stun.l.google.com:19302" }, // 谷歌的公共服务
// {
// urls: "turn:***",
// credential: "***",
// username: "***",
// },
// ],
});
主要会用到以下几个方法:
媒体协商方法:
- createOffer
- createAnswer
- setLocalDesccription
- setRemoteDesccription
重要事件:
onicecandidate
onaddstream
整个媒体协商过程可以简化为三个步骤对应上述四个媒体协商方法:
- 呼叫端创建 Offer(createOffer)并将 offer 消息(内容是呼叫端的 SDP 信息)通过信令服务器传送给接收端,同时调用 setLocalDesccription 将含有本地 SDP 信息的 Offer 保存起来
- 接收端收到对端的 Offer 信息后调用 setRemoteDesccription 方法将含有对端 SDP 信息的 Offer 保存起来,并创建 Answer(createAnswer)并将 Answer 消息(内容是接收端的 SDP 信息)通过信令服务器传送给呼叫端
- 呼叫端收到对端的 Answer 信息后调用 setRemoteDesccription 方法将含有对端 SDP 信息的 Answer 保存起来
经过上述三个步骤,则完成了 P2P 通信过程中的媒体协商部分,实际上在呼叫端以及接收端调用setLocalDesccription 同时也开始了收集各端自己的网络信息(candidate),然后各端通过监听事件 onicecandidate 收集到各自的 candidate 并通过信令服务器传送给对端,进而打通 P2P 通信的网络通道,并通过监听 onaddstream 事件拿到对方的视频流进而完成了整个视频通话过程。
实践
项目搭建
前端项目
- 项目使用
vue3+ts
,运行如下命令:
npm create vite@latest webrtc-client -- --template vue-ts
这里报错的可能解决方式,确保你使用的是支持ES模块的Node.js版本。Node.js在12.17.0之后的版本开始支持ES模块。你可以通过运行node -v来检查你的Node.js版本。如果版本过低,你可以考虑升级Node.js。
- cd webrtc-client 并且引入
tailwindcss
:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
- 在生成的
tailwind.config.js
配置文件中添加所有模板文件的路径。
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
- 修改
style.css
中的内容如下:
@tailwind base;
@tailwind components;
@tailwind utilities;
- 自定义修改
App.vue
中的内容如下:
<script lang="ts" setup>
import { ref } from 'vue'
const called = ref<boolean>(false) // 是否是接收方
const caller = ref<boolean>(false) // 是否是发起方
const calling = ref<boolean>(false) // 呼叫中
const communicating = ref<boolean>(false) // 视频通话中
const localVideo = ref<HTMLVideoElement>() // video标签实例,播放本人的视频
const remoteVideo = ref<HTMLVideoElement>() // video标签实例,播放对方的视频
// 发起方发起视频请求
const callRemote = () => {
console.log('发起视频');
}
// 接收方同意视频请求
const acceptCall = () => {
console.log('同意视频邀请');
}
// 挂断视频
const hangUp = () => {
console.log('挂断视频');
}
</script>
<template>
<div class="flex items-center flex-col text-center p-12 h-screen">
<div class="relative h-full mb-4">
<video
ref="localVideo"
class="w-96 h-full bg-gray-200 mb-4 object-cover"
></video>
<video
ref="remoteVideo"
class="w-32 h-48 absolute bottom-0 right-0 object-cover"
></video>
<div v-if="caller && calling" class="absolute top-2/3 left-36 flex flex-col items-center">
<p class="mb-4 text-white">等待对方接听...</p>
<img @click="hangUp" src="/refuse.svg" class="w-16 cursor-pointer" alt="">
</div>
<div v-if="called && calling" class="absolute top-2/3 left-32 flex flex-col items-center">
<p class="mb-4 text-white">收到视频邀请...</p>
<div class="flex">
<img @click="hangUp" src="/refuse.svg" class="w-16 cursor-pointer mr-4" alt="">
<img @click="acceptCall" src="/accept.svg" class="w-16 cursor-pointer" alt="">
</div>
</div>
</div>
<div class="flex gap-2 mb-4">
<button
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white"
@click="callRemote"
>发起视频</button>
<button
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white"
@click="hangUp"
>挂断视频</button>
</div>
</div>
</template>
执行完上面的步骤就可以运行
npm run dev
来在本地启动项目了
后端项目
创建一个
webrtc-server
的文件夹,执行
npm init
,一路回车即可,然后运行如下命令安装
socket.io
和
nodemon
:
npm install socket.io nodemon
创建
index.js
的文件,并添加如下内容:
const socket = require('socket.io');
const http = require('http');
const server = http.createServer()
const io = socket(server, {
cors: {
origin: '*' // 配置跨域
}
});
io.on('connection', sock => {
console.log('连接成功...')
// 向客户端发送连接成功的消息
sock.emit('connectionSuccess');
})
server.listen(3000, () => {
console.log('服务器启动成功');
});
在
package.json
中添加
start
命令,使用
nodemon
启动项目:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon index.js"
},
执行完后运行
npm run start
即在3000端口可启动node服务了
前端连接信令服务器
前端需要安装
socket.io-client
, 并连接信令服务器:
<script setup lang="ts">
// App.vue
import { ref, onMounted, onUnmounted } from 'vue'
import { io, Socket } from "socket.io-client";
// ...
const socket = ref<Socket>() // Socket实例
onMounted(() => {
const sock = io('localhost:3000'); // 对应服务的端口
// 连接成功
sock.on('connectionSuccess', () => {
console.log('连接成功')
});
socket.value = sock;
})
// ...
</script>
发起视频请求
角色:用户A–发起方,用户B–接收方
房间:类比聊天窗口
连接成功时加入房间:
// 前端代码
const roomId = '001'
sock.on('connectionSuccess', () => {
console.log('连接服务器成功...');
sock.emit('joinRoom', roomId) // 前端发送加入房间事件
})
// 服务端代码
sock.on('joinRoom', (roomId) => {
sock.join(roomId) // 加入房间
})
用户A发起视频请求并通知用户B:
- 用户A发起视频请求,并且通过信令服务器通知用户B
// 发起方发起视频请求
const callRemote = async () => {
console.log('发起视频');
caller.value = true;
calling.value = true;
await getLocalStream()
// 向信令服务器发送发起请求的事件
socket.value?.emit('callRemote', roomId)
}
- 用户B同意视频请求,并且通过信令服务器通知用户A
// 接收方同意视频请求
const acceptCall = () => {
console.log('同意视频邀请');
socket.value?.emit('acceptCall', roomId)
}
开始交换 SDP 信息和 candidate 信息:
- 用户A创建创建RTCPeerConnection,添加本地音视频流,生成offer,并且通过信令服务器将offer发送给用户B
const peer = ref<any>() // RTCPeerConnection实例
if (caller.value) { // 发送方
// 发送方收到同意视频事件
sock.on('acceptCall', async () => {
// 创建RTCPeerConnection
peer.value = new RTCPeerConnection()
// 添加本地音视频流
peer.value.addStream(localStream.value)
// 生成offer
const offer = await peer.value.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
})
console.log('offer', offer);
// 设置本地描述的offer
await peer.value.setLocalDescription(offer);
// 通过信令服务器将offer发送给用户B
socket.value?.emit('sendOffer', { offer, roomId })
}
})
- 用户B收到用户A的offer
sock.on('sendOffer', (offer) => {
if (called.value) { // 判断接收方
console.log('收到offer', offer);
}
})
- 用户B需要创建自己的RTCPeerConnection,添加本地音视频流,设置远端描述信息,生成answer,并且通过信令服务器发送给用户A
// 接收方收到offer
sock.on('sendOffer', async (offer: any) => {
if (called.value) {
const stream = await getLocalStream()
// 接收方创建自己的RTCPeerConnection对象
peer.value = new RTCPeerConnection();
// 添加本地音视频流
peer.value.addStream(stream);
// 获取candidate信息
peer.value.onicecandidate = (event: any) => {
if (event.candidate) {
// 向服务器发送candidate信息
sock.emit('sendCandidate', { roomId, candidate: event.candidate })
}
}
// 获取对方的音视频流
peer.value.onaddstream = (event: any) => {
// 拿到对方的视频流
calling.value = false
communicating.value = true
remoteVideo.value!.srcObject = event.stream;
remoteVideo.value!.play()
};
// 设置远端描述信息
await peer.value.setRemoteDescription(offer);
// 生成answer
const answer = await peer.value.createAnswer()
// 设置本地描述信息
await peer.value.setLocalDescription(answer);
// 发送answer
sock.emit('sendAnswer', { roomId, answer });
}
})
用户A收到用户B的answer
. 服务端
. // 收到接收方的answer
sock.on(‘sendAnswer’, ({ roomId, answer }) => {
// 向这个房间中的人广播这个事件
io.to(roomId).emit(‘receiveAnswer’, answer)
})
// 发送方收到接收方的answer
sock.on('receiveAnswer', (answer: any) => {
if (caller.value) {
// 设置远端描述信息
peer.value.setRemoteDescription(answer);
}
})
- 用户A获取candidate信息并且通过信令服务器发送candidate给用户B
// 通过监听onicecandidate事件获取candidate信息
peer.value.onicecandidate = (event: any) => {
if (event.candidate) {
console.log('用户A获取candidate信息', event.candidate);
// 通过信令服务器发送candidate信息给用户B
socket.value?.emit('sendCandidate', {
roomId,
candidate: event.candidate
})
}
}
用户B添加用户A的candidate信息
// 收到发送方的candidate信息
sock.on(‘sendCandidate’, ({ roomId, candidate }) => {
// 向这个房间中的人广播candidate信息
io.to(roomId).emit(‘receiveCandidate’, candidate)
})
// 接收candidate信息
sock.on('receiveCandidate', async (candidate: any) => {
await peer.value.addIceCandidate(candidate);
})
- 用户B获取candidate信息并且通过信令服务器发送candidate给用户A(如上)
peer.value.onicecandidate = (event: any) => {
if (event.candidate) {
console.log('用户B获取candidate信息', event.candidate);
// 通过信令服务器发送candidate信息给用户A
socket.value?.emit('sendCandidate', {
roomId,
candidate: event.candidate
})
}
}
- 用户A添加用户B的candidate信息(如上)
// 添加candidate信息
// 接收candidate信息
sock.on('receiveCandidate', async (candidate: any) => {
await peer.value.addIceCandidate(candidate);
})
- 接下来用户A和用户B就可以进行P2P通信流
// 监听onaddstream来获取对方的音视频流
peer.value.onaddstream = (event: any) => {
calling.value = false;
communicating.value = true;
remoteVideo.value!.srcObject = event.stream
remoteVideo.value!.play()
}
挂断视频
// 挂断视频
const hangUp = () => {
console.log('挂断视频');
socket.value?.emit('hangUp', roomId)
}
// 状态复原
const reset = () => {
called.value = false
caller.value = false
calling.value = false
communicating.value = false
peer.value = null
localVideo.value!.srcObject = null
remoteVideo.value!.srcObject = null
localStream.value = undefined
}
拓展:peerjs
文档:https://peerjs.com/docs/#start
服务端实现
// 使用peer搭建信令服务器
const { PeerServer } = require('peer');
const peerServer = PeerServer({ port: 3001, path: '/myPeerServer' });
前端实现
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Peer } from "peerjs";
const url = ref<string>()
const localVideo = ref<HTMLVideoElement>()
const remoteVideo = ref<HTMLVideoElement>()
const peerId = ref<string>()
const remoteId = ref<string>()
const peer = ref<any>()
const caller = ref<boolean>(false)
const called = ref<boolean>(false)
const callObj = ref<any>(false)
onMounted(() => {
//
peer.value = new Peer({ // 连接信令服务器
host: 'localhost',
port: 3001,
path: '/myPeerServer'
});
peer.value.on('open', (id: string) => {
peerId.value = id
})
// 接收视频请求
peer.value.on('call', async (call: any) => {
called.value = true
callObj.value = call
});
})
// 获取本地音视频流
async function getLocalStream(constraints: MediaStreamConstraints) {
// 获取媒体流
const stream = await navigator.mediaDevices.getUserMedia(constraints)
// 将媒体流设置到 video 标签上播放
localVideo.value!.srcObject = stream;
localVideo.value!.play();
return stream
}
const acceptCalled = async () => {
// 接收视频
const stream = await getLocalStream({ video: true, audio: true })
callObj.value.answer(stream);
callObj.value.on('stream', (remoteStream: any) => {
called.value = false
// 将远程媒体流添加到 video 元素中
remoteVideo.value!.srcObject = remoteStream;
remoteVideo.value!.play();
});
}
// 开启视频
const callRemote = async () => {
if (!remoteId.value) {
alert('请输入对方ID')
return
}
const stream = await getLocalStream({ video: true, audio: true })
// 将本地媒体流发送给远程 Peer
const call = peer.value.call(remoteId.value, stream);
caller.value = true
call.on('stream', (remoteStream: any) => {
caller.value = false
// 将远程媒体流添加到 video 元素中
remoteVideo.value!.srcObject = remoteStream;
remoteVideo.value!.play();
});
}
</script>