目的
Node-REDとWatsonAPIを使いたい!というわけで、BluemixのNode-REDでText to Speechノードを使い、入力したテキスト(英語)を読み上げるというものを作りました。
API経由で呼び出す方法ではなく、あえてTTSノードを使っています。
環境
- BluemixのダッシュボードでText-to-Speechのサービスを追加し、Node-REDサーバにバインドします。
- ベースとなるチャット画面はこちらを参考にインポート。
画面
チャット画面上部にオーディオタグを追加しました。
<audio controls id="audio"></audio>
画面下部の"What do you want to say?"と書いてあるボックスにテキストを入力し、Sendボタンを押します。
入力したテキストをTTSで音声データ(wav)に変換し、チャット画面上部のオーディオで再生するように実装します。
実装
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というのをなぜやらないといけないのかというのは、こちらの説明がわかりやすい。
まとめ
- 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":[]}]