基于WebRTC实现音视频通话

随着互联网的发展,实时音视频通话功能已经成为远程办公、社交娱乐和在线教育等领域中不可或缺的一项重要功能。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 的工作原理如下:

  1. 首先,通信双方收集本地网络地址(包括私有地址和公共地址)以及通过 STUN 和 TURN 服务器获取的候选地址。
  2. 接下来,双方通过信令服务器交换这些候选地址。
  3. 通信双方使用这些候选地址进行连接测试,确定最佳的可用地址。
  4. 一旦找到可用的地址,通信双方就可以开始实时音视频通话。

在 WebRTC 中网络信息通常用candidate来描述

针对上面三个问题的总结:就是通过 WebRTC 提供的 API 获取各端的媒体信息 SDP 以及 网络信息 candidate ,并通过信令服务器交换,进而建立了两端的连接通道完成实时视频语音通话。

常用的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 作为创建点对点连接的 API,是我们实现音视频实时通信的关键。

const peer = new RTCPeerConnection({
  // iceServers: [
  //   { url: "stun:stun.l.google.com:19302" }, // 谷歌的公共服务
  //   {
  //     urls: "turn:***",
  //     credential: "***",
  //     username: "***",
  //   },
  // ],
});

主要会用到以下几个方法:

媒体协商方法:

  • createOffer
  • createAnswer
  • setLocalDesccription
  • setRemoteDesccription

重要事件:

  • onicecandidate
  • onaddstream
     

整个媒体协商过程可以简化为三个步骤对应上述四个媒体协商方法:

  1. 呼叫端创建 Offer(createOffer)并将 offer 消息(内容是呼叫端的 SDP 信息)通过信令服务器传送给接收端,同时调用 setLocalDesccription 将含有本地 SDP 信息的 Offer 保存起来
  2. 接收端收到对端的 Offer 信息后调用 setRemoteDesccription 方法将含有对端 SDP 信息的 Offer 保存起来,并创建 Answer(createAnswer)并将 Answer 消息(内容是接收端的 SDP 信息)通过信令服务器传送给呼叫端
  3. 呼叫端收到对端的 Answer 信息后调用 setRemoteDesccription 方法将含有对端 SDP 信息的 Answer 保存起来

经过上述三个步骤,则完成了 P2P 通信过程中的媒体协商部分,实际上在呼叫端以及接收端调用setLocalDesccription 同时也开始了收集各端自己的网络信息(candidate),然后各端通过监听事件 onicecandidate 收集到各自的 candidate 并通过信令服务器传送给对端,进而打通 P2P 通信的网络通道,并通过监听 onaddstream 事件拿到对方的视频流进而完成了整个视频通话过程。

实践

项目搭建

前端项目

  1. 项目使用vue3+ts,运行如下命令:
npm create vite@latest webrtc-client -- --template vue-ts

2. 并且引入tailwindcss:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

3. 在生成的 tailwind.config.js 配置文件中添加所有模板文件的路径。

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

4.修改style.css中的内容如下:

@tailwind base;
@tailwind components;
@tailwind utilities;

5.自定义修改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.ionodemon

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:

  1. 用户A发起视频请求,并且通过信令服务器通知用户B
// 发起方发起视频请求
const callRemote = async () => {
  console.log('发起视频');
  caller.value = true;
  calling.value = true;
  await getLocalStream()
	// 向信令服务器发送发起请求的事件
  socket.value?.emit('callRemote', roomId)
}

2. 用户B同意视频请求,并且通过信令服务器通知用户A

// 接收方同意视频请求
const acceptCall = () => {
  console.log('同意视频邀请');
  socket.value?.emit('acceptCall', roomId)
}

开始交换 SDP 信息和 candidate 信息:

  1. 用户A创建创建RTCPeerConnection,添加本地音视频流,生成offer,并且通过信令服务器将offer发送给用户B
// 创建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 })
 

2.用户B收到用户A的offer

sock.on('sendOffer', (offer) => {
  if (called.value) { // 判断接收方
    console.log('收到offer', offer);
  }
})

3.用户B需要创建自己的RTCPeerConnection,添加本地音视频流,设置远端描述信息,生成answer,并且通过信令服务器发送给用户A

// 创建自己的RTCPeerConnection
peer.value = new RTCPeerConnection()
// 添加本地音视频流
const stream = await getLocalStream()
peer.value.addStream(stream)
// 设置远端描述信息
await peer.value.setRemoteDescription(offer);
const answer = await peer.value.createAnswer()
console.log(answer);
await peer.value.setLocalDescription(answer);
// 发送answer给信令服务器
socket.value?.emit('sendAnswer', { answer, roomId })

4.用户A收到用户B的answer

sock.on('sendAnswer', (answer) => {
  if (caller.value) { // 判断是否是发送方
    // 设置远端answer信息
    peer.value.setRemoteDescription(answer);
  }
})

5.用户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
    })
  }
}

6. 用户B添加用户A的candidate信息

// 添加candidate信息
sock.on('sendCandidate', async (candidate) => {
  await peer.value.addIceCandidate(candidate);
})

7.用户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
    })
  }
}

8.用户A添加用户B的candidate信息(如上)

// 添加candidate信息
sock.on('sendCandidate', async (candidate) => {
  await peer.value.addIceCandidate(candidate);
})

9.接下来用户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>

教程:WebRTC实现音视频通话

原文 基于WebRTC实现音视频通话 - 知乎 

★文末名片可以免费领取音视频开发学习资料,内容包括(FFmpeg ,webRTC ,rtmp ,hls ,rtsp ,ffplay ,srs)以及音视频学习路线图等等。

见下方!↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

猜你喜欢

转载自blog.csdn.net/yinshipin007/article/details/131988337