Koa2+websocket+webrtc实现视频通话

上一篇文章已经介绍了Koa2+websocket实现简单的消息中心聊天功能,但单纯文字聊天在实际的应用中具有很大的局限性,因此笔者在此基础上,针对于消息中心可能需要视频通话的场景,进一步研究了一下webrtc技术,并基于websocket与webrtc来实现了一对多的视频通话,本文也将介绍koa2+websocket+ webrtc如何实现视频通话功能。

webrtc是什么

webrtc(Web Real-Time Communication),是由Google发起的网页实时通信解决方案,其中包含视频音频采集,编解码,数据传输,音视频展示等功能,我们可以通过技术快速地构建出一个音视频通讯应用。它允许网络应用在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流、音频流或其他任意数据的传输。传统的音视频通话采取的是推流/拉流的模式来实现的,在推流/拉流的过程中需要一台流媒体服务器作为中间站来进行流媒体数据的转发,那么基于此方案就会存在两个问题,一个是因为存在中间站的转发而产生较高的延迟,另一个是清晰度难以保证。

webrtc则不一样,它是浏览器端点对点的传输数据,不需要中间流媒体服务器来进行转发,这样它的延迟性必然会大大降低,另一方面因为传输路线更短,所以清晰度高的数据流也更容易到达而不会产生网络拥堵而卡顿的现象,所以相较于传统的方案,webrtc来实现音视频通话可以更好的兼顾清晰度和延迟性。

如何获取视频流

视频通话的第一步是,客户端发起通话时获取视频流。首先chrome浏览器获取视频流的需要调用getUserMedia这个API,如下👇

const stream = await navigator.mediaDevices.getUserMedia()

getUserMedia获取视频流时具有一定的使用条件,必须满足如下之一:

  • 域名是localhost
  • 协议是https

当两者都不满足时,此时的navigator.mediaDevices值为undefined。

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;
}

具体使用时可以进行如下配置:

const stream = await navigator.mediaDevices.getUserMedia({
  autio: {
    latency: true,
    suppressLocalAudioPlayback: false,
    ...
  },
  video: {
    width: 1920,
    height: 1080,
    ...
  }
})

点对点连接的建立

webrtc的点对点连接的建立核心是基于RTCPeerConnection函数实现的,浏览器之间的一对一连接实际上是两个RTCPeerConnection实例的连接和通信。

因此第一步是创建连接实例👇

const peerA = new RTCPeerConnection();
const peerB = new RTCPeerConnection();

两个RTCPeerConnection实例之间的数据只能单向传输,即peerA作为发起端时会获取本地视频流数据并添加到实例,peerB则设置监听函数,获取peerA的视频流数据👇

const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });

localStream.getTracks().forEach(track => {
  peerA.addTrack(track, localStream);
});

peerB.ontrack = e => {
  if (e && e.streams) {
    console.log(e.streams[0]);
  }
};

第二步则是建立对等连接

上一步是如何添加与获取数据,但只有两个RTCPeerConnection实例建立了对等连接之后,端对端的通信才算打通。而对等连接的建立是基于SDP(RTCSessionDescription),即会话描述。对等连接的双方需要各自建立一个SDP,发起方的SDP称为offer,接收方的SDP称为answer。客户端之间的对等连接的建立本质上就是SDP的互换,在互换的过程中相互进行验证,验证完成即实现了对等连接。首先是发起方创建offer、接收方创建answer👇

const offer = await peerA.createOffer()
const answer = await peerB.createAnswer()

其次是互换SDP,发起方将answer设置为远程会话描述,offer设置为本地会话描述,而接收方将offer设置为远程会话描述,answer则设置为本地会话描述,设置了之后peerB才能通过ontrack监听函数来获取到peerA实例中添加的视频流数据👇

await peerA.setRemoteDescription(answer)
await peerA.setLocalDescription(offer)

await peerB.setRemoteDescription(offer)
await peerB.setLocalDescription(answer)

连接的最后一步是处理ICE-candidate(ice候选信息),peerA在执行setLocalDescription方法设置本地会话描述时会触发onicecandidate事件,此时需要将ice候选信息发送给peerB并设置候选,同理peerA设置完候选后,对等连接即建立完成👇

peerA.onicecandidate = event => {
  if (event.candidate) {
    peerB.addIceCandidate(event.candidate)
  }
}

peerB.onicecandidate = event => {
  if (event.candidate) {
    peerA.addIceCandidate(event.candidate)
  }
}

基于websocket实现客户端webrtc的对等连接

以上步骤我们实现了端对端的通信,但在我们视频通话的过程中,如何准确找到两个需要进行视频的客户端呢?这就需要依赖于我们上一篇文章介绍的websocket,通过websocket在服务端维护所有发起方和接受方的所有连接实例,然后在发起方建立视频连接请求时,通过维护的实例数组找到接收方并与其建立webrtc对等连接。websocket.js的改造,服务端发送方增加join、offer和candid指令的处理👇

const eventHandel = (message, ws, role, cusSender, cusReader) => {
  ...
  if (role == 'sender') {
    let arrval = message.split('|')
    let [type, userid, val] = arrval
    if (type == 'join') {
      let reader = cusReader.find(row => row.userid == userid)
      if (reader) {
        reader.send(`${type}|${ws.roomid}|${val}`)
      }
    }
    if (type == 'offer') {
      let reader = cusReader.find(row => row.userid == userid)
      if (reader) {
        reader.send(`${type}|${ws.roomid}|${val}`)
      }
    }
    if (type == 'candid') {
      let reader = cusReader.find(row => row.userid == userid)
      if (reader) {
        reader.send(`${type}|${ws.roomid}|${val}`);
      }
    }
    if (type == 'message') {
      console.log(cusReader,userid);
      let reader = cusReader.find(row => row.userid == userid)
      console.log(reader);
      if (reader) {
        console.log(message, type, val)
        reader.send(`${type}|${val}`)
      }
    }
  }
}

服务端接收方增加join和answer指令的处理👇

const eventHandel = (message, ws, role, cusSender, cusReader) => {
  if (role == 'reader') {
    let arrval = message.split('|')
    let [type, roomid, val] = arrval;
    if (type == 'join') {
      let seader = cusSender.find(row => row.roomid == roomid)
      if (seader) {
        seader.send(`${type}|${ws.userid}`)
      }
    }
    if (type == 'answer') {
      let sender = cusSender.find(row => row.roomid == roomid)
      if (sender) {
        sender.send(`${type}|${ws.userid}|${val}`)
      }
    }
   if (type == 'message') {
      let seader = cusSender.find(row => row.roomid == roomid)
      if (seader) {
        seader.send(`${type}|${val}`)
      }
    }
  }
 ...
}

基于websocket来建立webrtc的端对端对等连接过程如下。第1步,需要通信的双方与信令服务器建立websocket连接👇

socketA = new WebSocket('ws://localhost:3000/sender/1');
socketB = new WebSocket('ws://localhost:3000/reader/2');

第2步,发起方获取本地视频流并存储到localStream中,成功调用了摄像头与麦克风开始视频👇

localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });

第3步,接收方发送加入视频通话的请求,服务器解析接收方需要连接的发送方,并转发请求指令👇

...
if (role == 'reader') {
  let arrval = message.split('|')
  let [type, roomid, val] = arrval;
  if (type == 'join') {
    let seader = cusSender.find(row => row.roomid == roomid)
    if (seader) {
      seader.send(`${type}|${ws.userid}`)
    }
  }
}

第4步,发送方客户端接收到接收方的join请求,创建offer并发送给接收端👇

socketA.onmessage = evt => {
  let string = evt.data
  let value = string.split('|')
  if (value[0] == 'join') {
    peerInit(value[1]);
    recieveid = value[1];
    socket.send(`join|${recieveid}|成功加入视频房间${props.userid}`);
  }
}

const peerInit = async usid => {
  // 1. 创建连接
  peer = new RTCPeerConnection();
  // 2. 添加视频流轨道
  localStream.getTracks().forEach(track => {
    peer.addTrack(track, localStream)
  })

  peer.ontrack = e => {
    if (e && e.streams) {
      message.log('收到对方音频/视频流数据...');
      remoteVideo.srcObject = e.streams[0];
    }
  };

  // 3. 创建 SDP
  offer = await peer.createOffer()
  // 4. 发送 SDP
  socket.send(`offer|${usid}|${offer.sdp}`)
}

服务端接收到发送方offer指令后,找到对应的接收方并转发offer信息👇

...
if (type == 'offer') {
  let reader = cusReader.find(row => row.userid == userid)
  if (reader) {
    reader.send(`${type}|${ws.roomid}|${val}`)
  }
}

第5步,接收方客户端接收到offer指令后,将offer设置为远程会话描述,并创建answer并发送给服务端👇

socketB.onmessage = evt => {
  let string = evt.data
  let value = string.split('|')
  if (value[0] == 'offer') {
    transMedia(value)
  }
}

const transMedia = async arr => {
  let [_, roomid, sdp] = arr
  offer = new RTCSessionDescription({ type: 'offer', sdp })
  peer = new RTCPeerConnection();

  localStream.getTracks().forEach(track => {
    peer.addTrack(track, localStream);
  });

  peer.ontrack = e => {
    if (e && e.streams) {
      message.log('收到对方音频/视频流数据...');
      remoteVideo.srcObject = e.streams[0];
    }
  };

  await peer.setRemoteDescription(offer)
  answer = await peer.createAnswer()
  await peer.setLocalDescription(answer)
  socket.send(`answer|${roomid}|${answer.sdp}`)
}

服务端接收到接收方answer指令后,找到对应发起方并将转发answer信息到发起方👇

if (type == 'answer') {
  let sender = cusSender.find(row => row.roomid == roomid)
  if (sender) {
    sender.send(`${type}|${ws.userid}|${val}`)
  }
}

第6步,发起方接收到answer指令后,将answer设置为远程会话描述👇

socketA.onmessage = evt => {
  ...
  if (value[0] == 'answer') {
    answer = new RTCSessionDescription({
      type: 'answer',
      sdp: value[2]
    })
    peers[index].peer.setLocalDescription(offer)
    peers[index].peer.setRemoteDescription(answer)
  }
}

最后一步,发起方客户端发送candid指令,传递发送方ice-candidate候选信息👇

peer.onicecandidate = event => {
  if (event.candidate) {
    let candid = event.candidate.toJSON()
    socketA.send(`candid|${usid}|${JSON.stringify(candid)}`)
  }
}

服务端接收到发起方candid指令,转发candidate信息到接收方,接收方接收到candid指令后,添加candidate到peer实例中👇

socketB.onmessage = evt => {
  ...
  if (value[0] == 'candid') {
    let json = JSON.parse(value[2])
    let candid = new RTCIceCandidate(json)
    peer.addIceCandidate(candid)
  }
}

此时发起方和接收方端对端的连接就成功建立了,后续的视频流数据就可以通过webrtc直接在客户端之间进行传输。

视频通话vue组件开发

视频通话组件VideoConnect.vue界面主要包括三部分内容,一个是本地及远程视频播放的功能,一个是右侧连接建立的日志显示,最后一个是消息聊天的组件。

VideoConnect.vue组件的代码如下👇

<template>
  <div class="main">
    <div class="container">
      <div class="video-box">
        <video id="remote-video" autoplay controls></video>
        <video id="local-video" autoplay controls muted></video>
        <button v-if="startVisible" class="start" @click="start()">
          {{ label }}
        </button>
      </div>
      <div class="logger"></div>
    </div>
    <div class="message">
      <div class="sender">
       <div class="message-list">
         <message-item v-for="message of messageList" :key="message" :userid="message.userid" :role="message.role" :content="message.constent"></message-item>
       </div>
       <a-input class="message-input" v-model:value="value" @keydown.enter="onMessageSend"></a-input>
       <button class="send-btn" @click="onMessageSend">发送</button>
      </div>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, defineProps, onMounted } from 'vue';
import MessageItem from '../MessageCenter/MessageItem.vue';
interface MessageItem {
  userid: number,
  role: string,
  constent: string
}
const props = defineProps<{
  userid: 0,
  role: ''
}>();
let value = ref('');
const startVisible = ref(true);
const messageList = ref(new Array<MessageItem>());
const label = props.role === 'sender' ? '打开视频' : '加入视频';

let socket, offer, peer, localStream, answer, el, recieveid, localVideo, remoteVideo;
const peers = [];

const message =  {
  log (msg) {
    el.innerHTML += `<span>${new Date().toLocaleTimeString()}:${msg}</span><br/>`;
  },
  error (msg) {
    el.innerHTML += `<span class="error">${new Date().toLocaleTimeString()}:${msg}</span><br/>`;
  }
};

const target = props.role;

onMounted(() => {
  localVideo = document.querySelector('#local-video');
  remoteVideo = document.querySelector('#remote-video');
  el = document.querySelector('.logger');
  localVideo.onloadeddata = () => {
    message.log('播放本地视频');
    localVideo.play();
  }
  remoteVideo.onloadeddata = () => {
    message.log('播放对方视频');
    startVisible.value = false;
    remoteVideo.play();
  }
  createConnect();
});

const createConnect = () => {
  socket = new WebSocket(`ws://localhost:3000/webrtc/${props.role}/${props.userid}`);

  if (props.role === 'sender') {
    socket.onmessage = evt => {
      let string = evt.data
      let value = string.split('|')
      if (value[0] == 'join') {
        peerInit(value[1]);
        recieveid = value[1];
        message.log(`用户${recieveid}已加入房间!`);
        socket.send(`join|${recieveid}|成功加入视频房间${props.userid}`);
      }
      if (value[0] == 'answer') {
        const index = peers.findIndex(row => row.usid === value[1]);
        answer = new RTCSessionDescription({
          type: 'answer',
          sdp: value[2]
        })
        if (index >= 0) {
          peers[index].peer.setLocalDescription(offer)
          peers[index].peer.setRemoteDescription(answer)
        }
      }
      if (value[0] == 'candid') {
        let json = JSON.parse(value[2])
        let candid = new RTCIceCandidate(json)
        peer.addIceCandidate(candid)
      }
      if (value[0] == 'message') {
        let message = value[1];
        console.log(message, 'value1');
        messageList.value.push({
          userid: Number(recieveid),
          role: 'reciever',
          constent: message
        });
      }
    }
  } else {
    socket.onmessage = evt => {
      let string = evt.data
      let value = string.split('|')
      if (value[0] == 'join') {
        recieveid = value[1];
        message.log(`成功加入视频房间${recieveid}!`);
      }
      if (value[0] == 'offer') {
        transMedia(value)
      }
      if (value[0] == 'candid') {
        let json = JSON.parse(value[2])
        let candid = new RTCIceCandidate(json)
        peer.addIceCandidate(candid)
      }
      if (value[0] == 'message') {
        let message = value[1];
        console.log(message, 'value2');
        messageList.value.push({
          userid: Number(recieveid),
          role: 'reciever',
          constent: message
        });
      }
    }
  }
};

const peerInit = async usid => {
  // 1. 创建连接
  peer = new RTCPeerConnection();
  // 2. 添加视频流轨道

  message.log(`------ WebRTC ${target === 'sender' ? '发起方' : '接收方'}流程开始 ------`);
  message.log('将媒体轨道添加到轨道集');

  localStream.getTracks().forEach(track => {
    peer.addTrack(track, localStream)
  })

  peer.ontrack = e => {
    if (e && e.streams) {
      message.log('收到对方音频/视频流数据...');
      remoteVideo.srcObject = e.streams[0];
    }
  };

  peer.onicecandidate = event => {
    if (event.candidate) {
      let candid = event.candidate.toJSON()
      socket.send(`candid|${usid}|${JSON.stringify(candid)}`)
    }
  };

  peer.onconnectionstatechange = event => {
    if (peer.connectionState === 'connected') {
      message.log('对等连接成功!')
    }
    if (peer.connectionState === 'disconnected') {
      message.log('连接已断开!')
    }
  }

  // 3. 创建 SDP
  offer = await peer.createOffer()
  // 4. 发送 SDP
  socket.send(`offer|${usid}|${offer.sdp}`)

  // 5. 保存连接
  peers.push({usid, peer});
}

const transMedia = async arr => {
  let [_, roomid, sdp] = arr
  offer = new RTCSessionDescription({ type: 'offer', sdp })
  peer = new RTCPeerConnection();

  localStream.getTracks().forEach(track => {
    peer.addTrack(track, localStream);
  });

  peer.ontrack = e => {
    if (e && e.streams) {
      message.log('收到对方音频/视频流数据...');
      remoteVideo.srcObject = e.streams[0];
    }
  };

  peer.onconnectionstatechange = event => {
    if (peer.connectionState === 'connected') {
      console.log('对等连接成功!')
    }
    if (peer.connectionState === 'disconnected') {
      console.log('连接已断开!')
    }
  }

  await peer.setRemoteDescription(offer)
  answer = await peer.createAnswer()
  await peer.setLocalDescription(answer)
  socket.send(`answer|${roomid}|${answer.sdp}`)
}

const start = async () => {
  try {
    message.log('尝试调取本地摄像头/麦克风');
    localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    message.log('摄像头/麦克风获取成功!');
    localVideo.srcObject = localStream;

  } catch {
    message.error('摄像头/麦克风获取失败!');
  }
  if (props.role == 'reader') {
    message.log('尝试加入视频房间');
    socket.send(`join|1`);
  }
}

const onMessageSend = () => {
  messageList.value.push({
    userid: props.userid,
    role: 'sender',
    constent: value.value
  });
  socket.send(`message|${recieveid}|${value.value}`); // 发送信息
  value.value = ''; // 发送后清空输入框
}
</script>

<style lang="less" scoped>
...
</style>

最后实现的效果如下👇

图片

作者:jayray | 来源:公众号—— 玩心大胆子小

相关阅读:Vue+Koa2+websocket实现简单的消息中心

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐

发表回复

登录后才能评论