Amazon Echo で Google Drive 上の mp3 を 再生する (その1)

* 2020/10/3 11:04、2.4.に `rootFileId` 書き換えについて追記
* 2022/2/22 22:33、Google Drive から json を取得する際に StatusCode=303 が返ってエラーになるようになったため、`getJson()` の if 文に `|| res.statusCode === 303` を追加

# 今回の実装機能

* 発話により再生するプレイリストを指定する
* 指定されたプレイリスト (json 形式) に記載された曲を記載順に 1 回ずつ再生する

# はじめに

Google Play Music が終了し、YouTube Music に完全移行するまで[残り僅か](https://support.google.com/googleplaymusic/answer/9973710?hl=ja)になりました。
Google Play Music では無料版でもスマートスピーカーでプレイリストやアルバム、曲を指定して再生することができましたが、移行先の YouTube Music では Premium 会員にならないと指定再生ができません。曲指定のためだけに Premium 会員になるほどの情熱はありませんので、Google Home は諦めて Amazon Echo (Dot) で Google Drive 上の mp3 を再生するスキルを作ります。

## プレイヤーの要件

手持ちの音楽ファイルに対して Google Home + Google Play Music でできていたことができれば良いのですが、列挙すると以下の感じです。

* 無料であること
* スマートスピーカーに話しかけるだけで再生できること（スマホを使って良いなら YouTube Music で済みます）
* プレイリストを再生できること
* （できれば）次の曲や前の曲への再生指示、シャッフル再生、曲名の問合せができること

手持ちの曲を置いておく場所には AWS Lambda から HTTPS アクセスできるという条件があり、自前で用意できる人でなければクラウドストレージが必要です。Amazon S3 が一番楽に実装できますが容量が 5GB と少ないので、無料でも 15GB の容量がある Google Drive を使うことにしました。
今回の仕組み上 mp3 がどのアカウントのドライブに置いてあるかは関係ないので、自分なり家族なりで複数アカウントあればその分たくさんの容量が使えます。

# 1. Google Drive 側の準備

## 1.1. mp3 をアップロードして共有設定する

どのアカウントのどのフォルダでも良いので Google Drive に mp3 をアップロードします。
フォルダ単位でもファイル単位でも良いので共有設定を行い、アップロードした mp3 が**リンクを知っている全員**が閲覧可能なようにします。
![000_GoogleDriveShare.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/63453112-4ed1-2b31-5ab6-b3c31e33b40c.png)

リンクの `XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` の部分がファイル ID です。
説明の便宜上、2 つのファイルをアップロードし、ファイル ID がそれぞれ
`mp3fileId1XXXXXXXXXXXXXXXXXXXXXXX` と `mp3fileId2XXXXXXXXXXXXXXXXXXXXXXX` であるとします。

## 1.2. プレイリストを作成してアップロードする

下記形式の json ファイルを **utf-8(BOMなし)** で作成して Google Drive にアップロードし、共有設定を行います。
ファイル名はなんでも良いですが、後の説明で **favorite** というプレイリスト ID に対応するファイルのため `favorite.json` とします。

```json:favorite.json
[
  { "id": "mp3fileid1xxxxxxxxxxxxxxxxxxxxxxx", "info": "曲１の説明文" },
  { "id": "mp3fileid2xxxxxxxxxxxxxxxxxxxxxxx", "info": "曲２の説明文" }
]
```

## 1.3. プレイリストを管理する root.json を作成してアップロードする

プレイリストファイルをリストする `root.json` を作成して Google Drive にアップロードし、共有設定を行います。
例によって名前は何でも良いです。`favorite` というプロパティ名で先ほどの `favorite.json` のファイル ID を記述します。

```json:root.json
{
  "playlists": {
    "favorite": "favorite.json.fileIdxxxxxxxxxxxxx"
  }
}
```

# 2. Alexa のスキルを作成する

## 2.1. AlexaSkillsKit 開発者コンソールを開く

[Amazon Developer](https://developer.amazon.com/) にログインし、[amazon alexa](https://developer.amazon.com/ja-JP/alexa) のページに行って左上の「Alexa Skills Kit (ASK)」から「[開発者コンソール](https://developer.amazon.com/alexa/console/ask)」を選びます。
Developer アカウントが無い場合はログインフォームの下側に「Amazon Developerアカウントを作成」というボタンがあるのでアカウントを作成してください。

## 2.2. 新しいスキルを作る

1. 何もスキルがない状態での開発者コンソールは以下の感じです。まずは「**スキルの作成**」をクリックします。
![001_CreateSkill.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/9764d845-5f92-3d86-73c3-9ea1333f6c1e.png)

1. スキル名を入力します。ここでは `PlayMusic` という少し挑戦的な名前にしてみます。
![002_InputSkillName.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/82b9f45b-1fdf-2955-5c3b-528573be25d0.png)
その他の要素は初期状態（下記参照）のままで良いので「**スキルを作成**」をクリックします。
  * デフォルトの言語　：　日本語
  * スキルに追加するモデルを選択　：　カスタム
  * スキルのバックエンドリソースをホスティングする方法を選択　：　Alexa-Hosted (Node.js)

1. 画面が変わりますので、初期状態（下記参照）のまま「**テンプレートで続ける**」をクリックします。
  * スキルに追加するテンプレートを選択　：　Hello World スキル

1. 少し待つと選択したテンプレートを元にしたスキルが作成され、以下の画面になります。
![003_SkillCreated.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/cfeaf861-29ef-7ed0-733a-07d4fbd4309e.png)

## 2.3. スキルの設定を行う

### 2.3.1. 呼び出し名

左側のメニューから「**呼び出し名**」を選択し、スキルの呼び出し名に好きな名前（ここではプレイミュージック）を入力し、「**モデルを保存**」をクリックします。
この名前を使って「Alexa、**プレイミュージック**を開いて」とか「Alexa、**プレイミュージック**で**お気に入りの曲**を再生して」とか指示することになるのでしっくりくる名前にしてください。このスキルは**公開しない**ので名前の衝突を気にする必要はありません。
![004_InputInvocationName.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/305bb823-b477-4f68-9546-e1f11e072751.png)

### 2.3.2. インターフェース

左側のメニューから「**インターフェース**」を選択し「Audio Player」のスイッチを ON にし、「**インターフェースを保存**」をクリックします。
![005_Interface.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/3c0841c7-24ef-549d-3059-306040e6a0da.png)

### 2.3.3. スロットタイプ

1. 左側のメニューから「**アセット**」を展開して「**スロットタイプ(0)**」を選択し、「**+スロットタイプ**」をクリックします。
1. 「**Create a custom slot type with values**」を選び（初期選択済み）テキストボックスに「**Playlist**」と入力して「**Next**」をクリックします。すぐ後に出てくるスロット名と紛らわしいので「**PlaylistName**」の方が混乱しないかも知れません。
![006_AddSlotType.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/8c024261-281f-b46d-c4fa-89b6b95d3896.png)
1. 続いて「**このスロットタイプの新しい値を入力**」に「**お気に入り**」と入力して「**+**」をクリックし、「**ID**」に「**favorite**」を入力、「**同義語を追加**」に「**お気に入りの曲**」と入力して「**+**」をクリックします。
1. 下の画面イメージのようになったら「**モデルを保存**」をクリックします。
![007_AddSlotValue.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/232db3c5-0cee-08c3-3202-e4485b63eecd.png)

ここで「値」や「同義語」として設定されたワードに対し、その「ID」を `root.json` のプロパティ名とマッチングしてプレイリストの曲を再生します。値や同義語は Amazon Echo が発話の結果認識したワードになるため、例えば「お気に」のような同義語を設定しても、Echo が「起きに」とか「沖に」とかの認識しかしなければ再生できません。

### 2.3.4. インテント

1. 左側のメニューから「**対話モデル**」を展開して「**インテント**」を選択し、インテント一覧から「**HelloWorldIntent**」を探して削除します。
1. 「**+インテントを追加**」をクリックします。
1. 「**カスタムインテントを作成**」を選び（初期選択済み）テキストボックスに「**PlayMusicIntent**」と入力して「**カスタムインテントを作成**」をクリックします。
![008_InputIntentName.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/fcf7d87d-bfc7-a713-7ab9-8b3515aa0a51.png)
1. 「サンプル発話」を一旦無視した画面一番下までスクロールし、インテントスロットの「**名前**」に「**playlist**」を入力、「**スロットタイプ**」に「**Playlist**」を選択する
![009_SetIntentSlot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/1b0c136d-6905-0e3b-eeec-e547e24b7afb.png)
1. 画面一番上に戻りってサンプル発話を入力します。「{プレイリスト名}を再生して」という意図でどんな言い方をするか考え、それを全て入力します。一番下の「{playlist}」のみのサンプルは、「Alexa、プレイミュージックを開いて」などスキルの起動だけを発話した際に「プレイリスト名を教えてください。」と返答させるので、その時にプレイリスト名だけの発話で再生するためです。
![010_InputSampleUtterances.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/d008541c-3ac7-3d7a-602f-e9947f9744f0.png)
1. 「**モデルを保存**」をクリックし、「**モデルをビルド**」をクリックします。
  インテントやインターフェースなどの設定を変更した場合は「**モデルをビルド**」するまでスキルに反映されません。

## 2.4. 再生機能を実装する

1. 上側のメニューバー（ナビバー）で「**コードエディタ**」を選択すると、コードエディタ画面に遷移し、`index.js` が開いた状態になります。
1. 以下のコードを貼り付け、`rootFileId` の値を `root.json` のファイル ID に書き換えます。
1. 「**保存**」をクリックして「**デプロイ**」もクリックします。

<details><summary>index.js の全文（折り畳み）</summary><div>

```js:index.js
/* *
 * This sample demonstrates handling intents from an Alexa skill using the Alexa Skills Kit SDK (v2).
 * Please visit https://alexa.design/cookbook for additional examples on implementing slots, dialog management,
 * session persistence, api calls, and more.
 * */
const Alexa = require('ask-sdk-core');
const Https = require('https');

const rootFileId = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; // ★★ここを書き換える★★

const getRequestTypeOrIntentName = handlerInput => {
  return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
    ? Alexa.getIntentName(handlerInput.requestEnvelope)
    : Alexa.getRequestType(handlerInput.requestEnvelope);
};

const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
    },
    handle(handlerInput) {
        const speakOutput = 'プレイリスト名を教えてください。';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};

const PlayMusicHandler = {
    canHandle(handlerInput) {
        return [
            'PlayMusicIntent', 'AudioPlayer.PlaybackNearlyFinished'
        ].includes(getRequestTypeOrIntentName(handlerInput));
    },
    async handle(handlerInput) {
        const requestTypeOrIntentName = getRequestTypeOrIntentName(handlerInput);
        let behavior = 'REPLACE_ALL';
        let playlistId = '';
        let seed = 0;
        let track = 0;
        let loop = '';
        let repeat = '';
        let offset = 0;
        switch(requestTypeOrIntentName){
            case('PlayMusicIntent'): {
                // intent.slots.{スロット}.resolutions 配下にユーザーが選択したスロットが格納される
                // ユーザーが発話したプレイリスト名に対応する Google Drive 上の json ファイルを取得するが、
                // スロット値の ID を利用することで、発話の揺らぎに対応しつつ対象ファイルを一意にしている
                const resolvedSlot = handlerInput.requestEnvelope.request.intent.slots.playlist.resolutions.resolutionsPerAuthority[0].values;
                if (resolvedSlot === undefined) {
                    const speakOutput = 'プレイリストを認識できませんでした。もう一度お願いします。';
                    return handlerInput.responseBuilder
                        .speak(speakOutput)
                        .reprompt(speakOutput)
                        .getResponse();
                }
                const rootjson = await getJson(makeDriveUrl(rootFileId));
                playlistId = rootjson.playlists[resolvedSlot[0].value.id];
                break;
            }
            case('AudioPlayer.PlaybackNearlyFinished'): {
                // 曲の終了間際の場合は再生中の曲をそのままにするため REPLACE_ENQUEUED でキューを置き換える
                behavior = 'REPLACE_ENQUEUED';
                const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
                [playlistId, seed, track, loop, repeat, ] = audioPlayer.token.split(':');
                track = (+track) + 1;
                break;
            }
        }

        // token から切り出したままだと string になっているので数値型に直す
        [seed, track] = [+seed, +track];

        // 全て再生したら終了する
        const playlist = await getJson(makeDriveUrl(playlistId));
        if (track >= playlist.length) {
            return handlerInput.responseBuilder
                .addAudioPlayerClearQueueDirective('CLEAR_ALL')
                .withShouldEndSession(true)
                .getResponse();
        }

        // addAudioPlayerPlayDirective を利用して AudioPlayer に音楽再生の指示を応答する
        const idx = track;
        const url = makeDriveUrl(playlist[idx].id);
        const token = `${playlistId}:${seed}:${track}:${loop}:${repeat}:${playlist[idx].info}`;
        return handlerInput.responseBuilder
            .addAudioPlayerPlayDirective(behavior, url, token, offset, null)
            .getResponse();
    }
};

const CancelAndStopIntentHandler = {
    canHandle(handlerInput) {
        return [
            'AMAZON.StartOverIntent', 'AMAZON.NextIntent', 'AMAZON.PreviousIntent',
            'AMAZON.ShuffleOnIntent', 'AMAZON.ShuffleOffIntent',
            'AMAZON.LoopOnIntent', 'AMAZON.LoopOffIntent', 'AMAZON.RepeatIntent',
            'AMAZON.PauseIntent', 'AMAZON.ResumeIntent', 'AMAZON.HelpIntent',
            'AMAZON.CancelIntent', 'AMAZON.StopIntent', 'SessionEndedRequest'
        ].includes(getRequestTypeOrIntentName(handlerInput));
    },
    handle(handlerInput) {
        const speakOutput = '再生を終了します。';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .addAudioPlayerClearQueueDirective('CLEAR_ALL')
            .withShouldEndSession(true)
            .getResponse();
    }
};
/* *
 * FallbackIntent triggers when a customer says something that doesn’t map to any intents in your skill
 * It must also be defined in the language model (if the locale supports it)
 * This handler can be safely added but will be ingnored in locales that do not support it yet
 * */
const FallbackIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.FallbackIntent';
    },
    handle(handlerInput) {
        const speakOutput = 'よく分かりませんでした。もう一度お願いします。';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .reprompt(speakOutput)
            .getResponse();
    }
};
/* *
 * The intent reflector is used for interaction model testing and debugging.
 * It will simply repeat the intent the user said. You can create custom handlers for your intents
 * by defining them above, then also adding them to the request handler chain below
 * */
const IntentReflectorHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest';
    },
    handle(handlerInput) {
        const intentName = Alexa.getIntentName(handlerInput.requestEnvelope);
        const speakOutput = `${intentName} がトリガーされました。`;

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .getResponse();
    }
};
const PlaybackHandler = {
    canHandle(handlerInput) {
        return [
            'AudioPlayer.PlaybackStarted', 'AudioPlayer.PlaybackFinished', 'AudioPlayer.PlaybackStopped'
        ].includes(Alexa.getRequestType(handlerInput.requestEnvelope));
    },
    handle(handlerInput) {
        return handlerInput.responseBuilder.getResponse();
    }
};
/**
 * Generic error handling to capture any syntax or routing errors. If you receive an error
 * stating the request handler chain is not found, you have not implemented a handler for
 * the intent being invoked or included it in the skill builder below
 * */
const ErrorHandler = {
    canHandle() { return true; },
    handle(handlerInput, error) {
        const speakOutput = 'エラーが発生しました。再生を終了します。';
        console.log(`~~~~ Error handled: ${JSON.stringify(error)}`);

        return handlerInput.responseBuilder
            .speak(speakOutput)
            .addAudioPlayerClearQueueDirective('CLEAR_ALL')
            .withShouldEndSession(true)
            .getResponse();
    }
};

const makeDriveUrl = (fileId) => `https://drive.google.com/uc?export=download&id=${fileId}`;

const getJson = (url) => new Promise((resolve, reject) => {
    Https.get(url, (res) => {
        res.setEncoding('utf8');
        let json = '';

        if(res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
            resolve(getJson(res.headers.location));
        } else {
            let bufs = [];
            res.on('data', (chunk) => json += chunk);
            res.on("end", () => {
                try {
                    resolve(JSON.parse(json));
                } catch (err) {
                    console.log(err);
                    reject(err);
                }
            });
            res.on('error', (e) => {
                console.log(e.message);
                reject(e);
            });
        }
    });
});

/**
 * This handler acts as the entry point for your skill, routing all request and response
 * payloads to the handlers above. Make sure any new handlers or interceptors you've
 * defined are included below. The order matters - they're processed top to bottom 
 * */
exports.handler = Alexa.SkillBuilders.custom()
    .addRequestHandlers(
        LaunchRequestHandler,
        PlayMusicHandler,
        CancelAndStopIntentHandler,
        FallbackIntentHandler,
        IntentReflectorHandler,
        PlaybackHandler)
    .addErrorHandlers(ErrorHandler)
    .lambda();
```
</div></details>

## 2.5. テストを有効にする

スキルを公開しないと Amazon Echo でそのスキルを有効にして使えるようになりませんが、スキルのテストモードを**開発中**にすると、実機でテストできるようになりますので、これを利用して公開せずに作成したスキルを実機で動作させます。
上側のメニューバー（ナビバー）で「**テスト**」を選択、「非公開」となっているリストボックスを「**開発中**」に変更するだけです。

## 2.6. 実際に再生する

「Alexa、プレイミュージックでお気に入りの曲を再生して」と発話し、再生されれば成功です。
発話を受けても「お気に入りの曲を再生します」のような返答をするように作っていないので、曲が流れ始めるまで若干不安になりますが耐えてください。

## 2.7. プレイリストを追加する

例として `favorite` プレイリストのみ作成しましたが、追加する場合は下記の手順となります。開発コンソールを触る必要がある点がイケてないですが、事前に登録しておいても悪さはしないので、あらかじめ登録しておく手もあります。

1. mp3 をアップロードする
1. `favorite.json` に倣って新しいプレイリストファイルを作成してアップロードする
1. 上記ファイルの情報を `root.json` に追記する。行末のカンマの有無に注意
1. 開発コンソールの「アセット＞スロットタイプ」を選択し、「**Playlist**」をクリックしてスロット値として新しいプレイリスト名と、ID　に `root.json` に追記した際のプロパティ名を指定して、モデルをビルドする

# おわりに

Google や Amazon に抹殺されないか心配ですが、今回は単純な音楽再生機能を実装しました。
Google Drive 上の mp3 の共有リンクが漏洩すると問題ですので実践する方は自己責任でお願いします。
[その2](https://qiita.com/hokonin/items/7296472fd9393bc68daa) では今回のコードについて説明し、曲を進める／戻す、シャッフル再生する、などの機能実装については[その3](https://qiita.com/hokonin/items/e55b68f26da2f6100545) 以降で投稿する予定です。

# 参考

* [Alexa Skill KitでAudioPlayerスキルを実装する（NodeJS編）](https://qiita.com/yoshikawaa/items/bbf2791f397ce1efe44e)
* [Alexaのスキルを自作して手持ちの音楽ファイルを再生させよう](https://android-smart.com/2019/06/alexaskilldevelopmusic.html)