【実験】PaPeRo i をAlexa対応デバイスと連携させる

Alexa対応のスマートスピーカー(Amazon Echo や Echo Dot 等)をPaPeRo i と連携させ、人との会話を実現する方法を紹介します。

Alexa対応デバイスを利用して人との会話を行うサンプルアプリが、サンプルアプリケーションパックの一つとしてNECプラットフォームズ様より提供される予定です。
Alexaとの連携を実現する為、Amazon開発者ポータル上で対話モデルを構築し、IBM Cloud 上でSkillを稼働させます。
PaPeRo i のアプリケーションは、WebSocketでSkillと通信し、Skillからの指示により発話などの動作を行います。
サンプルアプリケーションパックの、PaPeRo i アプリケーションの実装言語はC++となりますが、本記事ではその部分をPythonで実装したものを紹介します。

本記事の動作手順では、Pythonで実装したPaPeRo i アプリケーションをWindowsPC上で動作させますが、Raspberry Pi やPaPeRo i 本体で動作させる事も可能です。
対話モデル及びSkillについては、サンプルアプリケーションに同梱される予定のものをそのまま使用します。

尚、本記事で使用する対話モデル・Skill・アプリケーションは、あくまで実験用で、商用にする場合にはSkillの公開審査を通す為の工夫が必要です。

手順(PaPeRo i 側)

(1) PaPeRo i 制御用WebSocket通信アドオンシナリオをまだインストールしていない場合は、「PaPeRo iをRaspberry Pi上のpythonから操作する」の「PaPeRo iにアドオンシナリオをインストール」に従ってインストール作業を行います。

手順(WindowsPC 側)

(1) Amazon開発者ポータル(https://developer.amazon.com/ja/alexa)にブラウザでアクセスし、右上の「サインイン」をクリックします。

(2) Amazonのアカウントがある場合はログインします。ない場合は「Amazon Developerアカウントを作成」をクリックし、アカウントを作成します。

(3) 右上の「あなたのAlexaコンソール」にマウスポインタを合わせ、表示されるメニューから「Skills」を選択します。

(4) 「スキルの作成」ボタンをクリックします。

(5) スキル名を任意に入力し、デフォルトの言語として「日本語」を選択し、スキルに追加するモデルとして「カスタム」を選択します。

(6) 「スキルを作成」をクリックします。

(7) 左端のメニューの「JSONエディター」をクリックします。

(8) 下記の内容をコピー・ペーストして、model.json を作成し、それを「.jsonファイルをドラッグアンドドロップ」と書かれたエリアにドラッグアンドドロップします(ファイルにせず、JSONエディタに直接貼り付けても可)。

{
    "interactionModel": {
        "languageModel": {
            "invocationName": "パペロの雑談",
            "intents": [
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": []
                },
                {
                    "name": "calcIntent",
                    "slots": [
                        {
                            "name": "firstNum",
                            "type": "AMAZON.NUMBER"
                        },
                        {
                            "name": "secondNum",
                            "type": "AMAZON.NUMBER"
                        },
                        {
                            "name": "calcAdd",
                            "type": "list_of_add"
                        },
                        {
                            "name": "calcSub",
                            "type": "list_of_sub"
                        }
                    ],
                    "samples": [
                        "{firstNum} {calcSub} {secondNum} は",
                        "{firstNum} {calcAdd} {secondNum} は"
                    ]
                },
                {
                    "name": "linkKeyIntent",
                    "slots": [
                        {
                            "name": "linkKey",
                            "type": "AMAZON.NUMBER"
                        }
                    ],
                    "samples": [
                        "識別番号は {linkKey}"
                    ]
                },
                {
                    "name": "sensorIntent",
                    "slots": [
                        {
                            "name": "tem",
                            "type": "list_of_tem"
                        },
                        {
                            "name": "hyg",
                            "type": "list_of_hyg"
                        },
                        {
                            "name": "lux",
                            "type": "list_of_lux"
                        }
                    ],
                    "samples": [
                        "今の {lux} は",
                        "現在の {lux} は",
                        "現在の {hyg} は",
                        "現在の {tem} は",
                        "今の {hyg} は",
                        "今の {tem} は"
                    ]
                }
            ],
            "types": [
                {
                    "name": "list_of_add",
                    "values": [
                        {
                            "name": {
                                "value": "たす",
                                "synonyms": [
                                    "[足す][+][プラス][たす]"
                                ]
                            }
                        }
                    ]
                },
                {
                    "name": "list_of_sub",
                    "values": [
                        {
                            "name": {
                                "value": "ひく",
                                "synonyms": [
                                    "[引く][ー][マイナス][ひく]"
                                ]
                            }
                        }
                    ]
                },
                {
                    "name": "list_of_tem",
                    "values": [
                        {
                            "name": {
                                "value": "温度",
                                "synonyms": [
                                    "[気温]"
                                ]
                            }
                        }
                    ]
                },
                {
                    "name": "list_of_hyg",
                    "values": [
                        {
                            "name": {
                                "value": "湿度"
                            }
                        }
                    ]
                },
                {
                    "name": "list_of_lux",
                    "values": [
                        {
                            "name": {
                                "value": "照度",
                                "synonyms": [
                                    "[明るさ]"
                                ]
                            }
                        }
                    ]
                }
            ]
        }
    }
}

(9) 「モデルを保存」をクリックします。

(10) 「モデルをビルド」をクリックします。

約1分後、「正常にビルドされました」と表示される事を確認します。

(11) Amazon開発者ポータルの画面はそのままにして、別なブラウザのウィンドウで、IBM Cloud(https://console.bluemix.net/registration/free)にアクセスします。
アカウントがない場合は、アカウント登録を行います。
既にアカウントがある場合はログインします。

(12) 左上のハンバーガーアイコンをクリックし、表示されるメニューから「ダッシュボード」を選択します

(13) 「リソースの作成」をクリックします。

(14) Cloud Foundryアプリから「Python」を選択します。

(15) アプリの情報を入力します。
アプリ名はアカウント内でユニークになるように入力し、ホスト名は他者を含めIBMクラウド内でユニークになるように入力します(両方とも半角英数字で)。

(16) 「作成」をクリックします。

(17) https://console.bluemix.net/docs/cli/reference/bluemix_cli/get_started.html#getting-started から、IBM Cloud CLIをダウンロードし、インストールします。

(18) こちらからskill.zipをダウンロードしてエクスプローラーで開き、中にあるskillフォルダを任意の場所にコピーします。
以下の説明では、skillフォルダをCドライブ直下に置いたと仮定します。

(19) c:\skill\Procfile をメモ帳で開きます。

(20) Amazon開発者ポータルの画面に戻り、「スキル一覧」をクリックします。

(21) 作成済スキルの「スキルIDの表示」をクリックします。

(22) 表示されるスキルIDを、c:\skill\Procfileの「xxxxxxxx」の部分にコピー&ペーストし、上書き保存します。

(23) コマンドプロンプトを開き、cd コマンドでカレントディレクトリをc:\skillに変更します。

(24) コマンドプロンプト上で、下記のコマンドを実行します。
bluemix api https://api.ng.bluemix.net
bluemix login -u メールアドレス -o メールアドレス -s dev

ここで、メールアドレスは、アカウント登録時に使用したメールアドレスです。

パスワードの入力を求められますので、IBM Cloudへのログイン時に使用したパスワードを入力します。

(25) 下記のコマンドを入力します。

bluemix app push アプリ名

アプリ名は(15)で入力したアプリ名です。

2~3分程度すると、下のような画面になりますので、routes:の隣に表示されている「ホスト名.mybluemix.net」の部分をマウスで選択し、コピーします。

(26) 下記の内容をコピー・ペーストし、「skill.txt」という名前で保存します。

ws://xxxxxxxx/ppr

ここで、「xxxxxxxx」の部分は、(11)でコマンドプロンプト画面からコピーした「ホスト名.mybluemix.net」に置き換えます。このskill.txtは、PaPeRo i アプリケーションを実行する際に使用します。

(27) Amazon開発者ポータルの画面に戻り、スキル名をクリックします。

(28) 左端のメニューから、「エンドポイント」をクリックします。

(29) HTTPSをクリックします。

(30) デフォルトの地域の「URIを入力」と書かれている部分に、「https://」と入力し、続けて(25)でコピーした「ホスト名.mybluemix.net」をペーストします。
また、その下の「SSL証明書の種類を選択」をクリックし、「開発用のエンドポイントは、証明機関が発行したワイルドカード証明書を持つドメインのサブドメインです」を選択します。

(31) 「エンドポイントを保存」をクリックします。

(32) 「テスト」をクリックします。

(33) 「このスキルでは、テストは無効になっています」の隣のスイッチをクリックします。

(34) 台詞の入力欄に「アレクサ、パペロの雑談を開いて」と入力し、Enterキーを押します。

(35) 「近くでパペロを起動して下さい。既に起動されている場合は、座布団のボタンを押して下さい」と表示される事を確認します。

(36) Alexa対応デバイス(Echoなど)に電源を接続します。

(37) Amazon Alexaサイト(https://alexa.amazon.co.jp/spa/index.html)にアクセスし、開発者ポータルと同じメールアドレス・パスワードでログインします。

(38) Alexa対応デバイスの初期セットアップをまだ実施していない場合は、設定をクリックし、画面の指示に従って初期セットアップを行います。

(39) 「スキル」をクリックします。

(40) 「有効なスキル」をクリックします。

(41) 「DEVスキル」をクリックします。

(42) Amazon開発者ポータルで登録したスキルが表示されますので、クリックします。

(43) 「スキルを無効にする」ボタンが表示されている事を確認します。
もしもボタンの表示が「スキルを有効にする」になっている場合は、ボタンをクリックして有効にします。

(44) Pythonをまだインストールしていない場合は、https://www.python.org/ からダウンロードしてインストールします。

(45) ws4py をまだインストールしていない場合は、コマンドプロンプトから下記のコマンドを入力する事によりインストールします。

pip install ws4py

(46) 「PaPeRo i でPythonを使えるようにする」にあるリンクから「PaPeRo i 制御用Pythonライブラリ」をダウンロードし、zipファイルの中身(pypapero.py)を任意のフォルダに置きます。

(47) 下記の内容をコピー・ペーストして、ppr_client.py を作成し、pypapero.py と同じフォルダに置きます。

# アレクサ連携(パペロの雑談) PaPeRo i 側
import sys
import json
import queue
import time
from enum import Enum

from ws4py.client.threadedclient import WebSocketClient

import pypapero


class State(Enum):
    st0 = 10
    st1 = 11
    end = 999


class SkillClient(WebSocketClient):
    def opened(self):
        print("Connected to skill")

    def closed(self, code, reason):
        print("Disconnected from skill")
        self.finished =True

    def received_message(self, msgrcv):
        message = str(msgrcv)
        skill_manager.que.put(message)


class SkillManager:
    def __init__(self, addr):
        self.addr = addr
        self.que = queue.Queue()
        self.connect_to_skill()
        self.time_keep_alive = time.monotonic()
        self.link_state = State.st0

    def connect_to_skill(self):
        success = False
        while not success:
            self.ws = SkillClient(self.addr, protocols=None)
            self.ws.finished = False
            try:
                self.ws.connect()
                success = True
            except:
                pass

    def get_msg_from_skill(self):
        try:
            message = self.que.get(block=True, timeout=0.1)
            try:
                msg_dic = json.loads(message)
            except:
                msg_dic = None
        except queue.Empty:
            msg_dic = None
        if (msg_dic is not None) and ("msgName" not in msg_dic):
            msg_dic = None
        return msg_dic

    def keep_alive(self):
        time_now = time.monotonic()
        if (time_now - self.time_keep_alive) >= 30:
            print("keepAlive")
            msg_dic_send = {"msgName": "keepAlive"}
            message = json.dumps(msg_dic_send)
            self.ws.send(message)
            self.time_keep_alive = time_now

    def forward(self):
        if self.ws.finished:
            success = False
            time.sleep(10)
            self.connect_to_skill()
        else:
            if self.link_state == State.st0:
                msg_dic_send = {"msgName": "linkKeyReq"}
                message = json.dumps(msg_dic_send)
                self.ws.send(message)
                self.link_state = State.st1
            msg_dic = self.get_msg_from_skill()
            if msg_dic is not None:
                if msg_dic["msgName"] == "speak":
                    self.link_state = State.end
                    papero_manager.enter_list_speech(msg_dic["text"])
                    papero_manager.hold_session = True
                elif msg_dic["msgName"] == "speakLinkKey":
                    print("linkKey = ", msg_dic["linkKey"])
                    text = msg_dic["linkKeyText"]
                    papero_manager.enter_list_speech(text)
                    papero_manager.hold_session = False
                elif msg_dic["msgName"] == "askTEM":
                    papero_manager.start_sensor("TEM")
                    papero_manager.hold_session = True
                elif msg_dic["msgName"] == "askHYG":
                    papero_manager.start_sensor("HYG")
                    papero_manager.hold_session = True
                elif msg_dic["msgName"] == "askLUX":
                    papero_manager.start_sensor("LUX")
                    papero_manager.hold_session = True
                elif msg_dic["msgName"] == "sessionEnd":
                    papero_manager.hold_session = False
        self.keep_alive()

    def send_ask_sensor_res(self, kind, value, valid):
        msg_dic_send = None
        if kind == "TEM":
            msg_dic_send = {"msgName": "askTEMRes", "value": value, "valid": valid}
        elif kind == "HYG":
            msg_dic_send = {"msgName": "askHYGRes", "value": value, "valid": valid}
        elif kind == "LUX":
            msg_dic_send = {"msgName": "askLUXRes", "value": value, "valid": valid}
        if msg_dic_send is not None:
            message = json.dumps(msg_dic_send)
            self.ws.send(message)

    def reset_link_state_for_retry(self):
        self.link_state = State.st0


class PaperoManager:
    def __init__(self, papero):
        self.papero = papero
        self.list_speech = []
        self.state = State.st0
        self.st_speech = State.end
        self.past_time_speech = 0
        self.prev_time = time.monotonic()
        self.st_sensor = State.end
        self.hold_session = False
        self.kind_sensor = ""
        self.listening = True
        self.listen_time = 0

    def forward(self):
        messages = papero.papero_robot_message_recv(0.1)
        now_time = time.monotonic()
        delta_time = now_time - self.prev_time
        self.prev_time = now_time
        if messages is not None:
            msg_dic_rcv = messages[0]
        else:
            msg_dic_rcv = None
        if papero.errOccurred != 0:
            print("------Error occured(main()). Detail : " + papero.errDetail)
            self.state = State.end
        if self.state == State.st0:
            if len(self.list_speech) > 0:
                self.start_speech(self.list_speech[0])
                self.state = State.st1
        elif self.state == State.st1:
            if self.st_speech == State.end:
                del self.list_speech[0]
                self.state = State.st0
        self.forward_speech(msg_dic_rcv, delta_time)
        self.forward_listen(delta_time)
        self.forward_sensor(msg_dic_rcv)
        if (msg_dic_rcv is not None) and (msg_dic_rcv["Name"] == "detectButton"):
            skill_manager.reset_link_state_for_retry()

    def start_speech(self, text):
        self.stop_listen()
        self.papero.send_turn_led_on("mouth", 
                                     ["NG3G3G3G3G3G3G3N", "2", "G3NG3NG3NG3NG3", "2",
                                      "NNNG3G3G3NNN", "2", "NNG3G3G3G3G3NN", "2",
                                      "NNNG3G3G3NNN", "2"], repeat=True)
        self.papero.send_start_speech(text)
        self.st_speech = State.st0
        self.past_time_speech = 0

    def forward_speech(self, msg_dic_rcv, delta_time):
        if self.st_speech == State.st0:
            self.past_time_speech += delta_time
            if self.past_time_speech > 0.3:
                self.papero.send_get_speech_status()
                self.st_speech = State.st1
        elif self.st_speech == State.st1:
            if (msg_dic_rcv is not None) and (msg_dic_rcv["Name"] == "getSpeechStatusRes"):
                if str(msg_dic_rcv["Return"]) == "0":
                    self.papero.send_turn_led_off("mouth")
                    self.start_listen()
                    self.st_speech = State.end
                else:
                    self.st_speech = State.st0
                    self.past_time_speech = 0

    def start_listen(self):
        if self.hold_session:
            self.papero.send_turn_led_on("ear", ["W3W3", "5"], repeat=True)
            self.listening = True
            self.listen_time = 0

    def forward_listen(self, delta_time):
        if self.listening:
            self.listen_time += delta_time
            if (self.listen_time > 10) or (not self.hold_session):
                self.stop_listen()

    def stop_listen(self):
        self.papero.send_turn_led_off("ear")
        self.listening = False

    def enter_list_speech(self, text):
        self.list_speech.append(text)

    def start_sensor(self, kind):
        self.kind_sensor = kind
        self.st_sensor = State.st0
        if (kind == "LUX"):
            self.papero.send_start_lum_sensor()
            self.papero.send_get_lum_sensor_value()
        else:
            self.papero.send_get_sensor_value()

    def forward_sensor(self, msg_dic_rcv):
        if self.st_sensor == State.st0:
            if msg_dic_rcv is not None:
                if msg_dic_rcv["Name"] == "getSensorValueRes":
                    if self.kind_sensor == "TEM":
                        if "TEM" in msg_dic_rcv:
                            skill_manager.send_ask_sensor_res("TEM", msg_dic_rcv["TEM"], True)
                        else:
                            skill_manager.send_ask_sensor_res("TEM", "-1", False)
                    elif self.kind_sensor == "HYG":
                        if "HYG" in msg_dic_rcv:
                            skill_manager.send_ask_sensor_res("HYG", msg_dic_rcv["HYG"], True)
                        else:
                            skill_manager.send_ask_sensor_res("HYG", "-1", False)
                    self.st_sensor = State.end
                elif msg_dic_rcv["Name"] == "getLumSensorValueRes":
                    if self.kind_sensor == "LUX":
                        if "Return" in msg_dic_rcv:
                            skill_manager.send_ask_sensor_res("LUX", str(msg_dic_rcv["Return"]), True)
                        else:
                            skill_manager.send_ask_sensor_res("LUX", "-1", False)
                    self.papero.send_stop_lum_sensor()
                    self.st_sensor = State.end


def get_skill_fpath_from_argv():
    skill_fpath = None
    arg_num = len(sys.argv)
    st = 0
    i = 1
    while i < arg_num:
        if st == 0:
            if sys.argv[i] == "-skill":
                st = 1
        else:
            skill_fpath = sys.argv[i]
            st = 0
        i += 1
    return skill_fpath


def get_url_from_file(fpath):
    f_in = None
    url = None
    if fpath != "":
        try:
            f_in = open(fpath, "r")
        except IOError:
            print("Cannot open " + fpath)
    if f_in is not None:
        line = f_in.readline()
        f_in.close()
        url = line.strip("\r\n ")
    return url


if __name__ == "__main__":
    simulator_id, robot_name, ws_server_addr = pypapero.get_params_from_commandline(sys.argv)
    skill_fpath = get_skill_fpath_from_argv()
    skill_url = get_url_from_file(skill_fpath)
    papero = None
    while papero is None:
        try:
            papero = pypapero.Papero(simulator_id, robot_name, ws_server_addr)
            if papero.errOccurred != 0:
                papero.papero_cleanup()
                papero = None
        except:
            papero = None
        if papero is None:
            time.sleep(10)
    if (papero.errOccurred == 0) and (skill_url is not None):
        papero_manager = PaperoManager(papero)
        skill_manager = SkillManager(skill_url)
        while papero_manager.state != State.end:
            skill_manager.forward()
            papero_manager.forward()
    papero.papero_cleanup()

(48) (26)で作成した skill.txt ファイルを、pypapero.py及びppr_client.pyと同じフォルダに置きます。

(49) コマンドプロンプトの cd コマンドで、カレントディレクトリを pypapero.py、ppr_client.py、skill.txt の置いてあるディレクトリに移動します。

(50) PaPeRo i のシミュレータを使用する場合は、下記のコマンドでアプリケーションを実行します。

python ppr_client.py -sim シミュレータID -skill skill.txt

PaPeRo i の実機を使用する場合は、下記のコマンドでアプリケーションを実行します。

python ppr_client.py -wssvr ws://PaPeRoiのIPアドレス:8088/papero -skill skill.txt

 シミュレータ又は実機のPaPeRo i が「アレクサ、パペロの雑談で、識別番号は〇〇〇〇〇」と発話し、それをAlexa対応デバイスが聞き取ると、続けてPaPeRo i が「こんにちは。今は足し算と引き算がしたいです。式をどうぞ」と発話し、それ以降、人がAlexa対応デバイスを通してPaPeRo iと会話できるようになります。
 例えば、Alexa対応デバイスに「1たす1は?」と話しかけると、PaPeRo i は「1たす1は2です。次の式をどうぞ」と答えます。
 また、「今の温度は?」と聞くと、「今のパペロの体感温度は〇〇度です」と答えます。
 他にどのような会話ができるかについては、Alexa対応デバイスに「ヘルプ」と言うと、PaPeRo i が説明してくれます。
 PaPeRo i が話し終わってから8秒以上人が何も言わずにいると、会話が終了してしまいますが、Alexa対応デバイスに「アレクサ、パペロの雑談を開いて」と言えば、PaPeRo i が「こんにちは。今は足し算と引き算がしたいです。式をどうぞ」と発話し、再び会話ができるようになります。

連携の仕組みについて

 PaPeRo i アプリケーションは、Skillに対してWebSocket接続を行った後、Alexa対応デバイスとの紐づけに使用する番号をSkillから取得し、PaPeRo i に「アレクサ、パペロの雑談で、識別番号は〇〇〇〇〇」と発話させます。
 これをAlexa対応デバイスが聞き取る事により、Skillでは、紐づけ番号を与えた PaPeRo i と、それを聞き取ったAlexa対応デバイスのデバイスIDを紐づけます。

 人がAlexaデバイスに話しかけると、SkillはAlexaサービスから、人の発話に対応したインテントを含むリクエストを受け取ります。各リクエストには、Alexaデバイスを特定できるデバイスIDが含まれています。
 PaPeRo iと連携しない通常のSkillは、Alexaデバイスが発話すべき台詞をAlexaサービスに応答として返しますが、本記事で使用するSkillはデバイスIDに紐づけられたPaPeRo i に発話指示を送ると同時に、Alexaサービスへは台詞の代わりに無言の発話指示を返します。
 これにより、PaPeRo i が発話すると同時に、Alexa対応デバイスも無言の発話を行い、PaPeRo i の声を聞き取る事を防止しています。
 無言の発話指示は、SSMLのbreakタグにより行います。

Skillの公開について

 AlexaのSkillはAmazonに申請する事によりスキルストアに公開する事ができます。公開されたSkillは、スマートフォンのAlexaアプリから選択し、有効にする事で、簡単に使えるようになります。
 本記事では、非公開のSkillを使用する為、Amazon開発者ポータル や IBM Cloud 上での作業が必要でしたが、Skillを公開する事により、ユーザーは、スマートフォンのAlexaアプリでSkillを有効化し、PaPeRo i にアプリをインストールするだけでPaPeRo i との会話ができるようになります。
 但し、Amazonに問い合わせてみた所、審査を通すためには「Alexaが無言」という動作では問題があり、人がAlexaデバイスに話しかけたらPaPeRo i だけでなくAlexaデバイスにも何かをしゃべらせる等の工夫が必要なようです。
 本記事のSkillの場合、工夫の具体例として、
・会話の終了直前以外では、PaPeRo i の発話に続いてAlexaが「パペロはこう言ってますが、いかがでしょうか?」と発話する
・会話終了直前(人が「ストップ」「キャンセル」と言った時)の PaPeRo i の発話時には、PaPeRo i の発話(「今日は話ができて楽しかったです」)に続いてAlexaが「私も楽しかったです」と発話する
といった動作を追加する事が考えられますが、それにより本当に審査を通るようになるかどうかは未確認です。

 Alexaが発話し、ロボットも連動するSkillが公開された事例として、「祈りのロボ」が挙げられます。
 このSkillは https://robotstart.info/2017/12/27/alexa-skill-praybots-by-tkawata.html でも詳しく紹介されており、Alexaとロボットが連動する様子を動画で確認する事ができますので、参考にするとよいでしょう。

 また、本記事で使用したSkillはIBM Cloud ライトアカウント上で動作させる想定となっていますが、同サービスには「10日間開発なしでアプリが停止する」等の制限がある為、スキルを公開する際には有料プランへの変更又は他のサーバーの利用を検討する必要があります。


0