grbl1.1+Arduino CNCシールドV3.5+bCNCを使用中。
BluetoothモジュールおよびbCNCのPendant機能でスマホからもワイヤレス操作可能。
その他、電子工作・プログラミング、機械学習などもやっています。
MacとUbuntuを使用。

CNCマシン全般について:
国内レーザー加工機と中国製レーザー加工機の比較
中国製レーザーダイオードについて
CNCミリングマシンとCNCルーターマシンいろいろ
その他:
利用例や付加機能など:
CNCルーター関係:



*CNCマシンの制作記録は2016/04/10〜の投稿に書いてあります。


2017年4月16日日曜日

IoTその6:音声認識+ESP8266(改良中)

以前から続いている音声認識によるESP8266制御についてです。 いろいろと音声認識のAPIやデバイス、そしてサーバの立ち上げ方や外部クラウドの利用の仕方など試してみましたが、結局のところWeb Speech APIを使い、スマホ、タブレット、ノートパソコンあるいはRaspberry Pi上のブラウザ(Chrome)で、複数あるESP8266をWebサーバとして通信させることにしました。

SnowboyVoice Recognition Module VR3.1も認識力は良かったのですが、事前に録音させた自分の声を元に認識させる仕組みであり、しかもあまり長い言葉をしゃべることができないし、正規表現も使うことができないため、やはりWeb Speech APIになってしまったという感じです。

改良点:
やりたいことは、リビング、寝室、トイレなどに設置したESP8266で電源のスイッチをオンオフすることです。 問題となっていたのは、複数の異なるIPアドレスを与えたWebサーバ同士をどうやってつなげるかということです。今回は、ブラウザからそれぞれのIPアドレスへリクエストを送る際に、今の状況(どれがオンでどれがオフになっているかなど)をアドレスの最後にクエリをつけて変数を渡してあげるという方法でなんとか解決できました。
音声認識のほうについては、前回のようにWeb Speech APIと正規表現によって、かなり柔軟に認識してくれるのでいいのですが、それよりもHTMLとJavaScriptによる変数の受け渡し方に時間がかかりました。
追記:
その後の改良案では、今回の方法(以下の方法)は使わず、直接AjaxのXMLHttpRequestを使う方法で解決できました。

3つのWeb Server間の変数渡し:
蛍光灯のON/OFFはIPアドレス192.168.3.12のESP8266
寝室とエアコンは192.168.3.13のESP8266
トイレは192.168.3.14のESP8266
という感じで、同じ画面でも3つ異なる送信先があります。
以下の状態だと、蛍光灯をONしたので、192.168.3.12にアクセスしているところです。つぎにエアコンをONにする場合は、192.168.3.13/air_on.htmlへ移動するということになります。エアコンONした瞬間、通常なら移動先では今の情報(蛍光灯がONになっているなど)を知らないので初期状態の画面(蛍光灯が画面表示上OFFになってしまう)になってしまいますが、移動の際にクエリを渡すので、移動先でもこの画面を再現できるというわけです。

操作画面:
以下が今回の操作画面です。というか、音声認識なので、あまり画面は重要ではないのですが、一応下半分には、それぞれのオンオフが可能なボタンがついています。
アドレスの.html?以下にクエリとして、各ボタンのオンオフ状態、そして音声のテキスト内容が後続して並んでいます。
クエリとして日本語文字列はエンコードしないといけないみたいですが、なぜか上のアドレスバーにはそのままエンコードされずに日本語が出ています。その後、HTMLで表示する際にもデコードしなおす必要があるようです。

追加するクエリの仕組み:
クエリ追加の仕組みは簡単で、それぞれのアドレス「〜.html?」以下に

1,0,0,1,0,1,0,1,COMの言葉,YOUの言葉

という感じで「,」で区切った10個の変数値が並んでいます。通常のクエリは、key=valueという組み合わせになり、JavaScript内でも連想配列で[key:value...]となりますが、面倒なのでkey(文字列)だけにしてあります。
本来なら、
〜.html?fluo=1&bed=0&aircon=0&wc=0&com=1234&you=5678
などとなるのかもしれませんが、今回は独自の勝手なやり方で。

ESP8266のほうでは、/fluo_onや/fluo_offのようなアドレスのリクエストがあった場合、対応するPINのオンオフ制御をしつつ、表示用HTML(CSS+JavaScript)が返されます。異なるアドレス(異なるスイッチ)に移動する際に、移動先アドレスのあとに?と上記変数を追加して、JavaScriptのwindow.location.hrefでURL移動しているだけです。
以前は、ESP8266に書き込んだHTMLの一部をArduino言語の変数に置き換えていたのですが、それだとHTMLやJavaScript内にある変数があるたびに、いちいち区切って書き込まなければならないので、今回のようなSPIFFSを使って一気にファイルをアップロードする際には不都合となってしまいます。

画面遷移時にやや問題:
リビング、寝室、トイレと場所が分かれているので、それぞれ異なるIPアドレスへアクセスすることになります。そのためブラウザの画面上では、見た目同じでもIPアドレス192.168.3.12から192.168.3.13へリクエストしたときに画面遷移しなければいけないので、画面の再読み込みが発生して一瞬チラついてしまいます。この辺はAjaxやAngularJSを使うと解決できるのかもしれませんが、Web技術にそれほど詳しいわけでもないので、今回はそこまではできていません。それでも結果的にはESP8266の技術というよりも、利用状況を表示させるHTMLとJavaScriptのほうが難しかったです。

その他のやり方:
当初は、PythonのFlask、あるいはNode.jsなどでサーバを立ち上げておいて、そのサーバが中枢的な役割を果たし、末端にいる複数のESP8266を管理するというイメージもありましたが、そのためにはリアルタイムの送受信であったり、WebSocketを使う方法などいろいろあるようで、そこまで大げさなことをすることもないかなと。

本題である音声認識の仕方や電源のオンオフのさせ方よりも、現在しゃべった言葉やスイッチのオンオフ状況の情報が画面遷移したあとでも保持されて表示されるという部分をどう処理するかで悩みました。 どうやら、同じドメイン内ならAjaxで変化させることができますが、異なるドメイン間になると難しいようで、このへんは割り切るしかありません。pjaxやAngularJsなら可能なのかもしれませんが、そこまで詳しくないので、これもそのうち。 それにしても、今回はかなり迂回しましたが、そのおかげでいろいろ勉強になったというか、たかだかWifiによるワイヤレススイッチなのに、Flask、Node.js、Node-RED、MQTTのようなサーバ関係やAjaxなどのJavaScriptの様々なスキルなどWeb技術ばかりでした。

SPIFFSでアップロード(やり方はページ下の方にあります):
ほぼシステムは出来上がりましたが、ESP8266の設置などはまだです。
今回のソースは以下です。CSSもあるので、かなり長くなりましたが、ESP8266にはSPIFFSを使って別にアップロードしたので大丈夫でした。
今回Speech to Textは使っていますが、Text to Speechは使っていないので音はでません。画面の文字だけです。重くなってしまうかもという懸念とたまに自分でしゃべった音に反応することがあったり、エラーにもなりかねないので外してしまいました。つまらなそうなら、また音声つきに戻そうかとも思っています。


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Speech API</title>
</head>
<body id="bg">
<div class="title" id="status">[音声認識開始]</div>
<div class="api"><p id="api">COM: 音声入力して下さい</p></div>
<div class="you"><p id="you">YOU: (認識された音声)</p></div>
<div class="btn" id="fluo_on" onclick="buttonClick('fluo_on')">蛍光灯: ON</div>
<div class="btn" id="fluo_off" onclick="buttonClick('fluo_off')">蛍光灯: OFF</div>
<div class="btn" id="bed_on" onclick="buttonClick('bed_on')">寝室: ON</div>
<div class="btn" id="bed_off" onclick="buttonClick('bed_off')">寝室: OFF</div>
<div class="btn" id="air_on" onclick="buttonClick('air_on')">エアコン: ON</div>
<div class="btn" id="air_off" onclick="buttonClick('air_off')">エアコン: OFF</div>
<div class="btn" id="wc_on" onclick="buttonClick('wc_on')">トイレ: ON</div>
<div class="btn" id="wc_off" onclick="buttonClick('wc_off')">トイレ: OFF</div>

<style type="text/css">
    body{
        text-align:center;
        font-family: 'Helvetica',sans-serif;
        color:#fff;
        margin:0px;
        padding:0px;
        background-color:#fff;
    }
    .title{
        width:100%;
        background-color:#fa8;
        margin:0px;
        padding:2% 0% 2% 0;
    }
    .api{
        text-align: left;
        width:96%;
        background-color:#e75;
        padding:2%;
        margin:1% 0 0 0;
    }
    .you{
        text-align: left;
        width:96%;
        background-color:#d66;
        padding:2%;
        margin:1% 0 0 0;
    }
    .btn{
        float:left;
        background-color:#aaa;
        padding:3% 0px;
        margin:1% 1% 0 1%;
        width:48%;
    }
    .btn:hover{
        opacity: 0.8;
        cursor: pointer;
    }

</style>
<script type="text/javascript">
    var flag_speech = 0;
    var youAns=[/^(?!.*(オフ|ない)).*(?=蛍光灯).*(?=(オン|on|音|つけて)).*$/,
                /^(?!.*(オン|ない)).*(?=蛍光灯).*(?=(オフ|off|切|消)).*$/,
                /^(?!.*(オフ|ない)).*(?=寝室).*(?=(オン|on|音|つけて)).*$/,
                /^(?!.*(オン|ない)).*(?=寝室).*(?=(オフ|off|切|消)).*$/,
                /^(?!.*(オフ|ない)).*(?=エアコン).*(?=(オン|on|音|つけて)).*$/,
                /^(?!.*(オン|ない)).*(?=エアコン).*(?=(オフ|off|切|消)).*$/,
                /^(?!.*(オフ|ない)).*(?=トイレ).*(?=(オン|on|音|つけて)).*$/,
                /^(?!.*(オン|ない)).*(?=トイレ).*(?=(オフ|off|切|消)).*$/];

    var apiAns=["はい、蛍光灯をオンにしました",
                "はい、蛍光灯をオフにしました",
                "はい、寝室をオンにしました",
                "はい、寝室をオフにしました",
                "はい、エアコンをオンにしました",
                "はい、エアコンをオフにしました",
                "はい、トイレをオンにしました",
                "はい、トイレをオフにしました"];
    
    var url_to=["fluo_on",
                "fluo_off",
                "bed_on",
                "bed_off",
                "air_on",
                "air_off",
                "wc_on",
                "wc_off"];

    var ipAddress=["http://192.168.3.12/",
                   "http://192.168.3.13/",
                   "http://192.168.3.14/"];

    var url_val=[];

    function setQuery(){
        if(window.location.search.length<1){
            for(var i=0;i<url_to.length;i++){
                if(i%2==0){
                    url_val[i]="0";
                    document.getElementById(url_to[i]).style.backgroundColor = '#aaa';
                }else{
                    url_val[i]="1";
                    document.getElementById(url_to[i]).style.backgroundColor = '#c2b'; 
                };     
            };
            url_val[url_to.length]="COM: 音声入力して下さい";
            url_val[url_to.length+1]="YOU: 認識された音声";
            document.getElementById("api").innerHTML=url_val[url_to.length];
            document.getElementById("you").innerHTML=url_val[url_to.length+1];
        }else{
            var s=window.location.search.slice(1);
            url_val=s.split(",");
            for(var i=0;i<url_to.length;i++){
                if(url_val[i]=="0"){
                    document.getElementById(url_to[i]).style.backgroundColor = '#aaa';
                }else if(url_val[i]=="1"){
                    document.getElementById(url_to[i]).style.backgroundColor = '#c2b';
                };
            };
            document.getElementById("api").innerHTML=decodeURI(url_val[url_to.length]);
            document.getElementById("you").innerHTML=decodeURI(url_val[url_to.length+1]);
        };
    };

    function buttonClick(id){
        var idx=url_to.indexOf(id);
        url_val[idx]="1";
        url_val[url_to.length]="COM: "+apiAns[idx];
        url_val[url_to.length+1]="YOU: ボタンで"+apiAns[idx].slice(3);
        if(idx%2==0){
            url_val[idx+1]="0";
        }else{                 
            url_val[idx-1]="0";
        }
        var ip="";
        if(idx==0||idx==1){
            ip=ipAddress[0];
        }else if(idx<=2 && idx<=5){
            ip=ipAddress[1];
        }else{
            ip=ipAddress[2];
        }
        window.location.href = ip+id+".html?"+url_val;
    };

    function delay_ms( ms ){
        var start = new Date();
        while( new Date() - start < ms );
    };
    
    function vr_function() {
        window.SpeechRecognition = window.SpeechRecognition || webkitSpeechRecognition;
        var rec = new webkitSpeechRecognition();
        rec.lang = 'ja-JP';
        rec.interimResults = false;
        rec.continuous = true;

        rec.onstart = function() {
            document.getElementById('status').innerHTML = "[ 認識中 ]";
        };
        rec.onerror = function() {
            document.getElementById('status').innerHTML = "[ エラー ]";
            if(flag_speech == 0){
                vr_function();
            };
        };
        rec.onsoundend = function() {
            document.getElementById('status').innerHTML = "[ 停止中 ]";
            vr_function();
        };

        rec.onresult = function(event) {
            var results = event.results;
            var youSaid = "";
            var apiSaid = "";
            var apiHtml = document.getElementById("api");
            var youHtml = document.getElementById("you");
            var bgcolor = document.getElementById("bg");
            for (var i = event.resultIndex; i < results.length; i++) {
                if (results[i].isFinal){
                    youSaid = results[i][0].transcript;
                    for(var j=0;j<youAns.length;j++){
                        if(youSaid.match(youAns[j])){
                            apiHtml.innerHTML ="COM:"+apiAns[j];
                            youHtml.innerHTML ="YOU:"+youSaid;
                            if(j%2==0){
                                url_val[j]="1";
                                url_val[j+1]="0";
                            }else{   
                                url_val[j-1]="0";
                                url_val[j]="1";
                            };
                            url_val[youAns.length]=encodeURI("COM: "+apiAns[j]);
                            url_val[youAns.length+1]=encodeURI("YOU: "+youSaid);
                            var ip="";
                            if(j==0||j==1){
                                ip=ipAddress[0];
                            }else if(j>=2 && j<=5){
                                ip=ipAddress[1];
                            }else{
                                ip=ipAddress[2];
                            }
                            window.location.href = ip+url_to[j]+".html?"+url_val;
                        }else{
                            apiHtml.innerHTML ="COM: 音声入力をどうぞ";
                            youHtml.innerHTML ="YOU: "+youSaid;
                            bgcolor.style.backgroundColor = '#fc9';
                        };
                    };
                    vr_function();                       
                }else{
                    document.getElementById('you').innerHTML = "[解析中]:" + results[i][0].transcript;
                    flag_speech = 1;
                };
            };
        };
        flag_speech = 0;
        document.getElementById('status').innerHTML = "[ 起動中 ]";
        rec.start();
    };

    window.onload = function() {
        setQuery();
        vr_function();
    };
</script> 
</body>
</html>


なお、Web Speech APIの途切れない利用法については、以下を参考にいたしました。
http://jellyware.jp/kurage/iot/webspeechapi.html


そして以下がESP8266のソース:

#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <WiFiClient.h>
#include "FS.h"

#define ledPin 13
#define relayPin 2

MDNSResponder mdns;

const char* ssid = "*****";
const char* password = "*****";
String webPage = "";
ESP8266WebServer server(80);

void setup(void){
  SPIFFS.begin();
  File f = SPIFFS.open("/index.html", "r");
  if (!f) {
    Serial.println("file open failed");
  }else{
    webPage = f.readString();
    f.close();
  }

  pinMode(ledPin, OUTPUT);
  pinMode(relayPin, OUTPUT);
  
  Serial.begin(115200); 
  delay(500);
  WiFi.begin(ssid, password);
  Serial.println("");

  while (WiFi.status() != WL_CONNECTED) {
    delay(5000);
    Serial.print(".");
  }
  WiFi.config(IPAddress(192,168,3,12),IPAddress(192,168,3,1),IPAddress(255,255,255,0));

  Serial.println("");
  Serial.print("Connected to ");
  Serial.println(ssid);
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
  
  if (mdns.begin("studio", WiFi.localIP())) {
    Serial.println("MDNS responder started");
  }
    
  server.on("/", [](){
    server.send(200, "text/html", webPage);
  });
  server.on("/index.html", [](){
    server.send(200, "text/html", webPage);
  });
  server.on("/fluo_on.html", [](){
    server.send(200, "text/html", webPage);
    digitalWrite(ledPin, HIGH);
    digitalWrite(relayPin, HIGH);
    delay(1000);
  });
  server.on("/fluo_off.html", [](){
    server.send(200, "text/html", webPage);
    digitalWrite(ledPin, LOW);
    digitalWrite(relayPin, LOW);
    delay(1000); 
  });

  server.begin();
  Serial.println("HTTP server started"); 
}
 
void loop(void){
  server.handleClient();
}

これは、一台のESP8266のソースで、このほかIPアドレスの異なる(対応するスイッチも異なる)ESP8266へは、多少内容を変更して書き直します。
基本的には、

server.on("/air_on.html,[](){...}

などと対応するスイッチに応じて変更するだけです。ほぼサンプルのままです。


SPIFFSによる外部ファイル(HTMLファイル)の読み込み:
まず、こちらのサイトなどを参考にToolをダウンロードしてインストールしておきます。
Macであれば、アプリケーション>Arduinoを右クリックで「パッケージの内容を表示」。
Contents>Java>tools>ESP8266>tool>esp8266fs.jar
の位置にダウンロード&解凍したesp8266fs.jarを入れておき、Arduino IDEを再起動でOK。

用意したindex.htmlを「ファイルを追加...」でスケッチ内に取り入れます。
ファイル選択画面でデスクトップなどにあるindex.htmlファイルを選択。
取り込んだら、以下で確認します。

そうすると、
このようにメインのソースとなるinoファイルと同じディレクトリにdataフォルダが出来上がり、その中にindex.htmlが取り込まれているのがわかります。
つぎに、


「ESP8266 Sketch Data Upload」を使ってindex.htmlをESP8266のメモリ内にアップロードします。2~3%ずつゆっくりとアップロードしていくので、かなり時間がかかります(3~5分)。
アップロード後、従来通りinoファイルをESP8266にアップロードして終了。

プログラム内でのindex.htmlの読み込み:
index.htmlの内容を読み込むために、setup()内で、

 SPIFFS.begin();
  File f = SPIFFS.open("/index.html", "r");
  if (!f) {
    Serial.println("file open failed");
  }else{
    webPage = f.readString();
    f.close();
  }

を使って読み込んでいます。f.readString()でString変数に代入して終了。
同様のやり方で複数のファイルも読み込むことができるらしいです。
しかし疑問に思ったのは、こうやって別々にアップロードされた外部ファイルは、アップロードするたびに上書きされてしまうのか?それとも残ったままになっているの?この辺はよくわからない。たぶん、上書き保存だと思うのだけれども。

追記:
外部ファイルのアップロード確認については、こちらに書いておきました。
続きの改良案はこちら

2017年4月13日木曜日

AliExpressのトラブル(Open Dispute)

いつも使っているAliExpressですが、今回初めてOpen Disputeをしてみました。Open Disputeというのは、辞書では「紛争」とでてきますが、いわゆるクレームのようなものです。
AliExpressでオーダー履歴を見てみると、今までで約100回ほど買い物をしています。といっても、一回の注文が数百円程度の品物が多いので合計しても大した金額にはなりません。安いものだと60円(送料無料)というものもあり、日本で言えばハガキを送る料金でちょっとした電子パーツなどが手に入るというのは、いまだに不思議です。

まずは注文:
今回始めてクレームをつけることになったのは、注文した品物が期限をすぎても届かなかったということからです。以下に内容を書いておきます。


2017/01/30に上の商品を注文しました。当時はレートの関係で310円、配送方法:普通郵便小包。いままでの経験だと、届くまでには2〜3週間かかるので気長に待ちます。

異常発覚:
その後そろそろ届くかなと、配送状況をチェックしてみると、以下のような表示。
なぜかキャンセル扱いになっています。こんなのは初めてです。もともとChina Post Ordinary Small Packet Plus(普通郵便小包)という一番安い配送方法なので、時間がかかったり、原則的にトラッキング不可です。届いたとしても、受け取り確認なしにポストに投函されるだけという一番雑な扱いです。
ちなみにもう少しましな配送方法だと、Air MailやRegistered Mail(書き留め扱い)というのがあり、いちおう日本からもトラッキング可能です。受け取る際にもサイン(ハンコ)が必要です。

問い合わせメール:
キャンセルされていたので、早速ショップに「キャンセルしてないのに、なんで、こんなことになってんの?」というような内容のメールを送ってみました。
24時間以内に何らかの返答があるというのが通例です。しかし、いつまでたっても返事がきません。この段階で注文から23日たっています。同時期に頼んだ他の品物は続々と届いています。
まあ、310円だし、しばらく放っておくことに。

通常は注文した際に期限が設定されます。今回の場合は約2ヶ月。その2ヶ月をすぎても届かなければクレーム(Open Dispute)できるということです。逆に言えば、すぐに手に入れたいものであっても、最低2ヶ月待たないとクレームすらつけられないということで、かなり気長に構えていなければいけません。

Open Dispute:
 そしてついに期限を向かえてしまいました。オーダー履歴をチェックすると、この取引自体期限切れになっていて、受け取り待ちリストから外されていました。オーダー全体履歴のほうから、この取引を探し出して、「Open Dispute」ボタンを押します(まだ期限以内なら、このボタンを押しても無効となるようです)。そうするとOpen Disputeの画面へ移行します。

まず、Disputeする選択方法があります。なぜキャンセル扱いになったのか理由はわからないので、「Package sent to a wrong address(違うところへ配送)」というのにしました(適当)。
そして、どうしたいかという希望欄があり、今回の場合は、品物を受け取っていないので、「返金(Refund)」ということになり、金額記入欄もあり、310円。

そして、文章による内容記入欄もあります。そこに、以前のメールと似たような内容のことを書いておきました。

「品物をまだ受け取っていない。以下のようなキャンセルのトラッキングになっている。なぜそうなったのかは知らないし、22日にショップに聞いてみたけど返答なし。」というような内容です。
Evidence(証拠)欄もありますが、今回の場合は特になし。もし不良品や配送途中における破損の場合は写真など送ることになると思います。

手続き終了(返金確定):
そしてOpen Dispute後、約3日で以下のようにショップが返金要求を受け入れて無事終了。


My AliExpress>Open Dispute>Dispute Detailに、「Dispute Finished、310円返金」という結果が出ています。
しかし、返金というけど、どうやって返金されるのか?と疑問に思って調べてみると、サイトの一番下のほうにHelpがあり、Return&Refundsを開くと書いてありました。

買い物の流れ:
・品物を注文する(クレジットカード支払い)
・クレジットカード支払い成立(AliExpressへ、ただしショップへはまだ支払われない)
・数週間後、品物が届いて受け取り確認をクリック
・AliExpressからショップへ品物代が支払われる
・取引終了

一旦AliExpressが支払い金額を預かって、その後、手数料など差し引いてショップに支払われるシステムになっているのだと思います(この時間差の分だけAliExpressに集められたお金で資産運用しているのかもしれません)。

ということから、Disputeすると:
・期限までに買い手からの品物受け取り確認がないので、AliExpressはショップに支払わない
・ショップもAliExpressからの支払いがないことを認める
・AliExpressが買い手へクレジットカード会社を通して返金する。
・クレジットカード会社がその返金を買い手の銀行口座へ送金する
という感じでしょうか。

今回はたった310円なので大したこともないのですが、たしかに数万円もする品物の場合だと、きちんと返金されるまでは不安かもしれません。
おそらく、AliExpressの多くのショップはドロップシッピング(無在庫転売)を行っているので、たまに手続き的なミスが発生してしまうのでしょう。しかし、ここまで成長したAliExpressなので、システム自体はけっこうしっかりしていると思います。手続きさえすれば特に問題ないと思います。ただし、手間暇や時間はかかると思います。
今回の場合は、注文してから2ヶ月、Disputeしてから3日で手続きは終了しましたが、さらに銀行へ返金されるまで数週間というところでしょうか。

この売買システムというよりは、品物が粗悪品であったり、スペックが表示内容と微妙に異なるということはいまだによくあります。注文するときは他のショップなどの表示内容と比較しつつ最終的な注文を確定したほうがいいと思います。


関連:
AliExpressのトラブル(その2)

2017年4月6日木曜日

音声認識:Voice Recognition Module VR3.1

ここしばらく音声認識が気になっていたので、引き続きいろいろ調べています。
AliExpressで見つけたArduino対応の音声認識モジュールがようやく届いたので試してみることに。

AliExpress.com Product - 1set Voice Recognition Module V3 Speed Recognition compatible with Ard for Arduino Support 80 Kinds of Voice Sound Board 2193円(送料込み)。
安くはないのですが、とんでもなく高いというわけでもないので、試しに購入してみました。これ以外にもいくつか音声認識モジュールはあるのですが、比較的小型(30x48mm)で、そこそこ認識力もありそうだったので。

どうやらELEHOUSEというところがメーカー/販売店らしく、このページから、マニュアルArduino用のライブラリがダウンロードできます。バージョン1や2もあるようで、今回購入したものはバージョン3.1でした。
似たようなモジュールとしてGreentechのVoice Recognition Moduleというのがあります。これはELEHOUSEのバージョン2に相当するモジュールで音声は3グループx5種類、合計15種類まで。基板の色が違うくらいで、構成や価格はほぼ同じです。

5Vでシリアル通信するので、Arduino Unoにはいいかもしれませんが、ESP8266などのような3.3Vのデバイスには、分圧などして電圧を下げないといけなさそうです。
言葉は80個(1.5秒/個)まで記憶できます。ただし一回でロードできるのはそのうち7個までのようですが、応用的な使い方として7個以上を使い分ける方法もあるようです。

グループ1:
0:グループ切り替えコマンド
1:音声1
2:音声2
3:音声3
4:音声4
5:音声5
6:音声6

グループ2:
0:グループ切り替えコマンド
1:音声1
2:音声2
3:音声3
4:音声4
5:音声5
6:音声6

という感じで、それぞれのグループの0番目をグループ切り替えコマンドに割り当てておけば、合計12個使い分けることもできると言う感じです。
グループ1をグループ2〜8へ移行するための分岐点にし、グループ2〜8の音声0をグループ1に戻るコマンドにして、音声1〜6までを通常コマンドに割り当てれば少なくとも42種類使えることになります。さらにツリー状に分岐させていけば80個まで使えるかもしれませんが、そこまで複雑な構造にしてしまうと途中で迷子になってしまいそうです。

ということでライブラリをArduino IDEにインストール。スケッチ>ライブラリをインクルード>ZIP形式のライブラリをインストール...でダウンロードしたzipファイル(解凍せず)を選択。

そして、スケッチ例からvr_sample_trainを選んでArduino Unoにアップロード。このプログラムでまずは音声を登録するようです。5Vなので、今回はArduino Unoで試しています。

配線はVCC、GND、TX、RXの4本だけです。Arduino UnoのほうはSoftwareSerialで通信するようで、サンプルにはTX、RX用にD2、D3のピンを使用することになっていますが、それ以外のピンでも大丈夫そうです。ESP8266の時は、使えるピンに変更したほうがよさそうです。

vr_sample_trainをアップロードしたら、シリアルモニタから音声登録ができます。
まず「settings」を入力すると登録モードに突入します。
sigtrain 0 switch
と入力する。この場合0番目に音声登録、「switch」は任意の名前。
そうすると、すぐに「Speak now」と表示が出るので、そこでマイクに向かって音声録音します。
さらに「Speak again」でもう一回録音し、「Success」が出て以下のようになれば登録完了です。

一個1.5秒しかないので、短い言葉しか入れることができません。例えば、「オン」と「オフ」を録音する場合、「オ」の後にくる「ン」や「フ」が曖昧だと、どちらも「オン」に識別されてしまうこともありました。だめなら、また入力しなおせばいいのですが、基本的にはハッキリしゃべったほうがいいのかもしれません。それから、似ている言葉はあまりつかわないほうがいいのかもしれません。
とはいうものの認識力はそこそこある感じです。

実際録音した音声を確認するには、
load 0 1 2 3 4
などと「load」のあとに録音した番号を連ねて入力します(最大7個まで)。
あとは、マイクに向かってそれぞれの言葉をしゃべると、認識されれば対応した番号が出てきます。
サンプルにあるLEDのオンオフ実験もやってみましたが、大体反応しました。「オン」はすぐに反応しますが、「オフ」は「フ」の部分をハッキリと発音しないと反応しないときもありました。
マイクとの距離は30cmほどで録音し1m離れたところからしゃべっても反応ありました。2m離れると反応しないので、マイクの感度をあげるか、離れたところから音声登録(録音)するといいのかもしれません。
とはいえ、このモジュールは比較的簡単に設定もできるし、Arduinoライブラリなどもあり使いやすいと思います。スタンドアロンのデバイスをすぐにつくることができると思います。


今回のモジュール以外では、Arduino用にuSpeechという音声認識ライブラリがあり、それも試してみましたが、上手くいかないので諦めてしまいました。
そのほか、Snowboyというオープンソースの音声認識システムも試しているところです。


ポイントとしては、長時間無音状態でも待機できるというのが条件です。音声認識させるために、画面をタップしたり、ボタンを押して音声入力するということをせず、気が向いたときに話しかけることで起動するという状態が理想的であり現実的に使える状況だと思います。このSnowboyは、長時間待機でき、インターネット接続なしでも使えるようです。このプログラム(Python)をRaspberry PiなどでRunさせておけばいいのかもしれません。
Amazon Echo/Alexaもgitにありますが、まだ日本語には対応していないようなので保留中です。

まだどの音声認識がいいかいろいろ試している段階です。以前試したWeb Speech APIが今の所個人的には一番便利そうです。もともとデバイス単体で使うというより、ネットワークに複数のデバイスをつなげて信号を送受信させようと思っているので、インターネットに常時接続していなければいけないということは、今の所問題ではないという感じです。Web Speech APIの場合は、Speech to TextとText to Speechなので、いろいろ応用できそうです。

音声認識ソフト
Google Speech API(月間60分まで無料、61分〜100万分まで$0.006/15秒)
Amazon Echo Alexa(Git hub)
Web Speech API(MDN)
Watson Speech to Text(月間1000分まで無料、以降$0.02/分)
Julius
Python Speech Recognition3.6.3(python2.6以上)
Snowboy

音声認識ハード
Matrix Voice(WIFI/BT/MCU:$65)
EasyVR(4860円)
Voice Recognition V3(2193円)
Speech Recognition LD3320(1606円)

関連:
音声認識Wifiスイッチ/ESP8266使用(まとめ)についてはこちら

2017年3月27日月曜日

IoTその5:音声認識でESP8266をWifi制御

いくつかのスイッチにIPアドレスを与えてスマホやパソコンのブラウザからオンオフできるようにはしてみましたが、次はそれを音声(アマゾンエコーのように?)でオンオフできないかとネットを探していました。
Google Cloud Speech APIがかなりすごいので、そのまま使えないかと思いましたが、月間60分までは無料、それ以上は15秒ごとに0.006ドルらしい(しかも月間100万分までの制限つき)。もし、ずっとつなぎっぱなしなら、1分で0.024ドル、1時間で1.44ドル、1日で34.56ドル、1ヶ月で1036.8ドルということになってしまいます。ということで、他の方法で。

Siri+Raspberry Pi+Arduinoでやっている例もありましたが、今回は単純に:
Web Speech API2012年のはここ
・ESP8266
でやってみました。

ネット上にあるWeb Speech APIのサンプルを試してみたりしましたが、まあまあの認識力があり、それなりには使えそうです。基本的にはChromeかFireFoxを使用しなければいけないようですが、MacBookからのChrome、AndroidからのChromeでも認識できました(FireFoxはまだ合成音声/発話のみらしいです)。

*デモページ(このブログ内)はこちらへ(日本語仕様)




自動タイムアウトという問題点:
Web Speech APIは、無言のままでいると約5〜6秒でタイムアウトしてしまい、その後はいくら話しかけても反応しなくなります。
大抵のサンプルは、ボタンをクリックし、タイムアウトする前に話しかけるという手順になっています。しかし、音声認識させるためにボタン操作するのであれば、電源用ボタンを画面上につくっておいて、それをクリックしたほうが早いということになってしまい二度手間です。
理想的には、音声が聞こえるまで長時間待機していられればいいのですが、この自動的にタイムアウトしてしまう機能がなんとかならないかと検索してみると

recognition.onend = function(event) {
    recognition.start();
}

このように認識コマンドが終了したら、またスタートさせる(ある意味無限ループ)と可能と書いてありました。プログラム的にはあまりよくなさそうですが、たしかに、これを試してみると、10秒経っても、あるいは数分経っても入力待機しており、その後話しかけても反応しました。
これとは別に、アマゾンエコーのようにずっと待機させておくには、同時にブラウザでそのアドレスのサイトをずっと立ち上げておかなければいけないという問題もあります。
追記:
実は上記の方法だとスマホChromeではエラーが出てフリーズしてしまうので、その後改良してエラーなしで動作するようになりました(まとめのページ中ほどに書いてあります)。

バックグラウントでも動く:
MacBookでプログラムを書いたページ(Chrome上で)をバックグラウンドで立ち上げていても反応するので(その分メモリや電力は消費され続けるけれども)、気が向いたときに話しかけても機能しました。


例えば上画像のように、このブログを書いている最中に別のタブにサイトを立ち上げておいても機能し続けます。Speech Recognitionタブの赤丸が消えると、音声認識は停止してしまいますが、ずっとつきっぱなしです。画面を最小化して隠してしまい、他のアプリケーションを立ち上げて作業していても機能しました。
ということで、実際使うかどうかは後回しにして、できるところまでやってみることに。APIの説明を見ると、文法の解析だったりとかけっこう複雑なこともできるのかもしれませんが、とりあえずこちらが用意したコマンド(言葉)を認識したら、オンオフするという程度のものをつくろうと思います。

手順としては:
ESP8266に、Web Speech API+Javascript+HTMLを書き込んで、パソコンやスマホからESP8266のIPアドレスにアクセスして、音声認識でスイッチをオンオフするという感じです。
今回は、照明器具の電源のオンオフとエアコンのオンオフ(赤外線通信)をしようと思います。
以下がESP8266に書き込んだプログラムです(その後改良したので、まだ挙動が変かも)。

 #include <Arduino.h>
#include <ESP8266WiFi.h>
#include <WiFiClient.h> 
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>

#define relayPin 5
#define IRPin  4
#define ledPin  15

#define duty_high 8
#define duty_low 16

MDNSResponder mdns;
const char* ssid        = "*****";
const char* password    = "*****";
ESP8266WebServer server(80);

unsigned long data_on[243] = {3400, 1400, 600, 250, 550, 250, 550, 1050, 550, 250, 550, 
1050, 550, 250, 550, 250, 550, 250, 550, 1050, 550, 1050, 550, 250, 550, 300, 500, 250, 
550, 1100, 500, 1100, 500, 300, 500, 300, 500, 300, 500, 300, 500, 300, 500, 300, 500, 
350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 
450, 350, 450, 350, 450, 350, 450, 350, 450, 400, 450, 350, 450, 1150, 450, 350, 450, 
350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 1150, 450, 1150, 450, 1150, 450, 
1150, 450, 1200, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 
450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 1200, 450, 350, 450,
350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 
450, 350, 450, 400, 400, 1200, 400, 400, 450, 350, 450, 350, 450, 350, 450, 350, 450, 
350, 450, 350, 450, 350, 450, 350, 450, 400, 400, 400, 400, 400, 400, 400, 400, 400, 
400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 450, 350, 450, 350, 450, 350, 450, 
350, 450, 350, 450, 350, 450, 350, 450, 400, 400, 400, 400, 400, 400, 400, 400, 400, 
400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 450, 350, 450, 1150, 450, 1150, 450, 
350, 450, 1200, 400, 400, 400, 400, 400, 1200, 400, 400, 400};

unsigned long data_off[99] = {3400, 1450, 550, 250, 550, 250, 500, 1100, 550, 250, 550, 
1050, 550, 250, 550, 250, 550, 250, 550, 1050, 550, 1050, 550, 300, 500, 250, 550, 300, 
500, 1100, 500, 1100, 500, 300, 500, 300, 500, 300, 500, 300, 500, 350, 450, 300, 500, 
350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 
450, 350, 450, 350, 450, 350, 450, 350, 450, 400, 450, 350, 450, 1150, 450, 350, 450, 
350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 
450, 350, 450};

String webPage="";

void handleRoot(){
  Serial.println("Access");
  contents();
  server.send(200, "text/html", webPage);
}

void lamp_on(){
  digitalWrite(relayPin,HIGH);
  contents();
  server.send(200, "text/html",webPage);
}

void lamp_off(){
  digitalWrite(relayPin,LOW);
  contents();
  server.send(200, "text/html",webPage);
}

void air_on() {
  int dataSize = sizeof(data_on) / sizeof(data_on[0]);
  for (int i = 0; i < dataSize; i++) {
    unsigned long duration = data_on[i];
    unsigned long start_time = micros();
    while (start_time + duration > micros()){
      digitalWrite(IRPin, 1-(i&1));
      delayMicroseconds(duty_high);
      digitalWrite(IRPin, 0);
      delayMicroseconds(duty_low);
    }
  }
  contents();
  server.send(200, "text/html",webPage);
}

void air_off() {
  int dataSize = sizeof(data_off) / sizeof(data_off[0]);
  for (int i = 0; i < dataSize; i++) {
    unsigned long duration = data_off[i];
    unsigned long start_time = micros();
    while (start_time + duration > micros()){
      digitalWrite(IRPin, 1-(i&1));
      delayMicroseconds(duty_high);
      digitalWrite(IRPin, 0);
      delayMicroseconds(duty_low);
    }
  }
  contents();
  server.send(200, "text/html",webPage);
}

void setup() {
  webPage="";  
  pinMode(IRPin, OUTPUT);
  pinMode(ledPin, OUTPUT);
  pinMode(relayPin, OUTPUT);
  digitalWrite(ledPin, LOW);
  digitalWrite(relayPin, LOW);

  Serial.begin(115200);
  WiFi.begin(ssid, password);
  WiFi.mode(WIFI_STA);

  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  WiFi.config(IPAddress(192,168,3,10),IPAddress(),IPAddress());
  Serial.println("");
  Serial.print("Connected to ");
  Serial.println(ssid);
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
  if (MDNS.begin("mirror")) {
    Serial.println("MDNS responder started");
  }
  server.on("/", handleRoot);
  server.on("/index.html", handleRoot);
  server.on("/lamp_on", lamp_on);
  server.on("/lamp_off", lamp_off);
  server.on("/air_on", air_on);
  server.on("/air_off", air_off);
  server.begin();
  MDNS.addService("http", "tcp", 80);    
}

void loop() {
  server.handleClient();
}

void contents(){
webPage="<!DOCTYPE html><html><head><meta charset='UTF-8'/>\
<title> Speech Recognition</title><script type='text/javascript'>\
var rec=new webkitSpeechRecognition();\
rec.continuous = true;\
rec.interimResults = false;\
rec.lang = 'ja-JP';\
rec.start();\
var apiSpeech=new SpeechSynthesisUtterance();\
apiSpeech.lang = 'ja-JP';\
apiSpeech.rate=1.2;\
apiSpeech.text='こんにちは';\
var mode=0;\
rec.onresult = function (e) {\
for (var i = e.resultIndex; i < e.results.length; ++i) {\
if (e.results[i].isFinal) {\
var youSaid= e.results[i][0].transcript;\
var apiSaid='';\
var apiHtml = document.getElementById('api');\
var youHtml = document.getElementById('you');\
if(mode==0){\
if(youSaid=='こんにちは'){mode=1;\
apiSaid=youSaid+'、どうぞ';\
}else{apiSaid=youSaid+'?';}}else{\
if(youSaid=='ライトスイッチオン'){mode=0;\
apiSaid='OK、'+youSaid;\
window.location.href = 'lamp_on';\
}else if(youSaid=='ライトスイッチオフ'){mode=0;\
apiSaid='OK、'+youSaid;\
window.location.href = 'lamp_off';\
}else if(youSaid=='エアコンスイッチオン'){mode=0;\
apiSaid='OK、'+youSaid;\
window.location.href = 'air_on';\
}else if(youSaid=='エアコンスイッチオフ'){mode=0;\
apiSaid='OK、'+youSaid;\
window.location.href = 'air_off';\
}else{apiSaid='もう一度';}}\
apiSpeech.text=apiSaid;\
speechSynthesis.speak(apiSpeech);\
apiHtml.innerHTML ='API:  '+apiSaid;\
youHtml.innerHTML='あなた:  '+youSaid;}}};\
var count = 0;\
var countup = function(){console.log(count++);};\
apiSpeech.onstart=function(){rec.stop();};\
apiSpeech.onend=function(){\
setTimeout(countup, 3000);\
rec.start();};\
rec.onend = function(){\
setTimeout(countup, 5000);\
rec.start();}\
</script>\
</head><body>\
<div style='font-size:30px; text-align:center;'>\
<p> Speech Recognition</p>\
<p id='api'>「こんにちは」で開始</p>\
<p id='you'>.....</p>\
</div><div style='font-size:18px; text-align:center;'>\
<p>MENU</p>\
<p>「こんにちは」:操作開始</p>\
<p>「ライトスイッチオン」</p>\
<p>「ライトスイッチオフ」</p>\
<p>「エアコンスイッチオン」</p>\
<p>「エアコンスイッチオフ」</p>\
</div></body></html>";}

最初はHTMLやJavascriptを外部読み込みさせようと思いましたが、読み込みの際にエラーが起きてしまったので、そのまま全部一つのプログラムに書き込んでしまいました。

HTML、Javascriptの部分でエラー:
後半のJavascriptの部分でもなぜかエラーがでてしまい、試行錯誤していると、どうやらこのHTMLやJavascriptが、改行なしにブラウザに渡されてしまうと、ブラウザ側が読み込めなくなるようでした。
たとえば、

apiSpeech.onstart=function(){rec.stop();};


この一行の最後の部分↑ですが、通常は波括弧「}」のあとにはセミコロンをつけないで書いていますが、あえて「};」というようにセミコロンを付け足してあります。これがないと、なぜかブラウザ上で上記のブログラムが読み込めなくなってしまって機能しませんでした。どうやらプログラム上の分節が上手くできないようで、セミコロンを何箇所かにつけたという感じです。これで一応機能するようになりました。

いろいろスペースあけたり改行したりしなかったり、エスケープシーケンス記号をつかったり試していたので、Javascriptの部分は読みにくくなっています。
以下にさらに改良した内容としてJavascriptの部分だけ書いておきます。

基本的には、最初に「こんにちは」というと、「こんにちは、操作メニューをどうぞ」と言われて操作メニューモードに入るという感じです。
そして、「エアコンスイッチオン」などというと、「OK、エアコンスイッチオン」と返答して、赤外線信号のページへ移行し信号を発するという仕組みです(まだ4つのメニューしかありません)。
認識されない場合は、「にちわ?」などと認識した言葉に?マークをつけて返事してきたり、「もう一度、操作メニューを」などと言ってきます。以下だけのHTML+Javascriptだけでも、音声認識だけなら確かめることができると思います。

追記/正規表現:
その後、音声入力の部分で正規表現を使えばいいということが分かったので、さらに改良中(というか、正規表現も勉強中)。いまのところ(以下のコードも)、こちらが求めている言葉通りでなければ反応しない仕様になっており、そのキーワードの前後に余計な言葉があっても反応しないということになってしまいます。正規表現を使えば、キーワードが含まれていればOKにできたり(あるいは除外することもでき)、余計な言葉(助詞なども)が前後に入っていても大丈夫のようです。
例えば、「ライトスイッチオン」や「エアコンスイッチオン」の場合なら、「ライト」「エアコン」「オン」「オフ」が重要ワードとなり、「オフ」や「(オンに)しない」などという言葉が入っていれば、反応しないようにもできます。「スイッチ」は共通なので、今回の場合なくてもいいかもしれません。

youSaid.match(/^(?!.*(オフ|ない)).*(?=ライト).*(?=(オン|on)).*$/)

たぶん、「ライト・オン」の場合はこんな感じでしょうか?
まだ、この条件に合わせた正規表現については確実ではないのですが、以前よりは確かに受け入れる言葉の幅が広がり、いままでは、「エアコンスイッチオン」と言わないとダメでしたが、「エアコンのスイッチをオンにして下さい」でも反応するようになり、「エアコンをオンにしないで」と言えば、重要ワード「エアコン」と「オン」が含まれていても「〜ない」が含まれているので、ダメという判定を出せるようになりました(まだ改良中なので、このサンプルはなしです)。


*これまでのデモページ(このブログ内)であれば、こちらへ(ブラウザはChromeで)

<!DOCTYPE html><html>
<head><meta charset="UTF-8"/>
<title> Speech Recognition</title>
<script type="text/javascript">
var SpeechRecognition = SpeechRecognition || webkitSpeechRecognition
var rec=new SpeechRecognition();
rec.continuous = true;
rec.interimResults = false;
rec.lang = "ja-JP";
//'en-US','en-GB','de-DE','fr-FR','it-IT','cmn-Hans-CN','ko-KR' 
rec.start();

var apiSpeech=new SpeechSynthesisUtterance();
apiSpeech.lang = "ja-JP";
apiSpeech.rate=1.2;
apiSpeech.volume=0.8;
apiSpeech.text="こんにちは";

var mode=0;
var count = 0;
var countup = function(){
  console.log(count++);
}

rec.onresult = function (e) {
  var youSaid= e.results[0][0].transcript;
  var apiSaid="";
  var apiHtml = document.getElementById("api");
  var youHtml = document.getElementById("you");
  if(mode==0){
    if(youSaid=="こんにちは"){
      mode=1;
      apiSaid=youSaid+"、操作メニューをどうぞ";
    }else{
      apiSaid=youSaid+"?";
    }
  }else{
    mode=0;
    if(youSaid=="ライトスイッチオン"){
      apiSaid="OK、"+youSaid;
      window.location.href = "lamp_on";
    }else if(youSaid=="ライトスイッチオフ"){
      apiSaid="OK、"+youSaid;
      window.location.href = "laml_off";
    }else if(youSaid=="エアコンスイッチオン"){
      apiSaid="OK、"+youSaid;
      window.location.href = "air_on";
    }else if(youSaid=="エアコンスイッチオフ"){
      apiSaid="OK、"+youSaid;
      window.location.href = "air_off";
    }else if(youSaid=="こんにちは"){
      apiSaid="操作メニューをどうぞ";
      mode=1;
    }else{
      apiSaid="もう一度、操作メニューを";
      mode=1;
    }
  }
  apiSpeech.text=apiSaid;
  speechSynthesis.speak(apiSpeech);
  apiHtml.innerHTML ="API:  "+apiSaid;
  youHtml.innerHTML="あなた:  "+youSaid;
  setTimeout(countup, 2000);
  count=0;
}

apiSpeech.onstart=function(){
  rec.stop();
}
apiSpeech.onend=function(){
  setTimeout(countup, 3000);
  count=0;
  rec.start();
}
rec.onend = function(){
  setTimeout(countup, 6000);
  count=0;
  rec.start();
}
</script>
</head><body>
<div style="font-size:30px; text-align:center;">
<p> Speech Recognition</p>
<p id="api">「こんにちは」で開始して下さい</p>
<p id="you">ここに認識された言葉が出ます</p>
</div>
<div style="font-size:18px; text-align:center;">
<br/>
<p>操作メニュー:</p>
<p>「ライトスイッチオン」</p>
<p>「ライトスイッチオフ」</p>
<p>「エアコンスイッチオン」</p>
<p>「エアコンスイッチオフ」</p>
</div>
</body></html>

実は、先ほどの無限ループのイベントハンドラが上手くいかず、たまにAPI自身がしゃべったことに反応して、さらに音声入力されてしまうということがあります。多少ディレイをつけてみたのですが、たまに変になります(まだ慣れていないJavascriptの問題かもしれません)。
しかし認識力は高く、ほぼ一発で反応してくれます。感度のいいマイクを用意すれば、数m離れた場所から話しかけても大丈夫かもしれません。期待以上に簡単にできたので、さらに完成度をあげて他のことにも応用できるようになればいいと思います。

これを生活のなかで実用化するには、Raspberry Piで常時このサイトを立ち上げておき、感度のいいマイクをつけておけばいいのかもしれません。Raspberry Pi用のマイクがないので、まだ確かめてはいませんが、Raspberry Piをステーションにして、複数のESP8266がぶらさがっているような仕組みにしてもいいのかもしれません。Raspberry PiならJuliusという音声認識もあるようなので、Raspberry Pi用のUSBマイクが手に入ったら試してみようと思います。
AliExpress.com Product - 2015 New Mini USB 2.0 Microphone MIC Audio Adapter Driver Free For MSN PC Notebook
157円(送料無料)。とりあえずこんなものでもいいのでUSBマイクを買っておこうかと。
AliExpress.com Product - 7.1 Channel 3D External USB Audio Sound Card Mic Adapter 3.5mm Jack Stereo Headset For Win XP / 7 8 Android Linux for Mac OS
133円(送料無料)。あるいは、マイクを差し込めるソケットつきの音声入力カードなら、こんな感じ。なぜか安い。

AliExpress.com Product - Free Shipping 5PCS APW7142KI-TRG APW7142KI APW7142 SOP8 in stock new and Original IC 2186円(送料無料)。Arduinoなどに直接音声認識させるならこのような音声認識モジュールがいいかと。マニュアルはこちら。実際、使用してみたときの内容はこちら


AliExpress.com Product - LD3320 ASR Voice Recognition Professional SP Voice Recognition Voice Module
1680円(送料無料)。こちらは少し安いモジュールですが、ネットを検索しても使い方が見つけられず。マニュアルは一応ここのはずなんだけど、なぜかない。基板は違うけどこれもLD3320

関連:
続きの改良案はこちら
音声認識Wifiスイッチ/ESP8266使用(まとめ)についてはこちら

2017年3月24日金曜日

IoTその4:再度ESP8266+Blynk

もともとはCNCマシンのワイヤレス化(bCNCのPendant機能Bluetooth装備など)から始まったのですが、bCNCのカメラ機能をIPカメラでも撮影可能にしようとしたところ、
Pythonの再学習(bCNC自体がPythonで書かれているため)
・Wifiデバイス(ESP8266ESP32)の利用
ということになり、今まで使っていたArduino IDEだけではなく、
ESP-IDF
PlatformIOAtom Editor
もやってみようかと。
要はパソコンやスマホのブラウザからもいろんなものを操作可能にしたいということなので、
・HTML
・Javascript
も同時に学習していかなければいけない。やらなければいけないことが一気に増えてしまったという感じです。

その結果、ある程度はESP8266を使って、
・エアコンのオンオフ(赤外線信号)
・温度と湿度の読み込み
・照明器具のオンオフ(あるいは、CNCマシンのメイン電源のオンオフ)
などをスマホのChrome上からも操作できるようになりました。

#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266WebServer.h>
#include <ESP8266mDNS.h>
#include <WiFiUdp.h>
#include <ArduinoOTA.h>

#include <Adafruit_Sensor.h>
#include <DHT.h>
#include <DHT_U.h>

#define RelayPin 5
#define IRLedPin 15
#define DHTPin   2
#define LedPin   4

#define duty_high 8
#define duty_low 16
#define DHTTYPE DHT11

DHT_Unified dht(DHTPin, DHTTYPE);

uint32_t delayMS;
String temp;
String humid;

const char* ssid = "*****";
const char* password = "*****";

MDNSResponder mdns;
ESP8266WebServer server(5900);

String webPage = "";

unsigned long airOn[243] = {
 3400, 1400, 600, 250, 550, 250, 550, 1050, 550, 250, 550, 1050, 550, 250, 550, 250, 550, 250, 550, 1050, 550, 
 1050, 550, 250, 550, 300, 500, 250, 550, 1100, 500, 1100, 500, 300, 500, 300, 500, 300, 500, 300, 500, 300,
 500, 300, 500, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350,
 450, 350, 450, 350, 450, 350, 450, 400, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350,
 450, 1150, 450, 1150, 450, 1150, 450, 1150, 450, 1150, 450, 1200, 450, 350, 450, 350, 450, 350, 450, 1150,
 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 1200, 450, 350,
 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 400,
 400, 1200, 400, 400, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 400,
 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 450, 350, 450, 350,
 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400,
 400, 400, 400, 400, 400, 400, 400, 400, 450, 350, 450, 1150, 450, 1150, 450, 350, 450, 1200, 400, 400, 400,
 400, 400, 1200, 400, 400, 400};
unsigned long airOff[99] = {
 3400, 1450, 550, 250, 550, 250, 500, 1100, 550, 250, 550, 1050, 550, 250, 550, 250, 550, 250, 550, 1050, 550,
 1050, 550, 300, 500, 250, 550, 300, 500, 1100, 500, 1100, 500, 300, 500, 300, 500, 300, 500, 300, 500, 350,
 450, 300, 500, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350,
 450, 350, 450, 350, 450, 350, 450, 400, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150,
 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450};

void setup() {
  pinMode(IRLedPin, OUTPUT);
  pinMode(LedPin, OUTPUT);
  pinMode(RelayPin, OUTPUT);
  digitalWrite(LedPin, HIGH);
  digitalWrite(RelayPin, LOW);
  dht.begin();
  sensor_t sensor;
  dht.temperature().getSensor(&sensor);
  dht.humidity().getSensor(&sensor);
  temp_humid();
  webContents();
  
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  delay(1000);
  WiFi.begin(ssid, password);
  Serial.println("");
  while (WiFi.waitForConnectResult() != WL_CONNECTED) {
    Serial.println("Connection Failed! Rebooting...");
    delay(5000);
    ESP.restart();
  }
  WiFi.config(IPAddress(192,168,3,10),IPAddress(192,168,3,1),IPAddress(255,255,255,0));
  
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
  
  if (mdns.begin("mirror", WiFi.localIP())) {
    Serial.println("MDNS responder started");
  }

  server.on("/", []() {
    temp_humid();
    webContents();
    server.send(200, "text/html", webPage);
  });
  server.on("/index.html", []() {
    temp_humid();
    webContents();
    server.send(200, "text/html", webPage);
  });
  server.on("/on", []() {
    server.send(200, "text/html", webPage);
    digitalWrite(LedPin, LOW);
    digitalWrite(RelayPin, HIGH);
    delay(1000);
  });
  server.on("/off", []() {
    server.send(200, "text/html", webPage);
    digitalWrite(LedPin, HIGH);
    digitalWrite(RelayPin, LOW);
    delay(1000);
  });
  server.on("/airon", []() {
    temp_humid();
    webContents();
    server.send(200, "text/html", webPage);
    air_on();
    delay(1000);
  });

  server.on("/airoff", []() {
    temp_humid();
    webContents();
    server.send(200, "text/html", webPage);
    air_off();
    delay(1000);
  });

  server.on("/temphumid", []() {
    webContents();
    server.send(200, "text/html", webPage);
    delay(1000);
  });

    ArduinoOTA.onStart([]() {
    Serial.println("Start");
  });
  ArduinoOTA.onEnd([]() {
    Serial.println("\nEnd");
  });
  ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
    Serial.printf("Progress: %u%%\r", (progress / (total / 100)));
  });
  ArduinoOTA.onError([](ota_error_t error) {
    Serial.printf("Error[%u]: ", error);
    if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
    else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
    else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
    else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
    else if (error == OTA_END_ERROR) Serial.println("End Failed");
  });
  ArduinoOTA.begin();  
  server.begin();
  Serial.println("HTTP server started");
  delayMS = sensor.min_delay / 1000;
}

void loop(void) {
  ArduinoOTA.handle();
  server.handleClient();
  yield();
}

void air_off() {
  int dataSize = sizeof(airOff) / sizeof(airOff[0]);
  for (int cnt = 0; cnt < dataSize; cnt++) {
    unsigned long len = airOff[cnt];
    unsigned long us = micros();
    while (us + len > micros()) {
      digitalWrite(IRLedPin, 1 - (cnt & 1));
      delayMicroseconds(duty_high);
      digitalWrite(IRLedPin, 0);
      delayMicroseconds(duty_low);
    }
  }
}

void air_on() {
  int dataSize = sizeof(airOn) / sizeof(airOn[0]);
  for (int cnt = 0; cnt < dataSize; cnt++) {
    unsigned long len = airOn[cnt];
    unsigned long us = micros();
    while (us + len > micros()) {
      digitalWrite(IRLedPin, 1 - (cnt & 1));
      delayMicroseconds(duty_high);
      digitalWrite(IRLedPin, 0);
      delayMicroseconds(duty_low);
    }
  }
}

void temp_humid() {
  delay(delayMS);
  sensors_event_t event;
  dht.temperature().getEvent(&event);
  if (isnan(event.temperature)) {
    temp = "Error reading!";
  } else {
    temp = String(event.temperature);
  }
  dht.humidity().getEvent(&event);
  if (isnan(event.relative_humidity)) {
    humid = "Error reading!";
  } else {
    humid = String(event.relative_humidity);
  }
}

void webContents() {
  webPage = "";
  webPage += "<html><header><title>WIFI SWITCH</title></header>";
  webPage += "<body style=\"text-align:center;font-size:48px\"><div>ESP8266 WEB SERVER</div><br/>";
  webPage += "<div><div>AC100V SWITCH:</div>";
  webPage += "<div><a href=\"on\"><button style=\"width:80%;font-size:60px\">AC100V:  ON</button></a></div><br/>";
  webPage += "<div><a href=\"off\"><button style=\"width:80%;font-size:60px\">AC100V: OFF</button></a></div><br/>";
  webPage += "<div>AIR-CON SWITCH:</div>";
  webPage += "<div><a href=\"airon\"><button style=\"width:80%;font-size:60px\">AIR-CON: ON</button></a></div><br/>";
  webPage += "<div><a href=\"airoff\"><button style=\"width:80%;font-size:60px\">AIR-CON: OFF</button></a></div>";
  webPage += "<div>Temperature: ";
  webPage += temp;
  webPage += " *C</div>";
  webPage += "<div>Humidity: ";
  webPage += humid;
  webPage += " %</div>";
  webPage += "</div></body></html>";
}


これは前回からの改良で、
・ESP8266
・赤外線LED(エアコン用)
・温度湿度センサー
・リレー(照明器具AC100V用)
を使っています。OTAのコードも含めたので、ワイヤレスでコードの書き換えが可能です。そのぶんコードも長く、メモリ消費量も多いプログラムとなりました。
基本的にはローカルネットワーク内でしか使えないのですが、
Wifi.config()を使えば固定IPアドレスにできるので、ルーターをポートフォワーディングしてみると、外部からもつながりました(スマホ4G通信で確認)。以下はブラウザ上の操作画面。


LANだけでなくWANからも操作できてよかったのですが、以前みつけたスマホアプリのBlynkでやってみたらどうなるかも試してみました。このアプリ見つけたのはいいのですが、しばらく使っていませんでした。どのくらい便利なのか?


以前、ざっと説明を見た限りでは、アプリ上でボタンやスイッチ部品を配置して、余計なコードは書かずにプログラムをアップロード(ワイヤレスでスマホから?)という感じでしたが、
実際手順に沿ってやってみると:
・スマホアプリをインストール+メールアドレスを登録
・New Project作成(使うハードウェアを指定:ESP8266など)
・セキュリティコードがメールへ届く
・Arduino IDEにBlynkライブラリをアップロードする
・Arduino IDEでセキュリティコードとWifiのIPアドレス+パスワードを専用プログラムに追記する
・専用プログラムをパソコンからUSB経由でESP8266へアップロード
・再度スマホアプリに戻り、好きなピンを割り当てながらボタンやスイッチを配置
・LANだけでなくWANからも操作可能
という感じです。


FirmataのようなBlynkファームウェア:
いちいちArduino IDEでプログラムを書かなくても済むというのが予想外に便利でした。最初は、スマホでボタンやスイッチへピンを割り当てて、それにあわせたプログラムをArduino IDEのほうでも書かなければいけないのかと思っていましたが、そうではなく、Blynkの専用プログラムがFirmataのようなファームウェアとして機能しているため、後からでも自由自在にBlynkアプリでESP8266のピンの機能割り当てができるという感じです。デジタル入力にするかデジタル出力にするか、それともPWM出力にするかなど、いちいちESP8266にプログラムをアップロードしなくても、その場で自由に変えられるということです。
ESP8266の場合は、
スケッチ例>Blynk>Boards_WiFi>ESP8266_Standalone
を選択し、
このプログラム内の、
YourAuthToken:メールで送られてきたセキュリティコード
YourNetworkName:自宅Wifiネットワーク名
YourPassword:自宅Wifiのパスワード
を書き込むだけでOKです。すべてのピンへの機能割り当てはBlynkのほうでしてくれるので、他のプログラムを書き込む必要はありません。
最初に送られてくるセキュリティコードはブロジェクトごとに使えるので、ボタンの配置や機能の割り当てを変えてもずっと使えます。

ということで、先ほどのプログラムをBlynkに適用させてみようかと。
しかし、赤外線信号の部分はBlynkではできなさそうなので、Blynkの応用的な機能であるVirtual Pinというものを使ってみました。このVirtual Pinは、実際のESP8266上のピンとは無関係で、Blynk上で使える変数やトリガーのようなものです。今回の赤外線信号の場合であれば、ボタンがオンで1、オフで0という値を割り当てておいて、Virtual Pinが1ならエアコンをオンにする赤外線信号を発する、0ならオフの信号を発するという感じにしておきます。そんな感じで、一度Virtual Pinを介して動作するプログラムにしてみました。

#define BLYNK_PRINT Serial
#include <ESP8266WiFi.h>
#include <BlynkSimpleEsp8266.h>
#include <Adafruit_Sensor.h>
#include <DHT.h>
#include <DHT_U.h>

#define IRPin  4
#define DHTPin  2
#define duty_high 8
#define duty_low 16
#define DHTTYPE DHT11
DHT_Unified dht(DHTPin, DHTTYPE);
int temp;
int humid;

char auth[] = "*****";
char ssid[] = "*****";
char pass[] = "*****";

unsigned long data_on[243] = {
 3400, 1400, 600, 250, 550, 250, 550, 1050, 550, 250, 550, 1050, 550, 250, 550, 250, 550, 250, 550, 1050, 550,
 1050, 550, 250, 550, 300, 500, 250, 550, 1100, 500, 1100, 500, 300, 500, 300, 500, 300, 500, 300, 500, 300,
 500, 300, 500, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350,
 450, 350, 450, 350, 450, 350, 450, 400, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350,
 450, 1150, 450, 1150, 450, 1150, 450, 1150, 450, 1150, 450, 1200, 450, 350, 450, 350, 450, 350, 450, 1150,
 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 1200, 450, 350,
 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 400,
 400, 1200, 400, 400, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 400,
 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 450, 350, 450, 350,
 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400, 400,
 400, 400, 400, 400, 400, 400, 400, 400, 450, 350, 450, 1150, 450, 1150, 450, 350, 450, 1200, 400, 400, 400,
 400, 400, 1200, 400, 400, 400};
unsigned long data_off[99] = {
 3400, 1450, 550, 250, 550, 250, 500, 1100, 550, 250, 550, 1050, 550, 250, 550, 250, 550, 250, 550, 1050, 550,
 1050, 550, 300, 500, 250, 550, 300, 500, 1100, 500, 1100, 500, 300, 500, 300, 500, 300, 500, 300, 500, 350,
 450, 300, 500, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150, 450, 350, 450, 350,
 450, 350, 450, 350, 450, 350, 450, 400, 450, 350, 450, 1150, 450, 350, 450, 350, 450, 350, 450, 350, 450, 1150,
 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450, 350, 450};

void setup(){
  Serial.begin(9600);
  Blynk.begin(auth, ssid, pass);
  pinMode(IRPin, OUTPUT);
  dht.begin();
  sensor_t sensor;
  dht.temperature().getSensor(&sensor);
  dht.humidity().getSensor(&sensor);
  dht.humidity().getSensor(&sensor);
}

BLYNK_WRITE(V0){
  int value = param.asInt();
  if (value) {
    air_on();
  } else {
    air_off();
  }
}

BLYNK_READ(V1){
  temp_humid();
  Blynk.virtualWrite(V1, String(temp)+" °C");
  Blynk.virtualWrite(V2, String(humid)+" %");
  Blynk.virtualWrite(V3, temp);
}

void air_on() {
  int dataSize = sizeof(data_on) / sizeof(data_on[0]);
  for (int cnt = 0; cnt < dataSize; cnt++) {
    unsigned long len = data_on[cnt];
    unsigned long us = micros();
    while (us + len > micros()) {
      digitalWrite(IRPin, 1 - (cnt & 1));
      delayMicroseconds(duty_high);
      digitalWrite(IRPin, 0);
      delayMicroseconds(duty_low);
    }
  }
}

void air_off() {
  int dataSize = sizeof(data_off) / sizeof(data_off[0]);
  for (int cnt = 0; cnt < dataSize; cnt++) {
    unsigned long len = data_off[cnt];
    unsigned long us = micros();
    while (us + len > micros()){
      digitalWrite(IRPin, 1 - (cnt & 1));
      delayMicroseconds(duty_high);
      digitalWrite(IRPin, 0);
      delayMicroseconds(duty_low);
    }
  }
}

void temp_humid(){
  sensors_event_t event;  
  dht.temperature().getEvent(&event);
  if (isnan(event.temperature)) {
    temp=0;
  }else {
    temp=int(event.temperature);
  }
  dht.humidity().getEvent(&event);
  if (isnan(event.relative_humidity)) {
    humid=0;
  }else {
    humid=int(event.relative_humidity);
  }
}

void loop(){
  Blynk.run();
}
こんな感じで、BLYNK_WRITE(V0)内に条件分岐をつくって赤外線信号のオンオフ操作をしています。BLYNK_READ(V1)でも、温度センサから読み取った値をBlynkアプリへ渡すようにしています。

スマホアプリのほうでは、こんな感じのボタン配置にしました。
一番上の黄色いスイッチが照明用、その下の白いスイッチがエアコン用、その下に温度と湿度表示、さらに温度のグラフもあります。一応4G通信で外部からアクセスも可能でした。
しかし、温度グラフに関しては、一旦このアプリを消してしまうとそれまでの記録も消えてしまってダメでした。もしかしたら配列を使って過去の記録を覚えさせていけないのかもしれません。このグラフ機能以外にもヒストリーグラフというウィジットがあって、それならいいのかもしれませんが、実はこのアプリ、各ウィジットにはポイント(コスト)があり、例えばボタン一個で200エナジー、グラフが400エナジーであり、合計の上限が決まっています。

ヒストリーグラフは何と900エナジーもします(赤い数値)。この段階で、ボタン2個なので400エナジー、温度と湿度数値表示で400エナジー、そしてグラフで400エナジー、このプロジェクトだけで合計1200エナジー消費しています。もう一つ別のプロジェクトもあって、そっちで500エナジー消費しているので、すべてのプロジェクトを合わせて1700エナジー使っていることになります。残り200エナジーしかありません(画面上部)。
そして、このエナジー量を増やすには課金が必要ということでした。すっかりオープンソースで無料なのかと思っていたら、なるほど、よくある一部有料というアプリでした。

ということで、温度グラフのかわりに消費エナジー500のビデオストリーミングも試してみました。
以前使ったIP WebCamというアプリでタブレットのカメラから配信させてみました。
LAN通信ですが、ちゃんと映ります。これはビデオなのでESP8266とは無関係。このビデオストリーミングの場合、カメラのIPアドレスの欄にhttp://192.168.3.2:8080/videoを入れると大丈夫でした。温度グラフよりも、こっちのほうがよさげ。
ただし、Blynkのサイトにも書いてありますが、Blynkサーバはストリーミングサーバを提供していないためWANからは見れないということです。その場合は他のサービスを使えと書いてあります。

とはいったものの、Blynkで当初予定していた内容はほぼできてしまいました。しかも設定なども簡単です。自前でHTMLやJavascriptまで書いてESP8266をサーバにしてもいいのですが、たしかに面倒。当然自分の思い描いているような内容にすることはできますが、Blynkだとあっというまにできてしまったので、これはこれでいいのかもしれません。この手のサービスはWANからもアクセス可能という部分が便利かもしれません。自宅のルーターをポートフォワーディングさせれば済むことですが、セキュリティなどのことも考えると面倒なので、外部サーバーを利用できるのであれば、それに越したことはありません。

次のステップとしては、ESP8266かESP32にカメラモジュールを接続して小型のIPカメラをつくろうと思っていますが、なかなか思うように進みません。その小型IPカメラをCNCマシンに搭載し、bCNCのカメラ機能をIP化するところまで行くというのがとりあえずの目標です。
ただ、そのついでにその他の照明や電源などもIP化しようとしているので、いろいろやることが増えているという現状です。あわよくば、スマホ上のボタン操作ではなく、音声入力で照明をオンにできないかとも考えていて、Python、HTML5、Javascript、場合によってはPHPなども同時進行という感じです。さらには、それらの開発環境となるAtom EditorやPlatformIOの使い方も学習中です。

人気の投稿