作業中のメモ

よく「計算機」を使って作業をする.知らなかったことを中心にまとめるつもり.

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, ''));
});