语音通话,如此简单?
前言
‘语音通话,如此简单?’ 看到这里有些朋友不经可能发出言论了,WebRTC语音视频通话?这是什么不起眼的技术?🤔简单来说他是基于WebSocket的只上而实现的语音视频对讲技术,想我们常见的,语音电话,视频聊天,网络直播,都是以WebRTC基础上实现的,最近公司业务也是从零添加了这方面的业务,作为大冤种的我当然要接下这份重任了😭,在没接触过这方面知识前,确实觉得有点挑战难度,但技术总归是要创新探索创新的,再说了,带薪学习难道不香吗哈哈哈。好了不废话了,各位道友请听我娓娓道来。
起源及应用
2010年5月,Google以6820万美元收购VoIP软件开发商Global IP Solutions的GIPS引擎,并改为名为“WebRTC”。WebRTC使用GIPS引擎,实现了基于网页的视频会议,并支持722,PCM,ILBC,ISAC等编码,同时使用谷歌自家的VP8视频解码器;同时支持RTP/SRTP传输等。
谷歌2011年6月3日宣布向开发人员开放WebRTC架构的源代码。这个源代码将根据没有专利费的BSD(伯克利软件发布)式的许可证向用户提供。开发人员可访问并获取WebRTC的源代码、规格说明和工具等。 ‘’
WebRTC 是 Web 实时通信(Real-Time Communication)的缩写,它既是 API 也是协议。WebRTC 协议是两个 WebRTC Agent 协商双向安全实时通信的一组规则。开发人员可以通过 WebRTC API 使用 WebRTC 协议。目前 WebRTC API 仅有 JavaScript 版本。
可以用 HTTP 和 Fetch API 之间的关系作为类比。WebRTC 协议就是 HTTP,而 WebRTC API 就是 Fetch API。
除了 JavaScript 语言,WebRTC 协议也可以在其他 API 和语言中使用。你还可以找到 WebRTC 的服务器和特定领域的工具。所有这些实现都使用 WebRTC 协议,以便它们可以彼此交互。
WebRTC 协议由 IETF 工作组在rtcweb中维护。WebRTC API 的 mdn
文档在webrtc。
W3C
文档在webrtc。
技术应用
WebRTC实现了基于网页的视频会议,标准是WHATWG 协议,目的是通过浏览器提供简单的javascript就可以达到实时通讯(Real-Time Communications (RTC))能力。
WebRTC项目的最终目的主要是让Web开发者能够基于浏览器(Chrome\FireFox...)轻易快捷开发出丰富的实时多媒体应用,而无需下载安装任何插件,Web开发者也无需关注多媒体的数字信号处理过程,只需编写简单的Javascript程序即可实现,W3C等组织正在制定Javascript 标准API,目前是WebRTC 1.0版本,Draft状态;另外WebRTC还希望能够建立一个多互联网浏览器间健壮的实时通信的平台,形成开发者与浏览器厂商良好的生态环境。同时,Google也希望和致力于让WebRTC的技术成为HTML5标准之一,可见Google布局之深远。
WebRTC提供了视频会议的核心技术,包括音视频的采集、编解码、网络传输、显示等功能,虽然名字前面带上了Web,让人联想到多处应用应该会在web端,但其实他也支持跨平台:windows,linux,mac,android。
WebSocket代码编写
废话不多说,代码实现才是王道。
首先想要建立起p2p连接通话,自然是要在WebSocket基础之上的,在utils文件夹创建一个socket.js文件,以我的项目是vue3版本的为例
import { Base64 } from 'js-base64'
import { publicEncrypt } from '@/utils/index'
import ReconnectingWebSocket from 'reconnecting-websocket'
import { useStore } from '@/store/user.js'
const store = useStore()
export const createdWebsocket = () => {
const query = Base64.encode(publicEncrypt(JSON.stringify({
userType: '2',
name: store.user.name,
adminId: store.user.id
})))
return new ReconnectingWebSocket(`wss://域名/地址/ws?key=${query}`)
}
WebSocket的连接库有很多,原生的也可以,后端给定的地址和参数再加上约定好的解密公钥即可建立连接,这时你就可以和服务端随意发消息啦!连接成功后有很多回调api,webSocket.onopen
:连接成功的回调,webSocket.onclose
:断开连接的回调,webSocket.onmessage
:收到服务端消息的回调等等,具体可参考阮一峰老师的这篇api解读文章。当然你也可以将socket实例存到store里面接收方便全局使用,根据场景需要即可。
是不是以为第一步就OK了?大No特No,WebSocket还有一个心跳机制,大致意思是socket建立成功后,长时间连接服务器不知道双方是否还需要通信或者已经断开了,所以就需要建立一个心跳机制来保证连接的稳定性,确保双方通信都是正常的,默认是一分钟双方不主动通信socket就会自动断开,前端和后端只要有一方处理心跳就行了,另一方处理消息接收后在回调给对方,正常都是前者发ping
,后者回pong
。在store文件夹建立一个socketHeartbeat.js,用来纪录心跳时间和和超时次数。
import { defineStore } from 'pinia'
export const useStore = defineStore('socket', {
state: () => ({
webSocket: null,
heartTime: null, // 心跳定时器实例
socketHeart: 0, // 心跳次数
HeartTimeOut: 20000, // 心跳超时时间
socketError: 0 // 错误次数
})
})
在需要需要建立socket连接页面编写心跳代码,也可以封装在utils里面
import { createdWebsocket } from '@/utils/socket'
import { useStore } from '@/store/socket.js'
const store = useStore()
const createdSocket = () => {
console.log('开始创建websocket')
store.webSocket = createdWebsocket()
store.webSocket.onopen = () => {
console.log('信令通道创建成功!')
resetHeart()
}
store.webSocket.onerror = () => console.error('信令通道创建失败!')
store.webSocket.onclose = () => {
// ElMessage({ message: 'socket服务器已经断开', type: 'error' })
console.log('socket断开连接!')
}
store.webSocket.onmessage = (event) => { console.log(event) }
sendSocketHeart()
}
// 发送心跳
const sendSocketHeart = () => {
store.heartTime = setInterval(() => {
if (store.socketHeart <= 2) {
console.log('心跳发送:', store.socketHeart)
store.webSocket.send(
JSON.stringify({
msgType: 100,
content: 'ping'
})
)
store.socketHeart = store.socketHeart + 1
} else {
reconnect()
}
}, store.HeartTimeOut)
}
// socket重连
const reconnect = () => {
store.webSocket.close()
if (store.socketError <= 2) {
clearInterval(store.heartTime)
createdSocket()
store.socketError = store.socketError + 1
console.log('socket重连', store.socketError)
} else {
console.log('重试次数已用完的逻辑', store.socketError)
clearInterval(store.heartTime)
}
}
// socket 重置心跳
const resetHeart = () => {
store.socketHeart = 0
store.socketError = 0
clearInterval(store.heartTime)
sendSocketHeart()
}
一切前置条件准备完毕,socket也能稳定连接了,接下来可以准备WebRTC了。
WebRTC代码编写
如果是用在外网商用用途的话,需要SDK服务,建议用第三方集成的SDK,更快部署也快,就是要收费,白嫖是不可能滴🤣,向出名的腾讯,声网都可以,我司的业务范围都是学校,用的都是内网,所以不用考虑SDK,后端只需要建立一个中转消息的信令服务器即可,用来转发两端需要建立连接前的消息通信,RTC连接成功后即可通话,p2p通信是不需要信令服务器的了,作用仅仅是转发消息,也做不了干预和监听,通话成功后期间就算服务断掉两端还是可以正常通话的(测过了)🤗。
到这里了,终于要开始了吧,大No特No,还有一个非常重要的前置条件,webrtc是P2P通信,也就是实际交流的只有两个人,而要建立通信,这两个人需要交换一些信息来保证通信安全。而且,webrtc必须通过ssh加密,也就是使用https协议、wss协议。前端项目在vite.config.js的server配置里面加上https: true
即可。
- 首先,Client A创建端点(Create PeerConnection),并添加音视频流(Add Streams)。接下来通知Client B,让Client B也创建一个端点。
- Client B收到通知后,Client B创建端点(Create PeerConnection),并添加音视频流(Add Streams),
- 接下来,Client B创建一个用于answer的SDP对象(Create Answer),保存并发送给Client A。 Client A收到用于answer的SDP后,保存下来。
- 然后, Client A创建一个用于offer的SDP对象(Create Office),保存并发送给Client B。
- 最后,Client B保存收到的用于offer的SDP对象
期间在建立WebRTC连接前还有需要交互页面,比如呼叫中接听中切展示页面,我这里主要展示连接成功后的code。在template部分创建接收流的两个video标签。
<div class="video-voice">
<video ref="remoteVideo" autoplay playsinline></video>
<video
ref="localVideo"
autoplay
playsinline
muted
></video>
</div>
用ref获取两个标签的实例,vue3中原生获取有点问题(试过了)
const remoteVideo = ref()
const localVideo = ref()
开始创建RTCPeerConnection实例
let pc = null
let localStream = null
const createPeerConnection = () => {
console.log('create RTCPeerConnection!')
// 呼叫者
pc = new RTCPeerConnection() // serverConfig
// 当获得到自己的公网地址后,发送给其它客户端
console.log(pc, 'pc实例方法')
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('搜集并发送候选人', event.candidate)
sendMessage({
type: 'candidate',
label: event.candidate.sdpMLineIndex,
id: event.candidate.sdpMid,
candidate: event.candidate.candidate
})
} else {
console.log('候选人收集完成!')
}
}
pc.ontrack = (e) => { // P2P 连接建立,获得对方的音视频媒体流 建立一条最优的连接方式
console.log(e, '建立连接获取媒体流')
// remoteStream = e.streams[0]
remoteVideo.value.srcObject = e.streams[0]
if (e && e.streams) {
console.log('收到对方音频/视频流数据...')
}
}
}
对方点击接听时或者确定接听调用摄像头和麦克风api,期间考虑用户电脑是否支持navigator.mediaDevices.getUserMedia(),其中还支持各种定制化配置参数,详情可以看mdn
const constraints = {
video: false, // 视频
audio: { // 语音
noiseSuppression: true, // 降噪
echoCancellation: true // 回音消除
}
}
const establishVideoCallConnection = async () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('不支持getUserMedia API语音通话!')
return
}
await navigator.mediaDevices.getUserMedia(constraints)
.then(getMediaStream)
.catch(err => {
ElMessage({ message: '检测不到音频播放源,无法语音通话', type: 'error' })
})
await createPeerConnection() // 创建rtc实例
await bindTracks() // 传输数据给另一端
await call() // 拨打发送本地offer消息
}
在此连接时需要信令服务器来帮忙转发我们所需要给对方传输的参数信息,和后端约定好,同理,对方发过来时可以用socket.onmessage来处理消息。建立连接时消息一般分为三类,offer(自己的SDP信息),answer(对方的SDP信息),candidate(交换信息),收到不同的消息类型会对应处理存取起来并且交换信息,连接成功后对方的流也会共享,赋值给video标签的srcObeject属性即可在页面上展示且语音流正常通话(如果想开启视频video: true
即可),具体代码都会给上对应注释。
function sendMessage (data) { // 发送消息
console.log('向另一端发送消息', data) // 服务器中转用onmessage方法接收处理
store.webSocket.send(JSON.stringify({
msgType: 6,
content: data
}))
}
function bindTracks () { // 传输数据给另一端
console.log('bind tracks into RTCPeerConnection!')
// add all track into peer connection
localStream.getTracks().forEach((track) => {
console.log(localStream.getTracks, '添加到连接项')
pc.addTrack(track, localStream)
})
}
store.webSocket.onmessage = async (event) => { // 收到消息回话
console.log(event)
/* 与服务端和对端消息监控----------------------------------------------------- */
const codeTips = JSON.parse(event.data)
if (codeTips.code === '5') { // code码和后端协商好的回话信息
const { type } = codeTips.content
if (type === 'call') { // 收到回话消息后webrtc连接
establishVideoCallConnection() // 建立webrtc连接
}
}
if (codeTips.content === 'pong') {
resetHeart() // 重置心跳
}
/* webrtc对讲连接前消息监控 收到offer信令------------------------------------- */
if (pc !== null) {
console.log('信令服务来消息了!', event)
const { code, content } = JSON.parse(event.data)
if (code === '5') {
const { type, sdp, label, candidate } = content
if (type === 'offer') { // 接收方
console.log('offer type')
navigator.mediaDevices.getUserMedia(constraints)// 与发起方一致,省略
pc.setRemoteDescription(new RTCSessionDescription({ type, sdp }))
pc.createAnswer().then(getAnswer).catch((err) => console.log(err))
} else if (type === 'candidate') {
console.log('candidate type')
const candidate1 = new RTCIceCandidate({
sdpMLineIndex: label,
candidate
})
pc.addIceCandidate(candidate1).then(() => {
console.log('Successed to add ice candidate')
})
.catch(err => {
console.error(err)
})
} else if (type === 'answer') {
pc.setRemoteDescription(new RTCSessionDescription({ type, sdp })) // 生成描述端连接的SDP应答并发送到对端
console.log('传输接收方(应答)SDP')
}
}
if (code === '3') { // 对方挂断收到的code码
console.log('对方已挂断')
closeLocalMedia()
}
}
}
/* 发送到对等端,以启动与远程对等端的新WebRTC连接 */
const call = async () => {
const offerOptions = {
offerToRecieveAudio: 1,
offerToRecieveVideo: 1
}
const offer = await pc.createOffer(offerOptions)
await pc.setLocalDescription(offer).catch(handleOfferError)
console.log('传输发起方本地SDP', offer)
sendMessage(offer)
}
function closeLocalMedia () { // 关闭媒体流
if (!(localStream === null || localStream === undefined)) {
localStream.getTracks().forEach((track) => {
track.stop()
})
console.log('关闭媒体流,挂断成功')
}
localStream = null
// localVideo.value.srcObeject = null
}
function getAnswer (desc) { // 接收应答
pc.setLocalDescription(desc)
// send answer sdp
sendMessage(desc)
}
function handleOfferError (err) {
console.error('Failed to create offer:', err)
}
到此p2p连接就完全ok了,可以愉快的和朋友语音视频对讲了😎,请注意是在内网下哈,外网需要在创建pc实例的时候传入serverConfig,这是SDK的服务了,有兴趣的朋友可以再去研究一下,若还需要添加通话录音功能可根据mdn文档api再进行封装改造,这个业务需求我司是晚点考虑的,实现之后再出一篇文章单独讲解,这里的实现几乎用的都是原生的写法,如果是单独只做web版的话可以用第三方库,像PeerJs
库就是专门对WebRTC封装的,用法及其简单,后面我也去试了一下确实真香,但我司的是web端与安卓端的语音对讲,所以不太使用,为了统一适用只能使用原生的了。不然可以省出一大把时间开始摸鱼学习😭😭。
分享到此结束了,欢迎大家评论探讨,如有不太对的地方还请指点😁。
转载自:https://juejin.cn/post/7205050701810647077