TypeScript を使って Clova Custom Extension を作ろう - オーディオコンテンツの再生

# はじめに

以前投稿した以下の記事をベースに、本投稿ではオーディオコンテンツを再生するExtensionを作成します。

[TypeScript を使って Clova Custom Extension を作ろう - Express](https://qiita.com/daisukeArk/items/78b82bb97d4b76d7ca08)
[TypeScript を使って Clova Custom Extension を作ろう - AWS Lambda](https://qiita.com/daisukeArk/items/9da5d039c46e031cbc01)

公式ドキュメントはこちら
https://clova-developers.line.me/guide/#/CEK/Guides/Build_Custom_Extension.md#DirectClientToPlayAudio
https://clova-developers.line.me/guide/#/CEK/Guides/Build_Custom_Extension.md#UpdateAudioURLForSecurity

# 目次

* 実装する機能について
* 実装
    * オーディオコンテンツの再生インテント(PlayIntent)の作成
    * イベント要求(EventRequest)の作成
    * オーディオコンテンツの再生インテント(DelayPlayIntent)の作成
    * `AudioPlay.StreamRequested`イベントに対する応答(AudioPlayer.StreamDeliver)の作成
    * イベント要求呼出の追加

# 実装する機能について

以下の機能を追加します。

* PlayIntent
    * オーディオコンテンツの再生
* DelayPlayIntent
    * StreamRequested`イベントによるオーディオコンテンツの再生を要求
* EventRequest
    * オーディオコンテンツ再生終了時の制御
    * `AudioPlay.StreamRequested`イベントに対する応答

EventRequestってなんぞやってところを公式ドキュメントから抜粋

> EventRequestは、ユーザの発話の有無に関わらず、デバイスの状態が変化したときにExtensionに送信されるメッセージです。これらのイベントは、デバイスの状態を取得したり、状態変化したことを検知することに利用できます。また、Extensionがオーディオコンテンツを提供する際にも使用されます。

オーディオコンテンツでは、再生・停止・一時停止・再生終了・再生再開、再生状況レポートなどの情報がそれぞれのタイミングでクライアントから送られてきます。
他にもExtensionが有効・無効化されたかなども送られてきたりします。

# 実装

実装にはSDKを利用していますが、型定義が無いものもあるのでanyで回避している箇所があります。

## オーディオコンテンツの再生インテント(PlayIntent)の作成

オーディオコンテンツを再生するには、レスポンスメッセージのディレクティブに`AudioPlayer.Play`ディレクティブを含めて返す必要があります。

ここでは、以下のレスポンスを返します。

* 音声再生前に「サンプルを再生します。」と発話する。
* 発話後、すぐにオーディオコンテンツを再生する。
* shouldEndSessionは`false`にする。

```typescript
  switch (intent) {
    case 'HelloWorldIntent':
      // ~~~ 省略 ~~~
      break;
    case 'PlayIntent':
      responseHelper.setSimpleSpeech(
        Clova.SpeechBuilder.createSpeechText('サンプルを再生します。')
      );

      // 型定義が無いのでanyで回避
      responseHelper.responseObject.response.directives = <any>[
        {
          header: {
            namespace: 'AudioPlayer',
            name: 'Play',
            dialogRequestId: (<any>responseHelper.requestObject.request).requestId,
            messageId: uuid.v4()
          },
          payload: {
            audioItem: {
              audioItemId: uuid.v4(),
              stream: {
                beginAtInMilliseconds: 0,
                url: process.env.AUDIO_URL,
                urlPlayable: true
              }
            },
            source: {
              name: 'sample'
            },
            playBehavior: 'REPLACE_ALL'
          }
        }
      ];
      break;
    default:
      // ~~~ 省略 ~~~
      break;
  }
```

* 音声再生前に「サンプルを再生します。」と発話する。

レスポンスに発話する内容を設定します。

```typescript
responseHelper.setSimpleSpeech(
  Clova.SpeechBuilder.createSpeechText('サンプルを再生します。')
);
```

* 発話後、すぐにオーディオコンテンツを再生する。

`AudioPlayer.Play`ディレクティブには、`header`と`payload`を含める必要があります。

`header`にはクライアントに何を指示したいのか、
`payload`には指示に必要な情報を含めます。

payloadには含まれたコンテンツをすぐに再生する為に、`urlPlayable: true`と`playBehavior: 'REPLACE_ALL'`を指定します。
`urlPlayable`はドキュメントにもありますが、`false`に設定すると次のオーディオストリームの再生直前に`AudioPlayer.StreamRequested`イベントが発生し、有効なオーディオコンテンツのURLを返す制御ができます。

`playBehavior: 'REPLACE_ALL'`は再生キューをすべてクリアして、送信されたオーディオストリームをすぐに再生するのに指定します。

```
payload: {
  audioItem: {
    audioItemId: uuid.v4(),
    stream: {
      beginAtInMilliseconds: 0,
      url: process.env.AUDIO_URL,
      urlPlayable: true
    }
  },
  source: {
    name: 'sample'
  },
  playBehavior: 'REPLACE_ALL'
}
```

* shouldEndSessionは`false`にする。

後述します。

## オーディオコンテンツの再生インテント(DelayPlayIntent)の作成

`StreamRequested`イベントによるオーディオコンテンツの再生を要求するには、前項と同様にレスポンスメッセージのディレクティブに`AudioPlayer.Play`ディレクティブを含めて返す必要があります。

前項と違いここでは、以下のレスポンスを返します。

* 音声再生前に「遅れてサンプルを再生します。」と発話する。
* 発話後、すぐにオーディオコンテンツを再生せずに`StreamRequested`イベントを発生させるために、`urlPlayable`を`false`に設定する。

```typescript
case 'DelayPlayIntent':
  responseHelper.setSimpleSpeech(
    Clova.SpeechBuilder.createSpeechText('遅れてサンプルを再生します。')
  );

  // 型定義が無いのでanyで回避
  responseHelper.responseObject.response.directives = <any>[
    {
      header: {
        dialogRequestId: (<any>responseHelper.requestObject.request).requestId,
        messageId: uuid.v4(),
        name: 'Play',
        namespace: 'AudioPlayer'
      },
      payload: {
        audioItem: {
          audioItemId: uuid.v4(),
          stream: {
            beginAtInMilliseconds: 0,
            url: 'clova:Test',
            urlPlayable: false
          }
        },
        source: {
          name: 'sample'
        },
        playBehavior: 'REPLACE_ALL'
      }
    }
  ];
  responseHelper.responseObject.response.shouldEndSession = true;
  break;
```

## イベント要求(EventRequest)の作成

公式ドキュメントから抜粋

> 再生の進行状況のレポートに関するEventRequestタイプのリクエストメッセージのうち、AudioPlayer.PlayFinishedイベントが含まれたメッセージを受け取った場合、Custom Extensionは、再生完了に対するクライアントの次のアクションをレスポンスメッセージで返す必要があります。そのアクションとして、次のオーディオコンテンツの再生を指示することができます。

ここで先程後述すると書いた`shouldEndSessionは false にする`ですが、
これをしておかないと、再度PlayIntentを呼び出すことが出来ませんでした。

ここではオーディオコンテンツ再生終了後に、`AudioPlayer.PlayFinished`イベントをハンドリングしてもう一度PlayIntentを呼び出せるようにします。

公式ドキュメントの`PlayFinished`メッセージサンプルを見ると以下のようになっています。
実際に送られてくるメッセージは以下のようになっています。

```json
{
  "context": [
    ...
  ],
  "event": {
    "namespace": "AudioPlayer",
    "name": "PlayFinished",
    "payload": {
      "offsetInMilliseconds": 110045,
      "token": ""
    }
  }
}
```

以下のように`AudioPlayer`, `PlayFinished`を捕捉し、レスポンスメッセージに以下を実装します。

* 再生が終了したことを発話し、待受状態にする。

```typescript
export const eventRequestHandler = async (responseHelper: Clova.Context) => {
  // 型定義が無いのでanyで回避
  const namespace = (<any>responseHelper.requestObject.request).event.namespace;
  const name = (<any>responseHelper.requestObject.request).event.name;

  switch (namespace) {
    case 'AudioPlayer':

      switch (name) {
        case 'PlayFinished':
          responseHelper.setSimpleSpeech(
            Clova.SpeechBuilder.createSpeechText('再生終了しました。')
          );
          responseHelper.setSimpleSpeech(
            Clova.SpeechBuilder.createSpeechText('再生して、と言ってください。'),
            true
          );
          break;
        default:
          break;
      }
      break;
    default:
      break;
  }
};
```

`StreamRequested`イベントも同様に捕捉し、有効なオーディオコンテンツのURLを応答する`StreamDeliver`ディレクティブを返します。

* `StreamDeliver`ディレクティブの`payload.audioStream`に`AudioPlayer.StreamRequested`イベントの`payload.audioStream`を設定する。
* `StreamDeliver`ディレクティブの`payload.audioStream.url`に有効なオーディオコンテンツのURLを設定する。
* `StreamDeliver`ディレクティブの`payload.audioStream.urlPlayable`を`true`に設定する。

```typescript
export const eventRequestHandler = async (responseHelper: Clova.Context) => {
  // 型定義が無いのでanyで回避
  const namespace = (<any>responseHelper.requestObject.request).event.namespace;
  const name = (<any>responseHelper.requestObject.request).event.name;

  switch (namespace) {
    case 'AudioPlayer':

      switch (name) {
        case 'StreamRequested':
          responseHelper.setSimpleSpeech(
            Clova.SpeechBuilder.createSpeechText('追加再生します。')
          );

          // StreamDeliverディレクティブ
          responseHelper.responseObject.response.directives = <any>[
            {
              header: {
                namespace: 'AudioPlayer',
                name: 'StreamDeliver',
                dialogRequestId: (<any>responseHelper.requestObject.request).requestId,
                messageId: uuid.v4()
              },
              payload: {
                audioItemId: uuid.v4(),
                audioStream: (<any>responseHelper.requestObject.request).event.payload.audioStream
              }
            }
          ];

          (<any>responseHelper.responseObject.response.directives[0].payload).audioStream.url = process.env.AUDIO_URL;
          (<any>responseHelper.responseObject.response.directives[0].payload).audioStream.urlPlayable = true;

          responseHelper.responseObject.response.shouldEndSession = true;
          break;
        default:
          break;
      }
      break;
    default:
      break;
  }
};
```


## イベント要求呼出の追加

以下のように作成した`eventRequestHandler`を呼び出しを追加します。
ここの処理はSDKを無視した独自実装になっているのでご自身の環境に合わせて呼び出してください。

```typescript:src/extension/clova-extension-lambda.ts
    switch (<any>context.requestObject.request.type) {
      case 'LaunchRequest':
        requestHandler = Handlers.launchRequestHandler;
        break;
      case 'IntentRequest':
        requestHandler = Handlers.intentHandler;
        break;
      case 'EventRequest':
        requestHandler = Handlers.eventRequestHandler;
        break;
      case 'SessionEndedRequest':
      default:
        requestHandler = Handlers.sessionEndedRequestHandler;
        break;
    }
```

# まとめ

本投稿では、`AudioPlayer.Play`, `AudioPlayer.StreamDeliver`ディレクティブを利用しオーディオコンテンツを再生するExtensionを作成しました。
これから作成される方の参考になれば幸いです。

今回のソースは以下にあります。
こちらのソースは以下の投稿の実装も兼ねてます。

[Clova Custom Extention の使用状況を QuickSight で！ - Qiita](https://qiita.com/daisukeArk/items/49dc0439cbfc04d8f7cc)

[daisukeArk/clova-extension-sample-audio-player - GitHub](https://github.com/daisukeArk/clova-extension-sample-audio-player)