HoloLens2 × Azure Cognitive Services（Speech SDKでテキストから音声合成）

# はじめに
[HoloLensアドベントカレンダー2020](https://qiita.com/advent-calendar/2020/hololens)の6日目の記事です。
前回の[記事](https://qiita.com/SatoshiGachiFujimoto/items/04756ec0760e17439df4)の続きで、画像分析APIで画像説明文を生成し、それを読み上げてみましょう。

# 開発環境
- Azure
   - Computer Vision API（画像分析 API） 
   - Speech SDK 1.14.0
- Unity 2019.4.1f1
- MRTK 2.5.1
- Windows 10 PC
- HoloLens2

# 導入
1．前回の[記事](https://qiita.com/SatoshiGachiFujimoto/items/04756ec0760e17439df4)まで終わらせてください。

2．[Speech SDKを設定する（Unity）](https://docs.microsoft.com/ja-jp/azure/cognitive-services/speech-service/quickstarts/setup-platform?tabs=unity%2Cwindows%2Cjre%2Cbrowser&pivots=programming-language-csharp)から[Microsoft.CognitiveServices.Speech.1.14.0.unitypackage](https://aka.ms/csspeech/unitypackage)をダウンロードし、インポートします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/1d21ca12-c5d0-41a9-3f37-24aceb2ed74e.png)

3．Azureポータルから音声を作成し、場所とキーをメモっておいてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/a15bf5e0-c0b1-c378-a0fa-f76efc644fca.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/628da9cf-7405-d75d-02d6-d3a0babc89c2.png)

4．[スピーカー出力に合成する](https://docs.microsoft.com/ja-jp/azure/cognitive-services/speech-service/get-started-text-to-speech?tabs=script%2Cwindowsinstall&pivots=programming-language-csharp)を参考にTapToCaptureAnalyzeAPI.csを編集します。画像分析APIで得られた画像説明文をSpeech SDKのsynthesizer.SpeakTextAsyncに投げます。

~~~TapToCaptureAnalyzeAPI.cs
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System;
using UnityEngine;
using Microsoft.MixedReality.Toolkit.Utilities;
using System.Threading.Tasks;
using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

// SpeechSDK 追加分ここから
using System.IO;
using System.Text;
using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;
// SpeechSDK 追加分ここまで

public class TapToCaptureAnalyzeAPI : MonoBehaviour
{

    // SpeechSDK 追加分ここから
    public AudioSource audioSource;
    async Task SynthesizeAudioAsync(string text) 
    {
        var config = SpeechConfig.FromSubscription("YourSubscriptionKey", "YourServiceRegion");
        var synthesizer = new SpeechSynthesizer(config, null); // nullを省略するとPCのスピーカーから出力されるが、HoloLensでは出力されない。

        // https://github.com/Azure-Samples/cognitive-services-speech-sdk/blob/master/quickstart/csharp/unity/text-to-speech/Assets/Scripts/HelloWorld.cs
        // Starts speech synthesis, and returns after a single utterance is synthesized.
        using (var result = synthesizer.SpeakTextAsync(text).Result)
        {
            // Checks result.
            if (result.Reason == ResultReason.SynthesizingAudioCompleted)
            {
                // Native playback is not supported on Unity yet (currently only supported on Windows/Linux Desktop).
                // Use the Unity API to play audio here as a short term solution.
                // Native playback support will be added in the future release.
                var sampleCount = result.AudioData.Length / 2;
                var audioData = new float[sampleCount];
                for (var i = 0; i < sampleCount; ++i)
                {
                    audioData[i] = (short)(result.AudioData[i * 2 + 1] << 8 | result.AudioData[i * 2]) / 32768.0F;
                }

                // The output audio format is 16K 16bit mono
                var audioClip = AudioClip.Create("SynthesizedAudio", sampleCount, 1, 16000, false);
                audioClip.SetData(audioData, 0);
                audioSource.clip = audioClip;
                audioSource.Play();

                // newMessage = "Speech synthesis succeeded!";
            }
            else if (result.Reason == ResultReason.Canceled)
            {
                var cancellation = SpeechSynthesisCancellationDetails.FromResult(result);
                // newMessage = $"CANCELED:\nReason=[{cancellation.Reason}]\nErrorDetails=[{cancellation.ErrorDetails}]\nDid you update the subscription info?";
            }
        }
    }
    // SpeechSDK 追加分ここまで

    public GameObject quad;

    [System.Serializable]
    public class Analyze
    {
        public Categories[] categories;
        public Color color;
        public Description description;
        public string requestId;
        public Metadata metadata;
    }

    [System.Serializable]
    public class Categories
    {
        public string name;
        public float score;
    }

    [System.Serializable]
    public class Color
    {
        public string dominantColorForeground;
        public string dominantColorBackground;
        public string[] dominantColors;
        public string accentColor;
        public bool isBwImg;
        public bool isBWImg;
    }

    [System.Serializable]
    public class Description
    {
        public string[] tags;
        public Captions[] captions;
    }

    [System.Serializable]
    public class Captions
    {
        public string text;
        public float confidence;
    }

    [System.Serializable]
    public class Metadata
    {
        public int height;
        public int width;
        public string format;
    }

    UnityEngine.Windows.WebCam.PhotoCapture photoCaptureObject = null;
    Texture2D targetTexture = null;

    private string endpoint = "https://<Insert Your Endpoint>/vision/v3.1/analyze";
    private string subscription_key = "<Insert Your API Key>";
    private bool waitingForCapture;

    void Start(){
        waitingForCapture = false;
    }
    
    public void AirTap()
    {
        if (waitingForCapture) return;
        waitingForCapture = true;

        Resolution cameraResolution = UnityEngine.Windows.WebCam.PhotoCapture.SupportedResolutions.OrderByDescending((res) => res.width * res.height).First();
        targetTexture = new Texture2D(cameraResolution.width, cameraResolution.height);

        // PhotoCapture オブジェクトを作成します
        UnityEngine.Windows.WebCam.PhotoCapture.CreateAsync(false, delegate (UnityEngine.Windows.WebCam.PhotoCapture captureObject) {
            photoCaptureObject = captureObject;
            UnityEngine.Windows.WebCam.CameraParameters cameraParameters = new UnityEngine.Windows.WebCam.CameraParameters();
            cameraParameters.hologramOpacity = 0.0f;
            cameraParameters.cameraResolutionWidth = cameraResolution.width;
            cameraParameters.cameraResolutionHeight = cameraResolution.height;
            cameraParameters.pixelFormat = UnityEngine.Windows.WebCam.CapturePixelFormat.BGRA32;

            // カメラをアクティベートします
            photoCaptureObject.StartPhotoModeAsync(cameraParameters, delegate (UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result) {
                // 写真を撮ります
                photoCaptureObject.TakePhotoAsync(OnCapturedPhotoToMemoryAsync);
            });
        });
    }

    async void OnCapturedPhotoToMemoryAsync(UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result, UnityEngine.Windows.WebCam.PhotoCaptureFrame photoCaptureFrame)
    {
        // ターゲットテクスチャに RAW 画像データをコピーします
        photoCaptureFrame.UploadImageDataToTexture(targetTexture);
        byte[] bodyData = targetTexture.EncodeToJPG();

        Response response = new Response();

        try
        {
            string query = endpoint + "?visualFeatures=Categories,Description,Color";
            var headers = new Dictionary<string, string>();
            headers.Add("Ocp-Apim-Subscription-Key", subscription_key);
            response = await Rest.PostAsync(query, bodyData, headers, -1, true);
        }
        catch (Exception e)
        {
            photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
            return;
        }

        if (!response.Successful)
        {
            photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
            return;
        }

        Debug.Log(response.ResponseCode);
        Debug.Log(response.ResponseBody);
        Analyze analyze = JsonUtility.FromJson<Analyze>(response.ResponseBody);
        Debug.Log(analyze.description.captions[0].text);

        // SpeechSDK 追加分ここから
        // 生成された画像説明文をSynthesizeAudioAsyncに投げる
        await SynthesizeAudioAsync(analyze.description.captions[0].text);
        // SpeechSDK 追加分ここまで
        
        // OpenCVを用いて結果をて画像に書き込み
        Mat imgMat = new Mat(targetTexture.height, targetTexture.width, CvType.CV_8UC4);
        Utils.texture2DToMat(targetTexture, imgMat);
        Debug.Log("imgMat.ToString() " + imgMat.ToString());
        Imgproc.putText(imgMat, analyze.description.captions[0].text, new Point(10, 100), Imgproc.FONT_HERSHEY_SIMPLEX, 4.0, new Scalar(255, 255, 0, 255), 4, Imgproc.LINE_AA, false);
        Texture2D texture = new Texture2D(imgMat.cols(), imgMat.rows(), TextureFormat.RGBA32, false);
        Utils.matToTexture2D(imgMat, texture);
        Renderer quadRenderer = quad.GetComponent<Renderer>() as Renderer;
        quadRenderer.material.SetTexture("_MainTex", texture);

        // カメラを非アクティブにします
        photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
    }

    void OnStoppedPhotoMode(UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result)
    {
        // photo capture のリソースをシャットダウンします
        photoCaptureObject.Dispose();
        photoCaptureObject = null;
        waitingForCapture = false;
    }
}
~~~

5．SpeechConfig.FromSubscriptionにキーと場所をコピペしてください。

6．最初は、synthesizer.SpeakTextAsyncでAudioConfigパラメーターを省略するだけでスピーカーから出力されるので行けると思ったのですが、HoloLensでは出力されませんでした。

~~~cs
static async Task SynthesizeAudioAsync() 
{
    var config = SpeechConfig.FromSubscription("YourSubscriptionKey", "YourServiceRegion");
    using var synthesizer = new SpeechSynthesizer(config);
    await synthesizer.SpeakTextAsync("Synthesizing directly to speaker output.");
}
~~~

7．そこで、InternetClientServer と PrivateNetworkClientServer の機能を有効にしたり、MixedRealityToolkitの[音声コマンドの設定](https://docs.microsoft.com/ja-jp/windows/mixed-reality/develop/unity/tutorials/mrlearning-speechsdk-ch1)をしてみたり、

> 音声認識と文字起こしには Speech SDK を使用するため、Speech SDK の機能の妨げにならないように、MRTK の音声コマンドを構成する必要があります。 これを実現するには、音声コマンドの開始動作を [Auto Start](自動開始) から [Manual Start](手動開始) に変更することができます。
[Hierarchy](階層) ウィンドウで MixedRealityToolkit オブジェクトを選択した状態で、[Inspector](インスペクター) ウィンドウで [Input](入力) タブを選択し、DefaultHoloLens2InputSystemProfile と DefaultMixedRealitySpeechCommandsProfile を複製し、音声コマンドの [Start Behavior](開始動作) を [Manual Start](手動開始) に変更します。

| | | |
|:--|:--|:--|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/569c9d76-9fca-1f5c-1fb5-0988c46188ea.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/c9aae254-4a1d-193b-cf55-198a5cd41c02.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/30971bba-b36f-116a-bacb-1e366819ee45.png)|

ARM64でビルドしたりしたのですが、関係ありませんでした。

8．調べた結果[【Unity】Microsoft Azure を用いてキャラクターを流暢に話させる](https://rightcode.co.jp/blog/information-technology/microsoft-azure-speak-fluently-unity)
や[cognitive-services-speech-sdkのHelloWorld.cs](https://github.com/Azure-Samples/cognitive-services-speech-sdk/blob/master/quickstart/csharp/unity/text-to-speech/Assets/Scripts/HelloWorld.cs)を参考に、音声合成結果をAudioSourceのclipに割り当てる必要がありました。

9．あとはTapToCaptureAnalyzeにAudioSourceをAdd Componentし、TapToCaptureAnalyzeAPIのAudioSourceにTapToCaptureAnalyzeをD&Dしてアタッチします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/650a5d7b-5c4a-3db3-e98e-f4a252e054e8.png)

# 実行
実行動画を見てください。エアタップすると画像キャプチャし、画像説明文を生成、読み上げてくれます。

"a hand holding a fanned out money" 札束を持つ手
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">テキストを読み上げ<a href="https://twitter.com/hashtag/TTS?src=hash&amp;ref_src=twsrc%5Etfw">#TTS</a> <a href="https://twitter.com/hashtag/HoloLens2?src=hash&amp;ref_src=twsrc%5Etfw">#HoloLens2</a> <a href="https://twitter.com/hashtag/MRTK?src=hash&amp;ref_src=twsrc%5Etfw">#MRTK</a> <a href="https://twitter.com/hashtag/SpeechSDK?src=hash&amp;ref_src=twsrc%5Etfw">#SpeechSDK</a> <a href="https://twitter.com/hashtag/Azure?src=hash&amp;ref_src=twsrc%5Etfw">#Azure</a> <a href="https://twitter.com/hashtag/CognitiveServices?src=hash&amp;ref_src=twsrc%5Etfw">#CognitiveServices</a> <a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://t.co/GBFkLzCkIJ">pic.twitter.com/GBFkLzCkIJ</a></p>&mdash; 藤本賢志(ガチ本)@XRKaigi (@sotongshi) <a href="https://twitter.com/sotongshi/status/1335284934299160576?ref_src=twsrc%5Etfw">December 5, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

スタートアップ夢の扉のヌンギルが完成しました！
明日は、弟子（@Horomoto-Asahi）による「MRTK関連で何かを書く」です。