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

# 今回の実装機能

* [1. 最初から再生](#1-最初から再生)
* [2. シャッフル再生オン・オフ](#2-シャッフル再生オンオフ)
* [3. 前の曲、次の曲](#3-前の曲次の曲)
* [4. ループ再生オン・オフ](#4-ループ再生オンオフ)
* [5. リピート再生](#5-リピート再生)
* [6. 一時停止、再開](#6-一時停止再開)
* [7. ヘルプ](#7-ヘルプ)
* [8. 曲情報確認](#8-曲情報確認)

今回で残りの機能すべてを実装、説明します。
過去記事はこちら　[その1](https://qiita.com/hokonin/items/7c497a9537032dbb7a46)　[その2](https://qiita.com/hokonin/items/7296472fd9393bc68daa)

# 機能実装

## 1. 最初から再生

```js:PlayMusicHandler
    case('AMAZON.StartOverIntent'): {
        const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        [playlistId, seed, , loop, , ] = audioPlayer.token.split(':');
        break;
    }
```

1 曲目から再生するため track を `0` にしますが、トークンの値を無視することで初期値の `0` のままにしています。
再生する曲自体が変わるため、再生中の曲を繰り返し再生するリピートフラグもクリア（トークンの値を無視）します。

## 2. シャッフル再生オン・オフ

シャッフルした曲順を 1024 文字と有限なトークンに埋め込むのは無理があるので、同じシャッフル結果を再現するための情報、乱数シードをトークンに埋め込み、毎回プレイリストをシャッフルしてその○曲目を再生するようにします。
問題は JavaScript 標準の `Math.random()` は乱数シードを指定できないという点で、乱数シードを指定できる疑似乱数生成器を実装します。

### 2.1. 疑似乱数生成器とシャッフル後曲番号取得

```js:index.js
// ↓ rootFIleId 定義 (前回9行目) 辺りにこの行を追加
const UINT32_MAX_NEXT = 2 ** 32;

// ↓ getJson の下 (前回205行目) 辺りに以下のコードを追加

// 0～1未満の実数の疑似乱数生成器
const getSeed = () => Math.floor(Math.random() * (UINT32_MAX_NEXT - 1)) + 1;
const getNext = (() => {
    let s = Uint32Array.of(getSeed());
    return seed => {
        if (seed) s[0] = seed;
        s[0] ^= s[0] << 13;
        s[0] ^= s[0] >> 17;
        s[0] ^= s[0] << 5;
        return s[0] / UINT32_MAX_NEXT;
    };
})();

// シャッフル後の index 番目のトラック番号を取得する
// 0～length-1 の連番をランダムに並べ替え、index 番目の数を返却する
const getShuffledTrack = (length, index, seed) => {
    getNext(seed);
    let seq = [...Array(length).keys()];
    while (length > index) {
        const pick = Math.floor(getNext() * length--);
        [seq[length], seq[pick]] = [seq[pick], seq[length]];
    }
    return seq[index];
};
```

疑似乱数生成器は [Xorshiftなどの擬似乱数をプロットして比較してみた](https://qiita.com/7shi/items/8219dcc22950253b26fb) の Xorshift 版のものを使いました。そして プレイリストの曲数分の連番を Fisher–Yates アルゴリズムでシャッフルし、その指定番目の値（元のプレイリストにおける曲番号）を返却します。
なお計算時間をケチるため、シャッフル後の指定番目の値が確定した時点で値を返すようにしています。（`while (length > index)` の部分）

### 2.2. 再生リクエスト処理

```js:PlayMusicHandler
    case('AMAZON.ShuffleOnIntent'):
    case('AMAZON.ShuffleOffIntent'): {
        const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        [playlistId, , , loop, , ] = audioPlayer.token.split(':');
        seed = requestTypeOrIntentName === 'AMAZON.ShuffleOnIntent' ? getSeed() : 0;
        handlerInput.responseBuilder.speak(`${seed ? 'シャッフル' : '最初から順番に'}再生します。`);
        break;
    }
```

シャッフル再生オンの場合は乱数シードを新たに生成 `getSeed()` し、オフの場合は `0` にします。上書きする乱数シード、オン・オフにより 1 曲目からの再生となるトラック番号、再生曲が変わり解除するリピートフラグについてはトークン値を無視します。

```js:PlayMusicHandler
    // addAudioPlayerPlayDirective を利用して AudioPlayer に音楽再生の指示を応答する
    const idx = seed ? getShuffledTrack(playlist.length, track, seed) : track;
    const url = makeDriveUrl(playlist[idx].id);
```

乱数シードから実際にプレイリストの何曲目を再生するかを求めます。前回 `const idx = track;` となっていた部分ですが、シャッフル再生時（`seed > 0`）はシャッフルした track 番目の曲番号、通常再生時は track 自体の番号を使って url を求めます。

## 3. 前の曲、次の曲

```js:PlayMusicHandler
    case('AMAZON.NextIntent'):
    case('AMAZON.PreviousIntent'): {
        const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        [playlistId, seed, track, loop, , ] = audioPlayer.token.split(':');
        track = (+track) + (requestTypeOrIntentName === 'AMAZON.NextIntent' ? 1 : -1);
        break;
    }
```

発話によって曲を変更するため、すぐ指定した曲を再生するよう `behavior` を `REPLACE_ALL` のままにする点を除いて、前回説明した `AudioPlayer.PlaybackNearlyFinished` とほぼ同じです。
トークンから取得したトラック番号に対し、次の曲なら `+1`、前の曲なら `-1` します。
再生する曲が変わるためリピートフラグはクリアします。

```js:PlayMusicHandler
    if (track >= playlist.length) {
        return handlerInput.responseBuilder
            .addAudioPlayerClearQueueDirective('CLEAR_ALL')
            .withShouldEndSession(true)
            .getResponse();
    } else if (track < 0) {
        track = 0;
    }
```

前の曲や次の曲がプレイリストの範囲を超える場合はそれぞれ以下のようにします。

* 前の曲：1 曲目を再生します（`track = 0`）
* 次の曲：再生を終了します（通常の全曲再生終了時と同じ）

このあとのループ再生でこの部分は手が入ります。

## 4. ループ再生オン・オフ

```js:PlayMusicHandler
    case('AMAZON.LoopOnIntent'):
    case('AMAZON.LoopOffIntent'): {
        const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        [playlistId, seed, track, , repeat, ] = audioPlayer.token.split(':');
        loop = requestTypeOrIntentName === 'AMAZON.LoopOnIntent' ? 'loop' : '';
        offset = audioPlayer.offsetInMilliseconds;
        const speakOutput = `ループ再生を${loop === 'loop' ? 'オン' : 'オフ'}にします。`;
        handlerInput.responseBuilder.speak(speakOutput);
        break;
    }
```

ループ再生オンかオフかでループフラグを `loop` か `''` にセットします。
ループフラグだけを変更したトークンをキューに登録しますが、そのままだと発話によって割り込んだ再生中の曲がまた最初からの再生になってしまうため、割り込み時の曲の位置 `offset` を取得しておき、その位置からの再生を指示することで再開したように見せかけます。

```js:PlayMusicHandler
    if (track >= playlist.length) {
        if (loop !== 'loop') {
            return handlerInput.responseBuilder
                .addAudioPlayerClearQueueDirective('CLEAR_ALL')
                .withShouldEndSession(true)
                .getResponse();
        }
        track = 0;
    } else if (track < 0) {
        track = loop === 'loop' ? playlist.length - 1 : 0;
    }
```

ループフラグがオンの場合、前の曲や次の曲がプレイリストの範囲を超えても循環するようにトラック番号を設定します。

## 5. リピート再生

```js:PlayMusicHandler
    case('AMAZON.RepeatIntent'): {
        const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        [playlistId, seed, track, loop, , ] = audioPlayer.token.split(':');
        repeat = 'repeat';
        offset = audioPlayer.offsetInMilliseconds;
        const speakOutput = '現在の曲を繰り返し再生します。';
        handlerInput.responseBuilder.speak(speakOutput);
        break;
    }
```

リピートフラグに `repeat` をセットします。ループオン・オフと同様、割り込んだ曲の再生位置オフセットを取得しておいて再開したように見せかけます。

```js:PlayMusicHandler
    case('AudioPlayer.PlaybackNearlyFinished'): {
        // 曲の終了間際の場合は再生中の曲をそのままにするため REPLACE_ENQUEUED でキューを置き換える
        behavior = 'REPLACE_ENQUEUED';
        const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        [playlistId, seed, track, loop, repeat, ] = audioPlayer.token.split(':');
        track = (+track) + (repeat === 'repeat' ? 0 : 1);
        break;
    }
```

リピートフラグは「再生中の曲を繰り返し再生する」機能のため、曲の再生終了間際に次の曲をセットする処理で同じ曲番号をセットします。リピートフラグはクリアしないため同じ曲を無限再生します。リピート解除のインテントはありませんので、代わりに「次の曲」をリクエストすることで次の曲に移りつつリピートが解除できます。

## 6. 一時停止、再開

```js:PlayMusicHandler
    case('AMAZON.PauseIntent'): {
        return handlerInput.responseBuilder
            .addAudioPlayerStopDirective()
            .withShouldEndSession(true)
            .getResponse();
    }
    case('AMAZON.ResumeIntent'): {
        const audioPlayer = handlerInput.requestEnvelope.context.AudioPlayer;
        [playlistId, seed, track, loop, repeat, ] = audioPlayer.token.split(':');
        offset = audioPlayer.offsetInMilliseconds;
        break;
    }
```

一時停止は AudioPlayer に `StopDirective` を送ります。再開時はトークン値とオフセットを取得してそのまま再生指示します。
ただし「Alexa、終了して」と発話した場合に `AMAZON.StopIntent` ではなく `AMAZON.PauseIntent` として扱われるみたいで、スキルを終了したつもりが一時停止になったりしますので、機能的に使わないなら実装しないか、明示的に「Alexa、プレイミュージックを終了して」と発話するか、「Alexa、キャンセルして」と CancelIntent などの別のインテントで発話するか、などの対応が必要になります。

## 7. ヘルプ

```js:PlayMusicHandler
    case('AMAZON.HelpIntent'): {
        const listNames = [
            'お気に入り'
            ].join('、');
        const speakOutput = `利用可能なプレイリストは、${listNames}、です。`;
        return handlerInput.responseBuilder.speak(speakOutput).getResponse();
    }
```

ひとりで使う分には関係ないですが、設定したプレイリスト名に何があったかを確認できるよう、ヘルプに対してプレイリスト名を列挙して返答するようにします。なんなら `root.json` にこれようのテキストを記述して読み込むように実装する手もあります。

## 8. 曲情報確認

```js:PlayMusicHandler
    case('AskInfoIntent'): {
        const [, , , , , info] = handlerInput.requestEnvelope.context.AudioPlayer.token.split(':');
        const speakOutput = `この曲は、${info}、です。`;
        return handlerInput.responseBuilder.speak(speakOutput).getResponse();
    }
```

再生中の曲情報を確認するための処理で、プレイリスト json の `info` プロパティに記述したテキストを読み上げます。
このインテントは標準インテントではなくカスタムインテントのため、単に「Alexa、曲名を教えて」と発話しても PlayMusic スキルに対するリクエストとして処理してくれません。少し面倒ですが「Alexa、プレイミュージックで曲名を教えて」などと `スキル名`＋`接続詞`＋`発話サンプル` の形で発話します。
スキル名を言わなくて済む方法を探しましたが無理でした。[Name-free Interactions](https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/understand-name-free-interaction-for-custom-skills.html) という仕組みに可能性を感じましたが日本では対応していないようで、スキル名を言う形で妥協しました。

### 8.1. AskInfoIntent

`PlayMusicIntent` を作成した時と同様に `AskInfoIntent` を作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/256480/7ff03c7e-05b9-eee7-a0c1-fbfc610b01ab.png)

曲名を訪ねる際のサンプル発話を適当に設定してください。一括編集機能を使うと1行1サンプルで記述したテキストを貼り付けられるので楽です。ここで設定したのは以下の13パターンです。

```text
曲名
この曲
この曲名
曲名を教えて
この曲を教えて
この曲名を教えて
曲名をおしえて
この曲をおしえて
この曲名をおしえて
この曲なに
この曲はなに
この曲何
この曲は何
```

## 9. その他修正箇所

```js:PlayMusicHandler
    canHandle(handlerInput) {
        return [
            'PlayMusicIntent', 'AMAZON.StartOverIntent',
            'AudioPlayer.PlaybackNearlyFinished', 'AMAZON.NextIntent', 'AMAZON.PreviousIntent',
            'AMAZON.ShuffleOnIntent', 'AMAZON.ShuffleOffIntent',
            'AMAZON.LoopOnIntent', 'AMAZON.LoopOffIntent', 'AMAZON.RepeatIntent',
            'AMAZON.PauseIntent', 'AMAZON.ResumeIntent',
            'AskInfoIntent', 'AMAZON.HelpIntent'
        ].includes(getRequestTypeOrIntentName(handlerInput));
    },
```

```js:CancelAndStopIntentHandler
    canHandle(handlerInput) {
        return ['AMAZON.CancelIntent', 'AMAZON.StopIntent', 'SessionEndedRequest']
            .includes(getRequestTypeOrIntentName(handlerInput));
    },
```

対応したリクエストやインテントを `CancelAndStopIntentHandler` から `PlayMusicHandler` に移すだけです。

# おわりに

これで Google Drive 版音楽プレイヤーの実装は完了です。
再生指示できるプレイリスト名を Playlist スロットタイプに登録する、Google Drive 上のファイル ID を調べてプレイリスト json ファイルを作る、root.json にそのプレイリストを記述する、など手間は掛かりますが機能的には使えるのではないかと思います。
プレイリストの作成補助として、Google Drive のフォルダからファイルを検索し、タグ情報のタイトルを読み取ってファイル ID とタイトルを列挙する Google スプレッドシート（Google Apps Script）も作ったので番外編として記事にするかも知れません。

# 参考

* [Xorshiftなどの擬似乱数をプロットして比較してみた](https://qiita.com/7shi/items/8219dcc22950253b26fb) 
* [連番の数字の配列を作成（es2015 ver)](https://qiita.com/sakymark/items/710f0b9a632c375fbc31)
* [JavaScriptで配列を正しくシャッフル](https://chooblarin.github.io/post/array-shuffle)