草庐IT

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

音视频开发老马 2023-11-20 原文

这篇文章开始会实现一个一对一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,获取的设备如图

我们注意到里面返回的设备ID和label是空的,这是由于浏览器的安全策略限制,必须授权摄像头或麦克风才能允许返回设备ID和设备标签,接下来我们介绍如何请求摄像头和麦克风 getUserMedia 这个API顾名思义,就是去获取用户的Meida的,那我们直接执行这个API来看看效果 ps: 由于掘金的代码片段的iframe没有配置allow="display-capture *;microphone *; camera *"属性,需要手动打开详情查看效果

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

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

这里介绍一下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;
}

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

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

 

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

  1. Mesh

  2. MCU

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

在这里由于设备的限制,我们采用Mesh的方案来进行开发 一对一的流程 我们建立一对一的链接需要知道后流程是怎么流转的,接下来上一张图,便可以清晰的了解

这里是由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的自动化测试,视频画中画,视频截图等功能

作者:sxuan

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

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

 

有关从0搭建一个WebRTC,实现多房间多对多通话,并实现屏幕录制的更多相关文章

  1. ruby - 使用 Vim Rails,您可以创建一个新的迁移文件并一次性打开它吗? - 2

    使用带有Rails插件的vim,您可以创建一个迁移文件,然后一次性打开该文件吗?textmate也可以这样吗? 最佳答案 你可以使用rails.vim然后做类似的事情::Rgeneratemigratonadd_foo_to_bar插件将打开迁移生成的文件,这正是您想要的。我不能代表textmate。 关于ruby-使用VimRails,您可以创建一个新的迁移文件并一次性打开它吗?,我们在StackOverflow上找到一个类似的问题: https://sta

  2. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  3. ruby-on-rails - 渲染另一个 Controller 的 View - 2

    我想要做的是有2个不同的Controller,client和test_client。客户端Controller已经构建,我想创建一个test_clientController,我可以使用它来玩弄客户端的UI并根据需要进行调整。我主要是想绕过我在客户端中内置的验证及其对加载数据的管理Controller的依赖。所以我希望test_clientController加载示例数据集,然后呈现客户端Controller的索引View,以便我可以调整客户端UI。就是这样。我在test_clients索引方法中试过这个:classTestClientdefindexrender:template=>

  4. ruby-on-rails - 如果 Object::try 被发送到一个 nil 对象,为什么它会起作用? - 2

    如果您尝试在Ruby中的nil对象上调用方法,则会出现NoMethodError异常并显示消息:"undefinedmethod‘...’fornil:NilClass"然而,有一个tryRails中的方法,如果它被发送到一个nil对象,它只返回nil:require'rubygems'require'active_support/all'nil.try(:nonexisting_method)#noNoMethodErrorexceptionanymore那么try如何在内部工作以防止该异常? 最佳答案 像Ruby中的所有其他对象

  5. ruby - 为什么 SecureRandom.uuid 创建一个唯一的字符串? - 2

    关闭。这个问题需要detailsorclarity.它目前不接受答案。想改进这个问题吗?通过editingthispost添加细节并澄清问题.关闭8年前。Improvethisquestion为什么SecureRandom.uuid创建一个唯一的字符串?SecureRandom.uuid#=>"35cb4e30-54e1-49f9-b5ce-4134799eb2c0"SecureRandom.uuid方法创建的字符串从不重复?

  6. ruby - 如何根据特征实现 FactoryGirl 的条件行为 - 2

    我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden

  7. ruby-on-rails - Rails - 从另一个模型中创建一个模型的实例 - 2

    我有一个正在构建的应用程序,我需要一个模型来创建另一个模型的实例。我希望每辆车都有4个轮胎。汽车模型classCar轮胎模型classTire但是,在make_tires内部有一个错误,如果我为Tire尝试它,则没有用于创建或新建的activerecord方法。当我检查轮胎时,它没有这些方法。我该如何补救?错误是这样的:未定义的方法'create'forActiveRecord::AttributeMethods::Serialization::Tire::Module我测试了两个环境:测试和开发,它们都因相同的错误而失败。 最佳答案

  8. ruby - 用 Ruby 编写一个简单的网络服务器 - 2

    我想在Ruby中创建一个用于开发目的的极其简单的Web服务器(不,不想使用现成的解决方案)。代码如下:#!/usr/bin/rubyrequire'socket'server=TCPServer.new('127.0.0.1',8080)whileconnection=server.acceptheaders=[]length=0whileline=connection.getsheaders想法是从命令行运行这个脚本,提供另一个脚本,它将在其标准输入上获取请求,并在其标准输出上返回完整的响应。到目前为止一切顺利,但事实证明这真的很脆弱,因为它在第二个请求上中断并出现错误:/usr/b

  9. ruby - 一个 YAML 对象可以引用另一个吗? - 2

    我想让一个yaml对象引用另一个,如下所示:intro:"Hello,dearuser."registration:$introThanksforregistering!new_message:$introYouhaveanewmessage!上面的语法只是它如何工作的一个例子(这也是它在thiscpanmodule中的工作方式。)我正在使用标准的ruby​​yaml解析器。这可能吗? 最佳答案 一些yaml对象确实引用了其他对象:irb>require'yaml'#=>trueirb>str="hello"#=>"hello"ir

  10. ruby - Rails 关联 - 同一个类的多个 has_one 关系 - 2

    我的问题的一个例子是体育游戏。一场体育比赛有两支球队,一支主队和一支客队。我的事件记录模型如下:classTeam"Team"has_one:away_team,:class_name=>"Team"end我希望能够通过游戏访问一个团队,例如:Game.find(1).home_team但我收到一个单元化常量错误:Game::team。谁能告诉我我做错了什么?谢谢, 最佳答案 如果Gamehas_one:team那么Rails假设您的teams表有一个game_id列。不过,您想要的是games表有一个team_id列,在这种情况下

随机推荐