WebRTC 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。随着WebRTC技术的日益成熟,越来越多的开发者开始将其应用于项目中,以实现浏览器间的实时语音、视频通信。而FreeSwitch,作为一款开源的电话交换软件平台,为开发者提供了强大的通信功能。
典型的webrtc技术栈如下图所示:
javascripts语音实现WebRTC的开源库主要有JSSIP和SIP,引用这些库实现与FreeSwitch的通讯非常简单。webrtc传输信令依赖websocket协议,需要加密的,否则的是不允许调用音视频资源的。但是部署一套使用ssl证书加密的环境也是一件稍嫌复杂的事情,今后再分享实现。
本章主要使用ws而非wss实现开发环境下的简易环境配置和部署,主要的浏览器为谷歌浏览器。
那么,如何在FreeSwitch中启用WebRTC呢?本文将带您一步步完成配置:
第一步:浏览器设置允许http协议正常执行
第二步:FreeSwitch基本配置
conf/vars.xml 有两个开关关闭,本文未实现SSL
<X-PRE-PROCESS cmd="set" data="internal_ssl_enable=false" /><X-PRE-PROCESS cmd="set" data="external_ssl_enable=false"/>
conf/sip_profiles/internal.xml 中确保下面两个配置打开
<param name="ws-binding" value=":5066"/><param name="wss-binding" value=":7443" />
修改/conf/vars.xml
<X-PRE-PROCESS cmd="set" data="external_sip_ip=stun:stun.freeswitch.org"/> <X-PRE-PROCESS cmd="set" data="external_rtp_ip=stun:stun.freeswitch.org"/> 换成 <X-PRE-PROCESS cmd="set" data="external_sip_ip=*.*.*.*"/> <X-PRE-PROCESS cmd="set" data="external_rtp_ip=xxx.xxx.xxx.xxx"/>
修改conf/sip_profiles/external.xml
<param name="ext-rtp-ip" value="$${external_rtp_ip}"/> <param name="ext-sip-ip" value="$${external_sip_ip}"/>
修改/conf/sip_profiles/internal.xml
<param name="ext-rtp-ip" value="$${external_rtp_ip}"/><param name="ext-sip-ip" value="$${external_sip_ip}"/>
延迟呼叫调整
NAT穿墙的调整
配置sip_profiles/internal.xm
<param name="apply-nat-acl" value="nat.auto"/>
注释掉sip_profiles/internal.xml中inbound-bypass-media配置
<!--<param name="inbound-bypass-media" value="true"/>--><param name="NDLB-force-rport" value="true"/>
到目前为止FreeSwitch基本配置完成,接下来写前端代码实现
目前本文主要使用JSSIP第三方包实现软电话呼叫,基本页面如下:
相关前端代码:
绑定事件
// 绑定userAgent事件 function setUAEvent() { // ws 开始尝试连接 userAgent.on('connecting', (args) => { console.log('ws尝试连接'); }); // ws 连接完毕 userAgent.on('connected', () => { console.log('ws连接完毕'); }); // ws 连接失败 userAgent.on('disconnected', () => { console.log('ws连接失败'); }) // SIP 注册成功,data:Response JsSIP.IncomingResponse收到的SIP 2XX响应的实例 userAgent.on('registered', e => { console.log('SIP注册成功') }); // SIP 注册失败,,data:Response JsSIP.IncomingResponse接收到的SIP否定响应的实例,如果失败是由这样的响应的接收产生的,否则为空 userAgent.on('registrationFailed', e => { console.log('SIP注册失败',e) }); //1.在注册到期之前发射几秒钟。如果应用程序没有为这个事件设置任何监听器,JsSIP将像往常一样重新注册。 // 2.如果应用程序订阅了这个事件,它负责ua.register()在registrationExpiring事件中调用(否则注册将过期)。 // 3.此事件使应用程序有机会在重新注册之前执行异步操作。对于那些在REGISTER请求中的自定义SIP头中使用外部获得的“令牌”的环境很有用。 userAgent.on('registrationExpiring', function(){ console.warn("registrationExpiring"); }); // SIP 取消注册 userAgent.on('unregistered', e => { console.log('SIP主动取消注册或注册后定期重新注册失败') }); userAgent.on('newRTCSession', function(data){ console.log('onNewRTCSession: ', data);console.log(`新的${data.originator === 'local' ? '外呼' : '来电'}`, data); currentSession = data.session; if(data.originator == 'remote'){ //incoming call console.log("incomingSession, answer the call"); timeAudio.src = './public/ring.wav'; timeAudio.play(); const caller = data.request.from._uri.user;// const caller_dom = document.getElementById('caller'); caller_dom.innerHTML="---"+caller+" 来电!---"; const myPhone = document.getElementById('myPhone'); myPhone.classList.add('shake-animation'); answerSession(currentSession);//应答处理}else{//拨出去的来电 timeAudio.src = './public/ringback.ogg'; timeAudio.play(); const calling = data.request.to._uri.user;// const caller_dom = document.getElementById('caller'); caller_dom.innerHTML="---正在呼叫:"+calling+"---"; callSession(currentSession); } }); userAgent.on('newMessage', function(data){ if(data.originator == 'local'){ console.info('onNewMessage , OutgoingRequest - ', data.request); }else{ console.info('onNewMessage , IncomingRequest - ', data.request); } }); }
处理来电应答代码
//接听事件:来电应答sessionfunction answerSession(session) { session.on("progress", function(data) { console.log("来电提示"); }); session.on("ended", (data) => { console.log("来电挂断", data); var caller_dom = document.getElementById('caller'); caller_dom.innerHTML="------"; if(audio!=null){ audio.pause(); } timeAudio.pause(); currentSession = null;//当前回话也初始化 //只有来电会震动 const myPhone = document.getElementById('myPhone'); myPhone.classList.remove('shake-animation'); }); session.on("failed", (data) => { console.log("无法建立通话"); if(audio!=null){ audio.pause(); } timeAudio.pause(); currentSession = null; //只有来电会震动 const myPhone = document.getElementById('myPhone'); myPhone.classList.remove('shake-animation'); });//实际工作中是没有任何意义的session.on('sdp', function(data){ console.log('onSDP, type - ', data.type, ' sdp - ', data.sdp);});// 接听成功session.on('accepted', function(data){//接受 console.log('onAccepted - ', data); if(data.originator == 'remote'){//去掉对方接受 console.log('对方接听!'); }else if(data.originator == 'local'){//来电--自己接受 console.log('来电自己接听!'); }}); //确认呼叫后激发//顺序:对方接听(accepted)--》对方接受(handle onConfirmed)--》onConfirmedsession.on('confirmed', function(data){//确认 console.log('onConfirmed - ', data); if(data.originator == 'remote'){ } timeAudio.pause();//只有来电会震动const myPhone = document.getElementById('myPhone');myPhone.classList.remove('shake-animation');});// 通话被挂起session.on('hold', (data) => { const org = data.originator; if (org === 'local') { console.log('通话被本地挂起:', org); } else { console.log('通话被远程挂起:', org); }});// 通话被继续session.on('unhold', (data) => { const org = data.originator; if (org === 'local') { console.log('通话被本地继续:', org) } else { console.log('通话被远程继续:', org); }});//绑定通话取消事件session.on("canceled", () => { console.log("通话被取消");//只有来电会震动const myPhone = document.getElementById('myPhone');myPhone.classList.remove('shake-animation');});}
呼叫代码处理:
//接听事件:呼叫处理function callSession(session) { session.on("progress", () => { console.log("响铃中"); });// 接听成功session.on('accepted', function(data){//接受 console.log('onAccepted - ', data); if(data.originator == 'remote'){//去掉对方接受 console.log('对方接听!'); }else if(data.originator == 'local'){//来电--自己接受 console.log('来电自己接听!'); } if(timeAudio!=null){ timeAudio.pause(); }});/** * 这种方式的音频加载有问题 * */ session.on("confirmed", (data) => { console.log("已接听", data); const remoteStream = new MediaStream(); const receivers = session.connection.getReceivers(); if (receivers){ receivers.forEach((receiver) =>{ if (receiver.track.kind === 'audio') { remoteStream.addTrack(receiver.track) } }); } try { audio.srcObject = remoteStream; } catch (error) { audio.src = URL.createObjectURL(remoteStream); }audio.play(); });session.on("failed", (data) => { console.log("无法建立通话"); if(audio!=null){ audio.pause(); } timeAudio.pause(); currentSession = null;}); session.on("ended", (data) => { console.log("通话结束",data); var caller_dom = document.getElementById('caller'); caller_dom.innerHTML="------"; if(audio!=null){ audio.pause(); } timeAudio.pause(); currentSession = null;//当前回话也初始化 });// 通话被挂起session.on('hold', (data) => { const org = data.originator; if (org === 'local') { console.log('通话被本地挂起:', org); } else { console.log('通话被远程挂起:', org); }});// 通话被继续session.on('unhold', (data) => { const org = data.originator; if (org === 'local') { console.log('通话被本地继续:', org) } else { console.log('通话被远程继续:', org); }});//绑定通话取消事件session.on("canceled", () => { console.log("通话被取消");});}
到目前为止前端代码基本已经实现完毕,
具体呼叫流程如下:
拨打呼叫:
呼叫结束
如果外部来电