Raspberry Pi + Juliusで、音声認識→カレンダー登録QR用コード生成をやってみる 前編

引き続きRaspberryPiでいろいろやってみます。

音声は昔からあるインターフェースですが、
音声アシスタントやスマートスピーカーの台頭により、
ここ数年で一気に使用頻度が上がったように感じます。
このあたりで一度触れておかないと完全に置いていかれそうなので、
遊びながら触っていこうと思います。
長くなったので2回に分けます。
今回は遊んだ記録を書いただけなので、こんなのあるんだー程度で読んでください。

使用ライブラリで遊んでみる編(こんかい)
実際の制作編(まだ)

成果物

画像1

[{"id":"dc3d9dbc.bae4f","type":"switch","z":"11d85bf6.e8a1e4","name":"RECOGOUT ?","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"<RECOGOUT>","vt":"str"},{"t":"eq","v":"</RECOGOUT>","vt":"str"},{"t":"else"}],"checkall":"true","repair":false,"outputs":3,"x":160,"y":1160,"wires":[["c299c398.f39e6"],["f425f465.d1a668"],["7779cce5.a51de4"]]},{"id":"f425f465.d1a668","type":"change","z":"11d85bf6.e8a1e4","name":"","rules":[{"t":"set","p":"payload","pt":"msg","to":"$flowContext('msgMarge') & payload","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":600,"y":1160,"wires":[["43beafc2.f15e5"]]},{"id":"c299c398.f39e6","type":"change","z":"11d85bf6.e8a1e4","name":"init flow.msgMarge ","rules":[{"t":"set","p":"msgMarge","pt":"flow","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":410,"y":1120,"wires":[[]]},{"id":"7779cce5.a51de4","type":"function","z":"11d85bf6.e8a1e4","name":"set flow.msgMarge ","func":"flow.set(\"msgMarge\", flow.get(\"msgMarge\")+ msg.payload);\n\n// flow.msgMarge = flow.msgMarge + msg.payload\nreturn msg;","outputs":1,"noerr":0,"x":410,"y":1200,"wires":[[]]},{"id":"4e2a9968.fd6d78","type":"tcp in","z":"11d85bf6.e8a1e4","name":"","server":"client","host":"localhost","port":"10500","datamode":"stream","datatype":"utf8","newline":"\\n","topic":"","base64":false,"x":130,"y":1100,"wires":[["dc3d9dbc.bae4f"]]},{"id":"43beafc2.f15e5","type":"xml","z":"11d85bf6.e8a1e4","name":"","property":"payload","attr":"","chr":"","x":790,"y":1160,"wires":[["6fdb33d5.186a7c"]]},{"id":"6fdb33d5.186a7c","type":"change","z":"11d85bf6.e8a1e4","name":"","rules":[{"t":"set","p":"tmpArray","pt":"msg","to":"payload.RECOGOUT.SHYPO.WHYPO[].\"$\".WORD","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":790,"y":1220,"wires":[["685f1990.f92d08"]]},{"id":"685f1990.f92d08","type":"function","z":"11d85bf6.e8a1e4","name":"convert 漢数字 to [0-9]","func":"/********************************************************\n *\n *  漢数字をアラビア数字にする\n *\n *  Copyright (c) 2005 AOK. All Rights Reserved.\n *\n ********************************************************/\n // from http://aok.blue.coocan.jp/jscript/kan2arb.html\n\nvar suuji = \"〇一二三四五六七八九零壱弐参肆伍陸質捌玖零壹貳參\";\nvar keta1 = \"十百千拾佰仟十陌阡\";\nvar keta2 = \"万億兆萬\";\n\nvar toArb = function(kanji) {\n  var i, r, a, b = 0, c, d, t = 0, f = false;\n  for (i = 0; i < kanji.length; i++) {\n    c = kanji.charAt(i);\n    if ((r = suuji.indexOf(c)) != -1) {\n      if (f === false) {\n        b += r % 10;\n        f = true;\n      } else {\n        b = b * 10 + r % 10;\n      }\n    } else if ((r = keta1.indexOf(c)) != -1) {\n      t += b;\n      d = t % 10;\n      a = (d === 0 ? 1 : d) * Math.pow(10, r % 3 + 1);\n      t += a - d;\n      b = 0;\n      f = false;\n    } else if ((r = keta2.indexOf(c)) != -1) {\n      t += b;\n      d = t % 10000;\n      a = d * Math.pow(10000, r % 3 + 1);\n      t += a - d;\n      b = 0;\n      f = false;\n    } else {\n      return kanji;\n    }\n  }\n  return t + b;\n};\n\n\nmsg.payload = msg.tmpArray.join('').replace('時半','時30分').replace('時から','時00分から')\n\nmsg.payload = msg.payload.split('').map(v=> toArb(v)).join('')\n\n\nreturn msg;","outputs":1,"noerr":0,"x":800,"y":1280,"wires":[["bf076bc9.f68b98"]]},{"id":"dcc33619.dc0a48","type":"debug","z":"11d85bf6.e8a1e4","name":"end","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":990,"y":1660,"wires":[]},{"id":"bf076bc9.f68b98","type":"switch","z":"11d85bf6.e8a1e4","name":"contains 'イベント' ?","property":"payload","propertyType":"msg","rules":[{"t":"cont","v":"イベント","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":800,"y":1340,"wires":[["7767eff4.3d8bc"]]},{"id":"4dcd0f0f.c8c0a","type":"exec","z":"11d85bf6.e8a1e4","command":"python3  ~/Documents/work/myScript.py","addpay":true,"append":"","useSpawn":"false","timer":"","oldrc":false,"name":"python kick","x":810,"y":1660,"wires":[["dcc33619.dc0a48"],["dcc33619.dc0a48"],["dcc33619.dc0a48"]]},{"id":"bc12009e.a9889","type":"function","z":"11d85bf6.e8a1e4","name":"set iCal","func":"\nconst targetStr = msg.payload.substr(msg.payload.indexOf('イベント') + 4)\nconst eventName = targetStr.substr(0,targetStr.indexOf(targetStr.match(/\\d+/g)[0]))\nconst tmpNum = targetStr.match(/\\d+/g)\nconst startDay = new Date(`${new Date().getFullYear()}/${tmpNum[0]}/${tmpNum[1]} ${tmpNum[2]}:${tmpNum[3]}`)\nstartDay.setHours(startDay.getHours() + 9)\nconst startDayIso =startDay.toISOString().replace(/-|:|\\./g,'').slice(0,-4)\nstartDay.setHours(startDay.getHours() + parseInt(tmpNum[4]))\n\nmsg.payload = \n`BEGIN:VEVENT\nDTSTART;TZID=Asia/Tokyo:${startDayIso}\nDTEND;TZID=Asia/Tokyo:${startDay.toISOString().replace(/-|:|\\./g,'').slice(0,-4)}\nDESCRIPTION:${eventName}\nSUMMARY:${eventName}\nUID:test@test\nSTATUS:CONFIRMED\nTRANSP:OPAQUE\nEND:VEVENT\n`\n\nreturn msg;","outputs":1,"noerr":0,"x":800,"y":1520,"wires":[["b221cc3a.de35f"]]},{"id":"7767eff4.3d8bc","type":"change","z":"11d85bf6.e8a1e4","name":"","rules":[{"t":"set","p":"payload","pt":"msg","to":"$substringAfter(payload, 'イベント')","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":800,"y":1400,"wires":[["443ec576.57e36c"]]},{"id":"443ec576.57e36c","type":"switch","z":"11d85bf6.e8a1e4","name":"num length == 5 ?","property":"$count($match(payload, /\\d+/))","propertyType":"jsonata","rules":[{"t":"eq","v":"5","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":810,"y":1460,"wires":[["bc12009e.a9889"]]},{"id":"b221cc3a.de35f","type":"file","z":"11d85bf6.e8a1e4","name":"file create","filename":"/home/pi/Documents/work/cal.txt","appendNewline":true,"createDir":false,"overwriteFile":"true","x":800,"y":1580,"wires":[["4dcd0f0f.c8c0a"]]},{"id":"16e645cb.1bdcaa","type":"comment","z":"11d85bf6.e8a1e4","name":"<RECOGOUT>","info":"","x":360,"y":1080,"wires":[]},{"id":"c1dc48a7.728d68","type":"comment","z":"11d85bf6.e8a1e4","name":"other","info":"","x":330,"y":1240,"wires":[]},{"id":"2193fdac.569e32","type":"comment","z":"11d85bf6.e8a1e4","name":"</RECOGOUT>","info":"","x":360,"y":1160,"wires":[]},{"id":"18bb9159.ac8b7f","type":"comment","z":"11d85bf6.e8a1e4","name":"true","info":"","x":970,"y":1340,"wires":[]},{"id":"a9497f1d.9fc4c","type":"comment","z":"11d85bf6.e8a1e4","name":"true","info":"","x":970,"y":1460,"wires":[]}]

なにこれ

登録したいイベントのタイトル・開始日時・期間(1h単位)を話すと、
Juliusで受け、解析し、上記のNode-REDで処理した後、
内容をiCalendar形式のQRコードにして電子ペーパーに表示します。
それをスマホのQRコードリーダなどで読み取るとカレンダーに登録ができます。
表示します、と記載していますが、実際には電子ペーパーのサイズが足らず、一部しか表示できません。ぐぬぬ。

当初の予定について

予定では、
1. AmazonEchoに発声
2. 専用スキル or IFTTTでイベント読み取り&情報加工
3. 自宅サーバにPOST
4. ディスプレイに表示
を考えていましたが、自宅サーバを外部公開するには(主にセキュリティ面の)知識が乏しく、、、断念しました。
音声の標準化処理などガバガバになりますが、全てローカルで作成します。
#「発声→Alexaで受け取り→ローカルで表示」までは、Alexaスキルの「Node-RED」を使用すると簡単に実装可能です。
#が、その場合細かい値渡しはできません。

使ったもの・技術

- ハード
  - RaspberryPi

  - 電子ペーパーモジュール

- ソフト・ライブラリなど
  - Julius
  - qrencode
  - Node-RED

Juliusで遊ぶ

JuliusはOSSの音声認識エンジンです。
詳しくはこちら

ヒト(筆者肉声)と、先日酔った勢いで購入したVOICEROID(妹のほう)で音声認識の精度を確認します。
ヒトはマイクからのオンライン入力、VOICEROIDはwavファイルに書き出して読み込み入力で実行しました。

海の公開を後悔する

ヒト
pass1_best:  公開 の 公開 を 後悔 する 。
pass1_best_wordseq: <s> 公開+名詞 の+助詞 公開+名詞 を+助詞 後悔+名詞 する+動詞 </s>
pass1_best_phonemeseq: silB | k o: k a i | n o | k o: k a i | o | k o: k a i | s u r u | silE
VOICEROID
pass1_best:  解約 ない しか ない 。
pass1_best_wordseq: <s> 解約+名詞 ない+助動詞 しか+助詞 ない+形容詞 </s>
pass1_best_phonemeseq: silB | k a i y a k u | n a i | sh i k a | n a i | silE

貴社の記者が汽車で帰社する

ヒト
pass1_best:  記者 の 記者 が 汽車 で 記者 する 。
pass1_best_wordseq: <s> 記者+名詞 の+助詞 記者+名詞 が+助詞 汽車+名詞 で+助詞 記者+名詞 する+動詞 </s>
pass1_best_phonemeseq: silB | k i sh a | n o | k i sh a | g a | k i sh a | d e | k i sh a | s u r u | silE
VOICEROID
pass1_best:  その 期間 客 さん。
pass1_best_wordseq: <s> その+連体詞 期間+名詞 客+名詞 さん+接尾辞 </s>
pass1_best_phonemeseq: silB | s o n o | k i k a N | ky a k u | s a N | silE

ここで、履き物を脱いでください

ヒト
pass1_best:  ここ で 履き物 を 脱い で ください 。
pass1_best_wordseq: <s> ここ+代名詞 で+助詞 履き物+名詞 を+助詞 脱い+動詞 で+助詞 ください+動詞 </s>
pass1_best_phonemeseq: silB | k o k o | d e | h a k i m o n o | o | n u i | d e | k u d a s a i | silE
VOICEROID
pass1_best:  六 年 八 万 くらい だ 。
pass1_best_wordseq: <s> 六+名詞 年+名詞 八+名詞 万+名詞 くらい+助詞 だ+助動詞 </s>
pass1_best_phonemeseq: silB | r o k u | n e N | h a ch i | m a N | k u r a i | d a | silE

ここでは、着物を脱いでください

ヒト
pass1_best:  ここ で は 着物 を 脱い で ください 。
pass1_best_wordseq: <s> ここ+代名詞 で+助詞 は+助詞 着物+名詞 を+助詞 脱い+動詞 で+助詞 ください+動詞 </s>
pass1_best_phonemeseq: silB | k o k o | d e | w a | k i m o n o | o | n u i | d e | k u d a s a i | silE
VOICEROID
pass1_best: 若い とき は い た 。
pass1_best_wordseq: <s> 若い+形容詞 とき+名詞 は+助詞 い+動詞 た+助動詞 </s>
pass1_best_phonemeseq: silB | w a k a i | t o k i | w a | i | t a | silE

アレクサ、今日の天気は?

ヒト
pass1_best:  アレックス は 、 今日 の 天気 は 。
pass1_best_wordseq: <s> アレックス+名詞 は+助詞 、+補助記号 今日+名詞 の+助詞 天気+名詞 は+助詞 </s>
pass1_best_phonemeseq: silB | a r e q k u s u | w a | sp | ky o: | n o | t e N k i | w a | silE
VOICEROID
pass1_best:  兄 さん と 厳禁 。
pass1_best_wordseq: <s> 兄+名詞 さん+接尾辞 と+助詞 厳禁+名詞 </s>
pass1_best_phonemeseq: silB | a n i | s a N | t o | g e N k i N | silE

デフォルトの状態でこの精度です。すごい。
ヒト発声に関しては(変換はさておき)ほぼ完璧に読み取れています。
最後の「アレクサ」については「アレックスは」になっていますが、これはJulius側で持っている辞書の中に「アレクサ」というワードがなかったため、近似で採用したものと思われます。
発音も似ています。
アレクサ  :/əlɛksə/
アレックスは:/æləksβ̞a/

VOICEROIDは…Juliusとは相性悪そうですね。
ちなみに、今回Juliusに読み取らせたVOICEROIDの音声はAmazonEchoでは認識できたものです。
音声アシスタントをもっと活用できるようになったら、VOICEROIDから全て操作してみたいですね、今回はやりません。

qrencodeで遊ぶ

qrencodeはテキストデータをQRコード化できるライブラリです。
詳しくはこちら

QRコードは(読み取る機器の機能如何にも拠りますが、)
格納するテキストデータの記述フォーマットによって色々できたりします。

たとえば、
連絡先登録(規格:vCard
カレンダー登録(規格:iCalendar) 
wifi接続設定…
などなど、最近だと決済手段の一つにもなっていますね。(他にも

どんな感じになるのか、実際に形式通りに記述し、QRコード化してみます。

連絡先

BEGIN:VCARD
VERSION:2.1
N:hoge;fuga
TEL;WORK;VOICE:01234567890
END:VCARD

画像2

カレンダー

BEGIN:VEVENT
DTSTART;TZID=Asia/Tokyo:20190101T090000
DTEND;TZID=Asia/Tokyo:20190101T100000
SUMMARY:hoge
DESCRIPTION:huga
UID:piyo@piyo
STATUS:CONFIRMED
TRANSP:OPAQUE
END:VEVENT

画像3wifiの接続設定

WIFI:S:hoge;T:WEP;P:huga;H:;

画像4

筆者所有のiPhoneで認識できることを確認しています。
wifiの接続設定とか、普通に使いどころが多そうでビビる。
セミナー会場とかコワーキングスペースとかでwifiに繋ぐとき、いちいち入力するのめっちゃ面倒じゃないですか…?
名刺情報とかも、画像のテキスト化処理よりも簡単でなにより間違いがないし、もっと使いどころあると思うんですけど、、、なんで流行ってないんだろ…。

Node-REDで遊ぶ

Node-REDはNodejs上で動作する、アプリケーションプラットフォームです。
詳しくはこちら
GUIで操作可能で、「ノード」と呼ばれる処理ユニットをつなぐことでプログラミングを行います(Scratchのような感じ)。
複雑な処理でない限りコードを記述する必要がほとんどなく、覚えることが少ないため学習コストがとても低いです。
GETリクエストを受けて、簡単なレスポンスを返すだけのものであれば、5分かからずに実装できます。

画像5

見た目はこんな感じ。
左のサイドバーに並んでいるカラフルなやつがノード、右側がエディタ画面です。
必要なノードを右側にドラッグ&ドロップしてプログラミングします。

まずは例によってHello Worldから。
ノードをこんな感じで繋げまして

画像6

templateノード(真ん中のやつ)には記述したいHTMLを記述します。
前後のノードはそれぞれHttpRequestを受けるノードと返すノードです。

<!DOCTYPE html>
<html lang="ja">
<head>
 <meta charset="UTF-8">
 <title>Node-RED</title>
</head>
<body>
 <h1>Hello World!</h1>  
</body>
</html>

書いたらデプロイボタンを押下。
ブラウザで「http://{RaspberryPiのIP}:1880/hello」にアクセスします。
#1880はNode-REDのデフォルトポート

画像7

表示されました。非常に簡単に実装できます。
#↓サンプル

[{"id":"6c74d6d.f876b28","type":"http response","z":"11d85bf6.e8a1e4","name":"","statusCode":"","headers":{},"x":290,"y":2340,"wires":[]},{"id":"b5b9871e.bac828","type":"http in","z":"11d85bf6.e8a1e4","name":"","url":"/hello","method":"get","upload":false,"swaggerDoc":"","x":100,"y":2220,"wires":[["cb78c4f0.413558"]]},{"id":"cb78c4f0.413558","type":"template","z":"11d85bf6.e8a1e4","name":"","field":"payload","fieldType":"msg","format":"handlebars","syntax":"plain","template":"<!DOCTYPE html>\n<html lang=\"ja\">\n<head>\n <meta charset=\"UTF-8\">\n <title>Node-RED</title>\n</head>\n<body>\n <h1>Hello World!</h1>  \n</body>\n</html>","output":"str","x":200,"y":2280,"wires":[["6c74d6d.f876b28"]]}]

普通のSSRでCSSやjavascriptも使えるため、動きのあるWebサイトの作成も可能です。
例↓

<!DOCTYPE html>
<html lang="ja">

<head>
 <meta charset="UTF-8">
 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/typicons/2.0.9/typicons.css">
 <link rel="stylesheet" href="style.css">
<title>Node-RED</title>
</head>

<body>
 <nav>
   <ul>
     <li onclick='move(this)' class='hoge active'></li>
     <li onclick='move(this)' class='fuga'></li>
     <li onclick='move(this)' class='piyo'></li>
   </ul>
 </nav>
 <main>
   <h1 id='text'>hoge</h1>
 </main>
</body>

<script>
   const textElement = document.getElementById('text')
   const navigateElements = document.getElementsByTagName('li')

   const move = (target) => {
     [...navigateElements].forEach(d => d.classList.remove('active'));
     target.classList.add('active')
     textElement.innerHTML = target.classList[0]
   }
</script>

</html>

ボタンを押すとアクティブなクラス名が表示されるだけです。

画像8

画像9

動きました。
#↓サンプル

[{"id":"4fb9dfbe.17234","type":"http response","z":"11d85bf6.e8a1e4","name":"","statusCode":"","headers":{},"x":370,"y":2160,"wires":[]},{"id":"69a1e139.c0eb9","type":"http in","z":"11d85bf6.e8a1e4","name":"","url":"/hoge","method":"get","upload":false,"swaggerDoc":"","x":140,"y":2100,"wires":[["5fa24dc.ac866b4"]]},{"id":"5fa24dc.ac866b4","type":"template","z":"11d85bf6.e8a1e4","name":"","field":"payload","fieldType":"msg","format":"handlebars","syntax":"plain","template":"<!DOCTYPE html>\n<html lang=\"ja\">\n\n<head>\n  <meta charset=\"UTF-8\">\n  <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/typicons/2.0.9/typicons.css\">\n  <title>Node-RED</title>\n</head>\n\n<body>\n  <nav>\n    <ul>\n      <li onclick='move(this)' class='hoge active'></li>\n      <li onclick='move(this)' class='fuga'></li>\n      <li onclick='move(this)' class='piyo'></li>\n    </ul>\n  </nav>\n  <main>\n    <h1 id='text'>hoge</h1>\n  </main>\n  <script>\n    const textElement = document.getElementById('text')\n    const navigateElements = document.getElementsByTagName('li')\n\n    const move = (target) => {\n      [...navigateElements].forEach(d => d.classList.remove('active'));\n      target.classList.add('active')\n      textElement.innerHTML = target.classList[0]\n    }\n\n  </script>\n  <style>\n    body {\n      display: grid;\n      grid: 100% / 40px calc(100% - 40px);\n      height: 100%;\n      margin: 0;\n      position: absolute;\n      width: 100%;\n    }\n\n    nav {\n      background-color: #000003;\n    }\n\n    main {\n      background-color: #4a828e;\n      border-left: 4px solid #ef2a6f;\n      color: #fefefe;\n    }\n\n    h1{\n      margin:.5em\n    }\n\n    ul {\n      margin: 0;\n      padding: 0;\n    }\n\n    li {\n      color: #fefefe;\n      list-style: none;\n      width: 40px;\n      height: 40px;\n      margin: 0;\n      text-align: center;\n      line-height: 40px;\n      font-size: 1.5em;\n    }\n\n    li.active:before {\n      color: #ef2a6f;\n    }\n\n    li:before {\n      font-family: 'typicons';\n    }\n\n    li.hoge:before {\n      content: '\\e08a';\n    }\n\n    li.fuga:before {\n      content: '\\e0c3';\n    }\n\n    li.piyo:before {\n      content: '\\e105';\n    }\n\n\n  </style>\n</body>\n\n</html>","output":"str","x":240,"y":2160,"wires":[["4fb9dfbe.17234"]]}]

前編おしまい

いろいろ使って遊んでみました。
軽く触ってみただけでも出来ることが多くてわくわくしますね。
後編では実際に作った過程を書いていきます。
ではまた後日。

散財します。