RaspberryPi Node-REDのカードリーダーからBluemix Node-REDのWatson Text To Speechを合わせて魔法発動ぽいカードをつくる

この記事はBluemixアドベントカレンダーに参加しよう!|teratail(テラテイル)の19日目の記事です。

前日の記事はo_chicchiさんの簡単な Watson Conversation 入門 – Qiitaの記事です。

RaspberryPi Node-REDにつないだカードリーダーとBluemix Node-REDのText To Speechを使って魔法発動ぽいカード(魔法名しゃべるカード)をつくります。

littleBits MAKEY MAKEYモジュールをNode-REDで読み取る – Qiita

こちらができるようになったので、キー入力に変換するカードリーダーについても待ち受けられると思いやってみます。

ぶっちゃけると、Watson Text To Speechをテストしてたんですが、真っ当なネタも思いついたけども、それよりも滑舌の良さ&声色の良さというところが気になってしまったので「これ、魔法的な言葉喋らせると素敵なんじゃないか!」と思ったとしか言いようがありません。

主な流れ

今回は以下のような流れを考えています。

  • RaspberryPi Node-REDにつないだカードリーダーでカードID読み取り
  • カードIDに合わせて魔法名を決めてMilkcocoaでBluemix Node-REDに送信
  • Bluemix Node-REDが受け取ってWatson Text To Speechで喋らせて魔法名を読み上げる
  • Good!!!

要するに完成品としてはこういうことです。

ファイヤボール!!!

では早速はじめてみましょう。

Bluemix Node-REDの設定

まず、Text To Speechが機能するように、関連付けを行います。

Text To Speechの接続を立ち上げます。

接続の作成ボタンを押して、

今回使うNode-REDを選んで、

このように使えるようにします。

Text To Speechの実装をサンプルを参考に行う

今回は、以下の記事を参考に、

Node-REDのText-to-Speech Nodeを使ってブラウザ上で音声を出力する | Urara's Blog

node-red-labs/basic_examples/text_to_speech at master · watson-developer-cloud/node-red-labs

以下のフローを使いました。

node-red-labs/tts_lab_webpage.json at master · watson-developer-cloud/node-red-labs

さらに手を加えてリアルタイムにしゃべるようにする

このサンプルですと、フォームにいちいち入れないと、音声が発動しないので、生成されるページにMilkcocoaを噛ませて文言が来たら文言をURLに付与しつつリロードして、自動発話させるようにします。

コメント部分を変更しました。

Genearate Replay と に部分がHTMLを生成する部分なので変更します。

GetTextToSay

<!DOCTYPE HTML>
<html>
    <head>
    <title>Simple Live Display</title>
    <script src="https://cdn.mlkcca.com/v0.6.0/milkcocoa.js"></script>
    <script>
        var milkcocoa = new MilkCocoa('          .mlkcca.com');
        var ds = milkcocoa.dataStore('messages');
        ds.on('send', function(sended) {
            window.location.replace(window.location.protocol + "//" + window.location.host + "/talk?text_to_say="+sended.value.cardText);
        });
    </script>
    </head>
    <body>
        <h1>Enter text to Say</h1>
        <form action="{{req._parsedUrl.pathname}}" method="get">
            <input type="text" name="text_to_say" id="" value="{{payload.text_to_say}}" />
            <input type="submit" value="Say it!"/>
        </form>
    </body>
</html>

Genearate Replay

<!DOCTYPE HTML>
<html>
    <head>
    <title>Simple Live Display</title>
    <script src="https://cdn.mlkcca.com/v0.6.0/milkcocoa.js"></script>
    <script>
        var milkcocoa = new MilkCocoa('           .mlkcca.com');
        var ds = milkcocoa.dataStore('messages');
        ds.on('send', function(sended) {
            window.location.replace(window.location.protocol + "//" + window.location.host + "/talk?text_to_say="+sended.value.cardText);
        });
    </script>
    </head>
    <body>
        <h1>You want to sa.y</h1>
        <p><q>{{payload}}</q></p>
        <p>Hear it:</p>
        <audio controls autoplay="autoplay">
          <source src="{{req._parsedUrl.pathname}}/sayit?text_to_say={{payload.text_to_say}}" type="audio/wav">
        Your browser does not support the audio element.
        </audio>
        <p>Thanks!</p>
        <form action="{{req._parsedUrl.pathname}}">
            <input type="text" name="text_to_say" id="" value="{{payload.text_to_say}}" />
            <input type="submit" value="Try Again" />
        </form>
    </body>
</html>

地味な自動再生修正

ソース中の自動再生させるところが、元ソースだと

<audio controls autoplay>

という省略形だと何故かうまく再生されなかったので

<audio controls autoplay="autoplay">

こうしたところ、WIndows Chromeでは無事再生されました。ほかはまだ未検証。

ということで、これらの対応を行うとMilkcocoaに文言が来るたびに

文言を話すようになります。

フローもおいておきます

[{"id":"586d6a33.b1a554","type":"change","z":"c437d77a.3187e8","name":"text to payload","rules":[{"t":"set","p":"payload","to":"msg.payload.text_to_say"},{"t":"set","p":"text_to_say","to":"msg.payload"}],"action":"","property":"","from":"","to":"","reg":false,"x":918,"y":433,"wires":[["3cbd1f9a.03dd4"]]},{"id":"e59fc5a4.dfe388","type":"http in","z":"c437d77a.3187e8","name":"","url":"/talk","method":"get","swaggerDoc":"","x":550,"y":692,"wires":[["f745a49d.a0a5e8"]]},{"id":"f745a49d.a0a5e8","type":"switch","z":"c437d77a.3187e8","name":"check text","property":"payload.text_to_say","propertyType":"msg","rules":[{"t":"nnull"},{"t":"else"}],"checkall":"false","outputs":2,"x":712,"y":691,"wires":[["a9bcb78b.b9dcd8"],["929e4cfb.925a7"]]},{"id":"929e4cfb.925a7","type":"template","z":"c437d77a.3187e8","name":"GetTextToSay","field":"payload","fieldType":"msg","format":"html","syntax":"mustache","template":"<!DOCTYPE HTML>\n<html>\n    <head>\n    <title>Simple Live Display</title>\n    <script src=\"https://cdn.mlkcca.com/v0.6.0/milkcocoa.js\"></script>\n    <script>\n        var milkcocoa = new MilkCocoa('          .mlkcca.com');\n        var ds = milkcocoa.dataStore('messages');\n        ds.on('send', function(sended) {\n            window.location.replace(window.location.protocol + \"//\" + window.location.host + \"/talk?text_to_say=\"+sended.value.cardText);\n        });\n    </script>\n    </head>\n    <body>\n        <h1>Enter text to Say</h1>\n        <form action=\"{{req._parsedUrl.pathname}}\" method=\"get\">\n            <input type=\"text\" name=\"text_to_say\" id=\"\" value=\"{{payload.text_to_say}}\" />\n            <input type=\"submit\" value=\"Say it!\"/>\n        </form>\n    </body>\n</html>\n","x":979,"y":733,"wires":[["599c8966.524578"]]},{"id":"599c8966.524578","type":"http response","z":"c437d77a.3187e8","name":"Reply","x":1196,"y":701,"wires":[]},{"id":"a9bcb78b.b9dcd8","type":"template","z":"c437d77a.3187e8","name":"Generate Reply","field":"payload","fieldType":"msg","format":"html","syntax":"mustache","template":"<!DOCTYPE HTML>\n<html>\n    <head>\n    <title>Simple Live Display</title>\n    <script src=\"https://cdn.mlkcca.com/v0.6.0/milkcocoa.js\"></script>\n    <script>\n        var milkcocoa = new MilkCocoa('          .mlkcca.com');\n        var ds = milkcocoa.dataStore('messages');\n        ds.on('send', function(sended) {\n            window.location.replace(window.location.protocol + \"//\" + window.location.host + \"/talk?text_to_say=\"+sended.value.cardText);\n        });\n    </script>\n    </head>\n    <body>\n        <h1>You want to sa.y</h1>\n        <p><q>{{payload}}</q></p>\n        <p>Hear it:</p>\n        <audio controls autoplay=\"autoplay\">\n          <source src=\"{{req._parsedUrl.pathname}}/sayit?text_to_say={{payload.text_to_say}}\" type=\"audio/wav\">\n        Your browser does not support the audio element.\n        </audio>\n        <p>Thanks!</p>\n        <form action=\"{{req._parsedUrl.pathname}}\">\n            <input type=\"text\" name=\"text_to_say\" id=\"\" value=\"{{payload.text_to_say}}\" />\n            <input type=\"submit\" value=\"Try Again\" />\n        </form>\n    </body>\n</html>\n","x":980,"y":659,"wires":[["599c8966.524578"]]},{"id":"dd610f1a.f2977","type":"http in","z":"c437d77a.3187e8","name":"","url":"/talk/sayit","method":"get","swaggerDoc":"","x":566,"y":472,"wires":[["28c51b7f.394584"]]},{"id":"cec15443.25f678","type":"http response","z":"c437d77a.3187e8","name":"Reply Speech binary","x":1376,"y":543,"wires":[]},{"id":"3cbd1f9a.03dd4","type":"watson-text-to-speech","z":"c437d77a.3187e8","name":"","lang":"en-US","langhidden":"en-US","voice":"en-US_MichaelVoice","voicehidden":"","format":"audio/ogg; codecs=opus","password":"","x":1035,"y":372,"wires":[["ea689275.fd913"]]},{"id":"28c51b7f.394584","type":"switch","z":"c437d77a.3187e8","name":"Check text","property":"payload.text_to_say","rules":[{"t":"nnull"},{"t":"else"}],"checkall":"true","outputs":2,"x":743,"y":479,"wires":[["586d6a33.b1a554"],["e2365e14.cf8b9"]]},{"id":"e2365e14.cf8b9","type":"template","z":"c437d77a.3187e8","name":"Error","field":"payload","format":"handlebars","template":"<h1>\nError:    No f or text_to_say query parameter\n</h1>","x":882,"y":526,"wires":[["cec15443.25f678"]]},{"id":"ea689275.fd913","type":"change","z":"c437d77a.3187e8","name":"Speech to payload","rules":[{"t":"set","p":"payload","pt":"msg","to":"speech","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":1154,"y":433,"wires":[["d8c98bd4.3a7048"]]},{"id":"d8c98bd4.3a7048","type":"function","z":"c437d77a.3187e8","name":"Set headers","func":"// Set the content type to audio wave\nvar wavFileName=msg.text_to_say.replace(' ','_');\nif(wavFileName.length>32) {\n    wavFileName=wavFileName.substr(0,32);\n}\nvar attch='attachment; filename='+encodeURIComponent(wavFileName+'.wav');\nmsg.headers={ 'Content-Type': 'audio/wav',\n              'Content-Disposition': attch};\n\nreturn msg;","outputs":1,"noerr":0,"x":1215,"y":487,"wires":[["cec15443.25f678"]]},{"id":"45573073.77325","type":"comment","z":"c437d77a.3187e8","name":"Milkcocoaコード追加","info":"","x":990,"y":613,"wires":[]},{"id":"a8de1fb0.5dde8","type":"comment","z":"c437d77a.3187e8","name":"Milkcocoaコード追加","info":"","x":990,"y":776,"wires":[]},{"id":"f073f723.dac7b8","type":"comment","z":"c437d77a.3187e8","name":"US EnglishのMichaelの声","info":"","x":1053,"y":326,"wires":[]}]

カードリーダー

カードリーダについては

を使いました。

このようにRaspberryPiにカードリーダーを差し込んだ上で、

littleBits MAKEY MAKEYモジュールをNode-REDで読み取る – Qiita

をベースにNode-REDの設定をしていきます。

RaspberryPi Node-REDの設定

このようなフローです。

HIDのIDはこのようなVID 65535 ・PID 53 で取得が可能です。

カード読み取り部分

カード読み取り部分のソースは以下の通りで、ENTERコードが来るまで値をためてカードIDはを構築します。

var stock = flow.get('stock')||'';

var KEYCODES = {
    "0x00":"[BLANK]",
    "0x04":"A",
    "0x05":"B",
    "0x06":"C",
    "0x07":"D",
    "0x08":"E",
    "0x09":"F",
    "0x0A":"G",
    "0x0B":"H",
    "0x0C":"I",
    "0x0D":"J",
    "0x0E":"K",
    "0x0F":"L",
    "0x10":"M",
    "0x11":"N",
    "0x12":"O",
    "0x13":"P",
    "0x14":"Q",
    "0x15":"R",
    "0x16":"S",
    "0x17":"T",
    "0x18":"U",
    "0x19":"V",
    "0x1A":"W",
    "0x1B":"X",
    "0x1C":"Y",
    "0x1D":"Z",
    "0x1E":"1",
    "0x1F":"2",
    "0x20":"3",
    "0x21":"4",
    "0x22":"5",
    "0x23":"6",
    "0x24":"7",
    "0x25":"8",
    "0x26":"9",
    "0x27":"0",
    "0x28":"[ENTER]"
}


// Bufferを文字列化
var buffer = msg.payload.toString('hex', 0, msg.payload.length);

var code = KEYCODES["0x"+buffer.substr(4,2).toUpperCase()];

msg = null;

if( code == '[BLANK]'){
    msg = null;
} else if( code == '[ENTER]'){
    msg = {payload:stock};
    flow.set('stock','');
} else {
    stock += code;
    flow.set('stock',stock);
}

return msg;

実際に読み込ませればカードIDが取得できます!もう少しだ。

魔法名呼び出し

魔法名呼び出しの部分は、カードに魔法名をラベルでつけて、

それに合わせてif文で名前を割り当てていきます。

var cardID = msg.payload;

msg.payload = {};

if(cardID == "05846624"){
    msg.payload.cardText = "Fire Ball";
} else if(cardID == "05846625"){
    msg.payload.cardText = "Calamity Wall";
} else if(cardID == "05846626"){
    msg.payload.cardText = "Ice Needle";
} else {
    msg.payload.cardText = "No Magic";
}
return msg;

Fire Ball!Calamity Wall!Ice Needle!

これでMilkcocoaに流してやれば出来上がりです。

実際動かしてみる

Bluemixのサーバーアドレス /talk ページを表示した状態で待ちます。

いざカードリーダ準備。

実際にカードを置いてみます。

カラミティウォール!!!

動画も見てみましょう。

無事野太い声で再生されました!

フローも置いておきます

[{"id":"4cdc575f.797028","type":"milkcocoa","z":"e1fbbb8f.eb5758","appId":"hotir0lok5g"},{"id":"80dddfe3.b8d06","type":"HIDConfig","z":"e1fbbb8f.eb5758","vid":"65535","pid":"53","name":""},{"id":"dfa9f2aa.e8174","type":"HIDdevice","z":"e1fbbb8f.eb5758","connection":"80dddfe3.b8d06","name":"","x":153,"y":107,"wires":[["b9d783cd.323b6"],[]]},{"id":"b9d783cd.323b6","type":"function","z":"e1fbbb8f.eb5758","name":"カードデータ読み取り","func":"var stock = flow.get('stock')||'';\n\nvar KEYCODES = {\n    \"0x00\":\"[BLANK]\",\n    \"0x04\":\"A\",\n    \"0x05\":\"B\",\n    \"0x06\":\"C\",\n    \"0x07\":\"D\",\n    \"0x08\":\"E\",\n    \"0x09\":\"F\",\n    \"0x0A\":\"G\",\n    \"0x0B\":\"H\",\n    \"0x0C\":\"I\",\n    \"0x0D\":\"J\",\n    \"0x0E\":\"K\",\n    \"0x0F\":\"L\",\n    \"0x10\":\"M\",\n    \"0x11\":\"N\",\n    \"0x12\":\"O\",\n    \"0x13\":\"P\",\n    \"0x14\":\"Q\",\n    \"0x15\":\"R\",\n    \"0x16\":\"S\",\n    \"0x17\":\"T\",\n    \"0x18\":\"U\",\n    \"0x19\":\"V\",\n    \"0x1A\":\"W\",\n    \"0x1B\":\"X\",\n    \"0x1C\":\"Y\",\n    \"0x1D\":\"Z\",\n    \"0x1E\":\"1\",\n    \"0x1F\":\"2\",\n    \"0x20\":\"3\",\n    \"0x21\":\"4\",\n    \"0x22\":\"5\",\n    \"0x23\":\"6\",\n    \"0x24\":\"7\",\n    \"0x25\":\"8\",\n    \"0x26\":\"9\",\n    \"0x27\":\"0\",\n    \"0x28\":\"[ENTER]\"\n}\n\n\n// Bufferを文字列化\nvar buffer = msg.payload.toString('hex', 0, msg.payload.length);\n\nvar code = KEYCODES[\"0x\"+buffer.substr(4,2).toUpperCase()];\n\nmsg = null;\n\nif( code == '[BLANK]'){\n    msg = null;\n} else if( code == '[ENTER]'){\n    msg = {payload:stock};\n    flow.set('stock','');\n} else {\n    stock += code;\n    flow.set('stock',stock);\n}\n\nreturn msg;","outputs":1,"noerr":0,"x":450,"y":149,"wires":[["f64d457e.d4fda8","6792b83e.fdbc38"]]},{"id":"f64d457e.d4fda8","type":"function","z":"e1fbbb8f.eb5758","name":"魔法名呼び出し","func":"var cardID = msg.payload;\n\nmsg.payload = {};\n\nif(cardID == \"18240708\"){\n    msg.payload.cardText = \"Fire Ball\";\n} else if(cardID == \"05856624\"){\n    msg.payload.cardText = \"Calamity Wall\";\n} else if(cardID == \"05518784\"){\n    msg.payload.cardText = \"Ice Needle\";\n} else {\n    msg.payload.cardText = \"No Magic\";\n}\nreturn msg;","outputs":1,"noerr":0,"x":769,"y":192,"wires":[["c3839e8b.01c41"]]},{"id":"c48c210c.8df64","type":"inject","z":"e1fbbb8f.eb5758","name":"","topic":"","payload":"05846625","payloadType":"str","repeat":"","crontab":"","once":false,"x":532,"y":248,"wires":[["f64d457e.d4fda8"]]},{"id":"c3839e8b.01c41","type":"milkcocoa out","z":"e1fbbb8f.eb5758","milkcocoa":"4cdc575f.797028","dataStore":"messages","operation":"send","targetId":"","name":"","x":994,"y":194,"wires":[[]]},{"id":"6792b83e.fdbc38","type":"debug","z":"e1fbbb8f.eb5758","name":"","active":true,"console":"false","complete":"false","x":697,"y":88,"wires":[]}]

ふりかえり

RaspberryPi Node-REDにつないだカードリーダーとBluemix Node-REDのWatson Text To Speechを使って魔法発動ぽいカードネタ、完了です。

GoogleのWeb Speech APIも楽しいのですが、こちらのほうがいろいろな場所でしっかり喋らせることができそうなのと、なによりも妙なカッコよさがあるので、さらに映像や反応を仕込んでみるのも楽しいなと思いました。

それでは、よき、Node-RED & Raspberry Pi & Watson Lifeを!