はじめに

こんにちは、キャスレーコンサルティングLS(リーディング・サービス)部の青木です。

今回は Node.js + socket.ioで、簡単なチャットアプリを作成し、
さらに、Web Speech API を使用して
音声のみによる、チャットメッセージの送信と
受信メッセージの自動読み上げを実現してみようと思います。

たとえキーボードの入力の仕方が分からなくても、
両手が荷物でいっぱいでも、このアプリを使えばチャットを行うことができます。

いつでも簡単に、チャットをしましょう。

目次

・音声チャットアプリの全体像
・チャット機能の作成
・メッセージ読み上げ機能の作成
・音声によるメッセージ送信機能の作成
・おわりに

音声チャットアプリの全体像

構成図

音声チャットアプリの構成は、下記となります。

図1.音声チャットアプリ構成図
chatAppOrverview

Client1 の役割

1. 音声の受信・テキストへの変換
Speech Recognition API がマイクを通して音声を受信し、発話内容をテキスト形式へ変換します。
2. 送信メッセージの確認
Speech Synthesis API がテキストを読み上げ、送信メッセージの確認を行います。
3. ChatServerにテキストメッセージを送信
socket.io がテキストメッセージを、ChatServerに送信します。

ChatServer の役割

1.テキストメッセージの受信・チャット相手への送信
socket.io が、メッセージの送受信を行います。

Client2 の役割

1. テキストメッセージの受信
socket.io が、ChatServerからのテキストメッセージを受信します。
2. 受信したテキストメッセージの発話
Speech Synthesis API が、受信したテキストメッセージを読み上げます。

チャットメッセージ送信の流れ

チャットメッセージを送信するまでの、UserとClientとのやり取りの流れは下記となります。

図2.チャットメッセージ送信の流れ
chatFlow

ディレクトリ構成と主要ファイル

【Web】
    ドキュメントルート
       ┃
       ┗━ chatRoom/
          ┃
          ┗━ index.html
          ┃
          ┗━ room.html
          ┃
          ┗━ js/
             ┃
             ┗━ SocketIOClass.js
             ┃
             ┗━ room.js

【ChatServer】
    プロジェクトルート
       ┃
       ┗━ chat.js
    

チャット機能の作成

音声に関する機能は一旦置いておいて、まずはチャット機能の作成を行います。
チャット機能は、socket.io を使用して作成していきます。
socketio
https://socket.io/
socket.ioについての説明は、既に様々な記事で紹介がされているので、本ブログでは省略させて頂きます。
以下ではチャット機能を実現する各ファイルと、その内容についての説明を行っていきます。

Client側のファイル

index.html

[役割]

チャットルームに入室するための画面。
入室するチャットルームの選択と、入室者情報の入力を行います。
※画面側(HTML,CSS)の実装内容は、省略します。
loginScreen

room.html

[役割]

チャットルーム内の画面。
チャットメッセージの送受信を行います。
※画面側(HTML,CSS)の実装内容は、省略します。
chatRoom

また、room.htmlでは下記のJavaScriptファイルを、インクルードします。

━ socket.io.js
━ SocketIOClass.js
━ room.js

※socket.io.js
  socket.ioを使用するための、クライアント用ライブラリ。
  Nodeサーバーのファイルを取得して、読み込みます。

<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%20type%3D%22text%2Fjavascript%22%20charset%3D%22UTF-8%22%20src%3D%22https%3A%2F%2Fxxx.xxx.xxx.xxx%3A3000%2Fsocket.io%2Fsocket.io.js%22%3E%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&lt;script&gt;" title="&lt;script&gt;" />
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%20type%3D%22text%2Fjavascript%22%20charset%3D%22UTF-8%22%20src%3D%22.%2Fjs%2FSocketIOClass.js%22%3E%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&lt;script&gt;" title="&lt;script&gt;" />
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-wp-preserve="%3Cscript%20type%3D%22text%2Fjavascript%22%20charset%3D%22UTF-8%22%20src%3D%22.%2Fjs%2Froom.js%22%3E%3C%2Fscript%3E" data-mce-resize="false" data-mce-placeholder="1" class="mce-object" width="20" height="20" alt="&lt;script&gt;" title="&lt;script&gt;" />

SocketIOClass.js

[役割]

ChatServerへ接続し、ClientとChatServerとのメッセージのやり取りを行います。

'use strict'

class SocketIOClass {

    constructor(userId) {

        // ChatServerへ接続します (xxx.xxx.xxx.xxx にはChat ServerのIPアドレスを指定します)
        this.socket = io.connect('xxx.xxx.xxx.xxx:3000');
        this.userId = userId;

        this.eventInit();
    }

    eventInit() {

        // ChatServerからメッセージを受信した際に実行される処理を定義します
        this.socket.on('message', (msg) => {

            // 受信したメッセージを画面に表示します
            addMsgToScreen(msg, this.userId);
        });

    }

    emit(evenName, msgObj) {

        // チャットメッセージをChatServerに送信します
        this.socket.emit(evenName, msgObj);
    }
}

room.js

[役割]

SocketIOClass を使用し、チャット機能を実現します。
'use strict'

$(function() {

 // チャットルームへの入室者情報を生成します
 const userId      = /* 入室ユーザのIDを取得します */,
       userName    = /* 入室ユーザの名前を取得します */,
       roomId      = /* 入室するチャットルームのIDを取得します */,
       joinMessage = {
           userId   : userId,
           userName : userName,
           roomId   : roomId
       };

 // チャットルームに入室します
 const socketIO = new SocketIOClass(userId);
 socketIO.emit( 'join', joinMessage );

 // チャットメッセージのsubmitボタンを押下した際の処理を定義します
 $("#messge-submit-btn").on("click", () => {

     const message = /* 画面で入力されたチャットの送信メッセージを取得します */,
           msgObj = {
               from    : userId,
               to      : 'all',
               message : message
           };

     // ChatServerにメッセージを送信します
     socketIO.emit( 'message', msgObj );
 });

});

/**
 * ChatServerから送信されてきたメッセージを画面に表示します
 *(SocketIOClass.jsから呼び出される関数)
 */
function addMsgToScreen(msg, myUserId) {

    // msg.messageに格納されているテキストメッセージを画面に表示します
    // 処理が画面側の実装に依存するため、詳細は省略します

}

ChatServer側(Node.js)のファイル

chat.js

[役割]

socket.ioを使用し、Clientから送信されてきたメッセージを同じチャットルームに入室しているClientに配信します。
const https   = require('https'),
      express = require('express'),
      app     = express(),
      fs      = require( 'fs' ),
      options = {
          key  : fs.readFileSync( './ssl/server_key.key' ),
          cert : fs.readFileSync( './ssl/server_crt.pem' )
      },
      server  = https.createServer(options,app),

      // socket.ioにChatServerのポートをListenさせます
      io = require('socket.io').listen(server);

// socketの接続ユーザ情報を保持する領域を初期化します
let dataStore = {};

io.sockets.on("connection", (socket) => {
   // Clientがsocketへの接続を確立した際に実行されます
   // 以下で、確立したsocketに対し各種のイベントが発火した際の処理を定義します

    socket.on( 'join', (msg) => {

        // roomIdの値をIDとするチャットルームへの入室を行います
        socket.join(msg.roomId, () => {
            // socket.ioの入室処理後のコールバック関数の処理を定義します

            const usrObj = {
                userId   : msg.userId,
                userName : msg.userName,
                roomId   : msg.roomId
            }

            // 入室者情報を保持します
            dataStore[socket.id] = usrObj;
        });
    });

    socket.on( 'message', (msg) => {

        // 今回のサンプルソースでは、[msg.to]の値は全て'all'になります(クライアント側でメッセージ送信時に設定を行っています)
        if(msg.to === 'all') {

            // 自分自信が入室しているチャットルーム全体にメッセージを送信します
            io.sockets.to(dataStore[socket.id].roomId).emit('message', msg)
        }
    });

    socket.on( 'disconnect', () => {
        // disconnect が発火した時点でsocketのroomからの退室処理は完了しています

        // 入室者情報を削除します
        delete dataStore[socket.id]
    });

});

// ChatServerにListenポートを設定します
server.listen(3000);

チャットルーム画面でメッセージを入力し、submitボタンをクリックすることで、
同じチャットルームに入室しているClientに、チャットメッセージを送信することができます。

【メッセージ送信側】
msgSubmit

【メッセージ受信側】
msgReceive

メッセージ読み上げ機能の追加

続いて、音声機能の作成を行っていきたいと思います。
まずは受信したメッセージの読み上げ機能を、作成します。

メッセージの読み上げ機能は、W3Cコミュニティグループによって仕様が策定され、
各ブラウザへの実装が行われている、 Web Speech APISpeech Synthesis API を使用して作成していきます。

room.js

以下の関数に処理を追加します。

/**
 * ChatServerから送信されてきたメッセージを画面に表示します
 *(SocketIOClass.jsから呼び出される関数)
 */
function addMsgToScreen(msg, myUserId) {

    // msg.messageに格納されているテキストメッセージを画面に表示します
    // 処理が画面側の実装に依存するため、詳細は省略します

    const from = msg.from,
          isMyselfMessage = (from === myUserId);

    // 受信したテキストを読み上げます(自分が送信したメッセージの読み上げは行いません)
    if (!isMyselfMessage) {

        let synthesis = new SpeechSynthesisUtterance();

        synthesis.text = msg.message;
        speechSynthesis.speak(synthesis);
    }
}

以上です。
上記の処理の追加のみで、ブラウザが自動でチャットの受信メッセージを読み上げてくれます。

【メッセージ受信側】
voiceReceive

PC:「こんにちは」

おおー。
とても簡単です。

音声によるメッセージ送信機能の追加

続いて、音声によるメッセージ送信機能を作成します。
音声によるメッセージ送信機能は、Web Speech API の Speech Recognition API を使用して作成していきます。

room.js

room.jsに処理を追加し、以下のようにします。

'use strict'

$(function() {

 // チャットルームへの入室者情報を生成します
 const userId      = /* 入室ユーザのIDを取得します */,
       userName    = /* 入室ユーザの名前を取得します */,
       roomId      = /* 入室するチャットルームのIDを取得します */,
       joinMessage = {
           userId   : userId,
           userName : userName,
           roomId   : roomId
       };

 // チャットルームに入室します
 const socketIO = new SocketIOClass(userId);
 socketIO.emit( 'join', joinMessage );

 // submitボタンを押下した際の処理を定義します
 $("#messge-submit-btn").on("click", () => {

     const message = /* 画面で入力されたチャットの送信メッセージを取得します */,
           msgObj = {
               from    : userId,
               to      : 'all',
               message : message
           };

     // ChatServerにメッセージを送信します
     socketIO.emit( 'message', msgObj );
 });

 /**
 * 以下の処理が音声によるメッセージ送信機能の追加部分
 */

 /** 音声認識用インスタンス */
 let recognition = new webkitSpeechRecognition();
 recognition.lang = "ja";

 /** 送信メッセージ */
 let submitMsg;

 // 会話の段階を制御するフラグ

 /** apiが発話するために音声認識が終了されたかどうか */
 let stopForApiSpeech = false;

 /** チャットの送信メッセージの音声認識中かどうか */
 let chatInputStart = false;

 /** チャットの送信メッセージの確認中かどうか */
 let chatSubmitConfirmed = false;

 // 音声認識中にマイクからのinputを受信した際の処理を定義します
 recognition.addEventListener('result', (e) => {

     let inputMsg = e.results[0][0].transcript;

     if (!chatInputStart) {
         // チャット初期状態の場合

         if ( inputMsg == "チャット スタート" ) {

             execApiSpeech("スピークしてくれ。");

             chatInputStart = true;
             return;

         }

         // メッセージが "チャット スタート" ではない場合は何もしないで処理を終了させます
         return;
     }

     // 以下、チャット初期状態ではない場合

     if (inputMsg == "チャットエンド") {

         execApiSpeech("スピーク終わり。");

         submitMsg = "";
         chatInputStart = false;
         chatSubmitConfirmed = false;

         return;
     }

     if (!chatSubmitConfirmed) {
         // チャットメッセージの受付中の場合

         execApiSpeech(inputMsg + "。を送信しますよ。");

         submitMsg = inputMsg;
         chatSubmitConfirmed = true;

         return;
     }

     // 以下、チャットメッセージの受付中ではない場合(チャットメッセージ確認中の場合)

     if ( inputMsg == "送信" ) {

         const msgObj = {
             from: userId,
             to: 'all',
             message: submitMsg
         }

         // ChatServerにメッセージを送信します
         socketIO.emit( 'message', msgObj );

         submitMsg = "";
         chatSubmitConfirmed = false;
     } else if ( inputMsg == "キャンセル" ) {

         execApiSpeech("メッセージを取り消しました。");

         submitMsg = "";
         chatSubmitConfirmed = false;
     } else {

         execApiSpeech("もう一度言ってください。");
     }

 });

 // 音声認識を終了した際の処理を定義します
 recognition.addEventListener('end', () => {

     if (stopForApiSpeech) {

         // APIが発話するために音声認識を停止した場合は
         // 音声認識の自動再スタートは行いません
         stopForApiSpeech = false;
         return;
     }

     // 音声認識を自動再スタートさせます
     recognition.start();
 });

 /**
  * SpeechSynthesisUtteranceインスタンスを取得します
  */
 function getSpeechSynthesisUtterance(msg) {

     // SpeechSynthesisUtteranceインスタンの設定を行います
     let synthesis = new SpeechSynthesisUtterance();
     synthesis.lang = 'ja';
     synthesis.text = msg;

     // 発話が終了した際の処理を定義します
     synthesis.addEventListener('end', () => {

         // 発話終了後に音声認識を再スタートさせます
         recognition.start();
     });

     return synthesis;
 }

 /**
  * SpeechSynthesisUtteranceインスタンスに発話をさせます
  */
 function execApiSpeech(msg) {

    // 発話をする前に、音声認識を停止します
    stopForApiSpeech = true;
    recognition.stop();

    // 発話します
    let synthesis = getSpeechSynthesisUtterance(msg);
    speechSynthesis.speak(synthesis);
 }

 // 音声認識を開始します(js読み込み時に開始します)
 recognition.start();

});

/**
 * ChatServerから送信されてきたメッセージを画面に表示します
 *(SocketIOClass.jsから呼び出される関数)
 */
function addMsgToScreen(msg, myUserId) {

    // msg.messageに格納されているテキストメッセージを画面に表示します
    // 処理が画面側の実装に依存するため、詳細は省略します

    const from = msg.from,
          isMyselfMessage = (from === myUserId);

    // 受信したテキストを読み上げます(自分が送信したメッセージの読み上げは行いません)
    if (!isMyselfMessage) {

        let synthesis = new SpeechSynthesisUtterance();

        synthesis.text = msg.message;
        speechSynthesis.speak(synthesis);
    }
}

以上、です。
上記の処理の追加で、音声のみでチャットを行うことができるようになります。

私:「チャットスタート」
PC:「スピークしてくれ。」
私:「こんにちは」
PC:「こんにちは。を送信しますよ。」
私:「送信」

【メッセージ送信側】
voiceSubmit

おおー。
PCに一回も触らずに声だけで、チャットメッセージを送信することができました。

【メッセージ受信側】
voiceReceive

PC:「こんにちは」

受信側では、もちろんメッセージを読み上げてくれます。

おわりに

チャットは、キーボードで文字を入力するのが普通ですが、
今回作成したアプリではキーボードに触らずに、チャットを行うことができました。
また、PCにメッセージを読み上げてもらうことも、実現することができました。

これまでは「手」と「目」を使わないとできなかったことが、
今回のチャレンジで「声」と「耳」のみで行うことが、できるようになりました。

もしかしたら数年後には、「手」も「目」も「声」も「耳」も使わなくても
メッセージのやり取りが行える日が来るかもしれませんね。

以上となります。
最後まで読んでくださり、ありがとうございました。