Dialogflow v2 で SSML を使おうとしてハマった話


背景

ちょうど1週間前の5月9日に社内で Google Assistant アプリ開発の HelloWorld を披露する機会がありました。
そして、偶然にも5月9日は言わずと知れた「ピッコロ記念日」でした。
Google Assistant に「今日はピッコロ記念日です。」と言わせるのは簡単ですが、
どうしてもピッコロ大魔王様の声で音声を流したかったのです。

そこで、SSML で <audio> を使おうとしたのが始まりでした。

この記事では試行錯誤順に記載していきます。
上手くいったパターンは一番最後に載ってます。

【NGパターン】Intent の TextResponse に設定してみる

確か、TextResponse に直接 SSML を書いても再生してくれるはずなので書いてみました。

そして、Integration をしてシミュレーターで試してみたところ、
以下のようなエラーになったのです。

MalformedResponse
expected_inputs[0].input_prompt.rich_initial_prompt.items[0].simple_response: 'display_text' must be set or 'ssml' must have a valid display rendering.

シミュレーターの Surface はデフォルトでは一番左の Phone が選択されていますが、
それだと、どうもエラーになってしまうようです。

試しに、以下のようにシミュレーターを Google Home にしてみると、
期待通りに再生されるようです。

display_text と言っているし、
どうやら、画面を持つデバイスの場合に怒られるようです。

【NGパターン】Fulfillment の Inline Editor で設定してみる

InlineEditor
agend.add('<speak><audio src="*****.mp3"/></speak>');

上記の部分です。
文字列を SSML にしてみました。

結果は変わらず。
シミュレーターが Phone だとエラーに。
Google Home だと、ちゃんと再生される。

【NGパターン】agend.add() にオブジェクトを渡してみる

agent.add() の先、つまり dialogflow-fulfillment を追ってみることにしました。
grep してみると text-response.js に以下のようなコードが。

text-response.js
getV2ResponseObject_(platform) {
  ~~
  if (this.ssml) {
    response.simpleResponses.simpleResponses[0].ssml = this.ssml;
  } else {
    response.simpleResponses.simpleResponses[0].textToSpeech = this.text;
  }
  response.simpleResponses.simpleResponses[0].displayText = this.text;
  ~~
}

this.ssmlthis.text という情報だけをもとに、
何も考えずに agent.add() に渡すものを文字列からオブジェクトに変えてみました。

- agend.add('<speak><audio src="*****.mp3"/></speak>');
+ agend.add({
+   text: '今日はピッコロ記念日です。',
+   ssml: '<speak><audio src="*****.mp3"/></speak>'
+ });

うーん。ダメだ。
そもそも動かない。。。
というか、ふと Cloud Function のログを見てみると、以下の通り Error を出力していました。

CloudFunctionログ
Error: unknown response type
    at WebhookClient.add (/user_code/node_modules/dialogflow-fulfillment/src/dialogflow-fulfillment.js:225:13)
    at test (/user_code/index.js:27:11)
    at WebhookClient.handleRequest (/user_code/node_modules/dialogflow-fulfillment/src/dialogflow-fulfillment.js:251:44)
    at exports.dialogflowFirebaseFulfillment.functions.https.onRequest (/user_code/index.js:68:9)
    at cloudFunction (/user_code/node_modules/firebase-functions/lib/providers/https.js:26:47)
    at /var/tmp/worker/worker.js:684:7
    at /var/tmp/worker/worker.js:668:9
    at _combinedTickCallback (internal/process/next_tick.js:73:7)
    at process._tickDomainCallback (internal/process/next_tick.js:128:9)

agent(WebhookClient)の add を見てみるとそりゃーダメです。
this.ssmlthis.text という情報だけで何も考えなかったのが悪い。

dialogflow-fulfillment.js
add(response) {
  if (typeof response === 'string') {
    response = new Text(response);
  }
  ~~
  } else {
    throw new Error('unknown response type');
  }
}

そして見事に throw !!

【NGパターン】直接 Text オブジェクトを agent.add() してみる

- const {Card, Suggestion} = require('dialogflow-fulfillment');
+ const {Card, Suggestion, Text} = require('dialogflow-fulfillment');


- agend.add({
+ agend.add(new Text({ 
    text: '今日はピッコロ記念日です。',
    ssml: '<speak><audio src="*****.mp3"/></speak>'
- });
+ }));

結果は変わらず。
シミュレーターが Phone だとエラーに。
Google Home だと、ちゃんと再生される。

【調査】真面目に追ってみる

入口は WebhookClient.add() 付近から、
そして、displayText を設定しているのが Text.getV2ResponseObject_()

シーケンス図


だいたいこんな感じか。。。

シーケンス図中段 alt の上側、
どうやら agent.add() に1つだけ文字列を渡す時と、
1つだけText オブジェクトを直接渡す時は、この alt の上側 に入るパターンの模様。

Text.getV2ResponseObject_() が呼ばれるためには alt の下側 に入らなければならない。
そう思い sendMessagesResponse_() 付近を見ていると以下のようなコードを発見

dialogflow-fulfillment
    // If AoG response and the first response isn't a text response,
    // add a empty text response as the first item
    if (
      requestSource === PLATFORMS.ACTIONS_ON_GOOGLE && messages[0] &&
      !(messages[0] instanceof Text) &&
      !this.existingPayload_(PLATFORMS.ACTIONS_ON_GOOGLE)
    ) {
      this.responseMessages_ = [new Text(' ')].concat(messages);
    }

※ ざっくり first response isn't a text response, add a empty text とのこと。
messagesagent.add() でどんどん追加されていくものです。

[new Text(' ')].concat(messages);
という、なんとも気になるコード。。。

messages[0] が Text ではないときに何かやっている。
Card オブジェクトの時とかに動くのだと思いますが。。。
確かにこのコードがあれば、messages が1個ではなくなります。

【OKパターン?】試しにやってみよう

+ agend.add(' ');
  agend.add(new Text({ 
    text: '今日はピッコロ記念日です。',
    ssml: '<speak><audio src="*****.mp3"/></speak>'
  }));

できたー。
できたー。。。?
1週間前と動きが変わったような?

1週間前 ' ' の空白のチャット(吹き出し)が Assistant から返ってきたような。。。
実は全角スペース入れちゃったのかもしれませんね。

【OKパターン】SSML に画面に表示できる文字列も入れてみる

そもそもエラーで 'ssml' must have a valid display rendering. と言われているので、
なんとなく SSML の中に <audio> だけでなく、何か文字列も入れれば良さそう。

ただし、単純に文字列を入れてしまうと音声再生後に読み上げられてしまうので、
そこも SSML でなんとかしないといけないです。

agend.add(new Text({
  text: '今日、5月9日は「ピッコロ記念日」です',
  ssml: `<speak><audio src="*****.mp3" /><sub alias="">今日、5月9日は「ピッコロ記念日」です。</sub></speak>`
}));

alias で読み上げられないように。

このパターンは、シーケンス図でいうところの alt の上側 のロジックに入り、
display_text 等は設定されないのですが、
Phone のシミュレーターで期待通りの動作をするパターンです。

まとめ

超神水は飲まなくて済んだ。