BluemixのNode-REDでText to Speechノードを使って音声を読み上げる




# 目的
Node-REDとWatsonAPIを使いたい！というわけで、BluemixのNode-REDでText to Speechノードを使い、入力したテキスト（英語）を読み上げるというものを作りました。
API経由で呼び出す方法ではなく、あえてTTSノードを使っています。


# 環境
* BluemixのダッシュボードでText-to-Speechのサービスを追加し、Node-REDサーバにバインドします。
* ベースとなるチャット画面は[こちら](https://www.ibm.com/developerworks/jp/cloud/library/cl-rtchat-app/)を参考にインポート。

# 画面
![チャット画面](https://qiita-image-store.s3.amazonaws.com/0/113316/b0fadfc3-abab-0ecc-e388-d4d1b6bf1136.png "スクリーンショット 2016-03-24 22.16.09.png")

チャット画面上部にオーディオタグを追加しました。
`<audio controls id="audio"></audio>`

画面下部の"What do you want to say?"と書いてあるボックスにテキストを入力し、Sendボタンを押します。
入力したテキストをTTSで音声データ（wav）に変換し、チャット画面上部のオーディオで再生するように実装します。

# 実装
![Kobito.DnxvJB.png](https://qiita-image-store.s3.amazonaws.com/0/113316/2ee9c355-d9e2-c13c-625e-fea94f5ed893.png "Kobito.DnxvJB.png")

text to speechノードの呼び出しはWebSocketを使用しています。
websocketには、入力したテキストをmsg.payloadに設定して渡しています。

```
    var payload = {
      message: message.value,
      user: user.value,
      ts: (new Date()).getTime(),
      payload: message.value
    };
```


text to speechで変換された音声データは、戻り値のpayload.speech.dataにバイナリデータ(wav)として設定されています。

```
"speech": { "type":"Buffer", "data":[82, 73, ...]}
```

このままではaudio.srcに設定できないので、Blobに変換してcreateObjectURL(blob)して設定します。

```
function playTTS(payload){
    var wavString = payload.speech.data;
    // console.log(wavString);
    var len = wavString.length;
    // console.log(len);
    var buf = new ArrayBuffer(len);
    // console.log(buf);
    var view = new Uint8Array(buf);
    for (var i = 0; i < len; i++) {
      view[i] = wavString[i] & 0xff;
    }
    console.log(view);
    var blob = new Blob([view], {type: "audio/wav"});
    var URL = window.URL || window.webkitURL
    var blobUrl = URL.createObjectURL(blob);
    // console.log(blobUrl);
    var audio = document.getElementById('audio');
    audio.src = blobUrl;
    audio.load();
    audio.preload = 'auto';
    audio.play();
  }
```

このあたりの実装方法を調べていたところ、viewに値を突っ込んでいるところで
wavString.charCodeAt(i) & 0xff
となっていましたが、今回はwavStringがStringではなくArrayだったため
wavString[i] & 0xff としています。

charCodeAt(i) & 0xffというのをなぜやらないといけないのかというのは、[こちら](https://javascript.g.hatena.ne.jp/edvakf/20100607/1275931930)の説明がわかりやすい。

# まとめ
* TTSノードからの戻り値のバイナリデータをaudioで出力させる方法を調べるのに手間取りました。
* Bluemix Node-REDのTTSノードは日本語対応していないため、API経由で呼び出したほうがいろいろ使えそうですね。Language Translationで翻訳した文を再生してみるのもいいかもしれません。
* MacbookのSafariでは再生時にエラーになります。Chromeでは再生できました。解析できていないので、もう少し調べてみないといけませんね。


# コード
```
[{"id":"68a12744.975ed8","type":"websocket-listener","z":"52dbf05.fad241","path":"/ws/english","wholemsg":"true"},{"id":"804b9d82.96fcd","type":"http in","z":"800bc702.676838","name":"","url":"/speech","method":"get","swaggerDoc":"","x":236,"y":349,"wires":[["2cbc3872.627ae"]]},{"id":"2cbc3872.627ae","type":"template","z":"800bc702.676838","name":"html","field":"","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<head>\n  <meta name=\"viewport\" content=\"width=320, initial-scale=1\">\n  <title>speech!</title>\n</head>\n\n<body>\n  <div id=\"wrapper\">\n    <div id=\"chat_box\" class=\"content\">\n            <p><audio controls id=\"audio\"></audio></p>\n    </div>\n\n    <div id=\"footer\">\n      <div class=\"content\">\n        <input type=\"text\" id=\"user\" placeholder=\"Who are you?\" />\n        <input type=\"text\" id=\"message\" placeholder=\"What do you want to say?\" />\n        <input type=\"button\" id=\"send_btn\" value=\"Send\" onclick=\"sendMessage()\">\n      </div>\n    </div>\n  </div>\n</body>\n\n<script type=\"text/javascript\">\n  var wsUri = \"ws://{{req.headers.host}}/ws/english\";\n  var ws = new WebSocket(wsUri);\n\n  function createSystemMessage(message) {\n    var message = document.createTextNode(message);\n\n    var messageBox = document.createElement('p');\n    messageBox.className = 'system';\n\n    messageBox.appendChild(message);\n\n    var chat = document.getElementById('chat_box');\n    chat.appendChild(messageBox);\n  }\n\n  function createUserMessage(user, message) {\n    var user = document.createTextNode(user + ': ');\n\n    var userBox = document.createElement('span');\n    userBox.className = 'username';\n    userBox.appendChild(user);\n\n    var message = document.createTextNode(message);\n\n    var messageBox = document.createElement('p');\n    messageBox.appendChild(userBox);\n    messageBox.appendChild(message);\n\n    var chat = document.getElementById('chat_box');\n    chat.appendChild(messageBox);\n  }\n\n  function playTTS(payload){\n    var wavString = payload.speech.data;\n    // console.log(wavString);\n    var len = wavString.length;\n    // console.log(len);\n    var buf = new ArrayBuffer(len);\n    // console.log(buf);\n    var view = new Uint8Array(buf);\n    for (var i = 0; i < len; i++) {\n      view[i] = wavString[i] & 0xff;\n    }\n    console.log(view);\n    var blob = new Blob([view], {type: \"audio/wav\"});\n    var URL = window.URL || window.webkitURL\n    var blobUrl = URL.createObjectURL(blob);\n    // console.log(blobUrl);\n    var audio = document.getElementById('audio');\n    audio.src = blobUrl;\n    audio.load();\n    audio.preload = 'auto';\n    audio.play();\n  }\n\n  ws.onopen = function(ev) {\n    createSystemMessage('[Connected]');\n  };\n\n  ws.onclose = function(ev) {\n    createSystemMessage('[Disconnected]');\n  }\n\n  ws.onmessage = function(ev) {\n    var payload = JSON.parse(ev.data);\n    createUserMessage(payload.user, payload.message);\n    playTTS(payload);\n\n    var chat = document.getElementById('chat_box');\n    chat.scrollTop = chat.scrollHeight;\n  }\n\n  function sendMessage() {\n    var user = document.getElementById('user');\n    var message = document.getElementById('message');\n\n    var payload = {\n      message: message.value,\n      user: user.value,\n      ts: (new Date()).getTime(),\n      payload: message.value\n    };\n\n    ws.send(JSON.stringify(payload));\n    message.value = \"\";\n  };\n</script>\n\n<style type=\"text/css\">\n  * {\n    font-family: \"Palatino Linotype\", \"Book Antiqua\", Palatino, serif;\n    font-style: italic;\n    font-size: 24px;\n  }\n\n  html, body, #wrapper {\n    margin: 0;\n    padding: 0;\n    height: 100%;\n  }\n\n  #wrapper {\n    background-color: #ecf0f1;\n  }\n\n  #chat_box {\n    box-sizing: border-box;\n    height: 100%;\n    overflow: auto;\n    padding-bottom: 50px;\n  }\n\n  #footer {\n    box-sizing: border-box;\n    position: fixed;\n    bottom: 0;\n    height: 50px;\n    width: 100%;\n    background-color: #2980b9;\n  }\n\n  #footer .content {\n    padding-top: 4px;\n    position: relative;\n  }\n\n  #user { width: 20%; }\n  #message { width: 68%; }\n  #send_btn {\n    width: 10%;\n    position: absolute;\n    right: 0;\n    bottom: 0;\n    margin: 0;\n  }\n\n  .content {\n    width: 70%;\n    margin: 0 auto;\n  }\n\n  input[type=\"text\"],\n  input[type=\"button\"] {\n    border: 0;\n    color: #fff;\n  }\n\n  input[type=\"text\"] {\n    background-color: #146EA8;\n    padding: 3px 10px;\n  }\n\n  input[type=\"button\"] {\n    background-color: #f39c12;\n    border-right: 2px solid #e67e22;\n    border-bottom: 2px solid #e67e22;\n    min-width: 70px;\n    display: inline-block;\n  }\n\n  input[type=\"button\"]:hover {\n    background-color: #e67e22;\n    border-right: 2px solid #f39c12;\n    border-bottom: 2px solid #f39c12;\n    cursor: pointer;\n  }\n\n  .system,\n  .username {\n    color: #aaa;\n    font-style: italic;\n    font-family: monospace;\n    font-size: 16px;\n  }\n\n  @media(max-width: 1000px) {\n    .content { width: 90%; }\n  }\n\n  @media(max-width: 780px) {\n    #footer { height: 91px; }\n    #chat_box { padding-bottom: 91px; }\n\n    #user { width: 100%; }\n    #message { width: 80%; }\n  }\n\n  @media(max-width: 400px) {\n    #footer { height: 135px; }\n    #chat_box { padding-bottom: 135px; }\n\n    #message { width: 100%; }\n    #send_btn {\n      position: relative;\n      margin-top: 3px;\n      width: 100%;\n    }\n  }\n</style>\n","x":425,"y":348,"wires":[["73d2e2e0.a0d35c"]]},{"id":"73d2e2e0.a0d35c","type":"http response","z":"800bc702.676838","name":"","x":607,"y":348,"wires":[]},{"id":"9852e538.cee01","type":"websocket in","z":"800bc702.676838","name":"","server":"68a12744.975ed8","client":"","x":187,"y":257,"wires":[["600df703.08308"]]},{"id":"600df703.08308","type":"function","z":"800bc702.676838","name":"","func":"delete msg._session;\n//msg.payload = msg.message;\nreturn msg;\n\n","outputs":1,"noerr":0,"x":351,"y":257,"wires":[["b8226832.18236"]]},{"id":"8a76e158.d0166","type":"websocket out","z":"800bc702.676838","name":"","server":"68a12744.975ed8","client":"","x":737,"y":258,"wires":[]},{"id":"b8226832.18236","type":"watson-text-to-speech","z":"800bc702.676838","name":"","lang":"english","voice":"en-US_MichaelVoice","format":"audio/wav","x":532,"y":258,"wires":[["8a76e158.d0166","b7ce263.5b52858"]]},{"id":"b7ce263.5b52858","type":"debug","z":"800bc702.676838","name":"","active":false,"console":"false","complete":"true","x":731,"y":204,"wires":[]}]
```