👌

Alexa スキル: かるたを読んでもらう

2023/01/11に公開約9,900字

幼稚園のかるたを読んでほしい

娘の通っている幼稚園では年明けに「かるた会」があるため、冬休み中にその年の指定かるたで練習します。
ちょうど自宅リビング用の Echo Dot を新調したところだったので、「あ、かるた読んでもらおうかな」と思い立ってスキルを作った備忘録です。

Node.js の方が情報量は多そうですが、Python でもできるので今回はなんとなく Python で。

会話の組み立て

幼稚園ルール

  1. 読み手(先生)が「読みます」と言う
  2. 取り手(子どもたち)が「はい」と言って手をひざにつく
  3. 読み手が読み札を読み上げる
  4. 取り手は「はい」と言って札を取る

「読みます」→「はい」→(札を読む)→「はい」の繰り返しです。

同じ「はい」だけど…

  • 「読みます」の後の「はい」と、札を読み上げた後の「はい」は区別しなければならない

  • 札を読み上げた後の「はい」はお手つきの場合があるので「はい」で次の札に移るのはやめた方がいい
    → 正しい札を取れたら「取りました」で次の札に移るようにする

8秒応えないとスキルが終了してしまう

これは Alexa スキルの仕様で、8秒間無応答だとスキルそのものが終了してしまいます。読み上げの後1回だけは reprompt できるのですが、2回目の読み上げの後も黙って札を探していると、スキルがそこで終わってしまうという事態に…うぅ、勝手にやめないで。

勝手に終わるのをできるだけ避けたいので「はい」と「取りました」以外の発話があった場合は直前の札を再度読み上げるようにしました。
娘にはすぐに見つからない場合は(なんでもいいのですが)「もう1回」と言ってもらうようにしました。

インテントとそれぞれの処理

直前のインテントは?

「読みます」の直後の「はい」だけ次の札に移るため、直前のインテントが何であったかを判断する必要があります。
応答インターセプター(PreviousIntentInterceptor) を作って直前のインテントをセッション変数(pre_intent) に保持するようにしました。

lambda/lambda_function.py
class PreviousIntentInterceptor(AbstractResponseInterceptor):
    """直前のインテントをセッション変数に保持する"""

    def process(self, handler_input, response):
        intent_name = "Unknown"
        if ask_utils.is_request_type("IntentRequest")(handler_input):
            intent_name = ask_utils.get_intent_name(handler_input)
        elif ask_utils.is_request_type("LaunchRequest")(handler_input):
            intent_name = "LaunchRequest"

        handler_input.attributes_manager.session_attributes["pre_intent"] = intent_name

インテントリクエストの場合は get_intent_name でインテント名が取得できますが、最初の「読みます」の起動リクエストも判断したかったので起動リクエストの場合は "LaunchRequest" が pre_intent に入るようにしました。

応答インターセプターの登録を忘れずに。

lambda/lambda_function.py
sb = SkillBuilder()

# インターセプターの登録
sb.add_global_response_interceptor(PreviousIntentInterceptor())

sb.add_request_handler(LaunchRequestHandler())
    :

読み札の管理

汎用的にするなら JSON ファイルに分けるなり API 作るなりするべきだと思いますが、今回は今年の指定かるた限定なので、そのまま辞書型のリストで定義しました。

起動リクエストでセッション変数にロードして、ランダムに取り出してリストから削除しつつ読み上げます。

lambda/lambda_function.py
class LaunchRequestHandler(AbstractRequestHandler):
    """Handler for Skill Launch."""

    def can_handle(self, handler_input):
        # type: (HandlerInput) -> bool

        return ask_utils.is_request_type("LaunchRequest")(handler_input)

    def handle(self, handler_input):
        # type: (HandlerInput) -> Response

        # 読み札のリストをセッション変数に保持する
        cards = [   # 実際には別ファイルに分割してロード
            {
                "index": "あ",
                "text": "あなの なか ねずみが おむすび まっている",
                "speech": '穴の 中<break time="0.5s"/> ねずみが おむすび 待っている',
                "audio": "011.mp3",
            },
            # : (い〜を)
            {
                "index": "ん",
                "text": "ぺったん がちょうに さわると くっついた",
                "speech": "ぺったん ガチョウに 触ると くっついた",
                "audio": "110.mp3",
            },
        ]
        handler_input.attributes_manager.session_attributes["cards"] = cards

        ask_output = "読みます"

        speak_output = "おはなしかるたを始めます。"
        speak_output += '準備はいいですか?<break time="1s"/>'
        speak_output += 'では…<break time="0.5s"/>'
        speak_output += ask_output

        return (
            handler_input.response_builder.speak(speak_output).ask(ask_output).response
        )
ex.ランダムピック
    # 残りの読み札から1枚取り出す
    cards = handler_input.attributes_manager.session_attributes["cards"]
    current_card = cards.pop(randrange(len(cards)))  # ランダムに取り出す

    # 現在の読み札をセッション変数に保持
    handler_input.attributes_manager.session_attributes["current_card"] = current_card
    # 残りの読み札リスト(セッション変数)を更新
    handler_input.attributes_manager.session_attributes["cards"] = cards

"はい"のインテント (GoOnIntent)

skill-package/interactionModels/custom/ja-JP.json
        {
          "name": "GoOnIntent",
          "slots": [],
          "samples": ["はい"]
        },
lambda/lambda_function.py
class GoOnIntentHandler(AbstractRequestHandler):
    """読み札を読み上げる"""

    def can_handle(self, handler_input):
        # type: (HandlerInput) -> bool
        return ask_utils.is_intent_name("GoOnIntent")(handler_input)

    def handle(self, handler_input):
        # type: (HandlerInput) -> Response
        previous_intent = handler_input.attributes_manager.session_attributes[
            "pre_intent"
        ]

        if previous_intent and previous_intent in ["LaunchRequest", "NextIntent"]:
            # 「読みます」の直後だけ次の読み札を引く
            card = carta.pick_card(handler_input)

        else:
            card = carta.get_current_card(handler_input)

        if card:
            audio_url = create_presigned_url(f'Media/read-aloud/{card["audio"]}')
            speak_output = '<audio src="' + html.escape(audio_url) + '"/>'

            return (
                handler_input.response_builder.speak(speak_output)
                .ask(speak_output)
                .response
            )

        speech = "よくわかりませんでした。もう一度言ってみてください"
        reprompt = "もう一度言ってみてください"

        return handler_input.response_builder.speak(speech).ask(reprompt).response

"取りました"のインテント (NextIntent)

skill-package/interactionModels/custom/ja-JP.json
        {
          "name": "NextIntent",
          "slots": [],
          "samples": ["取りました"]
        },
lambda/lambda_function.py
class NextIntentHandler(AbstractRequestHandler):
    """次の読み札に移る"""

    def can_handle(self, handler_input):
        # type: (HandlerInput) -> bool
        return ask_utils.is_intent_name("NextIntent")(handler_input)

    def handle(self, handler_input):
        # type: (HandlerInput) -> Response

        # 読み札の残りを確認
        cards = handler_input.attributes_manager.session_attributes["cards"]

        if cards:  # not empty
            speak_output = "読みます"

            return (
                handler_input.response_builder.speak(speak_output)
                .ask(speak_output)
                .response
            )
        else:
            speak_output = "読み札がなくなりました。かるた会を終わります。"

            return (
                handler_input.response_builder.speak(speak_output)
                .set_should_end_session(True)
                .response
            )

それ以外のインテント (FallbackIntent)

「はい」「取りました」以外の発話は標準ビルトインインテントの AMAZON.FallbackIntent で処理します。

lambda/lambda_function.py
class FallbackIntentHandler(AbstractRequestHandler):
    """Single handler for Fallback Intent."""

    def can_handle(self, handler_input):
        # type: (HandlerInput) -> bool
        return ask_utils.is_intent_name("AMAZON.FallbackIntent")(handler_input)

    def handle(self, handler_input):
        # type: (HandlerInput) -> Response
        logger.info("In FallbackIntentHandler")

        """予期しない発話をひろった場合は現在の読み札があればそれを繰り返す"""
        card = carta.get_current_card(handler_input)

        if card:
            audio_url = create_presigned_url(f'Media/read-aloud/{card["audio"]}')
            speak_output = '<audio src="' + html.escape(audio_url) + '"/>'

            return (
                handler_input.response_builder.speak(speak_output)
                .ask(speak_output)
                .response
            )

        speech = "よくわかりませんでした。もう一度言ってみてください"
        reprompt = "もう一度言ってみてください"

        return handler_input.response_builder.speak(speech).ask(reprompt).response

Alexa さんのイントネーションが微妙

かるたの読み札は川柳のような感じで普通の文章とは違うので、そのままテキストを読み上げてもらうのではイントネーションが微妙です。SSML でマークアップしても限りがあるので違和感がすごい。
結局、読み上げ音声の MP3 を作ってそちらを流すように変更しました。

日本語の読み上げソフトで音声ファイルを作る

  • VOICEVOX は音の高さまで微調整できる

  • CoeFont はより自然な声

今回は非公開のスキルなのでどちらでもいいですが、CoeFont で作ってみました。音声のクレジット表記はスキルの説明に書けばいいかと思いますが、かるたの方の著作権があるので公開はできないですね。

読み札の文章を打ち込んで、読み上げ速度はゆっくりに。アクセントをこねこね調整してそれっぽくします。
あいうえおのかるたなので頭文字を強調できるといいのですが、それは難しかったです。

WAV から MP3 に変換

VOICEVOX も CoeFont も WAV ファイルを作成できますが、音量が小さめです。最大音量で WAV 出力して、MP3 に変換するときに音量を調整します。
やり方はいろいろあるかと思いますが、今回は SoX でちゃちゃっと。

sox 011.wav -G -C 48 -r 16000 011.mp3

S3にアップロードした MP3 を再生する

Alexa-hosted 利用枠分の S3 へのアクセス

Alexa-hosted の S3 へは Alexa デベロッパーコンソールのコードエディタ上部の "S3 Storage" からアクセスできます。

Media フォルダの中にファイルをアップロードします。
今回は read-aloud フォルダに MP3 を入れました。

Python で S3 の MP3 ファイルを読み込む

ドキュメントのコード例が当てにならなかったので、Node.js の例を参考に Python で書き直します。
Python の方の音声ファイルのコード例は Audio Player を使っていますが、今回は短い MP3 を流したいだけなので Audio Player は不要です(なんでこんな不親切な例になったのか… Audio Player は別にしたほうがよくない?)

SSML の audio タグを使います。create_presigned_url で音声ファイルへの URL を取得しますが、audio タグの src に指定するときに HTML エスケープをするのがポイントです。

ex.音声ファイルの使用
import html

from utils import create_presigned_url

def handle(self, handler_input):
    # type: (HandlerInput) -> Response
    audio_url = create_presigned_url(f'Media/read-aloud/{card["audio"]}')
    speak_output = '<audio src="' + html.escape(audio_url) + '"/>'

    return (
        handler_input.response_builder.speak(speak_output)
        .ask(speak_output)
        .response
    )

参考: Alexa スキルを作れるようになるまでのアレコレ

Alexa 開発者アカウントの登録時に罠にハマる

AWS の開発経験があったり、Amazon.com (日本じゃなくてアメリカのアマゾン)のアカウントがあったりして、日本アマゾンと共通のメールアドレスを使っているとアメリカの方に引っ張られてしまい、日本の Alexa 開発者として登録されない。

わたしも例に漏れずアメリカの方で登録されてしまったようで。対処法は検索すればいろいろ出てくると思いますが、Amazon.com の方のアカウントのパスワードを変更してから、日本のアカウントの方のパスワードでログインして開発者登録し直したら日本の方で登録されました。アメリカと日本、それぞれで Alexa 開発者登録されている状態のようです。

日本の方の Alexa 開発者として正しく登録されれば、開発中のスキルが自分のアカウントに結びついているデバイス(Echo とか)でそのまま使えます。

Visual Studio Code ユーザなら拡張入れれば便利

Visual Studio Code を使って開発している場合は、Alexa Skills Kit (ASK) Toolkit を使うと楽です。

開発者アカウントでログインすれば、デベロッパーコンソールで作成済みのスキルをインポートして編集できるし、デプロイもVSCodeからできます。


Alexa さんに読んでもらうと、わたしも取り手に参加できるのでなかなかよさげでした。

Discussion

ログインするとコメントできます