Alexa Skill KitでAudioPlayerスキルを実装する（NodeJS編）

今回はAlexaスキルを利用して自前のMP3を流す方法について解説します。

## 背景

AmazonのキャンペーンでEcho Dot＋Amazon Music Unlimited(1か月分)を999円で買った！
これからは家でも音楽漬けの生活だぜ！

> 「Alexa！マイフェイバリットプレイリストを流して！」

あれ？自前のMP3流せないの？？？

### Alexaで利用可能な音楽サービス

https://www.amazon.co.jp/gp/help/customer/display.html?nodeId=GVJ7368ZJGFBDQHW

Alexaで利用可能な音楽サービスは、基本的に有料なものばかりです。
買ってから気づくとは愚かなり、、、

* Amazon Music (Prime | Unlimited)
* iTunes Music
* Spotify (Premium)
* dヒッツ
* うたパス

Amazon Primeは利用していますが、聴きたい曲はやはり足りませんw
たまに利用するだけのAIスピーカのためにサブスクする予定もないので、さて困りました、、、

## Alexaスキルを作ろう！（概要編）

ここではAlexaスキルの前提知識を解説します。必要ない人は読み飛ばして[実装編](#Alexaスキルを作ろう！（実装編）)を読んでください。

### Alexa Skill Kit (ASK)とは？

https://developer.amazon.com/ja-JP/alexa/alexa-skills-kit

Alexaで提供される機能は「スキル」という単位で、ユーザが自由に選択・追加することができます。
ユーザがスキルを開発・公開することもでき、その統合プラットフォームがAlexa Skill Kit (ASK)です。

とても親切なチュートリアルやリファレンスがあり、それほど迷うことなく開発できると思います。（すごい！）

> 注意点としては、Alexa SDKは現在Ver.2ですが、ネットにはVer.1の記事が多く混乱します。

### Alexaスキルの構成

Alexaスキルは大きく２種類に分かれます。

| スキルの種類 | 説明 |
|-------------|------|
| ユーザ自身が提供するスキル | ユーザが自前のエンドポイントで公開するスキルを呼び出す |
| Alexa Hosted Skill | ユーザがAWSで開発したスキルを呼び出す |

大量にアクセスする・長大なリソースが必要となるスキルなら、料金の関係上「ユーザ自身が提供するスキル」を選択しますが、そうでない大多数のスキルはスタンダードな「Alexa Hosted Skill」を利用します。

Alexa Hosted Skillで利用されるコンポーネントは以下の通りです。

| コンポーネント | 説明 |
|---------------|------|
| Amazon Echo | ユーザからのリクエストを受けるAIスピーカ |
| Alexa | Echoからのリクエストを受けてスキルを呼び出すエンドポイント |
| AWS Lambda | Alexaサービスからリクエストを受けてスキルを実行するエンドポイント |
| Amazon S3| スキルで利用するデータ（MP3など）を公開するエンドポイント |
| Amazon CloudWatch| スキルの実行ログを監視・公開するエンドポイント |

> Amazon Echoの部分はスマートフォンのAlexaアプリに置き換えることもできます。

開発における大きなポイントは以下の３つです。

* Alexaへのインテント（ユーザがしゃべる内容）を決める
* Lambdaで実行されるスキル（任意のスクリプト）を実装する
* データの持ち回り方（どのデータをS3に置くか）を決める

永続化が必要なデータ（MP3など）は必ずしもS3に配置する必要はありませんが、以下の要件を満たす必要があります。

* HTTPSアクセスできること（Lambdaの要件）
* プライベートアクセスできること（著作権等法令の要請）

この点、S3を利用すれば色々考える必要もないので楽です！
ちなみに、LambdaやS3はASKで作成したスキルに自動的に１つずつ割り当てられ、AWSの無料使用枠が適用されます。

https://aws.amazon.com/jp/free/?all-free-tier.sort-by=item.additionalFields.SortRank&all-free-tier.sort-order=asc

> スキルがネット上のLambdaで実行されるため、ローカルやイントラ環境のデータを扱うことはできません。データをネット上に配置するため、セキュリティには十分注意してスキルを運用しましょう。

### ASKコンソールの構成

ASKのWebコンソールでは、スキルの開発・デバッグから公開まで一通りの作業を実施することができます。

#### 開発

* スキルテンプレートからスキルの生成
* Alexaへのインテントの実装（GUI）
* Lambdaで実行されるスキルの実装（コーディング）
* S3のデータ操作（GUI）

#### デバッグ

* Webコンソールでのデバッグ
* 開発中のスキルの実機動作デバッグ

> 後で解説しますが、AudioPlayerは実機でデバッグする必要があります。

#### 公開

* Amazonによる審査・公開

## Alexaスキルを作ろう！（実装編）

ここからは、Alexaスキルの実装について解説していきます。

### Alexaスキルの要件

今回は、以下の要件を満たすスキルを開発します。

* ユーザが指定したプレイリストを再生する
* 曲はMP3形式で、S3に格納する
* 音楽プレイヤーの要件（一時停止・再開・次へ・前へ）を満たす
* 極力シンプルに（少ない仕組みで）実装する

### インテント（ユーザがしゃべる内容）の実装

> インテントはASKにてGUIで実装します。
> コーディングレスなので、チュートリアル見ながらやれば困ることはないでしょう。

インテントはスキルに対する命令です。
Amazonが標準で提供しているインテントと、ユーザが定義するインテントがあります。

https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/standard-built-in-intents.html

| インテント名 | 内容 | 発話例 |
|-------------|------|----|
|AMAZON.HelpIntent | スキルのヘルプ | ヘルプ |
|AMAZON.CancelIntent | スキルのキャンセル | キャンセルして | 
|AMAZON.StopIntent | スキルの停止 | 止めて |
|AMAZON.PauseIntent | 曲の一時停止：音楽プレイヤー向け（必須） | 一時停止して |
|AMAZON.ResumeIntent | 曲のスキルの再開：音楽プレイヤー向け（必須） | 再開して |
|AMAZON.NextIntent | 次の曲へ：音楽プレイヤー向け | 次へ |
|AMAZON.PreviousIntent | 前の曲へ：音楽プレイヤー向け | 前へ |

などなど。標準のインテントで実現できない命令は、ユーザが自由に定義できます。

**PlaylistIntent**

```text
{playlist} を再生して
```

`{playlist}`の部分はスロットと呼ばれ、「ひげだん」「きんぐぬー」といった具合にあらかじめ候補を指定しておきます。

> スロットの候補には値（ユーザがしゃべる内容）に対して一意のIDを振ることができます。
> （本実装では、後でスキル側で利用します）

### スキルの実装

> スキルはASKにてコーディングします。
> 標準でNodeJSとPythonがサポートされていますが、今回は実績やリファレンスが充実していたNodeJSを採用しています。

Lambdaのファイル構成は以下のようになります。

```bash
lambda/
 index.js : スキルを実装するファイル
 util.js  : S3にアクセスするユーティリティが定義されたファイル
 # 以下は今回のスキル用に追加
 playlists/
  プレイリスト名.json : プレイリストをJSON形式で格納する
```

プレイリストには、S3に格納したMP3ファイルのキー値（URLではない）を配列で定義します。

> キー値は`Media/xxx.mp3`のような形式で、S3に格納したファイルを見ると分かります。

#### スキルの起動（LaunchRequest）

まずはスキルを起動します。
チュートリアルやれば分かりますが、スキルではAlexaからのリクエストに対応するハンドラを実装します。

```javascript
const LaunchRequestHandler = {
    // (1)
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
    },
    // (2)
    handle(handlerInput) {
        const speakOutput = 'どのプレイリストを再生しますか？';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};
```

(1)
`canHandle`メソッドでは、対応するリクエストを選択します。
Alexaからのリクエストには以下の２種類があります。

* `LaunchRequest`：スキルの起動
* `IntentRequest`：スキルに対する命令

https://ask-sdk-for-nodejs.readthedocs.io/ja/latest/Processing-Request.html#id5

(2)
`handle`メソッドでは、スキルが行なう処理とAlexaへの応答を実装します。
`responseBuilder`で応答を構築しますが、`speak`でEchoがしゃべり、`reprompt`でEchoがユーザから次の命令を待ちます。

https://ask-sdk-for-nodejs.readthedocs.io/ja/latest/Building-Response.html

#### 音楽プレイヤーの起動（IntentRequest）

選択されたプレイリストから曲を再生します。

```javascript
// ファイルヘッダで読み込み
const Util = require('./util.js');

const PlaylistIntentHandler = {
    // (1)
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'PlaylistIntent';
    },
    handle(handlerInput) {
        // (2)
        const resolvedSlot = handlerInput.requestEnvelope.request.intent.slots.playlist.resolutions.resolutionsPerAuthority[0].values;
        if (resolvedSlot === undefined) {
            return handlerInput.responseBuilder
                .speak('プレイリストが検索できませんでした。')
                .getResponse();
        }
        const file = resolvedSlot[0].value.id;
        const playlist = require(`./playlists/${file}.json`);

        // (3)
        const track = playlist[0];
        const url = Util.getS3PreSignedUrl(track);
        const token = [ file, track ].join(':');
        return handlerInput.responseBuilder
            .addAudioPlayerPlayDirective('REPLACE_ALL', url, token, 0, null)
            .getResponse();
    }
};
```

(1)
`IntentRequest`のハンドラでは、対応するインテントを選択します。
ここでは、自分で定義した`PlaylistIntent`を選択しています。

(2)
`intent.slots.{スロット}.resolutions`配下に、ユーザが選択したスロットが格納されます。
今回はユーザが選択したプレイリストをもとに、`playlists/{プレイリスト}.json`ファイルを取得します。

> スロット候補（ユーザのしゃべる内容）は日本語で呼び名に揺らぎを持たせたいけれど、プレイリストのファイル名は英文字で一意の名前にしたいため、スロット候補に付与したIDを利用して処理しています。

(3)
`addAudioPlayerPlayDirective`を利用して、AlexaのAudioPlayerに音楽再生の指示を応答します。

http://ask-sdk-node-typedoc.s3-website-us-east-1.amazonaws.com/classes/responsebuilder.html#addaudioplayerplaydirective
与える引数は順に以下の通りです。

* `playBehavior`：AudioPlayerは曲をキューで管理します。`REPLACE_ALL`はキューをクリアしてセットしなおします。
* `url`：曲のURLです。S3のファイルキーから`getS3PreSignedUrl`で取得した時限性URLを指定しています。

    > S3 Presigned URLとは、プライベートなファイルへのアクセスを一時的に可能とする仕組みで、認可の複雑さを回避しつつファイルのセキュリティを確保することができます。
    > ASKのデフォルト実装では60秒間のみ公開するよう実装されています。

* `token`：再生中の曲を操作するためのトークンです。曲ごとに一意になる必要があります。[次節](#再生状態の引き継ぎ)で解説します。
* `offsetInMilliseconds`：曲の再生開始ポイントです。最初から再生するときは`0`で、一時停止から再開するときは任意のミリ秒になります。
* `expectedPreviousToken`：キューに次の曲を追加する場合に、前の曲のトークンを指定することで意図した順序で曲が追加されることを保証します。ここでは不要です。

他に`audioItemMetadata`があり、Echo Showなどの画面に表示するジャケットや曲名を指定します。Echo Dotでは不要です。ここまで拘ろうとすると、けっこう面倒ですね。

> Webコンソールによるデバッグでは、`addAudioPlayerPlayDirective`に対して「対応していません」というメッセージが流れるだけで、実際に音楽は再生されません。呼び出しの確認までできたら、あとは実機でデバッグしましょう。

##### 再生状態の引き継ぎ

プレイリスト開始時は最初の曲を再生すれば良いですが、次や前の曲を再生する場合は「**今、どのプレイリストのどの曲を再生しているのか**」を判断しなければなりません。これは複数のAlexaリクエスト間で引き継ぐ必要があります。

引き継ぐデータストアには、以下の３種類が考えられます。

| データストア | 説明 | 判定 |
|-------------|------|------|
| AudioPlayer | AudioPlayerは再生している曲のURLとトークンを保持しています。<br>トークンは自由にセットできるため、プレイリストと曲の情報を保持できます。 | 〇 |
| セッション | 前述の`reprompt`でユーザ入力を受け付ける場合など、Alexaスキルはセッションを利用して前リクエストの状態を保持します。<br>しかし、AudioPlayerを応答する場合はセッションを利用することができません。 | × |
| S3（永続化） | 再生中のプレイリストと曲をS3に一時保存します。Alexa SDKにS3永続化するためのAPIが用意されています。<br>S3へのアクセス数が増えるのと、単純に仕組みが複雑になり面倒です。 | △ |

ということで、今回はAudioPlayerに渡すトークンを利用して簡易に実現します。
トークンには`{プレイリスト}:{曲のS3キー値}`を指定しています。（あくまで今回の要件を実現するための実装です。）

> 今回は対応しませんが、ランダム再生に対応する場合はデータストアが必要になります。
> （プレイリストから`Math.random`で曲を取得するだけでは、同じ曲が何度も再生されてしまうため）

#### 次の曲の再生（AudioPlayer.PlaybackNearlyFinished）

再生している曲が終了したら、次の曲を再生します。
再生している曲の終了はAudioPlayerからのリクエストで検知することができます。

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

`AudioPlayer.PlaybackFinished`は曲の終了時にリクエストされますが、`AudioPlayer.PlaybackNearlyFinished`は、曲の終了直前にリクエストされるので、終了前に次の曲を用意する形になります。



```javascript
const PlaybackNearlyFinishedHandler = {
    // (1)
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'AudioPlayer.PlaybackNearlyFinished';
    },
    handle(handlerInput) {
        // (2)
        const AudioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        const t = AudioPlayer.token.split(':');
        const played = { file: t[0], track: t[1] };
        const playlist = require(`./playlists/${played.file}.json`);
        const cursor = playlist.indexOf(played.track);
        const track = cursor === playlist.length - 1 ? playlist[0] : playlist[cursor + 1];

        // (3)
        const url = Util.getS3PreSignedUrl(track);
        const token = [ file, track ].join(':');
        return handlerInput.responseBuilder
            .addAudioPlayerPlayDirective('REPLACE_ENQUEUED', url, token, 0, null)
            .getResponse();
    }
};

```

(1)
`AudioPlayer.PlaybackNearlyFinished`のハンドラを実装します。

(2)
AudioPlayerオブジェクトからトークンを取得し、そこからプレイリストの次の曲を取得します。

(3)
最初の再生の時と同様、`addAudioPlayerPlayDirective`で曲をキューに追加します。
なお、`playBehavior`を`ENQUEUE`にする場合は`expectedPreviousToken`が必要になるはずです。

> S3 Presigned URLを利用する場合、キューに次の曲以降のURLをセットしておいてもアクセス不能となるため、`ENQUEUE`は使えないに等しいです。キューに１曲しか入っていない場合、実質的に`REPLACE_ALL`と`REPLACE_ENQUEUED`は変わりません。

`IntentRequest`の`AMAZON.NextIntent`のハンドラも同様に実装できますが、`playBehavior`を`REPLACE_ALL`にしないと曲が終わらないと思います。

#### 一時停止と再開（AMAZON.PauseIntentとAMAZON.ResumeIntent）

ここからはポイントだけ。

* 一時停止

    `AMAZON.PauseIntent`をハンドルして`addAudioPlayerStopDirective`を応答します。

* 再開

    `AMAZON.ResumeIntent`をハンドルして`addAudioPlayerPlayDirective`で曲を開始します。
    その際`offsetInMilliseconds`にAudioPlayerから取得した現在の`offsetInMilliseconds`をセットすることで曲の途中から開始できます。

#### Echo Showやスマートフォンへの対応

Echo Showやスマートフォンでは、「画面のタッチ操作」に対応する必要があります。
画面のタッチ操作は、スキルに「PlaybackControllerからのリクエスト」として通知されます。

https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/audioplayer-interface-reference.html#touch-controls

ハンドラの実装方法は基本的に変わりませんが、注意点は以下の通りです。

* `PlaybackController.PlayCommandIssued`が最初からの再生と一時停止からの再生を兼ねる
* 一時停止は基本的にスキルを経ないでクライアント側で行われる

## まとめ

Alexa Skill Kitを利用することで、スキルに最低限必要なコンポーネントが自動的に整備され、簡単に開発することができました。

最初はPythonで開発しようとしたんですが、リファレンスが整備不足で参考例も少なかったため、難しく感じました。対してNodeJSはリファレンスが充実していて、大きく困ることはありませんでした。
また、Webコンソールでのデバッグはエラーの解析が難しく、AudioPlayerが起動できないなど手間取る部分がありました。（当初CloudWatchの存在を知らず、ログすらない状況でデバッグしていたり、、、）

S3に保存するのはあくまで個人の音楽ファイルなので、スキルは公開せず自分だけで楽しみましょう！