灏天阁

从 0 搭建 WebRTC 实现多房间多对多通话,实现屏幕录制

· Yin灏

这篇文章开始会实现一个一对一 WebRTC 和多对多的 WebRTC,以及基于屏幕共享的录制。本篇会实现信令和前端部分,信令使用 fastity 来搭建,前端部分使用 Vue3 来实现。

为什么要使用 WebRTC

WebRTC 全称 Web Real-Time Communication,是一种实时音视频的技术,它的优势是低延时。

本篇文章使用者要求

环境搭建及要求

废话不多说,现在开始搭建环境,首先是需要开启 socket 服务,采用的是 fastify 来进行搭建。详情可以见文档地址,本例使用的是 3.x 来启动的。接下来安装fastify-socket.io3.0.0 插件,详细配置可以见文档,此处不做详细解释。接下来是搭建 Vue3,使用 vite 脚手架搭建简单的 demo。

要求:前端服务运行在 localhost 或者 https 下。node 需要 redis 进行数据缓存

获取音视频

要实现实时音视频第一步当然是要能获取到视频流,在这里我们使用浏览器提供的 API,MediaDevices 来进行摄像头流的捕获

enumerateDevices

第一个要介绍的 API 是enumerateDevices,是请求一个可用的媒体输入和输出设备的列表,例如麦克风,摄像机,耳机设备等。直接在控制台执行 API,获取的设备如图

image.png

我们注意到里面返回的设备 ID 和 label 是空的,这是由于浏览器的安全策略限制,必须授权摄像头或麦克风才能允许返回设备 ID 和设备标签,接下来我们介绍如何请求摄像头和麦克风

getUserMedia

这个 API 顾名思义,就是去获取用户的 Meida 的,那我们直接执行这个 API 来看看效果

ps: 由于掘金的代码片段的 iframe 没有配置allow="display-capture *;microphone *; camera *"属性,需要手动打开详情查看效果

通过上述例子我们可以获取到本机的音视频画面,并且可以播放在 video 标签里,那么我们可以在获取了用户的流之后,重新再获取一次设备列表看看发生了什么变化

image.png

在获取了音视频之后,获取的设备列表的详细信息已经出现,我们就可以获取指定设备的音视频数据,详情可以见

这里介绍一下 getUserMedia 的参数 constraints,

视频参数配置

interface MediaTrackConstraintSet {
  // 画面比例
  aspectRatio?: ConstrainDouble;
  // 设备ID,可以从enumerateDevices中获取
  deviceId?: ConstrainDOMString;
  // 摄像头前后置模式,一般适用于手机
  facingMode?: ConstrainDOMString;
  // 帧率,采集视频的目标帧率
  frameRate?: ConstrainDouble;
  // 组ID,用一个设备的输入输出的组ID是同一个
  groupId?: ConstrainDOMString;
  // 视频高度
  height?: ConstrainULong;
  // 视频宽度
  width?: ConstrainULong;
}

音频参数配置

interface MediaTrackConstraintSet {
  // 是否开启AGC自动增益,可以在原有音量上增加额外的音量
  autoGainControl?: ConstrainBoolean;
  // 声道配置
  channelCount?: ConstrainULong;
  // 设备ID,可以从enumerateDevices中获取
  deviceId?: ConstrainDOMString;
  // 是否开启回声消除
  echoCancellation?: ConstrainBoolean;
  // 组ID,用一个设备的输入输出的组ID是同一个
  groupId?: ConstrainDOMString;
  // 延迟大小
  latency?: ConstrainDouble;
  // 是否开启降噪
  noiseSuppression?: ConstrainBoolean;
  // 采样率单位Hz
  sampleRate?: ConstrainULong;
  // 采样大小,单位位
  sampleSize?: ConstrainULong;
  // 本地音频在本地扬声器播放
  suppressLocalAudioPlayback?: ConstrainBoolean;
}

一对一连接

当我们采集到了音视频数据,接下来就是要建立链接,在开始之前需要科普一下 WebRTC 的工作方式,我们常见有三种 WebRTC 的网络结构

  1. Mesh
  2. MCU
  3. SFU 关于这三种模式的区别可以查看 文章来了解

在这里由于设备的限制,我们采用 Mesh 的方案来进行开发

一对一的流程

我们建立一对一的链接需要知道后流程是怎么流转的,接下来上一张图,便可以清晰的了解

1825097218-5db028f8d5205.webp

这里是由 ClientA 发起 B 来接受 A 的视频数据。上图总结可以为 A 创建本地视频流,把视频流添加到 PeerConnection 里面 创建一个 Offer 给 B,B 收到 Offer 以后,保存这个 offer,并响应这个 Offer 给 A,A 收到 B 的响应后保存 A 的远端响应,进行 NAT 穿透,完成链接建立。

话已经讲了这么多,我们该怎么建立呢,光说不做假把式,接下来,用我们的项目创建一个来试试

初始化

首先启动 fastify 服务,接下来在 Vue 项目安装socket.io-client@4然后连接服务端的 socket

import { v4 as uuid } from "uuid";
import { io, Socket } from "socket.io-client";
const myUserId = ref(uuid());
let socket: Socket;
socket = io("http://127.0.0.1:7070", {
  query: {
    // 房间号,由输入框输入获得
    room: room.value,
    // userId通过uuid获取
    userId: myUserId.value,
    // 昵称,由输入框输入获得
    nick: nick.value,
  },
});

可以查看 chrome 的控制台,检查 ws 的链接情况,如果出现跨域,请查看 socket.io 的 server 配置并开启 cors 配置。

创建 offer

开始创建 RTCPeerConnection,这里采用 google 的公共 stun 服务

const peerConnect = new RTCPeerConnection({
  iceServers: [
    {
      urls: "stun:stun.l.google.com:19302",
    },
  ],
});

根据上面的流程图我们下一步要做的事情是用上面的方式获取视频流,并将获取到的流添加到 RTCPeerConnection 中,并创建 offer,把这个 offer 设置到这个 rtcPeer 中,并把 offer 发送给 socket 服务

let localStream: MediaStream;

stream.getTracks().forEach((track) => {
  peerConnect.addTrack(track, stream);
});

const offer = await peerConnect.createOffer();
await peerConnect.setLocalDescription(offer);
socket.emit(
  "offer",
  { creatorUserId: myUserId.value, sdp: offer },
  (res: any) => {
    console.log(res);
  }
);

socket 服务收到了这份 offer 后需要给 B 发送 A 的 offer

fastify.io.on("connection", async (socket) => {
  socket.on("offer", async (offer, callback) => {
    socket.emit("offer", offer);
    callback({
      status: "ok",
    });
  });
});

处理 offer

B 需要监听 socket 里面的 offer 事件并创建 RTCPeerConnection,将这个 offer 设置到远端,接下来来创建响应。并且将这个响应设置到本地,发送 answer 事件回复给 A

socket.on(
  "offer",
  async (offer: { sdp: RTCSessionDescriptionInit; creatorUserId: string }) => {
    const peerConnect = new RTCPeerConnection({
      iceServers: [
        {
          urls: "stun:stun.l.google.com:19302",
        },
      ],
    });

    await peerConnect.setRemoteDescription(offer.sdp);
    const answer = await peerConnect.createAnswer();
    await peerConnect.setLocalDescription(answer);
    socket.emit("answer", { sdp: answer }, (res: any) => {
      console.log(res);
    });
  }
);

处理 answer

服务端广播 answer

socket.on("offer", async (offer, callback) => {
  socket.emit("offer", offer);
  callback({
    status: "ok",
  });
});

A 监听到 socket 里面的 answer 事件,需要将刚才的自己的 RTCpeer 添加远端描述

socket.on("answer", async (data: { sdp: RTCSessionDescriptionInit }) => {
  await peerConnect.setRemoteDescription(data.sdp);
});

处理 ICE-candidate

接下来 A 会获取到 ICE 候选信息,需要发送给 B

peerConnect.onicecandidate = (candidateInfo: RTCPeerConnectionIceEvent) => {
  if (candidateInfo.candidate) {
    socket.emit(
      "ICE-candidate",
      { sdp: candidateInfo.candidate },
      (res: any) => {
        console.log(res);
      }
    );
  }
};

广播消息是同理这里就不再赘述了,B 获取到了 A 的 ICE,需要设置候选

socket.on("ICE-candidate", async (data: { sdp: RTCIceCandidate }) => {
  await peerConnect.addIceCandidate(data.sdp);
});

接下来 B 也会获取到 ICE 候选信息,同理需要发送给 A,待 A 设置完成之后便可以建立链接,代码同上,B 接下来会收到流添加的事件,这个事件会有两次,分别是音频和视频的数据

处理音视频数据

peerConnect.ontrack = (track: RTCTrackEvent) => {
  if (track.track.kind === "video") {
    const video = document.createElement("video");
    video.srcObject = track.streams[0];
    video.autoplay = true;
    video.style.setProperty("width", "400px");
    video.style.setProperty("aspect-ratio", "16 / 9");
    video.setAttribute("id", track.track.id);
    document.body.appendChild(video);
  }
  if (track.track.kind === "audio") {
    const audio = document.createElement("audio");
    audio.srcObject = track.streams[0];
    audio.autoplay = true;
    audio.setAttribute("id", track.track.id);
    document.body.appendChild(audio);
  }
};

到这里你就可以见到两个视频建立的 P2P 链接了。到这里为止只是建立了视频的一对一链接,但是我们可以通过这些操作进行复制,就能进行多对多的连接了。

多对多连接

在开始我们需要知道,一个人和另一个人建立连接双方都需要创建自己的 peerConnection。对于多人的情况,首先我们需要知道进入的房间里面当前的人数,给每个人都创建一个 RtcPeer,同时收到的人也回复这个 offer 给发起的人。对于后进入的人,需要让已经创建音视频的人给后进入的人创建新的 offer。

基于上面的流程,我们现在先实现一个成员列表的接口

成员列表的接口

在我们登录 socket 服务的时候我们在 query 参数里面有房间号,userId 和昵称,我们可以通过 redis 记录对应的房间号的登录和登出,从而实现成员列表。

可以在某一个人登录的时候获取一下 redis 对应房间的成员列表,如果没有这个房间,就把这个人丢进新的房间,并且存储到 redis 中,方便其他人登录这个房间的时候知道现在有多少人。

fastify.io.on("connection", async (socket) => {
  const room = socket.handshake.query.room;
  const redis = fastify.redis;
  let userList;
  // 获取当前房间的数据
  await getUserList();

  async function getUserList() {
    const roomUser = await redis.get(room);
    if (roomUser) {
      userList = new Map(JSON.parse(roomUser));
    } else {
      userList = new Map();
    }
  }

  async function setRedisRoom() {
    await redis.set(room, JSON.stringify([...userList]));
  }

  function rmUser(userId) {
    userList.delete(userId);
  }

  if (room) {
    // 将这人加入到对应的socket房间
    socket.join(room);
    await setRedisRoom();
    // 广播有人加入了
    socket.to(room).emit("join", userId);
  }
  // 这个人断开了链接需要将这个人从redis中删除
  socket.on("disconnect", async (socket) => {
    await getUserList();
    rmUser(userId);
    await setRedisRoom();
  });
});

到上面为止,我们实现了成员的记录、广播和删除。接下来是需要实现一个成员列表的接口,提供给前端项目调用。

fastify.get("/userlist", async function (request, reply) {
  const redis = fastify.redis;
  return await redis.get(request.query.room);
});

多对多初始化

由于需要给每个人发送 offer,需要对上面的初始化函数进行封装。

/**
 * 创建RTCPeerConnection
 * @param creatorUserId 创建者id,本人
 * @param recUserId 接收者id
 */
const initPeer = async (creatorUserId: string, recUserId: string) => {
  const peerConnect = new RTCPeerConnection({
    iceServers: [
      {
        urls: "stun:stun.l.google.com:19302"
      }
    ]
  })
  return peerConnect;
})

由于存在多份 rtc 的映射关系,我们这里可以用 Map 来实现映射的保存

const peerConnectList = new Map();

const initPeer = () => {
   // ice,track,new Peer等其他代码
   ......
   peerConnectList.set(`${creatorUserId}_${recUserId}`, peerConnect);
}

获取成员列表

上面实现了成员列表。接下来进入了对应的房间后需要轮询获取对应的成员列表

let userList = ref([]);
const intoRoom = () => {
    //其他代码
    ......

    setInterval(()=>{
      axios.get('/userlist', { params: { room: room.value }}).then((res)=>{
        userList.value = res.data
      })
    }, 1000)
}

创建多对多的 Offer 和 Answer

在我们获取到视频流的时候,可以对在线列表里除了自己的人都创建一个 RTCpeer,来进行一对一连接,从而达到多对多连接的效果。

// 过滤自己
const emitList = userList.value.filter((item) => item[0] !== myUserId.value);
for (const item of emitList) {
  // item[0]就是目标人的userId
  const peer = await initPeer(myUserId.value, item[0]);
  await createOffer(item[0], peer);
}

const createOffer = async (
  recUserId: string,
  peerConnect: RTCPeerConnection,
  stream: MediaStream = localStream
) => {
  if (!localStream) return;
  stream.getTracks().forEach((track) => {
    peerConnect.addTrack(track, stream);
  });
  const offer = await peerConnect.createOffer();
  await peerConnect.setLocalDescription(offer);
  socket.emit(
    "offer",
    { creatorUserId: myUserId.value, sdp: offer, recUserId },
    (res: any) => {
      console.log(res);
    }
  );
};

那么在 socket 服务中我们怎么只给对应的人进行事件广播,不对其他人进行广播,我们可以用找到这个人 userId 对应的 socketId,进而只给这一个人广播事件。

// 首先获取IO对应的nameSpace
const IONameSpace = fastify.io.of("/");

// 发送Offer给对应的人
socket.on("offer", async (offer, callback) => {
  // 重新从reids获取用户列表
  await getUserList();
  // 找到目标的UserId的数据
  const user = userList.get(offer.recUserId);
  if (user) {
    // 找到对应的socketId
    const io = IONameSpace.sockets.get(user.sockId);
    if (!io) return;
    io.emit("offer", offer);
    callback({
      status: "ok",
    });
  }
});

其他人需要监听 socket 的事件,每个人都需要处理对应自己的 offer。

socket.on("offer", handleOffer);
const handleOffer = async (offer: {
  sdp: RTCSessionDescriptionInit;
  creatorUserId: string;
  recUserId: string;
}) => {
  const peer = await initPeer(offer.creatorUserId, offer.recUserId);
  await peer.setRemoteDescription(offer.sdp);
  const answer = await peer.createAnswer();
  await peer.setLocalDescription(answer);
  socket.emit(
    "answer",
    {
      recUserId: myUserId.value,
      sdp: answer,
      creatorUserId: offer.creatorUserId,
    },
    (res: any) => {
      console.log(res);
    }
  );
};

接下来的步骤其实就是和一对一是一样的了,后面还需要发起 offer 的人处理对应 peer 的 offer、以及 ICE 候选,还有流进行挂载播放。

socket.on('answer', handleAnswer)
// 应答方回复
const handleAnswer = async (data: { sdp: RTCSessionDescriptionInit, recUserId: string, creatorUserId: string }) => {
  const peer = peerConnectList.get(`${data.creatorUserId}_${data.recUserId}`);
  if (!peer) {
    console.warn('handleAnswer peer 获取失败')
    return;
  }
  await peer.setRemoteDescription(data.sdp)
}
// ......处理播放,处理ICE候选

到目前为止,就实现了一个基于 mesh 的 WebRTC 的多对多通信。在这里附上了一个完整的 Demo 可供参考 socketServer FontPage

基于 WebRTC 的屏幕录制

getDisplayMedia

这个 API 是在 MediaDevices 里面的一个方法,是用来获取屏幕共享的。

这个  MediaDevices   接口的  getDisplayMedia() 方法提示用户去选择和授权捕获展示的内容或部分内容(如一个窗口)在一个  MediaStream  里.  然后,这个媒体流可以通过使用  MediaStream Recording API  被记录或者作为WebRTC  会话的一部分被传输。

await navigator.mediaDevices.getDisplayMedia();

MediaRecorder

获取到屏幕共享流后,需要使用 MediaRecorder这个 api 来对流进行录制,接下来我们先获取屏幕流,同时创建一个 MeidaRecord 类

let screenStream: MediaStream;
let mediaRecord: MediaRecorder;
let blobMedia: Blob[] = [];
const startLocalRecord = async () => {
  blobMedia = [];
  try {
    screenStream = await navigator.mediaDevices.getDisplayMedia();
    screenStream.getVideoTracks()[0].addEventListener("ended", () => {
      console.log("用户中断了屏幕共享");
      endLocalRecord();
    });

    mediaRecord = new MediaRecorder(screenStream, { mimeType: "video/webm" });

    mediaRecord.ondataavailable = (e) => {
      if (e.data && e.data.size > 0) {
        blobMedia.push(e.data);
      }
    };

    // 500是每隔500ms进行一个保存数据
    mediaRecord.start(500);
  } catch (e) {
    console.log(`屏幕共享失败->${e}`);
  }
};

获取到了之后可以使用 Blob 进行处理

const replayLocalRecord = async () => {
  if (blobMedia.length) {
    const scVideo = document.querySelector("#screenVideo") as HTMLVideoElement;
    const blob = new Blob(blobMedia, { type: "video/webm" });
    if (scVideo) {
      scVideo.src = URL.createObjectURL(blob);
    }
  } else {
    console.log("没有录制文件");
  }
};

const downloadLocalRecord = async () => {
  if (!blobMedia.length) {
    console.log("没有录制文件");
    return;
  }
  const blob = new Blob(blobMedia, { type: "video/webm" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `录屏_${Date.now()}.webm`;
  a.click();
};

这里有一个基于 Vue2 的完整例子

ps: 由于掘金的代码片段的 iframe 没有配置allow="display-capture *;microphone *; camera *"属性,需要手动打开详情查看效果

后续将会更新,WebRTC 的自动化测试,视频画中画,视频截图等功能

- Book Lists -