webRTC进阶,音视频通话联通

lxf2023-03-14 19:54:01

我正在参加「AdminJS·启航计划」

WebRTC协议简介,清晰的说明了什么是sdp,什么是ice,什么是stun,什么是nat,这部分是必看内容

1. webRTC连接握手概念图解

WebRTC允许在两个设备之间进行实时的对等媒体交换。通过称为信令的发现和协商过程建立连接. 在这里就有一点像http建立连接握手一样,WebRTC也必须进行一种发现和媒体格式协商,以使不同网络上的两个设备相互定位。这个过程被称为信令,并涉及两个设备连接到第三个共同商定的服务器。通过这个第三方服务器,这两台设备可以相互定位,并交换协商消息。

为此两个设备直接建立WebRTC需要一个信令服务器来实现双方通过网络进行连接,一般来说这个信令服务器就是一个WebSocket服务,也用http/https做信令服务的

1.1 常规握手连接

如图:

webRTC进阶,音视频通话联通

  • 一般第一步,需要准备聊天(信令)服务器来处理信令,服务器支持多种消息格式来处理不同的任务,比如注册新用户、设置用户名、发送公共信息等等。

  • 第二步交换会话描述信息,一般来说,连接发起方会通过信令服务器(或者http协议)发送直接的offer(sdp),紧接着接受方收到发起方的信号,并将他提供的offer(sdp)设置到本地,然后创建一个answer(sdp)发送给发起方,让发起方将anwser(sdp)设置到本地,这样各种的网络协议就互相交换完成了

  • 如果有必要(两个节点需要交换 ICE 候选来协商他们自己具体如何连接),还会交换ICE候选,ICE协议内容一般是各自创建offer或者answer的时候,触发ice方法回调中得到的。

  • 以上协议交换完成,也就是握手结束

1.2 与流媒体服务器连接

上面介绍的一直常规的一般方式的点对点连接,但是大部分企业服务应用中不是采取的这种客户端的点对点连接,而是使用了一个流媒体服务器作为中转,是客户端用WebRTC与流媒体服务器建立连接,然后推流和拉流

  • 一般第一步就是推流,也就是客户端与流媒体服务器建立连接,首先也是发送offer给流媒体服务器
  • 然后服务器应答,会发送一个answer给客户端,客户端设置answer即可
  • 接着就是拉取流,这要新建立一个rtc连接,发送offer给流媒体服务器
  • 然后服务器应答,会发送一个answer给客户端,客户端设置answer即可

从上面步骤来看,也就是这种方式客户端要建立两个RTC连接,分别用于流的推送和流的拉取播放

2. 使用RTCPeerConnection接口建立连接

通过上面基本步骤了描述,web端提供了一个接口RTCPeerConnection来创建rtc连接,接下来就要使用RTCPeerConnection来建立webRTC连接

2.1 常规握手连接(伪代码

这里的信令服务器,我这边假设使用的是ws作为信令服务

import type { Socket } from 'socket.io';
import socket from 'socket.io-client';

type ConnectAtionType = 'call' | 'join' | 'sendoffer' | 'sendanwser' | 'condidate' | 'close';

const socketIns = socket(`wss://${ip}/`);

const player:HTMLVideoElement = document.getElementById('localVideo')
let rtcInsAction = null;

const sedSocktMsg = (option: { action: ConnectAtionType; content?: string }) => {
    if (!socketIns) return;
    socketIns.emit(
        'message',
        Object.assign(option, {
        target,
        username,
        tid: new Date().getTime() + '',
        socketid: socketIns.id,
        }),
    );
};

const createConnect = async (transdirect:'recvonly' | 'sendrev',sdp?:RTCSessionDescriptionInit)=>{
    const _configuration = {
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
    };
    const rtcIns  = new RTCPeerConnection(_configuration);
    rtcIns.addEventListener('track', (e) => {
      if (e.streams && e.streams[0]) {
        // 这里就是远程发来的媒体流
        console.log(e.streams[0])
        player?.setAttribute('autoplay', '');
        player?.setAttribute('playsinline', '');
        player && (player.srcObject = e.streams[0]);
      }
    });
    // 监听触发的icecandidate的事件,一般来说只要使用了RTCPeerConnection.setLocalDescription就能触发
    rtcIns.addEventListener('icecandidate', (e) => {
      if (e.candidate) {
        sedSocktMsg({
            action:"condidate",
            content:JSON.stringify(e.candidate)
        })
      }
    });
    // 监听连接状态
    rtcIns.addEventListener('connectionstatechange', (e) => {
      const RTCPeerConnection: RTCPeerConnection = e.target as RTCPeerConnection;
      const state = RTCPeerConnection.connectionState as ConnectionState;
      console.log(state)
    });
    // 如果是仅仅是接受方,就不需要捕获媒体,直接接受远端的流即可
    if (transdirect === 'recvonly') {
      rtcIns.addTransceiver('video', { direction: 'recvonly' });
      rtcIns.addTransceiver('audio', { direction: 'recvonly' });
    } else {
      const streams = await getUserMediaStream();
      streams.getTracks().forEach((track) => {
        rtcIns?.addTrack(track, streams);
      });
    }

    if(sdp){
        await rtcIns.setRemoteDescription(sdp); 
        const answer = await rtcIns.createAnswer();
        await rtcIns.setLocalDescription(answer);
        // 这里的逻辑是如果是接受方,那么createConnect就要接受一个offer(sdp)
        // 创建answer发送
        sedSocktMsg({
            action:"sendanwser",
            content:JSON.stringify(answer)
        })
    }else{
        // 创建offer并发送给远程
        const offer = await rtcIns.createOffer();
        await rtcIns.setLocalDescription(offer);
        sedSocktMsg({
            action:"sendoffer",
            content:JSON.stringify(offer)
        })
    }

    return {
        close:()=>{
            rtcIns.close()
        },
        setRemoteSdp:(sdp: RTCSessionDescriptionInit)=>{
            rtcIns.setRemoteDescription(sdp);
        },
        addIceCandidate: (sdp: RTCIceCandidate) => {
            rtcIns.addIceCandidate(sdp);
        },
    }
    
}

socketIns.on('message',(message)=>{
    const { action, result } = message;
    if (result === 'success') {
        switch (action) {
            case 'applyoffer': {
                // 发起了联通的请求建立连接
                crateConnect(JSON.parse(message.constent)).then( res =>{
                    rtcInsAction = res
                });
                break;
            }
            case 'addCondidate': {
                // 设置对方发过来的ice协议
                rtcInsAction.addIceCandidate(JSON.parse(message.constent))
                break;
            },
            case 'applyanwser': {
                 // 设置对方发过来的answer协议
                rtcInsAction.setRemoteSdp(JSON.parse(message.constent));
                break;
            },
            case 'close': {
                rtcInsAction.close();
                break
            }
        }
    }
})

以上就是对1部分的文字翻译成代码的结构

值得注意的是关闭webrtc的时候,仅仅只把RTCPeerConnection这个点对点的实例对象关闭是不完整的,还需要把他里面的tack媒体轨道停止追踪

因此对上面代码close部分还需要获取在这个点对点连接中的流轨道,然后停止流的追踪,再然后关闭点对点连接(补充:关闭本地流的追踪,这部分一般是要对业务情况做判断是否需要关闭本地流追踪,如果视频连接断掉,没有其他业务需要用到捕获的本地流,那么本地流追踪也是要关闭的追踪的),改造如下:

close:()=>{
    const receivers = rtcIns.getReceivers().map((item) => {
        return item.track;
    });

    const senders = peerConnection.getSenders().map((item) => {
        return item.track;
    });
    [...receivers,...senders].forEach( track =>{
        track?.stop();
    })
    rtcIns.close()
}

2.2 与流媒体服务器连接

与流媒体服务器建立连接,差别其实不是很大,把流媒体服务器想象成一个客户端就行了。而且在仅仅只有拉流的时候,不需要捕获本地流,所以直接用

rtcIns.addTransceiver('video', { direction: 'recvonly' });
rtcIns.addTransceiver('audio', { direction: 'recvonly' });

来创建一个单向流即可