タイマー付きの音声レシピをGoogle Cloud Text-to-Speechで作りAlexaで再生

マイルールが決まっているレシピ、お持ちですか？
例えば、コーヒーのドリップ（1湯目、2湯目、3湯目）や、お好み焼きの焼き方（表3分、裏4分蒸し焼き、表3分）など。

複数のステップがあるので、Alexaにタイマーをいちいち設定するのも面倒です。そこで、タイマー付きの音声レシピをGoogle CloudのText-to-Speechで作り、Alexaに再生してもらうことにしました。

このスキルは個人で使う前提で、公開はしていません。かなり手を抜いた実装になっていること、ご容赦ください。

# 使い方

「Alexa、（アプリ名）を開いて」と話しかけると、Alexaが音声レシピを再生します。

# 作り方

次の3ステップで作ります。

1. SSMLでレシピを記述しMP3ファイルを作成
2. MP3をS3にアップロード
3. AlexaでMP3を再生

## 1. SSMLでレシピを記述しMP3ファイルを作成

音声合成マークアップ言語 SSML（[Speech Synthesis Markup Language](https://www.w3.org/TR/speech-synthesis/)）でレシピを記述します。
Google Cloudで使えるSSMLは、Alexaで使えるSSMLよりも表現力が豊富です。BGMを流したり、発話のタイミングを時間指定で調整できます。

https://cloud.google.com/text-to-speech/docs/ssml?hl=ja

今回は、コーヒーのドリップ用レシピを作ってみました。作ったSSMLは末尾に掲載します。

- 3回に分けてドリップする。1湯目は1分、2湯目は1分、3湯目は2分、それぞれ時間をおく。
- 10秒間でお湯を入れ切る。1から10まで数える。
- 次のお湯を入れるタイミングを知らせるのに、5秒前からカウントダウンする。
- BGMを流す。

Cloud Consoleから[Text-to-Speech](https://console.cloud.google.com/speech/text-to-speech)にアクセスして、APIを有効にしてください。SSMLを入力すると、音声を合成してウェブブラウザー上で確認できます。


Text-to-Speechを使うポイントです。

- 最大サイズは5000Byte
  - ちょっと凝ったことをやろうとすると、すぐに5000Byteの上限に達してしまいます。
  - もし上限に達したら、`xml:id`で使うIDを短縮するなど、文字数を削減してください。
- 使える音声モデル
  - モデルによる音声の違いは、次のページで確認できます。今回は自然な発話の`ja-JP-Neural2-B`を採用しました。
  - https://cloud.google.com/text-to-speech/docs/voices?hl=ja
- 料金
  - Neural2はお高いですが、100万文字/月までは無料です。
  - https://cloud.google.com/text-to-speech/pricing?hl=ja
- Alexaに合わせたMP3を作成
  - 詳細設定では以下の設定をして、SYNTHESIZEしました。
    - Volume gainを最大値（16）
    - サンプルレートは24000
  - 少し音が小さくなるのと、[AlexaのSSMLでaudioを再生する時のサンプルレート](https://developer.amazon.com/ja-JP/docs/alexa/custom-skills/speech-synthesis-markup-language-ssml-reference.html#h3_converting_mp3)に合わせた結果です（サンプルレートは指定しなくても大丈夫かも知れませんが、試していません）。

## 2. MP3をS3にアップロード

Alexaの[Develper Console](https://developer.amazon.com/alexa/console/ask)にアクセスし、Create SkillでJavaScriptのHosted Skillを作ります。
Codeタブでエディターを開き、S3 Storageを選択してS3のタブを開きます。
MP3ファイルをアップロードします。

## 3. AlexaでMP3を再生

AudioPlayerを有効化してから、Skillのコーディングをしていきます。
コードではMP3ファイルのURLを取得し、addAudioPlayerPlayDirectiveで再生します。PauseIntentHandlerを追加して、MP3再生中に終了できるようにします。
なお、AudioPlayerを使うテストは、実機が必要です。
テスト画面には右下にトーストで`AudioPlayer is currently an unsupported namespace. Check the device log for more information.`と表示されます。

### AudioPlayerを有効化

AudioPlayerを有効化しておきます。

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

> 開発者コンソールで、カスタム > インターフェースに移動します。
> Audio Playerオプションを有効にしてからインターフェースを保存をクリックします。必ずモデルをビルドをクリックして対話モデルを再ビルドしてください。

有効化し忘れた場合、テストした時にJSON Inputに次のエラーが表示されます。

```json
"error": {
  "type": "INVALID_RESPONSE",
  "message": "The requested skill has not declared that it implements the AudioPlayer interface. Please update the skill's configuration from the Developer Console."
}
```

### MP3ファイルのURLを取得

S3にアクセスするには、有効期限付きのURLを使います。util.jsを読み込みます。

https://developer.amazon.com/ja-JP/docs/alexa/hosted-skills/alexa-hosted-skills-media-files.html

```javascript:index.js
const Util = require('/util.js');
...
const url = Util.getS3PreSignedUrl('Media/synthesis.mp3');
```

### addAudioPlayerPlayDirectiveで再生

```javascript:index.js
const LaunchRequestHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest';
    },
    handle(handlerInput) {
        const speakOutput = 'ようこそ！';
        const url = Util.getS3PreSignedUrl('Media/synthesis.mp3');
        const token = 'token';
        return handlerInput.responseBuilder
            .speak(speakOutput)
            .addAudioPlayerPlayDirective('REPLACE_ALL', url, token, 0, null)
            .getResponse();
    }
};
```

### PauseIntentHandlerを追加

MP3ファイルの再生が終わる前に、Skillを中止できるようにしておきましょう。`AMAZON.PauseIntent`を処理します。
`withShouldEndSession(true)`でSkillを終了します。

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

```javascript:index.js
const PauseIntentHandler = {
    canHandle(handlerInput) {
        return Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest'
            && Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.PauseIntent';
    },
    handle(handlerInput) {
        return handlerInput.responseBuilder
            .speak('良い一日を!')
            .withShouldEndSession(true)
            .getResponse();
    }
};
...
exports.handler = Alexa.SkillBuilders.custom()
    .addRequestHandlers(
        LaunchRequestHandler,
        PauseIntentHandler, //追加
        HelpIntentHandler,
        CancelAndStopIntentHandler,
        FallbackIntentHandler,
        SessionEndedRequestHandler,
        IntentReflectorHandler)
    .addErrorHandlers(
        ErrorHandler)
    .withCustomUserAgent('sample/hello-world/v1.2')
    .lambda();
```

# SSML

コーヒーをドリップするタイマー付き音声レシピです。BGMはPeriTuneさんの楽曲がオススメです！

https://peritune.com/blog/2019/02/10/cafe_musette/

```xml
<speak>
  <par>
    <media xml:id="f">
      <speak>今日も美味しいコーヒーを淹れましょう。1回目のお湯を注ぎはじめましょう。</speak>
    </media>
    <media begin="f.end+1s">
      <speak>1</speak>
    </media>
    <media begin="f.end+2s">
      <speak>2</speak>
    </media>
    <media begin="f.end+3s">
      <speak>3</speak>
    </media>
    <media begin="f.end+4s">
      <speak>4</speak>
    </media>
    <media begin="f.end+5s">
      <speak>5</speak>
    </media>
    <media begin="f.end+6s">
      <speak>6</speak>
    </media>
    <media begin="f.end+7s">
      <speak>7</speak>
    </media>
    <media begin="f.end+8s">
      <speak>8</speak>
    </media>
    <media begin="f.end+9s">
      <speak>9</speak>
    </media>
    <media begin="f.end+10s">
      <speak>10</speak>
    </media>
    <media begin="f.end" end="f.end+60s" fadeOutDur="5s" soundLevel="-20dB">
      <audio src="..." />
    </media>
    <media begin="f.end+55s">
      <speak>5</speak>
    </media>
    <media begin="f.end+56s">
      <speak>4</speak>
    </media>
    <media begin="f.end+57s">
      <speak>3</speak>
    </media>
    <media begin="f.end+58s">
      <speak>2</speak>
    </media>
    <media begin="f.end+59s">
      <speak>1</speak>
    </media>

    <media xml:id="s" begin="f.end+60s">
      <speak>2回目のお湯を注ぎはじめましょう。</speak>
    </media>
    <media begin="s.end" end="s.end+60s" fadeOutDur="5s" soundLevel="-20dB">
      <audio src="..." />
    </media>
    <media begin="s.end+1s">
      <speak>1</speak>
    </media>
    <media begin="s.end+2s">
      <speak>2</speak>
    </media>
    <media begin="s.end+3s">
      <speak>3</speak>
    </media>
    <media begin="s.end+4s">
      <speak>4</speak>
    </media>
    <media begin="s.end+5s">
      <speak>5</speak>
    </media>
    <media begin="s.end+6s">
      <speak>6</speak>
    </media>
    <media begin="s.end+7s">
      <speak>7</speak>
    </media>
    <media begin="s.end+8s">
      <speak>8</speak>
    </media>
    <media begin="s.end+9s">
      <speak>9</speak>
    </media>
    <media begin="s.end+10s">
      <speak>10</speak>
    </media>

    <media begin="s.end+55s">
      <speak>5</speak>
    </media>
    <media begin="s.end+56s">
      <speak>4</speak>
    </media>
    <media begin="s.end+57s">
      <speak>3</speak>
    </media>
    <media begin="s.end+58s">
      <speak>2</speak>
    </media>
    <media begin="s.end+59s">
      <speak>1</speak>
    </media>

    <media xml:id="t" begin="s.end+60s">
      <speak>3回目のお湯を注ぎはじめましょう。</speak>
    </media>
    <media begin="t.end" end="t.end+120s" fadeOutDur="5s" repeatCount="2" soundLevel="-20dB">
      <audio src="..." />
    </media>

    <media begin="t.end+1s">
      <speak>1</speak>
    </media>
    <media begin="t.end+2s">
      <speak>2</speak>
    </media>
    <media begin="t.end+3s">
      <speak>3</speak>
    </media>
    <media begin="t.end+4s">
      <speak>4</speak>
    </media>
    <media begin="t.end+5s">
      <speak>5</speak>
    </media>
    <media begin="t.end+6s">
      <speak>6</speak>
    </media>
    <media begin="t.end+7s">
      <speak>7</speak>
    </media>
    <media begin="t.end+8s">
      <speak>8</speak>
    </media>
    <media begin="t.end+9s">
      <speak>9</speak>
    </media>
    <media begin="t.end+10s">
      <speak>10</speak>
    </media>

    <media begin="t.end+115s">
      <speak>5</speak>
    </media>
    <media begin="t.end+116s">
      <speak>4</speak>
    </media>
    <media begin="t.end+117s">
      <speak>3</speak>
    </media>
    <media begin="t.end+118s">
      <speak>2</speak>
    </media>
    <media begin="t.end+119s">
      <speak>1</speak>
    </media>

    <media xml:id="last" begin="t.end+120s">
      <speak>美味しいコーヒーをお楽しみください！</speak>
    </media>

  </par>
</speak>
```