JsSIP による WebRTC-SIP の構築 その①
どうも,筆者です.
以前,FreePBX で IP 電話の環境を構築した.その際に,UCP(User Control Panel) と WebPhone というモジュールを追加した. しかし,スマホで UCP の WebPhone が利用できなかったため,自分で WebRTC-SIP を構築することとした.また,ここでは,JsSIP ライブラリを用いることとした.
参考サイト
外観
ログインしたときの見え方を以下に示す.あまり凝った作りにしていないため,見た目は悪い.
実装
ここでは,SPA(Single Page Application) として実装している.見栄えの関係上,HTML 部分と Javascript 部分を分けて示す.
HTML部分
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <meta name="description" content="WebRTC"> <meta name="auther" content="freepbx"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" integrity="sha384-B0vP5xmATw1+K9KRQjQERJvTumQW0nPEzvF6L/Z6nronJ3oUOFUFpCjEUQouq2+l" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/gitbrent/bootstrap4-toggle@3.6.1/css/bootstrap4-toggle.min.css"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css" integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous"> <link rel="icon" type="image/png" href="/favicon.ico"> <title>WebPhone</title> <style> .dialpad-btn { border-radius: 0px; } </style> </head> <body> <div class="container"> <div class="row justify-content-center mt-1"> <div class="col-12"> <h1 class="h1">WebPhone</h1> </div> </div> <div class="row justify-content-center mt-1"> <div class="col-12"> <div class="row"> <div class="col-12"> <h3 class="h3">Setup</h3> </div> </div> <div class="row"> <div class="col-12"> <label>Extension Number</label> <input type="tel" class="form-control" id="extension-number" placeholder="enter the extension number"> </div> </div> <div class="row"> <div class="col-12"> <label>Extension Password</label> <input type="password" class="form-control" data-toggle="password" id="extension-password" placeholder="enter the password"> </div> </div> <div class="row mt-1"> <div class="col-12"> <button type="button" id="login-status" class="btn btn-primary btn-block">Login</button> </div> </div> </div> </div> <div class="row justify-content-center mt-1"> <div class="col-12"> <div id="wrapper"> <!-- Incoming Call --> <div class="row"> <div class="col-12"> <div id="incoming-call" style="display: none;"> <hr> <div class="row"> <div class="col-12"> <h3 class="h3">Incoming Call</h3> <p> <label>Incoming:</label> <span id="incoming-call-number">Unknown</span> </p> </div> </div> <div class="row mt-1"> <div class="col-12 col-lg-6"> <button type="button" id="answer" class="btn btn-success btn-block">Answer</button> </div> <div class="col-12 col-lg-6"> <button type="button" id="reject" class="btn btn-danger btn-block">Reject</button> </div> </div> </div> <div id="call-status" style="display: none;"> <div class="row"> <div class="col-12"> <h5 class="h5" id="call-info-text">info text goes here</h5> <p> <label>Peer:</label> <span id="call-info-number">info number goes here</span> </p> </div> </div> </div> </div> </div> <!-- Dial Field --> <div class="row"> <div class="col-12"> <div id="dial-field" style="display: none;"> <hr> <!-- To Field --> <div class="row"> <div class="col-12"> <h3 class="h3">Dial Pad</h3> <div class="row"> <div class="col-12"> <input type="tel" id="to-field" class="form-control" placeholder="enter the number"> </div> </div> </div> </div> <div class="row mt-1"> <div class="col-12 col-lg-6"> <button type="button" id="clear-field" class="btn btn-outline-secondary btn-block">Clear</button> </div> <div class="col-12 col-lg-6"> <button type="button" id="delete-field" class="btn btn-outline-danger btn-block">Delete</button> </div> </div> <!-- Dial Pad --> <div class="row mt-3"> <div class="col-7 offset-2 col-md-4 offset-md-4"> <div class="row no-gutters"> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">1</button></div> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">2</button></div> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">3</button></div> </div> <div class="row no-gutters"> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">4</button></div> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">5</button></div> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">6</button></div> </div> <div class="row no-gutters"> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">7</button></div> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">8</button></div> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">9</button></div> </div> <div class="row no-gutters"> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">*</button></div> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">0</button></div> <div class="col-4"><button type="button" class="btn btn-outline-dark btn-block dialpad-btn">#</button></div> </div> </div> </div> <div class="row mt-3"> <div class="col-12"> <div class="row mt-1"> <div class="col-12"> <button type="button" id="call" class="btn btn-success btn-block">Call</button> </div> </div> <div class="row mt-1"> <div class="col-12 col-lg-6"> <button type="button" id="hangup" class="btn btn-danger btn-block">Hangup</button> </div> <div class="col-12 col-lg-6"> <button type="button" id="mute-mode" class="btn btn-primary btn-block">Mute (sound are not muted now)</button> </div> </div> </div> </div> </div> </div> </div> </div> </div> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js" integrity="sha512-bLT0Qm9VnAYZDflyKcBaQ2gg0hSYNQrJ8RilYldYQ1FxQYoCLtUjuuRuZo+fjqhx/qtq/1itJ0C2ejDxltZVFg==" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-Piv4xVNRyMGpqkS2by6br4gNJ7DXjqk09RmUpJ8jgGtD7zP9yug3goQfGII0yAns" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/gitbrent/bootstrap4-toggle@3.6.1/js/bootstrap4-toggle.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jssip/3.1.2/jssip.js" integrity="sha512-QWvPQCHjnZ9MksHgz1GRkjRVuj+BJZIV/3fBvFOs7N99N2dBaeHesIQ/+52jJOLowS2JLU6fGjQZFJfIzzFN7A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://unpkg.com/bootstrap-show-password@1.2.1/dist/bootstrap-show-password.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/EventEmitter/5.2.8/EventEmitter.min.js" integrity="sha512-AbgDRHOu/IQcXzZZ6WrOliwI8umwOgLE7sZgRAsNzmcOWlQA8RhXQzBx99Ho0jlGPWIPoT9pwk4kmeeR4qsV/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script> (function () { // Javascript 部分に示す }()); </script> </body> </html>
Javascript 部分
class WebPhone extends EventEmitter { constructor() { super(); JsSIP.debug.enable('JsSIP:*'); // Setup server information const SERVER_NAME = 'sample.example.com'; const SIP_PORT = 12345; const WEBSOCKET_PORT = 8443; const baseUrl = `${location.protocol}//${location.host}`; const socket = new JsSIP.WebSocketInterface(`wss://${SERVER_NAME}:${WEBSOCKET_PORT}/ws`); 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}, }; this.noAnswerTimeout = 15; // Time (in seconds) after which an incoming call is rejected if not answered 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(username, password) { if (this.phone) { return; } 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 = () => { this.ringTone.pause(); this.ringTone.currentTime = 0; this.session = null; this.emit('resetSession', this.getPhoneParameter()); }; // Enable the remote audio const addAudioTrack = () => { this.session.connection.ontrack = (ev) => { this.ringTone.pause(); this.ringTone.currentTime = 0; this.remoteAudio.srcObject = ev.streams[0]; }; }; if (this.session) { this.session.terminate(); } this.session = event.session; 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') { // 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() { if (this.phone) { this.phone.stop(); this.phone = null; this.session = null; this.confirmCall = false; this.lockDtmf = false; this.isEnable = false; } } call(destNum) { if (this.phone) { this.phone.call(destNum, this.callOptions); } } answer() { if (this.session) { this.session.answer(this.callOptions); if (this.confirmCall) { clearTimeout(this.confirmCall); this.confirmCall = false; } } } hangup() { if (this.session) { this.ringTone.pause(); this.ringTone.currentTime = 0; this.session.terminate(); } } updateMuteMode() { if (this.session) { const isMuted = this.session.isMuted().audio; if (isMuted) { this.session.unmute({audio: true}); } else { this.session.mute({audio: true}); } this.emit('changeMuteMode', !isMuted); } } updateDtmf(text) { if (!this.lockDtmf) { this.lockDtmf = true; this.dtmfTone.play(); if (this.session) { this.session.sendDTMF(text); } this.emit('pushdial', text); } } getPhoneParameter() { const ret = { session: this.session, ringTone: this.ringTone, isEnable: this.isEnable, }; return ret; } getPhoneStatus() { return this.isEnable; } } // Update Login Status 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'); } }; const init = () => { 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); }; // Create Web Phone instance const webPhone = new WebPhone(); // Register callback functions 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) => { $('#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); }); // Register callback functions of html elements 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-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').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, '')); }); }; $(init);
詳細
実装内容の詳細に関しては,その②で説明する.