音声認識でロボットのように動くGoogle Homeの作り方

石巻ハッカソン2018で開発した動くGoogle Home

今年の石巻ハッカソン2018で動くGoogle Homeを作成しました。自分だけの特別な動くGoogle Homeということで、ハッカソンでのプロジェクト名は「夢のマイホーム」というプロジェクト名にしました。

下が初めてインテグレーションに成功して動いた時の動画です。ハッカソン2日目の夜に撮影しました。

このプログでは、この「動くGoogle Home」をどうやって作ったのかを説明したいと思います。

ハードウェア構成

まず、ハードウェア構成は下のようになっています。

  • ラズベリーパイ
  • Arduino Uno x 2
  • USBマイク
  • USBスピーカー
  • タミヤ製キャタピラー、ギアボックス、DCモーター
  • モーターコントローラー
  • コンデンサマイク&アンプモジュール
  • 青色LED x 10
  • サーボモーター
  • レッドブルの空き缶 x 2
  • ダンボール
  • 黒い半透明の下敷き

音声認識部分

Google Home的な音声認識/発話の機能を担う部分はラズベリーパイにJabra社製のスピーカー&マイク一体型デバイスを繋いで構成しました。今回はJabraを使いましたが、一体型である必要は全くなくUSBマイク、USBスピーカー別々になったものを接続しても同じことができます。

次にラズベリーパイにGoogle Assistant LibraryをインストールすることでラズベリーパイにGoogle Homeと同じ機能を搭載しました。

Google Assistant Libraryをラズベリーパイにインストールする方法はGoogleの公式のドキュメントとして下に詳細なステップが書いてあり、これに従いました。

英語で書かれていて、途中ハマりやすい箇所がいっぱいあるので、後日別ブログにこのGoogle Assistant Libraryのセットアップのステップを詳細にまとめたものを書く予定です。

上の手順に従いGoogle Assistant Libraryのセットアップが完了した後、自分のGoogle AssistantアプリでAssitantの言語を日本語に設定すると、

“Ok, Google, 今日の天気は?”

というように日本語でラズベリーパイに問いかけることができるようになります。

しかし今回はこれから説明するDevice Actions(音声入力からラズベリーパイのローカルのデバイスの機能を動かす)を実装する時に、日本語では音声を認識してくれない箇所が出てきます。色々と試してみましたが、現状はDevice Actionsで日本語を使うことはできなそうです。

ということで私も泣く泣く、Google Assistantアプリ上でAssistantの言語を英語にして開発を進めました。

これ以降の機能を実装するためにはGoogle Assistantアプリで言語を英語に設定し、自分のラズベリーパイに
“Ok, Google, What is the weather today?”
と英語で問いかけた時に英語で答えが返ってくる状態にして進めてください。

どの部分で日本語では認識できなくなるのかは後の章でもう一度触れたいと思います。

音声認識部分からのハードウェア制御

上のリンクからGoogle Assistant Libraryをセットアップすればラズベリーパイを通常のGoogle Homeと同じように使うことができますが、さらに下のリンク(Install Device Hardware)に書かれたステップに従っていくと音声入力から自分独自のハードウェア制御をすることができます。

このセットアップを進めていくとGoogle Assistantがユーザーが喋った文章から意味を拾って特別なアクションにつなげるためのIntent, Entity, Fulfillmentを設定するファイルactions.jsonが出て来ます。(Dialog Flowの、Intent, Entity, Fulfillmentと全く同じものです。)↓

つまりactions.jsonというファイルには、

  • どういったセリフに反応し、セリフ中の数字などのパラメーターをどうやって取得するか -> Intent
  • セリフ中のある部分の単語をどういうパラメーターとして取り出したいか(例えばユーザーがセリフ中で言及した動物をanimalという変数に格納したい)-> Entity
  • セリフに反応した際、Python側のどのハンドラーにどういう情報を渡すか -> Fulfillment (Python側のハンドラーについては後ほど説明します)

が定義されています。

今回はこのactions.jsonを下のように拡張しました。

この中にはMove, Turn, Hackathonという名前の3つのアクションについてIntentとFulfillmentが記述されています。(Entityは全てのアクションで共通して使われる(使える)ので一番下の”types”というブロックに書かれています。

各アクションの役割は以下になります。

  • Move: ロボットの前進を指示するためのアクション
  • Turn: ロボットの首を左右に向ける指示をするためのアクション
  • Hackathon:ハッカソンの意味を聞くと、とある音声ファイルを再生するためのアクション

それでは1つ1つのアクションをさらに細かく見ていきます

Move (ロボットの前進&後進のための音声トリガー)

これはロボットの前進を指示するアクションです。

下の括弧から始まる部分がcom.goldrushcomputing.actions.Moveという名前のアクションです。

{
"name": "com.goldrushcomputing.actions.Move",
"availability": {

ここの中に”intent”という名前のブロックがあり、その中に下のような”trigger”というブロックがあります。

これはDialog Flowで言うところのIntentのトリガーセンテンスを定義します。

この場合は、”go”や、”move”で始まる言葉に反応するように4パターンの文を定義しています。

"move ($GoDirection:go_direction)? ($Speed:speed)? for $SchemaOrg_Number:number seconds",

という最初の1文をさらに詳しく説明します。

moveの後

  • ($GoDirection:go_direction)?
  • ($Speed:speed)?
  • $SchemeOrg_Number:number

という3つの変数のようなものがあることがわかると思います。これらの変数を〜で置き換えると“move 〜 〜for〜 seconds”という文になり、「~秒動いて、〜〜」。と言うようなセリフだと言うことが推測できるのではないでしょうか。

正確にはこの文章は

“move <方向> <スピード> for <数> 秒”

という構成でできていて、

「<方向>に、<スピード>の速さで、<数>秒間動いてください。」

という文章のテンプレートを定義してます。

  • <方向>が$GoDirectionというタイプ(型)のEntity(Dialog FlowのEntityです)
  • <スピード>が$SpeedというタイプのEntity
  • <数>が$SchemaOrg_NumberというタイプのEntity

であるという定義がされています。

このEntityはactions.jsonのtypesというブロックに入っています。

この中に$Speed$GoDirectionという型が定義されています。

Speedの定義を詳しく見てみましょう。

{
"name": "$Speed",
"entities": [
{
"key": "SLOWLY",
"synonyms": [
"slowly",
"slow"
]
},
{
"key": "NORMALLY",
"synonyms": [
"normally",
"regular"
]
},
{
"key": "QUICKLY",
"synonyms": [
"quickly",
"fast",
"quick"
]
}
]
},

まず、型の名前の前には$をつけます。

entitiesブロックの中に3つのEntityが定義されているのがわかると思います。それぞれのEntityはkeyとsynonymsというデータを持っています。

もし、ユーザーがこのsynonyms(類似後)リストの中に入っている言葉を話した場合、key名が値として変数に格納されます。

もう一度例に戻ってみましょう。

move $Speed:speed

というトリガー文であれば、もしユーザーが”move slowly”もしくは、”move slow”と話せば、key名に設定されている”SLOWLY”という文字列が$Speed:speedのコロン以降のspeedという変数(パラメーター)に格納されます。

では、この変数はどこで宣言されているかというと、actions.jsonの各Intentのparametersブロックの中で定義されています。

"parameters": [
{
"name": "number",
"type": "SchemaOrg_Number"
},
{
"name": "speed",
"type": "Speed"
},
{
"name": "go_direction",
"type": "GoDirection"
}
]

同じようにGoDirection型のEntityは下のように定義されていますので、

"name": "$GoDirection",
"entities": [
{
"key": "FORWARD",
"synonyms": [
"forward",
"further"
]
},
{
"key": "BACKWARD",
"synonyms": [
"back",
"backward"
]
}
]

ような宣言文があってユーザが”move back”と言った場合は”BACKWORD”という文字列がgo_direction変数に格納されます。

move $GoDirection:go_direction"

最後に<数>を表す$SchemaOrg_Numberという型のEntityについて説明します。これはactions.jsonのtypesというブロックに入っていません。これはデフォルトで用意されている数字型です。(Integerと同じだと思いますが、小数点もいけるのかはまだ試していません)

"move for $SchemaOrg_Number:number seconds",

と書くと、”move for 5 seconds”とユーザーが話しかけた時に、numberという変数(パラメーター)に5が入るようになります。

それでは、もう一度トリガーセンテンスを見てみます。Moveアクションのintentブロックを見てみましょう。

"intent": {
"name": "com.goldrushcomputing.intents.Move",
"parameters": [
{
"name": "number",
"type": "SchemaOrg_Number"
},
{
"name": "speed",
"type": "Speed"
},
{
"name": "go_direction",
"type": "GoDirection"
}
],
"trigger": {
"queryPatterns": [
"move ($GoDirection:go_direction)? ($Speed:speed)? for $SchemaOrg_Number:number seconds",
... ]
}
},

トリガーセンテンスのmove ($GoDirection:go_direction)? ($Speed:speed)? for $SchemaOrg_Number:number secondsがどういうセリフのテンプレートなのかもう見えるようになったのではないでしょうか?

$GoDirection:go_directionと$Speed:speedが( )?で括られていますが、( )?で括るとこれらのエンティティはオプショナルになり、ユーザーが話さなくてもこのトリガーセンテンスによってインテントが起動することを意味します。

つまり、以下の文章全てでインテントが起動します。

  • move forward slowly for 5 seconds
  • move for 5 seconds
  • move slowly for 5 seconds.
  • move forward for 5 seconds

実は、私が試した中で、なぜか最後の文章だけインテントが起動しませんでした。slowly($Speed型)もforward($GoDirection型)もオプショナルのはずなのですが、なぜか、forward($GoDirection型)のみの時はインテントが反応しません。

これについてはまだ原因がわかっておらず、今後わかったらこの記事を更新したいと思います。

そういう訳で今回は下の3パターンのようなセンテンスで、Moveインテントを起動することができました。

  • move forward slowly for 5 seconds
  • move for 5 seconds
  • move slowly for 5 seconds.

これで、Moveインテントの起動の仕方がわかりました。それではここから何が起こるのでしょうか?それは、同じMoveアクションの中のfullfilmentのブロックを見るとわかります。

fulfillmentはDailogFlowでもIntentが起動した時のハンドラー(通常はFirebase Functionsというnode.js上のハンドラー)のことですが、このfulfillmentについて記述した箇所がここになります。

ここで、”items”というarrayに、”simpleResponse”と”deviceExecution”というブロックが見えると思います。

simpleResponseはこのMoveインテントが起動した時に喋るセリフです。deviceExecutionはこのMoveインテントが起動した時にデバイスに対してアクションを起こす時にfullfillmentのハンドラーに送るコマンドとパラメーターのリストを定義しています。

つまりDevice Ations, つまりdeviceExecutionの機能を使うには、ここにハンドラーの中身を書くのではなく、ハンドラーに渡す情報(コマンド名と、パラメーター)を記述します。

actions.jsonを作り終えたら、下の手順のDeploy the Action Packageの項の手順に沿ってactions.jsonをActions on Googleの自分のプロジェクトにDeployします。

actions.jsonの編集→ディプロイという作業はラズベリーパイ上ではなく、手元のMacからでもできます。

上のリンクの内容と重複しますが、下の2つのコマンドによってactions.jsonをディプロイします。

./gactions update --action_package actions.json --project <my_project_id>./gactions test --action_package actions.json --project <my_project_id>

では、このfulfillmentブロックで記述したコマンドとパラメーターをどこで受け取るのかを次章で説明します。

Device Execution(ロボットの前進&後進のためのFulfillment)

今回、Google Assistant Libraryをラズベリーパイにインストールする手順の最後の手順で出てくるGoogle Assistant Libraryのサンプルコードであるhotword.pyを拡張してfulfillmentを実装しました。

先にもリンクを挙げたGoogle Assistant Libraryをラズベリーパイにインストールするチュートリアルの最後に出てくるサンプルコードであるhotword.pyを起動するとGoogle Homeと同じようにGoogle Assistantの機能が使えるようになります。つまりGoogle Homeに話しかけた時と同じ応答がラズベリーパイに繋いだスピーカーから返ってくるようになります。

“Ok, Google, What is the weather today?”

と聞けば今日の天気を英語で教えてくれます。

先の章でも触れましたが、今回Device Actions (actions.jsonで定義したアクション)をactions.jsonで定義したトリガーセンテンスで呼び出すために、Google Assistantの言語を英語にしなければなりません。

私の経験ではactions.jsonのトリガーセンテンス(queryPatterns)に日本語を書いてもGoogle Assistantが理解してくれませんでした。
つまりIntentが起動しませんでした。

このhotword.pyのソースの中で、process_event()という関数があります。

この関数は、ユーザーが会話を”OK, Google”で始めた時や、ユーザーがその後にホームに何か問いかけをした時などのイベントごとに呼ばれます。

この中で、イベントのタイプがDevice Actionだった時に、イベントオブジェクトの中から先ほどactions.jsonfulfillmentブロックの中で定義したcommandparamsを受け取ることができます。

ここでcommandの名前が先ほどactoins.jsonのfulfillmentブロックのdeviceExecutionブロックで定義したMoveアクションのcommand名(com.goldrushcomputing.commands.Move)だったら、paramsの中から、同じくdeviceExecutionブロックの中で定義したparams名の’go_direction’, ‘number’, ‘speed’で値を取り出します。この値をパラメータとしてPythonの関数move()を呼び出し、そこで、モーターを動かす処理をします。

モーターの制御

def move(direction, duration, speed):
command = "move" + "," + str(direction) + "," + str(duration) + "," + str(speed) + "?"
result = send_command(command)
return result

今回は上のmove()関数の中で、USBでラズベリーパイと繋がったArduinoにシリアル通信を行い、その中でモーターを動かすようにArduino側に司令を送ります。

move()関数の中では、まずシリアルでArduinoに送るためのコマンドを生成します。

コマンドは

move,<direction>,<duration>,<speed>?

という文法の文字列にしました。directionが方向(0:ストップ、1:前進、2:後進)、durationが前進する時間、つまりモーターを回す時間(秒数)、speedがモーターを回すスピード(0~ 255の値)となっています。

?がコマンドの終了文字列です。

move,1,5,255?

というコマンドは、モーターを最大速度(255)で5秒間前進方向に回す命令となります。

コマンドの文字列を生成した後は、下のsend_command()関数でラズベリーパイにつながったArduinoにシリアル通信で文字列を送信します。

def send_command(command):
result = -1
try:
arduino_serial = serial.Serial(
"/dev/ttyACM0",
115200,
timeout=1.0,
write_timeout=2.0
)
arduino_serial.reset_output_buffer()
arduino_serial.reset_input_buffer()
time.sleep(3) # For complete Arduino initialization
print("sending command:" + command)
result_int = arduino_serial.write(command.encode('ascii'))
if result_int != len(command):
print("command length mismatch")
time.sleep(0.25)
arduino_bytes = arduino_serial.readline(10)
arduino_data = arduino_bytes.decode("utf-8")
print("Pong:" + arduino_data)
result = arduino_data
arduino_serial.close()
except Exception as e:
result = -1
return result

これで音声認識からデバイスの操作までの一連の流れができました。

今回はモーターなどのハードウェアの制御をArduinoに任せ、ラズベリーパイかららはArduinoにコマンドを送って操作するアーキテクチャにしました(下図)。

send_command()関数は上の図の2つのArduinoのうち、上のArduinoにシリアルでコマンドを送っています。

これは、ハッカソン中にラズベリーパイ側の実装とArduino側の実装に分業ができるようにこうしましたが、もちろんラズベリーパイのGPIOピンを使って直接モーターを操作するようにすることもできます。むしろこちらの方がシンプルな実装になって良いかも知れません。

下のGoogleの公式のDevice Actionsのチュートリアルでも、ラズベリーパイのGPIOピンを使ってLEDを光らせるサンプルが説明されています。

モーターの制御(Arduino側の実装)

Arduino側では、コマンドを受け取るインタープリターが常に動いてシリアルポートからの入力を監視しています。シリアルポートから文字を受け取るとバッファにため、終了文字’?’を受け取った時点で一文をコマンドだと理解し、コマンドに該当した処理を呼び出します。

このインタープリターのコードは今回は内容も複雑なため説明はしませんが、下の記事などを参考にして作ることができます。

コマンドを受け取った後は、下のmove()関数で、パラメータの値を読み取り、moveFunction()関数で、モーターの制御を行います。

今回はTOSHIBAのモータードライバーTA7291Pを使いました。このドライバは、モーターにかける電圧の強さと、向きをコントロールできるため、スピードの調整や、前進/後進の切り替えができるようになります。

このモータードライバーの使い方は下のサイトが参考になります

move()関数から呼び出されるmoveFunction()関数では、下のように、信号用のピンのHIGH, LOWを指定された方向に合わせて切り替えることと、スピード用のピンに指定された量の値を設定しています。

int move(char **args){
int direction = atoi(args[1]);
int time = 5;
int speed = 255;

if (args[2] != NULL) {
time = atoi(args[2]);
}
if (args[3] != NULL) {
speed = atoi(args[3]);
}
servo.write(90);
delay(500);
moveFunction(direction, speed);
delay(time * 1000);
moveFunction(0, 0);

return 1;
}
void moveFunction(int direction, int speed){
pinMode(IN1,OUTPUT); //信号用ピン IN1(5番ピン)
pinMode(IN2,OUTPUT); //信号用ピン IN2(6番ピン)
//IN2 LOW -> Forward
//IN1 LOW -> Back
if(direction == 0){ //Stop
analogWrite(IN1, 0);
analogWrite(IN2, 0);
}else if(direction == 1){ //Forward
analogWrite(IN1, 0);
analogWrite(IN2, 255);
}else if(direction == 2){ //Backward
analogWrite(IN1, 255);
analogWrite(IN2, 0);
}
//valが大きいほど出力値も大きくなる
analogWrite(SPEED_PIN , speed); //出力値:1~255
}

上のコードで、IN1は5、IN2は6、SPEED_PINは4番のモータコントローラーのピンにつながっています。

ここまでで、”move <forward/backward> <slowly/normally/quickly> for <number> seconds”という声の指示でモーターを制御するところまで動くようになります。

今回制御しているモーターは下のタミヤのタンク工作基本セットのモーターです。

http://www.tamiya.com/japan/products/70108/index.html

首(サーボ)と目(LED)の制御

今回は、キャタピラで移動するためのモーター以外にも、”Ok, Google. Turn Left”、”Ok, Google. Turn Right”で首を左右に回すという機能も製作しました。

actions.jsonには、com.goldrushcomputing.actions.Turnというアクションを追加し、その中で、

"turn $Direction:direction"

というトリガーセンテンスに反応するようにIntentを設定し、新たに$DirectionというEntityを定義しました。

$Directionの定義は下のようになっており、”left”と”right”に反応し、それぞれ”LEFT”、”RIGHT”というキーワードをIntentのパラメータに格納するようになっています。

{
"name": "$Direction",
"entities": [
{
"key": "LEFT",
"synonyms": [
"left"
]
},
{
"key": "RIGHT",
"synonyms": [
"right"
]
}
]
}

これを、Google Assistantサンプルコードhotword.pyの中のprocess_event()の中で下のようにハンドリングします。

if event.type == EventType.ON_DEVICE_ACTION:
for command, params in event.actions:
print('Do command', command, 'with params', str(params))


elif command == "com.goldrushcomputing.commands.Turn":
direction_str = params['direction']
direction = 0
if direction_str == "LEFT":
direction = 1
elif direction_str == "RIGHT":
direction = 0
result = turn(direction)

process_event()の中でエベントのアクションの中にcom.goldrushcomputing.actions.Turnというコマンドがあったらdirectionパラメーターを取り出してturn()という関数を呼び出します。
turn()の中で下のようにコマンドをArduino側に投げます。

def turn(direction):
command = "turn" + "," + str(direction) + "?"
result = send_command2(command)
return result

Moveアクションの時とまったく同じ構成ですが、1つだけ違うのは、send_command()ではなく、send_command2()という関数を使い、move()の時とは違うArduinoにコマンドを投げています。

今回のハッカソンでは、開発を分業するため、1つのArduinoをDCモーター(キャタピラ)の制御に、もう1つのArduinoを首と目の制御に使うようにしました。DCモーターが付いているArduinoがdev/ttyACM0のUSBにつながり、もう1つのArduinoがdev/ttyACM1に繋がっています。

つまり、turn()関数の中では、turn,1?(右)、turn,2?(左)のようなコマンドを上の図中の下のArduinoに投げています。

首のサーボモーターの制御(Arduino側)

Arduino側では下のような回路を組みました。右側の部分がServoモーターのコントロールに関する部分です。左側の部分、LEDとマイクがつながっている部分については次の章で説明します。

ArduinoのServoモーター制御のコードは下のようになります。

#include <Servo.h>
const int SERVO_CTRL = 2;
Servo servo;
void setup()
{
...
//Servoのポート設定
pinMode(SERVO_CTRL,OUTPUT);
servo.attach(SERVO_CTRL);
}int turn(char **args){
int direction = atoi(args[1]); //0: Right, 1: Left
seeFunction(direction);
return 1;
}
//someInnerFunction
void someInnerFunction(){
}void seeFunction(int see_direction) {
// 2 -> front(90deg)
// 1 -> left(30deg)
// 0 -> right(150deg)

if (see_direction == 1) { // left(30deg)
servo.write(150);
} else if (see_direction == 0) { // right(150deg)
servo.write(30);
} else { //front(90deg);
servo.write(90);
}
}

送られて来たdirectionパラメーターの値により、1だったら、150度、0だったら30度の位置にサーボモーターを回転させて首を左右に動かすようにしています。

目(LED)の制御(Arduino側)

こちらは、コードの説明は割愛しますが、上の回路でAE-MICAMPというコンデンサマイクとアンプが一帯になったモジュールを使うと、マイクの拾う音が0〜1023の値で返ってきます。その値に応じて5列のLED(1列あたり2個)のLEDを音量に合わせて光らせています。

これによって、ユーザーやGoogle Homeが喋っている時に音量に合わせて眼が光る効果を作りました。

おまけの機能

そのほかの機能として、今年の石巻ハッカソンのテーマが「ハッカソン」だったので、「What is Hackathon?」と聞くと特別なことを答えるようにしました。

この動くGoogle Homeは先にも説明したように普通のGoogle Homeと同じ機能を持っていますので、「What is Java?」などと聞くとJava言語のことについてWikipediaを参考にして説明してくれます。

もちろん、「What is Hackathon?」と聞くと、ハッカソンの内容をWikipediaを 参考にして説明してくれるのですが、この部分をハックしました。

actions.jsonに戻って、com.goldrushcomputing.actions.Hackathonというアクションを追加します。

このアクションのIntentに、下のようなトリガーセンテンスを定義します。

"queryPatterns": [
"What is Hackathon?",
"What is Hackathon",
"What is Hackathon ?",
"Who is Hackathon?",
"Who is Hackathon",
"Who is Hackathon ?"
]

これで、準備は整いました。

com.goldrushcomputing.commands.Hackathon

というコマンドがhotword.pyにくるので、このコマンドが来た時にしたのhackathon()関数を呼びます。ここでラズベリーパイの標準の音声再生ソフトであるaplayを使って、あらかじめ録音しておいたPCM音声ファイルを再生しています。

def hackathon():
res = subprocess.call('aplay --format=S16_LE --rate=22050 ~/hackathon_answer.raw', shell=True)

この音声ファイルは、ヴォイスチェンジャーを使ってヘリウムガスを吸った時のような声のエフェクトでハッカソンの定義を説明している音声です。最後に石巻ハッカソンが世界で一番いけてますよねと褒めて終わるようになっている音声です。(会場の受けはまずまずでした(^^; )

発表では無事に全ての機能が動きました。

今回開発したコードはここからダウンロードできます。内容は

  • actions.json
  • hotword.py
  • ishinomaki_arduino.ino (Arduinoのコード。2台とも同じコードを動かしています)
  • hackthon_answer.raw (ハッカソンについて説明してくれる音声ファイル。)

です。一部arduinoのコマンドインタープリターを担っているモジュールだけは事情があって今回は入れていません。上の説明中に参考リンクを貼りましたがコマンドインタープリターの作り方は色々なところに載っているので参考にしてみてください。

Google Assistant LibraryやGoogle Homeに関する質問や、開発のご相談などはこちらにご連絡ください。
@mizutory
mizutori@goldrushcomputing.com

Goldrush Computing株式会社代表。iPhone/Androidアプリのプログラマー。守備範囲はSwift, Kotlin, Python, Js, Java, Obj-C, C#, C, Assembly。前職はSonyEricsson。3児のパパ。

Goldrush Computing株式会社代表。iPhone/Androidアプリのプログラマー。守備範囲はSwift, Kotlin, Python, Js, Java, Obj-C, C#, C, Assembly。前職はSonyEricsson。3児のパパ。