JsSIP による WebRTC-SIP の構築 その②
どうも,筆者です.
前回
前回の続きとなる.
workspacememory.hatenablog.com
動きの方を重視したいため,HTML の解説は省略する.
Javascript の実装は,大きく分けて JsSIP ライブラリを利用する部分とライブラリからのコールバックを受け,UI (User Interface) ,すなわち HTML 要素を更新する部分の 2 つがある.WebPhone クラスは,JsSIP ライブラリと UI を更新する際の薄い Wrapper となる.
WebPhone クラス
メソッド一覧
WebPhone クラスは,以下のメソッドを持つ.
login
ユーザ名(ここでは内線番号)とパスワードを受け取り,ログイン処理を実施する.
logout
ログアウト処理を実施する.
call
電話をかける処理を実施する.事前にログイン処理を済ませ,ユーザ認証が完了していないと利用できない.
answer
着信に応答する.事前にログイン処理を済ませ,ユーザ認証が完了していないと着信しない.
hangup
電話を切る.
updateMuteMode
ミュートオン/オフの状態を変更する.デフォルトはミュートオフ.
updateDtmf
ダイアル時の押下音を鳴らす.押下音は,変更可能.
getPhoneParameter
UI を更新する際に渡すパラメータを定義する.
getPhoneStatus
WebPhone の利用可否を返す.true の場合は利用可,false の場合は利用不可.
コンストラクタ
ここでは,JsSIP ライブラリを用いて,PBX サーバと通信するための初期設定を行う.PBX サーバは,Asterisk を利用している想定とする.
constructor() { // EventEmitter を利用するため,親クラスのコンストラクタを呼び出す super(); // デバッグ用のログ出力 JsSIP.debug.enable('JsSIP:*'); // // Setup server information // // 接続先のサーバ名 const SERVER_NAME = 'sample.example.com'; // SIP 通信時のポート番号(Asterisk の設定に合わせる) const SIP_PORT = 12345; // WebSocket を利用する際のポート番号(Asterisk の設定に合わせる) const WEBSOCKET_PORT = 8443; // 音声ファイル指定用の URL(自身の環境に合わせる) const baseUrl = `${location.protocol}//${location.host}`; // WebSocket の作成(接続先は,自身の環境に合わせる) const socket = new JsSIP.WebSocketInterface(`wss://${SERVER_NAME}:${WEBSOCKET_PORT}/ws`); // SIP サーバの URL(接続先は,自身の環境に合わせる) this.sipUrl = `${SERVER_NAME}:${SIP_PORT}`; this.config = { sockets: [socket], uri: null, password: null, session_timers: false, realm: 'asterisk', display_name: null, }; this.callOptions = { mediaConstraints: { audio: true, video: false }, // ここでは,音声のみを利用可能とする }; // 応答なしと判断する際の時間(sec) this.noAnswerTimeout = 15; // 着信音.ファイルは自前で用意し,サーバに配置すること. this.ringTone = new window.Audio(`${baseUrl}/audio/ringtone.mp3`); // ダイアル時のプッシュ音.ファイルは自前で用意し,サーバに配置すること. this.dtmfTone = new window.Audio(`${baseUrl}/audio/dtmf.wav`); this.ringTone.loop = true; this.remoteAudio = new Audio(); this.remoteAudio.autoplay = true; // クラス内変数の初期化 this.phone = null; this.session = null; this.confirmCall = false; this.lockDtmf = false; this.isEnable = false; this.dtmfTone.onended = () => { this.lockDtmf = false }; // bind this.login = this.login.bind(this); this.logout = this.logout.bind(this); this.call = this.call.bind(this); this.answer = this.answer.bind(this); this.hangup = this.hangup.bind(this); this.updateMuteMode = this.updateMuteMode.bind(this); this.updateDtmf = this.updateDtmf.bind(this); this.getPhoneParameter = this.getPhoneParameter.bind(this); this.getPhoneStatus = this.getPhoneStatus.bind(this); }
login メソッド
ユーザ名(ここでは,内線番号)とパスワードを用いて,ログイン処理を行う.ログイン処理に成功すると,着信可能状態となる.ログイン処理に失敗するとログアウト処理を行う.どちらの場合も,イベントが発火する.
login(username, password) { // username: ログインユーザ名(ここでは,内線番号) // password: パスワード // 既にインスタンスが生成されている場合は,何もせずに抜ける if (this.phone) { return; } // 既存の config をベースに,uri, display_name を設定し,SIP 通信用のインスタンスを生成 const config = $.extend(true, $.extend(true, {}, this.config), { uri: `sip:${username}@${this.sipUrl}`, password: password, display_name: username }); this.phone = new JsSIP.UA(config); // In the case of user's authentication is success this.phone.on('registered', (event) => { // ログインに成功した場合,状態を更新し,イベントを発火させる this.isEnable = true; this.emit('registered', this.getPhoneParameter()); }); // In the case of user's authentication is failed this.phone.on('registrationFailed', (event) => { // ログインに失敗した場合,ログアウト処理を行い,イベントを発火させる const err = `Registering on SIP server failed with error: ${event.cause}`; this.logout(); this.emit('registrationFailed', err, this.getPhoneParameter()); }); // In the case of firing an incoming or outgoing session/call this.phone.on('newRTCSession', (event) => { // Reset current session const resetSession = () => { // 発信者が hangup した場合を考慮し,着信音を停止 this.ringTone.pause(); this.ringTone.currentTime = 0; // 現在の session を破棄し,イベントを発火させる this.session = null; this.emit('resetSession', this.getPhoneParameter()); }; // Enable the remote audio const addAudioTrack = () => { // 音声通信用に callback を設定 this.session.connection.ontrack = (ev) => { // 着信音を停止し,通話相手の音声が発信者側に届くように設定 this.ringTone.pause(); this.ringTone.currentTime = 0; this.remoteAudio.srcObject = ev.streams[0]; }; }; // 前回の session が残っている場合は破棄 if (this.session) { this.session.terminate(); } // 確立した session を取得 this.session = event.session; // イベント発火時の callback を設定 this.session.on('ended', resetSession); this.session.on('failed', resetSession); this.session.on('accepted', () => this.emit('accepted', this.getPhoneParameter())); this.session.on('peerconnection', addAudioTrack); this.session.on('confirmed', () => this.emit('confirmed', this.getPhoneParameter())); // Check the direction of this session if (this.session._direction === 'incoming') { // 着信時に着信音を再生.TimeOut 用の処理を定義 // In the case of incoming this.ringTone.play(); this.confirmCall = setTimeout(() => { this.hangup(); this.emit('noanswer', this.getPhoneParameter()); }, this.noAnswerTimeOut * 1000); } else { // 発信後,相手の音声を再生できるように設定内容を更新 // In the case of outgoing addAudioTrack(); } // イベント発火 this.emit('newRTCSession', this.getPhoneParameter()); this.emit('changeMuteMode', false); }); this.phone.start(); }
logout メソッド
logout() { // インスタンスが生成されている場合のみ処理を実施 if (this.phone) { this.phone.stop(); // 使用済みの変数をリセット this.phone = null; this.session = null; this.confirmCall = false; this.lockDtmf = false; this.isEnable = false; } }
call メソッド
call(destNum) { // destNum: 発信先の内線番号 // インスタンスが生成されている場合のみ処理を実施 if (this.phone) { // 事前に設定した callOption を指定し,引数で受け取った番号に電話をかける this.phone.call(destNum, this.callOptions); } }
answer メソッド
answer() { // session が確立している場合のみ処理を実施 if (this.session) { // 着信に応答 this.session.answer(this.callOptions); // TimeOut 用の callback 関数が定義されている場合は,リセット処理を実施 if (this.confirmCall) { clearTimeout(this.confirmCall); this.confirmCall = false; } } }
hangup メソッド
hangup() { // session が確立している場合のみ処理を実施 if (this.session) { // 着信音を止め,セッションを中断 this.ringTone.pause(); this.ringTone.currentTime = 0; this.session.terminate(); } }
updateMuteMode メソッド
updateMuteMode() { // session が確立している場合のみ処理を実施 if (this.session) { const isMuted = this.session.isMuted().audio; // 現在の mute 状態をもとに,状態を更新 if (isMuted) { this.session.unmute({audio: true}); } else { this.session.mute({audio: true}); } // イベントを発火させる this.emit('changeMuteMode', !isMuted); } }
updateDtmf メソッド
updateDtmf(text) { // text: 押下したダイアル番号(「*」と「#」を含む) // ダイアル時の押下音が再生されていないときのみ処理を実施 if (!this.lockDtmf) { this.lockDtmf = true; this.dtmfTone.play(); // session が確立しているときは,押下した番号を送信 if (this.session) { this.session.sendDTMF(text); } // イベントを発火させる this.emit('pushdial', text); } }
getPhoneParameter メソッド
getPhoneParameter() { // 公開するパラメータの設定 const ret = { session: this.session, ringTone: this.ringTone, isEnable: this.isEnable, }; return ret; }
getPhoneStatus メソッド
getPhoneStatus() { // WebPhone の状態を返却 return this.isEnable; }
ログイン状態の更新
ログイン状態に応じて,HTML 要素を更新する関数を以下に示す.
const updateLoginStatus = (isEnable) => { const element = $('#login-status'); // ログイン状態に応じて,ボタン名やデザインを更新 if (isEnable) { element.text('Logout'); element.removeClass('btn-primary'); element.addClass('btn-danger'); } else { element.text('Login'); element.addClass('btn-primary'); element.removeClass('btn-danger'); } };
init 関数
初期化処理を行う.
User Interface の更新
着信時と発信時の状態に合わせ,表示する HTML 要素を制御する.
// User Interface の更新 const updateCallUI = (callType) => { const answer = $('#answer'); const hangup = $('#hangup'); const reject = $('#reject'); const muteMode = $('#mute-mode'); const call = $('#call'); // 着信時の処理 // In the case of incoming if (callType === 'incoming') { // hide hangup.hide(); hangup.prop('disabled', true); muteMode.hide(); muteMode.prop('disabled', true); // show answer.show(); answer.prop('disabled', false); reject.show(); reject.prop('disabled', false); } // 発信時の処理 // In the case of outgoing or busy else { // hide answer.hide(); answer.prop('disabled', true); reject.hide(); reject.prop('disabled', true); // show hangup.show(); hangup.prop('disabled', false); muteMode.show(); muteMode.prop('disabled', false); } // 共通処理 call.hide(); call.prop('disabled', true); };
WebPhone インスタンスの生成
// Create Web Phone instance const webPhone = new WebPhone();
WebPhone によるイベント発火時の callback の登録
// ここでの引数 params の内容は,getPhoneParameter メソッドを参照のこと webPhone.on('registered', (params) => { // ログイン状態を更新 updateLoginStatus(params.isEnable); $('#wrapper').show(); $('#incoming-call').hide(); $('#call-status').hide(); $('#dial-field').show(); $('#call').show(); $('#call').prop('disabled', false); $('#hangup').hide(); $('#hangup').prop('disabled', true); $('#mute-mode').hide(); $('#mute-mode').prop('disabled', true); $('#to-field').focus(); }); webPhone.on('registrationFailed', (err, params) => { // err: エラーメッセージ $('#wrapper').hide(); // ログイン状態を更新 updateLoginStatus(params.isEnable); console.log(err); // エラーメッセージの内容をアラートで示す alert(err); }); webPhone.on('resetSession', (params) => { $('#wrapper').show(); $('#incoming-call').hide(); $('#call-status').hide(); $('#dial-field').show(); $('#call').show(); $('#call').prop('disabled', false); $('#hangup').hide(); $('#hangup').prop('disabled', true); $('#mute-mode').hide(); $('#mute-mode').prop('disabled', true); }); webPhone.on('accepted', () => {return;}); webPhone.on('confirmed', (params) => { const session = params.session; if (session.isEstablished()) { const extension = session.remote_identity.uri.user; const name = session.remote_identity.display_name; const infoNumber = (name) ? `${extension} (${name})` : extension; $('#incoming-call').hide(); $('#incoming-call-number').html(''); $('#call-info-text').html('In Call'); $('#call-info-number').html(infoNumber); $('#call-status').show(); $('#dial-field').show(); params.ringTone.pause(); updateCallUI('busy'); } }); webPhone.on('newRTCSession', (params) => { const session = params.session; if (session.isInProgress()) { const extension = session.remote_identity.uri.user; const name = session.remote_identity.display_name; const infoNumber = (name) ? `${extension} (${name})` : extension; if (session._direction === 'incoming') { $('#incoming-call').show(); $('#incoming-call-number').html(infoNumber); $('#call-status').hide(); updateCallUI('incoming'); } else { $('#incoming-call').hide(); $('#incoming-call-number').html(''); $('#call-info-text').html('Ringing...'); $('#call-info-number').html(infoNumber); $('#call-status').show(); updateCallUI('outgoing'); } } }); webPhone.on('noanswer', (params) => { $('#incoming-call-number').html('Unknown'); $('#call-info-text').html('No Answer'); $('#call-info-number').html('Unknown'); }); webPhone.on('changeMuteMode', (isMuted) => { const muteMode = $('#mute-mode'); if (isMuted) { muteMode.text('Unmute (sound are muted now)'); muteMode.addClass('btn-warning'); muteMode.removeClass('btn-primary'); } else { muteMode.text('Mute (sound are not muted now)'); muteMode.removeClass('btn-warning'); muteMode.addClass('btn-primary'); } }); webPhone.on('pushdial', (text) => { const toField = $('#to-field'); const fieldValue = toField.val(); toField.val(fieldValue + text); });
HTML 要素に関するイベント発火時の callback の登録
// 要素内で Enter キー押下時の処理 const chkEnterKey = (event, element) => { if (event.key === 'Enter') { element.click(); } }; // ユーザ名(ここでは,内線番号)とパスワードの入力欄に対する設定 $('#extension-number').focus(); $('#extension-number').keyup((event) => chkEnterKey(event, $('#login-status'))); $('#extension-password').keyup((event) => chkEnterKey(event, $('#login-status'))); // ログインボタン押下時の処理 $('#login-status').click((event) => { const validator = (username, password) => { const judge = (val) => !val || !val.match(/\S/g); if (judge(username) || !/\d+/.test(username)) { throw new Error('Invalid Extension Number'); } if (judge(password)) { throw new Error('Invalid Extension Password'); } }; if (webPhone.getPhoneStatus()) { webPhone.logout(); $('#wrapper').hide(); updateLoginStatus(false); } else { const username = $('#extension-number').val(); const password = $('#extension-password').val(); try { validator(username, password); webPhone.login(username, password); } catch (err) { alert(err.message); } } }); // ダイアルパッド押下時の処理 $('.dialpad-btn').click((event) => { const text = $(event.currentTarget).text(); webPhone.updateDtmf(text); }); // clear ボタン,delete ボタン押下時の処理 $('#clear-field').click((event) => { const toField = $('#to-field'); toField.val(''); }); $('#delete-field').click((event) => { const toField = $('#to-field'); const fieldValue = toField.val(); toField.val(fieldValue.substring(0, fieldValue.length - 1)); }); // call ボタン,answer ボタン,hangup ボタン,reject ボタン,mute ボタン押下時の処理 $('#call').click(() => { const destNum = $('#to-field').val(); webPhone.call(destNum); }); $('#answer').click(webPhone.answer); $('#hangup').click(webPhone.hangup); $('#reject').click(webPhone.hangup); $('#mute-mode').click(webPhone.updateMuteMode); // 発信番号記入欄に関する設定 $('#to-field').keyup((event) => chkEnterKey(event, $('#call'))); $('#to-field').keypress((event) => { const value = String.fromCharCode(event.which); const ret = /[0-9\*#]/.test(value); return ret; }); $('#to-field').change((event) => { const element = $(event.currentTarget); const value = element.val(); element.val(value.replace(/[^0-9\*#]/g, '')); });