基于WebRTC實現(xiàn)音視頻通話功能
隨著互聯(lián)網(wǎng)的發(fā)展,實時音視頻通話功能已經(jīng)成為遠(yuǎn)程辦公、社交娛樂和在線教育等領(lǐng)域中不可或缺的一項重要功能。WebRTC作為一種開放標(biāo)準(zhǔn)的實時通信協(xié)議,能輕松實現(xiàn)瀏覽器之間的實時音視頻通信。
本次主要分享基于WebRTC的音視頻通話技術(shù),講解WebRTC原理和音視頻傳輸?shù)汝P(guān)鍵概念,
通過案例實踐,帶大家掌握如何搭建一個音視頻通話應(yīng)用。
背景
隨著互聯(lián)網(wǎng)技術(shù)的飛速發(fā)展,實時音視頻通話已經(jīng)成為在線教育、遠(yuǎn)程辦公、社交媒體等領(lǐng)域的核心且常用的功能。WebRTC(Web Real-Time Communication)作為一項開放的實時通信標(biāo)準(zhǔn),為開發(fā)者提供了快速構(gòu)建實時音視頻通話系統(tǒng)的能力。在本課程中,我們將從0到1使用 WebRTC 構(gòu)建一個基于 P2P 架構(gòu)的音視頻通話的應(yīng)用案例。
應(yīng)用場景
- 點對點視頻聊天:如 微信視頻 等實時視頻通話應(yīng)用。
- 多人視頻會議:企業(yè)級多人視頻會議系統(tǒng),如飛書、釘釘、騰訊會議等。
- 在線教育:如騰訊課堂、網(wǎng)易云課堂等。
- 直播:游戲直播、課程直播等。
P2P通信原理
P2P 通信即點對點通信。
要實現(xiàn)兩個客戶端的實時音視頻通信,并且這兩個客戶端可能處于不同網(wǎng)絡(luò)環(huán)境,使用不同的設(shè)備,都需要解決哪些問題?
主要是下面這 3 個問題:
- 如何發(fā)現(xiàn)對方?
- 不同的音視頻編解碼能力如何溝通?
- 如何聯(lián)系上對方?
下面我們將逐個討論這 3 個問題。
如何發(fā)現(xiàn)對方?
在 P2P 通信的過程中,雙方需要交換一些元數(shù)據(jù)比如媒體信息、網(wǎng)絡(luò)數(shù)據(jù)等等信息,我們通常稱這一過程叫做“信令(signaling)”。
對應(yīng)的服務(wù)器即“信令服務(wù)器 (signaling server)”,通常也有人將之稱為“房間服務(wù)器”,因為它不僅可以交換彼此的媒體信息和網(wǎng)絡(luò)信息,同樣也可以管理房間信息。
比如:
1)通知彼此 who 加入了房間;2)who 離開了房間 3)告訴第三方房間人數(shù)是否已滿是否可以加入房間。
為了避免出現(xiàn)冗余,并最大限度地提高與已有技術(shù)的兼容性,WebRTC 標(biāo)準(zhǔn)并沒有規(guī)定信令方法和協(xié)議。在本課程中會使用websocket來搭建一個信令服務(wù)器
不同的音視頻編解碼能力如何溝通?
不同瀏覽器對于音視頻的編解碼能力是不同的。
比如: 以日常生活中的例子來講,小李會講漢語和英語,而小王會講漢語和法語。為了保證雙方都可以正確的理解對方的意思,最簡單的辦法即取他們都會的語言,也就是漢語來溝通。
在 WebRTC 中:有一個專門的協(xié)議,稱為 Session Description Protocol(SDP),可以用于描述上述這類信息。
因此:參與音視頻通訊的雙方想要了解對方支持的媒體格式,必須要交換 SDP 信息。而交換 SDP 的過程,通常稱之為媒體協(xié)商。
如何聯(lián)系上對方?
其實就是網(wǎng)絡(luò)協(xié)商的過程,即參與音視頻實時通信的雙方要了解彼此的網(wǎng)絡(luò)情況,這樣才有可能找到一條相互通訊的鏈路。
理想的網(wǎng)絡(luò)情況是每個客戶端都有自己的私有公網(wǎng) IP 地址,這樣的話就可以直接進(jìn)行點對點連接。實際上呢,出于網(wǎng)絡(luò)安全和其他原因的考慮,大多數(shù)客戶端之間都是在某個局域網(wǎng)內(nèi),需要網(wǎng)絡(luò)地址轉(zhuǎn)換(NAT)。
在 WebRTC 中我們使用 ICE 機(jī)制建立網(wǎng)絡(luò)連接。ICE 協(xié)議通過一系列的技術(shù)(如 STUN、TURN 服務(wù)器)幫助通信雙方發(fā)現(xiàn)和協(xié)商可用的公共網(wǎng)絡(luò)地址,從而實現(xiàn) NAT 穿越。
ICE 的工作原理如下:
- 首先,通信雙方收集本地網(wǎng)絡(luò)地址(包括私有地址和公共地址)以及通過 STUN 和 TURN 服務(wù)器獲取的候選地址。
- 接下來,雙方通過信令服務(wù)器交換這些候選地址。
- 通信雙方使用這些候選地址進(jìn)行連接測試,確定最佳的可用地址。
- 一旦找到可用的地址,通信雙方就可以開始實時音視頻通話。
在 WebRTC 中網(wǎng)絡(luò)信息通常用candidate來描述
針對上面三個問題的總結(jié):就是通過 WebRTC 提供的 API 獲取各端的媒體信息 SDP 以及 網(wǎng)絡(luò)信息 candidate ,并通過信令服務(wù)器交換,進(jìn)而建立了兩端的連接通道完成實時視頻語音通話。
常用的API
音視頻采集getUserMedia
// 獲取本地音視頻流 const getLocalStream = async () => { const stream = await navigator.mediaDevices.getUserMedia({ // 獲取音視頻流 audio: true, video: true }) localVideo.value!.srcObject = stream localVideo.value!.play() return stream }
核心對象 RTCPeerConnection
RTCPeerConnection 作為創(chuàng)建點對點連接的 API,是我們實現(xiàn)音視頻實時通信的關(guān)鍵。
const peer = new RTCPeerConnection({ // iceServers: [ // { url: "stun:stun.l.google.com:19302" }, // 谷歌的公共服務(wù) // { // urls: "turn:***", // credential: "***", // username: "***", // }, // ], });
主要會用到以下幾個方法:
媒體協(xié)商方法:
- createOffer
- createAnswer
- setLocalDesccription
- setRemoteDesccription
重要事件:
- onicecandidate
- onaddstream
整個媒體協(xié)商過程可以簡化為三個步驟對應(yīng)上述四個媒體協(xié)商方法:
- 呼叫端創(chuàng)建 Offer(createOffer)并將 offer 消息(內(nèi)容是呼叫端的 SDP 信息)通過信令服務(wù)器傳送給接收端,同時調(diào)用 setLocalDesccription 將含有本地 SDP 信息的 Offer 保存起來
- 接收端收到對端的 Offer 信息后調(diào)用 setRemoteDesccription 方法將含有對端 SDP 信息的 Offer 保存起來,并創(chuàng)建 Answer(createAnswer)并將 Answer 消息(內(nèi)容是接收端的 SDP 信息)通過信令服務(wù)器傳送給呼叫端
- 呼叫端收到對端的 Answer 信息后調(diào)用 setRemoteDesccription 方法將含有對端 SDP 信息的 Answer 保存起來
經(jīng)過上述三個步驟,則完成了 P2P 通信過程中的媒體協(xié)商部分,實際上在呼叫端以及接收端調(diào)用setLocalDesccription 同時也開始了收集各端自己的網(wǎng)絡(luò)信息(candidate),然后各端通過監(jiān)聽事件 onicecandidate 收集到各自的 candidate 并通過信令服務(wù)器傳送給對端,進(jìn)而打通 P2P 通信的網(wǎng)絡(luò)通道,并通過監(jiān)聽 onaddstream 事件拿到對方的視頻流進(jìn)而完成了整個視頻通話過程。
實踐
項目搭建
前端項目 項目使用vue3+ts
,運行如下命令:
npm create vite@latest webrtc-client -- --template vue-ts
并且引入tailwindcss
:
npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p
在生成的 tailwind.config.js
配置文件中添加所有模板文件的路徑。
/** @type {import('tailwindcss').Config} */ module.exports = { content: [ "./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], }
修改style.css
中的內(nèi)容如下:
@tailwind base; @tailwind components; @tailwind utilities;
自定義修改App.vue
中的內(nèi)容如下:
<script lang="ts" setup> import { ref } from 'vue' const called = ref<boolean>(false) // 是否是接收方 const caller = ref<boolean>(false) // 是否是發(fā)起方 const calling = ref<boolean>(false) // 呼叫中 const communicating = ref<boolean>(false) // 視頻通話中 const localVideo = ref<HTMLVideoElement>() // video標(biāo)簽實例,播放本人的視頻 const remoteVideo = ref<HTMLVideoElement>() // video標(biāo)簽實例,播放對方的視頻 // 發(fā)起方發(fā)起視頻請求 const callRemote = () => { console.log('發(fā)起視頻'); } // 接收方同意視頻請求 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" >發(fā)起視頻</button> <button class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white" @click="hangUp" >掛斷視頻</button> </div> </div> </template>
執(zhí)行完上面的步驟就可以運行npm run dev
來在本地啟動項目了
后端項目
創(chuàng)建一個webrtc-server
的文件夾,執(zhí)行npm init
,一路回車即可,然后運行如下命令安裝socket.io
和nodemon
:
npm install socket.io nodemon
創(chuàng)建index.js
的文件,并添加如下內(nèi)容:
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('連接成功...') // 向客戶端發(fā)送連接成功的消息 sock.emit('connectionSuccess'); }) server.listen(3000, () => { console.log('服務(wù)器啟動成功'); });
在package.json
中添加start
命令,使用nodemon
啟動項目:
"scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "nodemon index.js" },
執(zhí)行完后運行npm run start
即在3000端口可啟動node服務(wù)了
前端連接信令服務(wù)器
前端需要安裝socket.io-client
, 并連接信令服務(wù)器:
<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'); // 對應(yīng)服務(wù)的端口 // 連接成功 sock.on('connectionSuccess', () => { console.log('連接成功') }); socket.value = sock; }) // ... </script>
發(fā)起視頻請求
角色:用戶A–發(fā)起方,用戶B–接收方
房間:類比聊天窗口
連接成功時加入房間:
// 前端代碼 const roomId = '001' sock.on('connectionSuccess', () => { console.log('連接服務(wù)器成功...'); sock.emit('joinRoom', roomId) // 前端發(fā)送加入房間事件 }) // 服務(wù)端代碼 sock.on('joinRoom', (roomId) => { sock.join(roomId) // 加入房間 })
用戶A發(fā)起視頻請求并通知用戶B: 用戶A發(fā)起視頻請求,并且通過信令服務(wù)器通知用戶B
// 發(fā)起方發(fā)起視頻請求 const callRemote = async () => { console.log('發(fā)起視頻'); caller.value = true; calling.value = true; await getLocalStream() // 向信令服務(wù)器發(fā)送發(fā)起請求的事件 socket.value?.emit('callRemote', roomId) }
用戶B同意視頻請求,并且通過信令服務(wù)器通知用戶A
// 接收方同意視頻請求 const acceptCall = () => { console.log('同意視頻邀請'); socket.value?.emit('acceptCall', roomId) }
開始交換 SDP 信息和 candidate 信息: 用戶A創(chuàng)建創(chuàng)建RTCPeerConnection,添加本地音視頻流,生成offer,并且通過信令服務(wù)器將offer發(fā)送給用戶B
// 創(chuàng)建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); // 設(shè)置本地描述的offer await peer.value.setLocalDescription(offer); // 通過信令服務(wù)器將offer發(fā)送給用戶B socket.value?.emit('sendOffer', { offer, roomId })
用戶B收到用戶A的offer
sock.on('sendOffer', (offer) => { if (called.value) { // 判斷接收方 console.log('收到offer', offer); } })
用戶B需要創(chuàng)建自己的RTCPeerConnection,添加本地音視頻流,設(shè)置遠(yuǎn)端描述信息,生成answer,并且通過信令服務(wù)器發(fā)送給用戶A
// 創(chuàng)建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); // 設(shè)置本地描述的offer await peer.value.setLocalDescription(offer); // 通過信令服務(wù)器將offer發(fā)送給用戶B socket.value?.emit('sendOffer', { offer, roomId })
用戶A收到用戶B的answer
sock.on('sendAnswer', (answer) => { if (caller.value) { // 判斷是否是發(fā)送方 // 設(shè)置遠(yuǎn)端answer信息 peer.value.setRemoteDescription(answer); } })
用戶A獲取candidate信息并且通過信令服務(wù)器發(fā)送candidate給用戶B
// 通過監(jiān)聽onicecandidate事件獲取candidate信息 peer.value.onicecandidate = (event: any) => { if (event.candidate) { console.log('用戶A獲取candidate信息', event.candidate); // 通過信令服務(wù)器發(fā)送candidate信息給用戶B socket.value?.emit('sendCandidate', { roomId, candidate: event.candidate }) } }
用戶B添加用戶A的candidate信息
// 添加candidate信息 sock.on('sendCandidate', async (candidate) => { await peer.value.addIceCandidate(candidate); })
用戶B獲取candidate信息并且通過信令服務(wù)器發(fā)送candidate給用戶A(如上)
peer.value.onicecandidate = (event: any) => { if (event.candidate) { console.log('用戶B獲取candidate信息', event.candidate); // 通過信令服務(wù)器發(fā)送candidate信息給用戶A socket.value?.emit('sendCandidate', { roomId, candidate: event.candidate }) } }
用戶A添加用戶B的candidate信息(如上)
// 添加candidate信息 sock.on('sendCandidate', async (candidate) => { await peer.value.addIceCandidate(candidate); })
接下來用戶A和用戶B就可以進(jìn)行P2P通信流
// 監(jiān)聽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) } // 狀態(tài)復(fù)原 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
服務(wù)端實現(xiàn)
// 使用peer搭建信令服務(wù)器 const { PeerServer } = require('peer'); const peerServer = PeerServer({ port: 3001, path: '/myPeerServer' });
前端實現(xiàn)
<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({ // 連接信令服務(wù)器 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) // 將媒體流設(shè)置到 video 標(biāo)簽上播放 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 // 將遠(yuǎn)程媒體流添加到 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 }) // 將本地媒體流發(fā)送給遠(yuǎn)程 Peer const call = peer.value.call(remoteId.value, stream); caller.value = true call.on('stream', (remoteStream: any) => { caller.value = false // 將遠(yuǎn)程媒體流添加到 video 元素中 remoteVideo.value!.srcObject = remoteStream; remoteVideo.value!.play(); }); } </script>
到此這篇關(guān)于基于WebRTC實現(xiàn)音視頻通話的文章就介紹到這了,更多相關(guān)WebRTC音視頻通話內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
vue模板配置與webstorm代碼格式規(guī)范設(shè)置
這篇文章主要介紹了vue模板配置與webstorm代碼格式規(guī)范設(shè)置詳細(xì)的相關(guān)資料,需要的朋友可以參考一下文章得具體內(nèi)容,希望對你有所幫助2021-10-10vue3中el-table實現(xiàn)表格合計行的示例代碼
這篇文章主要介紹了vue3中el-table實現(xiàn)表格合計行,本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2024-01-01Vue自定義過濾器格式化數(shù)字三位加一逗號實現(xiàn)代碼
這篇文章主要介紹了Vue自定義過濾器格式化數(shù)字三位加一逗號的實現(xiàn)代碼,需要的朋友可以參考下2018-03-03vue表格n-form中自定義增加必填星號的實現(xiàn)代碼
這篇文章主要介紹了vue表格n-form中自定義增加必填星號,本文通過實例代碼給大家介紹的非常詳細(xì),需要的朋友可以參考下2024-12-12vue-echarts高度縮小時autoresize失效的原因和解決辦法
Vue-Echarts是一個基于ECharts封裝的輕量級、易用的圖表組件庫,它允許你在Vue.js應(yīng)用中方便地集成ECharts,這是一個強(qiáng)大而直觀的數(shù)據(jù)可視化庫,本文給大家介紹了vue-echarts高度縮小時autoresize失效的原因和解決辦法,需要的朋友可以參考下2024-12-12vue播放flv、m3u8視頻流(監(jiān)控)的方法實例
隨著前端大屏頁面的逐漸壯大,客戶的...其中實時播放監(jiān)控的需求逐步增加,視頻流格式也是有很多種,用到最多的.flv、.m3u8,下面這篇文章主要給大家介紹了關(guān)于vue播放flv、m3u8視頻流(監(jiān)控)的相關(guān)資料,需要的朋友可以參考下2023-04-04