2014年7月21日月曜日

PCで音声認識してmbedを制御する(解説編)

この記事はPCで音声認識してmbedを制御するに対する解説記事です。



全体構成

システム全体の構成を以下に示します。大きく4つの部分から構成しています。

mbed

パソコンのシリアルポートと接続し、パソコンからJSON形式の命令を受け取って動作します。
実際にはmbedとパソコンをUSBケーブルで繋ぐと、仮想的なRS-232ポートとして認識されます。

Webサーバー

Webブラウザとmbedの橋渡しをします。Webブラウザから受け取ったGET要求を解釈し、シリアルポートにJSON形式の命令を送ります。
JavaScriptからシリアルポートを直接扱えませんので、このサーバーを作成して橋渡しさせます。

index.html

マイクの音声をWeb Speech APIで文字列に変換し、その文字列を元にWebサーバーに命令を送ります。
XMLHttpRequestを使い、http://localhost:8080/mbed?cmd=right
のようなURLにGET要求を送ります。

音声認識サーバー

Web Speech APIが内部的に呼び出すサーバーです。いや、もしかしたらWeb Speech APIは外部サーバーを使わないで実装されてるかもしれませんが、まあほぼ、Googleのサーバーを使っていると思うわけです。なんせこの記事の執筆時点では、Google ChromeでしかWeb Speech APIが使えませんので。
Web Speech APIの利用側からは音声認識サーバーを意識する必要はありません。

動作

自作Webサーバーに置いてあるindex.htmlをGoogle Chromeで開き、パソコンのマイクに向かってしゃべります。「みぎ!」とか「ひだり!」とか。
次にWeb Speech APIを使い、その音声をテキストに変換します。「右」とか「左」という文字列が帰ってきます。
その結果を基にして、自作WebサーバーにGETリクエストを送ります。http://localhost:8000/mbed?cmd=right
という感じのURLに対してXMLHttpRequestを使ってGETすればOKです。

GETリクエストを受け取ったWebサーバーは、pySerialライブラリを使ってmbedにJSON形式の命令を送ります。
コマンドは{"cmd"="right"}みたいな文字列を送ります。rightの部分には、GETリクエストのcmd引数の値を埋め込みます。

mbedは常にシリアルポートを監視しており、1文字来る度にpicojsonライブラリで解析します。JSON形式の数値、文字列、オブジェクトのいずれか一つ受信するとpicojsonから処理が帰ってきますので、picojsonから得られたJSONオブジェクトを利用し、mbedに付いてるLEDを制御します。例えば以下のように。
if (obj["cmd"] == "right") {
    led1 = 1;
}

詳細説明

picojsonとmbed

picojsonは色々な使い方ができます。std::istreamの>>演算子を使う、文字列のバッファを与える、入力イテレータを与える、という3つの方法がありますが、今回は一番最後の入力イテレータの方法を使います。
シリアルポートからは標準入力とか文字列のバッファと違い、データが1文字ずつ送られて来ますし、JSONオブジェクトの区切りもpicojsonに自動でやって欲しいからです。

ここでちょっと問題がありました。std::istream_iteratorを使うと、picojsonの実装の関係で文字が1文字遅れてしまうのです。この問題を詳しく書くのは別の機会に譲るとして、次のようなデータをシリアルポートに送ると、最後の{が受信された時点で、初めて1つのJSONオブジェクトがpicojsonから返されます。
{"cmd"="right"}{
これはまずいです。mbedを制御しようとしてPythonから命令を送ってもすぐには反応せず、次の命令を送ったときに初めて前の命令が実行される、という動作になってしまうのです。
これを解決するのに、自作の入力イテレータを実装しました。それが次のクラス。


template <class T, class CharT = char,
          class Traits = std::char_traits<CharT>,
          class Distance = std::ptrdiff_t>
class NoDelayIstreamIterator
{
    mutable std::istream_iterator<T, CharT, Traits, Distance> iter_;
    mutable bool next_;
public:
    typedef typename std::istream_iterator<T, CharT, Traits, Distance>::istream_type istream_type;

    NoDelayIstreamIterator()
        : iter_(), next_(false)
    {}
    NoDelayIstreamIterator(istream_type& s)
        : iter_(s), next_(false)
    {}
    NoDelayIstreamIterator(const NoDelayIstreamIterator& x)
        : iter_(x.iter_), next_(x.next_)
    {}

    const T& operator*() const
    {
        if (next_)
        {
            ++iter_;
            next_ = false;
        }
        return *iter_;
    }

    NoDelayIstreamIterator<T, CharT, Traits, Distance>& operator++()
    {
        next_ = true;
        return *this;
    }

    NoDelayIstreamIterator<T, CharT, Traits, Distance> operator++(int)
    {
        NoDelayIstreamIterator<T, CharT, Traits, Distance> temp(*this);
        temp.next_ = true;
        return temp;
    }

    bool operator==(const NoDelayIstreamIterator& y) const
    {
        return iter_ == y.iter_ && next_ == y.next_;
    }

    bool operator!=(const NoDelayIstreamIterator& y) const
    {
        return ! (*this == y);
    }
};

operator++では実際にはデータを読み込まず、続くoperator*の実行でデータを読み取るのがポイントです。picojsonでは++の後は必ず*が呼ばれますので、ちょっと実装をサボっています。連続で++を呼び出してはいけません。

利用側は次のようなコードになります。

int main() {
    picojson::value json;
    picojson::default_parse_context ctx(&json);
    NoDelayIstreamIterator<char> input(cin);
    string err;
    
    for (;;)
    {
        input = picojson::_parse(ctx, input, NoDelayIstreamIterator<char>(), &err);
        if (!err.empty()) {
            cerr << err << endl;
        }
        
        picojson::value::object& o = json.get<picojson::value::object>();
        std::string cmd = o["cmd"].get<std::string>();

後はmbedのLEDをDigitalOut - ピン出力で紹介されているような方法を使うなどして、LEDをピカピカさせましょう!(筆者は4つのLEDを左右へ順番に光らせるプログラムを書いたのですが、ソースコードを消してしまったので掲載できませんでした。ごめんなさい)

PythonとpySerial

mbed側でJSON形式の文字列をシリアルポート経由で受け取り、LEDを制御するまで解説しました。ここではパソコン側の実装を説明します。パソコン側は、Pythonを用いてWebサーバーを作り、その上でindex.htmlを動かし、またシリアルポートへJSON文字列を送信します。

まずWebサーバーです。PythonでWebサーバーというと幾つかライブラリがあります。今回はTornadoを使いました。

その前にWebサーバーの解説

Webサーバーと言われてピンと来ない方のために少し解説します。Webサーバーとは超大雑把に言えば、ファイルを配信するコンピュータのことです。皆さんは普段、インターネットで情報を見るのにInternet ExplorerとかFirefoxとかGoogle ChromeとかのWebブラウザーを使っていると思います。現にこのブログを見てくださっているということは、ほぼ確実に何らかのWebブラウザーを使っているはずです。そして、皆さんが見ている情報は、Webサーバーが配信してくれています。
このブログの記事も、例にもれずBloggerさんが保持するWebサーバー(以降、Bloggerサーバー)上に保管されています。Webブラウザーを使ってBloggerサーバーへ「uchanoteブログの2014年5月『PCで音声認識してmbedを制御する』の記事をくれ!」と要求すると、Bloggerサーバーが該当の記事の内容を送り返してくれるのです。

その要求を具体的に表すのがURLです。「uchanoteブログの2014年5月『PCで音声認識してmbedを制御する』の記事」を要求する場合、次のようなURLになります。WebブラウザーにこのURLを打ち込むと該当の記事を読めます。

http://uchanote.blogspot.jp/2014/05/pcmbed.html

URLには構造があります。大きく以下の3つの部分に別れています。

  • "http://":通信方式を表します。以降の話には関係ないので、詳しくは書きません。
  • "uchanote.blogspot.jp":Webサーバーを識別する名前です。他のサーバーと重複しない名前になっています。
  • "/2014/05/pcmbed.html":実際に内容を要求するファイルの名前です。2014年の5月のpcmbed.htmlという記事を要求しています。

という感じです。このURLをWebブラウザーのURL欄に貼り付けますと、内部的に
GET /2014/05/pcmbed.html HTTP/1.1
という命令に変換され、Webサーバーへと送信されます。Webサーバー側はこの文字列を読み込んで解析し、「ふむふむ。Webブラウザー君はuchanote.blogspot.jp上の/2014/05/pcmbed.htmlというファイルをGETしたいのだな」と理解します。

Webサーバーが送り返すのはファイルでなくてもいい

で、ここが大切なのですが、/2014/05/pcmbed.htmlの部分は実際のファイル名である必要がありません。Webサーバーが理解できる何かの名前であればいいのです。そこにファイル名を書けば、Webブラウザーへ送るべきデータをファイルとして保管しておけて楽だ、というだけなのです。
例えば
GET /happy_birthday HTTP/1.1
という命令を受けて、実際には/2014/05/pcmbed.htmlのファイルを送り返してもいいですし、そもそも実在するファイルの中身を送り返す必要もありません。Webブラウザーは、Webサーバー側に本当に/happy_birthdayという名のファイルが有るのか、それともWebサーバーがその場限りの適当なデータをでっち上げているのかは判断できません。

WebサーバーはWebブラウザーから文字列を受け取ると

  1. 文字列を解釈してどんな命令かを理解し
  2. 命令に基いて何か作業をし(普通は、送り返すためのデータを準備する)
  3. Webブラウザーにデータを送り返す
という動作をします。Webサーバーは本来、3番目が最終的な目的なのですが、今回は2番目が主役となります。Webブラウザーからの命令に基いてmbedを制御する、これこそが今回やりたかったことです。Webブラウザーに送り返すデータなんて、「Success」のような固定の文字列で十分です。(何も送り返さなくてもいいくらいです)

PythonでWebサーバー

ということで、今回作るWebサーバーは
GET /なんちゃら HTTP/1.1
の「/なんちゃら」の部分をmbed制御用指令だと解釈し、mbedに信号を送るものにします。こうすることで、WebブラウザーのURL欄に「http://localhost/なんちゃら」と入力すると、mbedが「なんちゃら」の通りの動きをしてくれます。ということで作ったWebサーバーのソースコードを以下に示します。全体でこれだけです。

import argparse
import serial
import tornado.ioloop
import tornado.web

class IndexHandler(tornado.web.RequestHandler):
    def get(self):
        f = open('index.html')
        self.write(f.read())

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        cmd = self.get_argument('cmd', default=None)
        if cmd is not None:
            s = '{{"cmd":"{0}"}}'.format(cmd)
            serial_port.write(s.encode('ascii'))
            self.write('Sent command to serial port: ' + s)
            print('Sent command to serial port: ' + s)
        else:
            self.write('cmd parameter must be specified')

app = tornado.web.Application([
    (r'/', IndexHandler),
    (r'/mbed', MainHandler),
])

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Fujimin Web Server')
    parser.add_argument('--serial-device', default='/dev/tty.usbmodem1422')
    args = parser.parse_args()

    global serial_port
    serial_port = serial.Serial(args.serial_device, 9600)
    app.listen(8080)
    tornado.ioloop.IOLoop.instance().start()

「/なんちゃら」の部分は
app = tornado.web.Application([
    (r'/', IndexHandler),
    (r'/mbed', MainHandler),
])
に対応します。「/」をGETするとIndexHandlerが、「/mbed」をGETするとMainHandlerが起動します。詳しく言うと、
GET / HTTP/1.1
というGET要求についてWebブラウザーに送るデータを準備するのがIndexHandler、
GET /mbed HTTP/1.1
というGET要求についてWebブラウザーに送るデータを準備するのがMainHandlerということです。

IndexHandlerクラスの中のgetメソッドを見てみると、index.htmlファイルを開き(open)、そのファイルの中身を全部読み込んで(read)、それをWebブラウザーに送ります(self.write)。簡単ですね。普通のWebサーバーの動作です。
ちなみに、index.htmlファイルにはWeb Speech APIを利用した音声認識の仕組みと、音声認識の結果を使って/mbedにGET要求を発行する仕組みが入っています。詳しくは後で説明します。

MainHandlerクラスの中のgetメソッドが大切な部分です。この中の
serial_port.write(s.encode('ascii'))
という行が、実際にmbedに指令を送っている行です。mbedはパソコンのシリアルポートにつながっているので、そのシリアルポートに対して送りたい文字列をwriteします。

cmd = self.get_argument('cmd', default=None)
という行で、GET要求の「cmd引数の値」を取得してきます。上では解説しませんでしたが、GET要求の「/なんちゃら」は詳しく見ると構造を持っているのです。

GET /mbed?cmd=right HTTP/1.1
などと書くと、/mbedというファイル(本当はファイルではありませんが、Webブラウザーからはファイルに見えています)をGETするときに、追加の情報をWebサーバーに送ることができます。それが引数です。この例では「?cmd=right」の部分が引数ですね。

で、その引数を取得するのがself.get_argumentです。
cmd = self.get_argument('cmd', default=None)
と書けば、「cmdという引数名」に設定された値を取ってきます。今回の例では「right」が取得されます。もし、GET要求にcmd引数がなかったり、cmd引数に値が設定されていない場合、Noneとなります。
要するに
GET /mbed?cmd=hoge HTTP/1.1
というGET要求がくれば、変数cmdには"hoge"という文字列が入ります。
WebブラウザーのURL欄に「http://localhost/mbed?cmd=hoge」と入力することでこのGET要求を発行することができます。

後は取得した引数の値を使ってmbedマイコンが解釈できる指令を作成し、mbedに送るだけです。今回作成したmbedマイコンのプログラムはJSON形式の指令を受け付けるので、
'{{"cmd":"{0}"}}'.format(cmd)
と書いてJSON形式の文字列を生成しています。.format(cmd)は{0}にcmdを埋め込む命令なので、結果として「{"cmd":"hoge"}」という文字列になります。({{が{に、}}が}になってるのは.formatの機能です。誤植ではありません)

pySerial

Webサーバーで残るはシリアルポートの説明です。

PythonでRS-232を使うにはpySerialを使います。Windows、Mac、Linuxで同じようにシリアルポートの制御プログラムを作れます。

mbedマイコンは実際にはUSBでパソコンと繋がりますが、mbed側にUSBとシリアル通信の変換チップが載っているため、プログラムからはあたかもシリアルポートで接続されているように見えます。ということで、pySerialで簡単に制御できます。

実際にpySerialを使っている部分は、Webサーバーのプログラムに組み込まれています。まずシリアルポートを開きます。
serial_port = serial.Serial(args.serial_device, 9600)
args.serial_device という名前のシリアルポートを、通信速度 9600bps で開きます。args.serial_device にはWebサーバーの起動時に指定したシリアルポート名、例えば "/dev/tty.usbmodem1422"が入ります。Windowsだと"COM3"とかになるでしょう。

シリアルポートを開いたら、後はシリアルポートに対してデータを送ったり、今回はやっていませんがシリアルポートからデータを受け取ったりできます。今回のWebサーバーのプログラムでデータを送っているのが以下の行。
s = '{{"cmd":"{0}"}}'.format(cmd)
serial_port.write(s.encode('ascii'))
先ほど開いたシリアルポート「serial_port」に対して、変数sの中身を送ります。変数sには「{"cmd":"right"}」のような文字列が格納されていますので、それをそのままmbedに送ればいい…と思いきや、pySerialのwriteメソッドは、引数に「文字列」を渡せません。文字列を「バイト列」に変換してから渡しましょう。

この辺りの話を詳しく書こうとすると本が一冊書けてしまいますので、表面的な説明のみに留めます。Python(特にバージョン3)では、文字列とバイト列を明確に区別します。文字列とは、そのままの意味で0個以上の文字の列です。文字列「cmd」は「c」と「m」と「d」の3文字の列です。
で、この文字列をシリアルポートに送るには、文字の列ではなくバイトの列に変換する必要があります。文字列に対して「文字列.encode('文字コード')」と書けば、文字列をバイト列に変換できます。ある文字をどんなバイト列に変換するか(例えば文字「c」を16進数で「0x63」に変換する)というルールが文字コードです。今回はASCII文字コードを使います。「"cmd".encode('ascii')」と書けば「0x63」「0x6d」「0x64」の3バイトの列になります。

ということで、sに「{"cmd":"right"}」が入っているときにs.encode('ascii')と書けば、「7b, 22, 63, 6d, 64, 22, 3a, 22, 72, 69, 67, 68, 74, 22, 7d」という15バイトの列になります。それをserial_portにwriteすることでmbedに送信します。

index.html

パソコンのマイクから音声を受け取り、Web Speech APIで文字列に変換し、自作WebサーバーにGET要求を発行するためのHTMLファイルです。ファイル全体を以下に示します。
<!DOCTYPE HTML>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>Web Speech API Test</title>
  </head>
  <body onload="recognition.start();">
    <form>
      <input type="text" id="speech_result" value="認識結果"></input>
    </form>
    <script>
      if(typeof(String.prototype.trim) === "undefined")
      {
        String.prototype.trim = function() 
        {
          return String(this).replace(/^\s+|\s+$/g, '');
        };
      }
      var recognition = new webkitSpeechRecognition();
      recognition.continuous = true;
      recognition.lang = 'ja-JP';
      recognition.onsoundstart = function(event) {
        console.log(event);
      }
      recognition.onresult = function(event) {
        console.log(event);
        var word = event.results[event.results.length - 1][0].transcript;
        word = word.trim();
        document.getElementById('speech_result').value = word;
        var cmd = null;
        if (word === '右') {
          cmd = 'right';
          } else if (word === '左') {
          cmd = 'left';
          } else if (word === '止まれ') {
          cmd = 'off';
        }
        if (cmd != null) {
          var request = new XMLHttpRequest();
          request.open('GET', '/mbed?cmd=' + cmd);
          request.send();
          console.log(cmd)
        }
      }
    </script>
  </body>
</html>

Web Speech APIを使う準備が以下の部分です。
      var recognition = new webkitSpeechRecognition();
      recognition.continuous = true;
      recognition.lang = 'ja-JP';
      recognition.onsoundstart = function(event) {
        console.log(event);
      }
      recognition.onresult = function(event) {
        ...
      }

Web Speech APIについて詳しくはWebアプリに高機能な音声認識を追加するWeb Speech API - Kesin's diaryなどを参考にしてください。
var recognition = new webkitSpeechRecognition();
でWeb Speech APIインスタンスを作り、各種設定をします。

  • continuous = true : 連続変換モードを有効にする。マイクに向かって喋るたびに、自動で音声認識が行われます。
  • lang = 'ja-JP' : マイクに入る音声が日本語であることを示します。
  • onsoundstart = function(event) : 音声認識が始まったときに呼ばれる関数を設定します。今回はデバッグ用にJavaScriptコンソールにeventを表示しています。
  • onresult = function(event) : 音声認識が完了したときに呼ばれる関数を設定します。この関数の中身が、index.htmlファイルの中で最も重要な処理です。以下で説明していきます。

Web Speech APIで変換した結果を、デバッグ用に画面に表示する処理が以下です。Web Speech APIの変換結果はevent.resultsに書き込まれていますので、そこから希望の文字列を取得し、speech_resultテキストボックスに書き込みます。画面へ表示せずともmbedは制御できますが、Web Speech APIがどのように変換してくれたかを確認したいので表示しています。
        var word = event.results[event.results.length - 1][0].transcript;
        word = word.trim();
        document.getElementById('speech_result').value = word;
文字列を表示する場所として、inputタグを用いて1行テキストボックスを作っています。document.getElementByIdで探せるように、id="speech_result"としておきます。
    <form>
      <input type="text" id="speech_result" value="認識結果"></input>
    </form> 

自作WebサーバーにGET要求を送るのが以下です。
          var request = new XMLHttpRequest();
          request.open('GET', '/mbed?cmd=' + cmd);
          request.send();
XMLHttpRequestを使うと、同じWebサーバー内に置いてある別のページに対しGET要求を送れます。ここでは/mbedページに対し、?cmd=rightのような引数を付けたGET要求を生成し、発行します。すると、自作WebサーバーのMainHandlerが起動する、という仕組みです。

まとめ

以上で今回作ったシステムの全体を解説しました。index.htmlがユーザーとの接点となり、Web Speech APIでマイクからの音声をテキストに直し、そのテキストを元にWebサーバーへGET要求を発行し、Webサーバーがmbedにシリアル通信で指令を出します。
長文となってしまいましたが、この記事が何かのお役にたてれば幸いです。