UnityでWatsonと雑談APIを利用したChatBotを作る

Unityの勉強としてWatsonと雑談APIを利用したChatBotを作成してみたのでメモです。

参考：
[Watson×Unity！初心者でもできる、VR 空間で Unity ちゃんとおしゃべりアプリ！](https://www.ibm.com/developerworks/jp/cloud/library/unity/index.html)
[developerWorks記事「Watson×Unity！初心者でもできる、VR 空間で Unity ちゃんとおしゃべりアプリ！」を試してみる](https://qiita.com/yamachan360/items/911204e57957bd4bbef0)

実施内容は上記記事とほぼ同じ。
Credentialsの作り方あたりに少し違いがありました。環境によって違う場合があるようです。
他には、WatsonのConversationの場合は会話データを作る必要があるため、今回はリクルート製のTalkAPIを使用しました。
http://a3rt.recruit-tech.co.jp/product/talkAPI/
このAPIを利用すると、会話メッセージを送ると雑談応答を返してくれるのでそれっぽいChatBotが簡単に作れます。

処理の流れは
1. マイクから音声を取得してSpeechToTextでテキスト化
2. 雑談APIにテキストを送信して応答テキストを取得
3. 応答をTextToSpeechで音声化して再生
です。

実施環境は

Unity Version 2018.2.15f1 on Mac
IBM Watson SDK for Unity v2.11.0

## 事前準備(外部サービス)

- SpeechToTextサービスを作成する
- TextToSpeechサービスを作成する

今回はどちらもLiteプランで作りましたが、私の場合はSpeechToTextのCredentialがAPIキーになっていました。

```
{
  "apikey": "xxxxxxx",
  "iam_apikey_description": "Auto generated apikey during resource-key operation for Instance - xxxxxxxxxxx",
  "iam_apikey_name": "xxxxxxxxxxxxxxxxxxx",
  "iam_role_crn": "xxxxxxxxxxxxxxxx",
  "iam_serviceid_crn": "xxxxxxxxxxxxxxxxxx",
  "url": "https://stream.watsonplatform.net/speech-to-text/api"
}
```

Watsonサービスはモノによっては、もしくは環境によってはユーザ/パスワード形式だったりAPIキーだったりしますが、それぞれの場合でCredentialsの作成方法が異なります。
作成方法の違いはそれぞれのサービス作成で触れます。

- A3RTのAPI Keyを取得する

サイトの指示に従って利用登録することでAPIキーが取得できます。

## 事前準備(Unityアセット)

- Unity-chan! Model をインポート
- IBM Watson SDK for Unity をインポート

それぞれアセットストアからインポートします。

## ユニティちゃんをSceneに配備し、スクリプトを割り当てる

Assets/UnityChan/Prefabs/unitychan
をHierarchyにドロップしてユニティちゃんをSceneに登場させます。

Hierarchyのunitychanを選択するとInspectorに詳細パラメータが表示されるので、
[Add Component] → [New Script] から新しいスクリプトを作成して割り当てます。

アセットのプレハブにデフォルトで紐付いているIdle ChangerとFace Updateの2スクリプトは今回は使用しないので無効化します。

ここから新しく割り当てたスクリプトに実装を書いていきます。

---

## 環境変数のセット

DeveloperWorksの記事ではWatsonSDKをインポートするとUnityのWatsonメニューにConfigurationEditorがある旨書かれていますが、このメニューは現在無くなっています。
そのため、Exampleの作法に従って認証情報はInspectorで管理する形にしていきます。

### Speech To Text

```
#region Watson SpeechToTextの設定
[Space(10)]
[Header("Watson SpeechToText Config")]
[Tooltip("SpeechToText service URL")]
[SerializeField]
private string sttServiceUrl = "";
[Tooltip("The authentication api key.")]
[SerializeField]
private string sttApiKey = "";
#endregion
```

作成したSpeechToTextサービスの認証情報をInspectorからセットします。
<img width="276" alt="スクリーンショット 2018-11-18 20.27.35.png" src="https://qiita-image-store.s3.amazonaws.com/0/204890/23e964df-4279-03e0-21e9-1b72114a3d6f.png">


### TalkAPI

```
#region TalkAPIの設定
[Space(10)]
[Header("TalkAPI Config")]
[Tooltip("A3RT API URL")]
[SerializeField]
private string a3rtURL = "";
[Tooltip("A3RT API Key")]
[SerializeField]
private string a3rtApiKey = "";
#endregion
```
<img width="273" alt="スクリーンショット 2018-11-17 23.25.51.png" src="https://qiita-image-store.s3.amazonaws.com/0/204890/56d621fb-6eba-543b-a259-cb48fa10eafe.png">

### Text To Speech

```
#region Watson TextToSpeechの設定
[Space(10)]
[Header("Watson TextToSpeech Config")]
[Tooltip("TextToSpeech service URL")]
[SerializeField]
private string ttsServiceUrl = "";
[Tooltip("The authentication username.")]
[SerializeField]
private string ttsUsername = "";
[Tooltip("The authentication password.")]
[SerializeField]
private string ttsPassword = "";
#endregion
```

<img width="278" alt="スクリーンショット 2018-11-15 23.36.51.png" src="https://qiita-image-store.s3.amazonaws.com/0/204890/f2e14e5d-36b4-9e4e-efd2-67ae122e3919.png">


## Speech To Text (STT)

認証情報をセットしたら実装です。

マイクから音声を取得してSTTに流します。

今回の実装コードについてはほぼWatsonSDKに含まれているExamplesを参考としています。

Start時にサービスインスタンスを初期化します。
APIキーの場合、内部では
[APIキーでトークンを生成]→[トークンでサービスにアクセス]
という手順を取るため、バックグラウンドでトークンを生成します。

```
void Start () {
    // 今回は無条件でデフォルトのマイクIDを取得
    microphoneID = Microphone.devices[0];
    ...
    // サービスインスタンスを初期化
    StartCoroutine(SetSttToken());
}

private IEnumerator SetSttToken(){
    TokenOptions iamTokenOptions = new TokenOptions()
    {
        IamApiKey = sttApiKey
    };

    //  Create credentials using the IAM token options
    var credentials = new Credentials(iamTokenOptions, sttServiceUrl);
    while (!credentials.HasIamTokenData())
        yield return null;

    sttService = new SpeechToText(credentials)
    {
        StreamMultipart = true
    };
}
```

次に会話のトリガーをセットします。今回はキーボード入力で"s"キーを押した際に音声取得を開始するようにします。

```
void Update () {
    if (Input.GetKeyDown("s")) {
        Active = true;
        StartRecording();
    }
    ...
}
```

Activeは音声取得の状態を管理するプロパティです。
RecognizeModelを日本語用に`ja-JP_BroadbandModel`に変更しています。

```
public bool Active
{
    get { return sttService.IsListening; }
    set
    {
        if (value && !sttService.IsListening)
        {
            // 音声言語モデル
            sttService.RecognizeModel = "ja-JP_BroadbandModel";
            sttService.DetectSilence = true;
            sttService.EnableWordConfidence = true;
            sttService.EnableTimestamps = true;
            sttService.SilenceThreshold = 0.01f;
            sttService.MaxAlternatives = 0;
            sttService.EnableInterimResults = true;
            sttService.OnError = SttOnError;
            sttService.InactivityTimeout = -1;
            sttService.ProfanityFilter = false;
            sttService.SmartFormatting = true;
            sttService.SpeakerLabels = false;
            sttService.WordAlternativesThreshold = null;
            // 音声認識結果を受け取るコールバック
            sttService.StartListening(OnRecognize);
        }
        else if (!value && sttService.IsListening)
        {
            sttService.StopListening();
        }
    }
}
```

StartRecording及び、IEnumeratorなRecordingHandlerによって別スレッドで音声を取得し、STTに送信していきます。

```
private void StartRecording()
{
    if (recordingRoutine == 0)
    {
        UnityObjectUtil.StartDestroyQueue();
        recordingRoutine = Runnable.Run(RecordingHandler());
    }
}

private IEnumerator RecordingHandler()
{
    Debug.LogFormat("Start recording. devices: {0}", microphoneID);
    recording = Microphone.Start(microphoneID, true, recordingBufferSize, recordingHZ);
    yield return null;

    if (recording == null)
    {
        StopRecording();
        yield break;
    }

    bool bFirstBlock = true;
    int midPoint = recording.samples / 2;
    float[] samples = null;

    while (recordingRoutine != 0 && recording != null)
    {
        int writePos = Microphone.GetPosition(microphoneID);
        if (writePos > recording.samples || !Microphone.IsRecording(microphoneID))
        {
            Debug.LogErrorFormat("Recording Error. Microphone disconnected.");

            StopRecording();
            yield break;
        }

        if ((bFirstBlock && writePos >= midPoint)
          || (!bFirstBlock && writePos < midPoint))
        {
            samples = new float[midPoint];
            recording.GetData(samples, bFirstBlock ? 0 : midPoint);

            AudioData record = new AudioData();
            record.MaxLevel = Mathf.Max(Mathf.Abs(Mathf.Min(samples)), Mathf.Max(samples));
            record.Clip = AudioClip.Create("Recording", midPoint, recording.channels, recordingHZ, false);
            record.Clip.SetData(samples, 0);

            sttService.OnListen(record);

            bFirstBlock = !bFirstBlock;
        }
        else
        {
            int remaining = bFirstBlock ? (midPoint - writePos) : (recording.samples - writePos);
            float timeRemaining = (float)remaining / (float)recordingHZ;

            yield return new WaitForSeconds(timeRemaining);
        }

    }

    yield break;
}
```

音声認識が完了するとOnRecognizeがコールバックされるので、結果を取り出して`recognizeText`内部変数に格納します。

```
private void OnRecognize(SpeechRecognitionEvent result, Dictionary<string, object> customData)
{
    if (result != null && result.results.Length > 0)
    {
        foreach (var res in result.results)
        {
            foreach (var alt in res.alternatives)
            {
                var reply = alt.transcript;
                if (res.final)
                {
                    Debug.Log("[音声]" + reply);
                    Active = false;
                    StopRecording();
                    recognizeText = reply;
                    return;
                }
            }
        }
    }
}
```

STTでは、音声認識の最中でも一定間隔でコールバックされ、途中の認識結果が取得できます。
音声が一定時間途切れると、会話が切れたと判断されて解析が確定し、finalフラグが立った状態で返ってきますので、この時の結果を取得して音声取得を止めて完了です。

## 雑談API

音声認識の結果が`recognizeText`に格納されたことをトリガーにしてAPI呼び出しを実行します。

```
void Update()
{
    ...
    if (recognizeText != null)
    {
        var text = recognizeText;
        recognizeText = null;
        StartCoroutine(Chat(text));
    }
}
```

ここは通常のREST APIリクエストになります。
レスポンスのJSONはMiniJSONを利用しました。
MiniJSONは今回WatsonSDKに含まれていたものです。

```
internal IEnumerator Chat(string text)
{
    Debug.Log("Start Chat");
    WWWForm form = new WWWForm();
    form.AddField("apikey", a3rtApiKey);
    form.AddField("query", text);
    var url = a3rtURL;
    var request = UnityWebRequest.Post(url, form);
    yield return request.SendWebRequest();

    if (request.isHttpError || request.isNetworkError)
    {
        Debug.LogFormat("chat request failed. {0}", request.error);
    }
    else
    {
        if (request.responseCode == 200)
        {
            var json = Json.Deserialize(request.downloadHandler.text) as Dictionary<string, object>;
            if (json.ContainsKey("results"))
            {
                var r = json["results"] as List<object>;
                if (r.Count > 0)
                {
                    var c = r[0] as Dictionary<string, object>;
                    var res = (string)c["reply"];
                    Debug.Log("[AI]" + res);
                    yield return Speech(res);
                }
            }
        }
        else
        {
            Debug.LogFormat("chat response failed. response code:{0}", request.responseCode);
        }
    }
}
```

ここで受け取った応答を次の`Speech`コルーチンに渡して音声再生につないでいきます。

## Text To Speech (TTS)

STTと同様、事前にサービスインスタンスを作成しておきます。
こちらはユーザ/パスワード形式なのでCredentialsの作成はシンプルです。

```
void Start () {
    ...
    // サービスインスタンスを初期化
    Credentials ttsCredentials = new Credentials(ttsUsername, ttsPassword, ttsServiceUrl);
    ttsService = new TextToSpeech(ttsCredentials)
    {
        // 音声を日本語に設定
        Voice = VoiceType.ja_JP_Emi
    };
    ...
}
```

TalkAPIから応答を受け取るとTTSへとテキストを渡し、音声データへと変換していきます。

```
private IEnumerator Speech(string message)
{
    Debug.Log("Start Speech To Text");
    ttsService.ToSpeech(HandleToSpeechCallback, TtsOnError, message, true);
    while (!synthesizeTested)
        yield return null;

}
```

`HandleToSpeechCallback`が、TTSによる音声変換が完了した際に呼ばれるコールバックです。
SDK経由でAudioClipが返ってくるので、AudioSourceを作成してPlayします。

```
void HandleToSpeechCallback(AudioClip clip, Dictionary<string, object> customData = null)
{
    if (Application.isPlaying && clip != null)
    {
        Debug.Log("Play speech");
        GameObject audioObject = new GameObject("AudioObject");
        AudioSource source = audioObject.AddComponent<AudioSource>();
        source.spatialBlend = 0.0f;
        source.loop = false;
        source.clip = clip;
        source.Play();
        Destroy(audioObject, clip.length);

        synthesizeTested = true;
    }
}
```

これでTalkAPIが返してきたテキストが音声再生されます。

## 感想とか

今回の全体のサンプルコードはGithubにおいてあります。
https://github.com/kazuhiro1982/unity-talk-sample

WatsonSDKの仕様が割と頻繁に変わるのか、developerWorksの記事が古くなってしまっていたりするのが慣れていないとなかなか大変さがありました。
SDK自身に含まれているExamplesが一番信用できそうかなという感じですね。
雑談APIで気軽に会話できるのも楽しいです。
<img width="492" alt="スクリーンショット 2018-11-16 0.38.59.png" src="https://qiita-image-store.s3.amazonaws.com/0/204890/98cb97b9-6c90-dd48-848d-03a4c5c3d726.png">