ZHANGYU.dev

October 14, 2023

JsSIP踩坑笔记

JavaScript5.5 min to read

近日做的一个功能是页面打电话,使用了WebRTC的技术,实际上使用了JsSIP后,难度就直线下降到库的使用了,光学习库的使用是没有意义的,所以还是得先了解一下WebRTC的原理

原理简述

WebRTC是点到点,数据通道是P2P的,但是依旧需要服务器的支持,服务器的作用基本就是传输WebRTC所需要的信令信息,告诉浏览器端应该如何连接

在连接过程中,比较重要的点就是Network Address Translation (NAT)穿透,通常也叫做打洞,因为为了解决ipv4地址不足,大多数客户端都处在路由器的内网环境,比如内网地址为192.168.1.3,这时候需要绕过防火墙,建立一个在公网可见的唯一地址,通过NAT穿透,这个地址会被映射为公网IP+唯一端口,如182.150.184.98:52054

而这个穿透的过程通常使用Interactive Connectivity Establishment (ICE),也就是ICE协议框架,在此协议框架里,有两种服务器,分别为Session Traversal Utilities for NAT(STUN)服务器,和Traversal Using Relays around NAT(TURN)服务器,一般情况只会使用到STUN服务器,获得公网唯一可见地址后两端便可建立连接,但是如果路由器不允许主机直连,就需要TURN服务器来转发数据

STUN连接过程图示

image

TURN连接过程图示

image

JsSIP踩坑笔记

SIP协议和上面的WebRTC其实没有太大关系,通常的WebRTC并不会使用SIP协议,SIP协议用于发起、维持和终止实时会话包括语音、视频、消息的应用程序

JsSIP它主要就是解析SIP信令,让我们和服务器知道现在应该打电话,还是接电话,顺便封装了WebRTC的东西,不需要手动建立RTCPeerConnection连接

看看它官网的示例

var socket = new JsSIP.WebSocketInterface('wss://sip.myhost.com');

var configuration = {
  sockets  : [ socket ],
  uri      : 'sip:alice@example.com',
  password : 'superpassword'
};

var ua = new JsSIP.UA(configuration);

ua.start();

// 注册打电话的回调事件
var eventHandlers = {
  'progress': function(e) {
    console.log('call is in progress');
  },
  'failed': function(e) {
    console.log('call failed with cause: '+ e.data.cause);
  },
  'ended': function(e) {
    console.log('call ended with cause: '+ e.data.cause);
  },
  'confirmed': function(e) {
    console.log('call confirmed');
  }
};

var options = {
  'eventHandlers'    : eventHandlers,
  'mediaConstraints' : { 'audio': true, 'video': true }
};

var session = ua.call('sip:bob@example.com', options);

先说基本流程

它的问题在于事件绑定有点迷惑,我现在也不是特别懂,它有2种事件绑定方式

ua绑定事件

ua.on('connected', () => console.log('[SIP Phone] : Connected (On Call)'));
ua.on('registered', () => console.log('[SIP Phone] : Registered (On Call)'));
ua.on('registrationFailed', () => console.log('[SIP Phone] : Registration Failed (On Call)'));

这是连接、注册、注册失败的事件,有一个最重要的事件,是newRTCSession,代表有新的通话

ua.on('newRTCSession', (e) => {
  // 其中能拿到session对象和originator对象
  // originator代表通话时本地呼出还是远程呼入
  const { session, originator } = e;

  // session绑定的事件才是最重要的

  // 连接中
  session.on('connecting', () => {});
  // 连接已接受
  session.on('accepted', () => {});
  // 接通,在这一步可以处理音频播放
  // 接通并不代表对方已经接受,接通代表 滴 滴 滴
  session.on('confirmed', () => {});
  // 结束
  session.on('ended', () => {});
  // 失败
  session.on('failed', () => {});

  // 手动让打孔结束,最多4次,有时候等待时间会很长
  let iceCandidateCount = 0;
  session.on('icecandidate', (data) => {
    if (iceCandidateCount++ > 4) data.ready();
  });
}

事件注册上了,那么如何播放音频呢?

通过查阅了很多资料,发现了至少3种方式

const audioElement; // 获取到的页面audio元素
ua.on('newRTCSession', (e) => {
  const { session } = e;
  // session.connection代表的即是RTCPeerConnection实例对象
  session.on('confirmed', (event) => {
    // 第一种方式,已被废弃掉,但是chrome 80可用,此api在MDN上无法找到
    audioElement.srcObject = session.connection.getRemoteStreams()[0];
    // 第二种方式,已被废弃掉了,但是还是可用
    session.connection.onaddstream = (e) => {
      audioElement.srcObject = e.stream;
    }
    // 第三种方式,未被废弃,呼出触发,呼入不触发……
    session.connection.ontrack = (e) =>{
      audioElement.srcObject = e.streams[0]
    }

    // 前三种都被废弃了,最后研究了半天
    // 闪亮登场的最终方式
    const stream = new MediaStream();
    const receivers = session.connection?.getReceivers();
    if (receivers) receivers.forEach((receiver) => stream.addTrack(receiver.track));
		audioElement.srcObject = stream;
    // 最后都要播放
    audioElement.play();
  });
}

确定了如何将声音播放了,下一步研究如何接电话

接电话、挂电话

在流程里,应该是这样的

ua.on('newRTCSession', (e) => {
  const { session } = e;
  session.answer();
}

但是这样实际肯定行不通,因为这个事件肯定是需要绑定到按钮上,所以需要把这个事件挪出来,所以需要一个变量保存session对象

function sip() {
  // ...
  let currentSession;
  ua.on('newRTCSession', (e) => {
    const { session } = e;
    currentSession = session;
  });
  // 包一层接电话函数
  const answer = (options) => {
    try {
      if (currentSession) currentSession.answer(options);
      else console.error('接通时RTCSession对象为空');
    } catch (e) {
      console.warn('电话接通失败:', e.message);
    }
  };
  return {
    answer
  };
}

打电话和挂电话同理,都需要将事件返回出去,挂在元素上,最后封装后应该这样返回

return {
  call, // 打电话
  terminate, // 挂电话
  answer, // 接听
};

在封装过程中,有一个比较重要的点就是this,实际上session的每一个回调都可以用this访问session对象,在箭头函数写多了、函数体里不使用this的规则后都忘记了this这个东西了

所以可以直接从外部传入一个整体回调,它们都this都可以访问session对象,所以也可以自己加自定义的回调,最后使用call绑定this就好了

// 当前状态
export enum Originator {
  Local = 'local',
  Remote = 'remote',
  Idle = 'idle',
}

// 可传入的回调
export interface CallEventHandlers {
  connecting?(this: RTCSession, event: SessionConnectingEvent): void;
  accepted?(this: RTCSession, event: SessionAcceptedEvent): void;
  confirmed?(this: RTCSession, event: SessionConfirmedEvent, stream: MediaStream): void;
  ended?(this: RTCSession, event: SessionEndedEvent): void;
  failed?(this: RTCSession, event: SessionFailedEvent): void;
  // 自定义当有新来电时的事件
  newCall?(this: RTCSession, originator: Originator): void;
  // 自定义电话呼叫滴滴滴后对方接通的事件
  connected?(this: RTCSession, originator: Originator): void;
}

这里还有一个坑,我没有找到官方对应的解决方案,就是呼出 滴 滴 滴后,没有对方接听的事件,这个事件对应我上面的connected事件,我这里的解决方式是后台得在写一个websocket,通过这个来告知我是否接听

总结

使用JsSIP不需要处理SIP信令,也不需要学WebRTC相关知识,虽然个人觉得它的事件形式不太好,ts类型也不完善,但是已然是一个非常好,非常简单易用的库了

个人认为页面打电话这一套的技术难点主要还是在后台,所以我很佩服我们这儿的后台,我脑子里完全无法想象他一个人是怎么搞设备插了几百张卡,又怎么连接到linux,又怎么搭STUNTURN服务器,又怎么写服务能和我通话,真到牛皮

我觉得也许SIP协议都不应该放在前端处理,应该服务端再搞一个SIP协议解析的中间件,前端单纯使用WebRTC就更好了,说起来本来觉得这个打电话没什么难度,周末想自己学习WebRTC写一个在线的视频demo,结果好像难度很大的样子就放弃了,今天想想怎么能轻言放弃,下周一定得搞一搞


参考资料:https://developer.mozilla.org/zh-CN/docs/Web/API/WebRTC_API