kun432's blog

Alexaなどスマートスピーカーの話題中心に、Voiceflowの日本語情報を発信してます。たまにAWSやkubernetesなど。

〜スマートスピーカーやVoiceflowの記事は右メニューのカテゴリからどうぞ。〜

今更ながらAlexaのAudio Playerをきちんと理解してみる③ 〜複数の曲を扱ってみる〜

コードを書いてAudioPlayerをきちんと理解しよう、の第3回目です。一応これで最後です。

今日は複数の曲を扱ってみたいと思います。

Alexaは複数の曲をどう扱うか?

第1回でお伝えしたとおり、AudioPlayerは基本的にキューイング処理になっていて、スキルからのキューイングを受けて再生するだけです。なので、スキル側でプレイリストの管理を行う必要があります。

以下のようなプレイリストを用意しました。せっかくなので前回やったアートワークも設定してみます。すいません、もうhostedのS3は使ってません・・・(色々めんどくさくて・・・)

曲はフリーBGM配布サイト「DOVA-SYNDROME」様で多数の楽曲を公開されている「しんさんわーくす」様の以下の楽曲を使用させていただきました。

画像は前回と同じように pixabayで写真素材を公開されている bertvthul 様の写真を使用させていただきました。

https://pixabay.com/ja/users/bertvthul-1134851/

これらをすべてS3にアップしておきます。

では、コードを書いていきましょう。

音楽再生の開始

まず、PlayAudioIntentを受けるPlayAudioIntentHandlerです。

PlayAudioIntentはスキルを起動して楽曲再生を行う最初のトリガーとなるインテントです。したがって、かならず1曲目を再生するようにしています。すなわち、AMAZON.StartOverIntentも同じハンドラで処理すればよいですね。

曲順のデータを持っているプレイリストは配列になっているので、1曲目、すなわち、0番目をかならず再生することになります。

        const song = playlist[0];

あとは0番目の配列に入っているオブジェクトのそれぞれの要素を割り当てるだけです。

            .addAudioPlayerPlayDirective('REPLACE_ALL', song.url, song.token, 0, null, song.metadata)

再開・レジューム

次にResumeIntentHandlerです。

ResumeIntentの場合は、一時停止中の曲のトークンと、停止位置がオフセットで戻ってくるということでしたよね。今回使用しているプレイリストは各曲の曲順をそのままトークン文字列として設定していますので、Alexaから返ってきたトークンと同じトークンを持つ曲データをarray.findで探せばよいですね。

なお、一時停止を行うPauseIntentHandlerは単にaddAudioPlayerStopDirectiveを返すだけなので、特に修正は必要ありません。

次の曲・前の曲

はい、プレイリストらしい処理ですね。「次」の場合はAMAZON.NextIntent、「前」の場合はAMAZON.PreviousIntentでリクエストが飛んできますので、それぞれのハンドラを作ります。NextIntentHandlerで説明します。

まず、Resumeのときと同じように現在再生中の曲のトークンを受け取ります。そのトークンからプレイリストの何番目を再生しているかを取得します。array.findIndexを使うと任意の条件に該当する値のインデックス番号が取れますので、そのインデックス番号をインクリメントしてあげれば次の曲データを取得できます。あとはそれをaddAudioPlayerPlayDirectiveに渡すだけです。

同じようにPreviousIntentHandlerはこんな感じになります。こちらは逆にデクリメントします。

ちなみにNextIntentHandlerの以下のところ、

        let next_index;
        if (current_index === playlist.length - 1) {
            next_index = 0;
        } else {
            next_index = current_index + 1;            
        }
        const song = playlist[next_index];

わかりやすさ優先で書きましたが、もっとシンプルに書けますね。

        const song = current_index === playlist.length - 1 ? playlist[0] : playlist[current_index + 1];

PreviousIntentHandlerの方もこうなります。

        const song = current_index === 0 ? playlist[playlist.length -1] : playlist[current_index - 1];

自動的に次の曲を再生する

ここまでの説明はすべてユーザの発話によるものでした。これ以外にAudioPlayer自身が自動的にリクエストを飛ばすものがあります。

AudioPlayerリクエス
AudioPlayerは以下のリクエストを送信し、再生状況の変更についてスキルに通知します。

リクエストタイプ説明
AudioPlayer.PlaybackStartedPlayディレクティブで以前に送信されたオーディオストリームの再生をAlexaが開始すると、スキルに送信されます。これにより、スキルは再生が正常に開始したことを確認できます。
AudioPlayer.PlaybackFinishedAlexaが再生しているストリームが終了すると、送信されます。
AudioPlayer.PlaybackStopped音声リクエストやAudioPlayerディレクティブに対応して、Alexaがオーディオストリームの再生を停止したときに送信されます。
AudioPlayer.PlaybackNearlyFinished現在再生中のストリームが終了間際で、デバイスが新しいストリームを受信する準備ができている場合に送信されます。
AudioPlayer.PlaybackFailedストリームを再生しようとしているときに、Alexaにエラーが発生した場合に送信されます。

AudioPlayerインターフェースのリファレンス | Alexa Skills Kit

つまり、特に発話等をしなくてもAudioPlayerの再生状況が逐一飛んでくるわけですね。CloudWatchのログを見るとわかりますが、ここまでの実装だけだと、以下のようなエラーが多数出ていると思います。

Error handled: AskSdk.GenericRequestDispatcher Error: Unable to find a suitable request handler.
    at Object.createAskSdkError (/var/task/node_modules/ask-sdk-runtime/dist/util/AskSdkUtils.js:22:17)
    at GenericRequestDispatcher.<anonymous> (/var/task/node_modules/ask-sdk-runtime/dist/dispatcher/GenericRequestDispatcher.js:145:49)
    at step (/var/task/node_modules/ask-sdk-runtime/dist/dispatcher/GenericRequestDispatcher.js:44:23)
    at Object.next (/var/task/node_modules/ask-sdk-runtime/dist/dispatcher/GenericRequestDispatcher.js:25:53)
    at fulfilled (/var/task/node_modules/ask-sdk-runtime/dist/dispatcher/GenericRequestDispatcher.js:16:58)

これらはAudioPlayerからのリクエストによるもので、まだ実装していないのと、この部分はインテントに対するリクエストでもないのでErrorHandlerが処理していたというわけです。

以下にも書いてありますね。

AudioPlayerを使用すると、オーディオの「再生開始」や「終了」又、「もうすぐ終了する」などのリクエストがスキルに送られます。 特に必要がなければ無視しても良いのですが、スキルに実装がないとLambdaでエラーとなってしまいますので、下記のように処理しておくことをお勧めします。

const PlaybackHandler = {   canHandle(h) {
    const type = h.requestEnvelope.request.type;
    return (type == 'AudioPlayer.PlaybackStarted' || // 再生開始
      type == 'AudioPlayer.PlaybackFinished' || // 再生終了
      type == 'AudioPlayer.PlaybackStopped' || // 再生停止
      type == 'AudioPlayer.PlaybackNearlyFinished' || // もうすぐ再生終了
      type == 'AudioPlayer.PlaybackFailed'); // 再生失敗
  },
  async handle(handlerInput) {
    return handlerInput.responseBuilder
      .getResponse();
  }
};

Alexa-SDK Ver2(その8) AudioPlayer | Developers.IO

ということで、これを実装していきましょう。と言ってもすべてを実装する必要はなく、自動的に次の曲を再生するだけならAudioPlayer.PlaybackNearlyFinishedだけで良いと思います。

const PlaybackNearlyFinishedHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'AudioPlayer.PlaybackNearlyFinished';
    },
    async handle(handlerInput) {
        const AudioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        const token  = AudioPlayer.token;
        const current_index = playlist.findIndex(track => track.token === token);
        const song = current_index === playlist.length - 1 ? playlist[0] : playlist[current_index + 1];
        return handlerInput.responseBuilder
            .addAudioPlayerPlayDirective('ENQUEUE', song.url, song.token, 0, token, song.metadata)
            .getResponse();
    }
};

AudioPlayer.PlaybackNearlyFinishedは現在再生中の曲が終わりに近づいたら飛んできます。そして繰り返しになりますが、AudioPlayerは基本的にキューイング制御です。つまり、現在の曲が終わる直前に次の曲をキューに入れて、いうことになります。

addAudioPlayerPlayDirectiveで曲を追加する際に、playBehavior に ENQUEUEを指定することでキューを追加します。これにより、上記の例ではプレイリストの順に曲が再生し続けるようになります。

ENQUEUEの場合、ユーザの発話(AMAZON.PreviousIntentとかAMAZON.NextIntentとか)と同時に行われた場合にタイミングによってはキューの順序が入れ替わってしまう可能性があるため、現在再生中の曲のトークンをexpectedPreviousTokenとして指定してあげることが必須になります。

            .addAudioPlayerPlayDirective('ENQUEUE', song.url, song.token, 0, token, song.metadata)

このあたりの処理は非常に複雑なので一度読んでみることをオススメします。

https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/audioplayer-interface-reference.html#playlist-progression:embeded

最後にAudioPlayer.PlaybackNearlyFinished以外のAudioPlayerからのリクエストはエラーになってしまうので、以下のように空のレスポンスを返すようにしておけばCloudWatchにエラーが出力されることもありません。

const PlaybackHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope).startsWith === 'AudioPlayer.'
    },
    async handle(handlerInput) {
      return handlerInput.responseBuilder
      .getResponse();
  }
};

ただしこの部分はPlaybackNearlyFinishedHandlerよりも後に処理されるようにしてください。じゃないとPlaybackNearlyFinishedHandlerもこっちで処理されてしまい、次の曲が自動的に再生されなくなってしまいます。

exports.handler = Alexa.SkillBuilders.custom()
    .addRequestHandlers(
        LaunchRequestHandler,
        PlayAudioIntentHandler,
        PauseIntentHandler,
        ResumeIntentHandler,
        NextIntentHandler,
        PreviousIntentHandler,
        PlaybackNearlyFinishedHandler,     // こちらが先  
        PlaybackHandler,                   // こちらが後
        HelpIntentHandler,
        CancelAndStopIntentHandler,
        SessionEndedRequestHandler,
        //IntentReflectorHandler, // make sure IntentReflectorHandler is last so it doesn't override your custom intent handlers
    )
・・・

まとめ

3回に分けてAudioPlayerについて色々やってみました。一度コードを書いてしまえばそれほど複雑ではないのですが、少なくとも普通のカスタムスキルでSSML使ってオーディオを再生する場合とはいろいろ違うというのがわかると思います。

Voiceflowだとこのあたりを良くも悪くも隠蔽しちゃうので、うまくいくときはいいんですが、うまく行かないときはなぜか?がわかりにくいんですよね。まあバグももちろんあるとは思うのですが、そもそも普通のカスタムスキルでやるような会話フローとは変わる、ということをイマイチ理解しにくい。ここをどう伝えるかは課題だなーと思います。

今回はやってませんが他にも、

  • シャッフルやループの場合はどうするのか?
  • 画面付きデバイスの場合どうするのか?

など、AudioPlayerスキルはまだまだ奥が深いです。機会があればまたやってみたいと思います。

サンプルコード

最終的なサンプルコードはこちらにあります。適宜修正していただければと思います。

参考にさせていただいた・参考になるサイト