【Unity】GoogleSTT(Streaming)を2つの方法で試した

#1. はじめに

UnityでGoogleSpeechToTextのStreamingを2つの方法で試してみました. Unityでマイクからの音声を取得するには, いくつかの方法があり, その内の2つを利用しました. 具体的には, OnAudioFilterRead()とAudioClip.GetData()を使いました.
UnityでGoogleSTTをしたい方や, いくつかの方法を比較してみたい方に参考になると思います.

**補足**
マイク音声取得の別の方法として, Platform依存の音声系ライブラリをUnityに入れてやる, みたいな方法もあるそうです.

#2. 準備

- Assets/Plugins/ にGoogleStreamingSpeechToText用のdllを入れる

dllを入れる所が１番の正念場かもしれません. 僕は, こちらを参考に頑張りました.

https://linuxtut.com/en/c6f6719d92a6d9a18815/

行き詰まった場合は, 僕が使った Plugins/ を使ってもらって結構です.

https://drive.google.com/file/d/1KkPgTNxThtovN0oG0AxxzJLvuxrblh-g/view?usp=sharing

- GCPから SpeechToTextを有効化したサービスアカウントキー(credentials.json)を取得

- Assets/StreamingAssets/ に, credentials.jsonを配置

- AudioMixerで MicMixerを作成し, Masterの子要素として MuteMicを作成.(名前は何でも良い) 
ハウリングしないように, MuteMicのdBを最下限-80dBに設定.

![Screen Shot 2021-06-18 at 1.13.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599936/a29e8577-0266-419a-3fee-124b38f3f0f7.png)

- GameObjectを作成し, AudioSourceをアタッチ

- アタッチしたAudioSourceのOutputに, 先程作ったMuteMicを設定

- Start用とStop用のButtonを作成

- 以下で説明しているスクリプトを作成し, GameObjectにアタッチし, スクリプトにButtonを割り当てる

![Screen Shot 2021-06-18 at 1.15.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599936/0b19a2b3-ad9d-af1a-cdec-7cca38165a0e.png)


#3. スクリプト

### 1つ目の方法( OnAudioFilterRead()を使用 ) 

```GoogleSTT1.cs
using System;
using System.IO;
using System.Collections;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine.UI;
using UnityEngine;
using Google.Protobuf;
using Google.Cloud.Speech.V1;
using Google.Api.Gax.Grpc;

[RequireComponent(typeof(AudioSource))]
public class GoogleSTT1 : MonoBehaviour
{
    [SerializeField] Button startButton;
    [SerializeField] Button stopButton;
    private AudioSource audioSource;
    private AudioConfiguration audioConfig;
    private SpeechClient.StreamingRecognizeStream response;
    private Task responseHandler;
    private CancellationTokenSource cancellationTokenSource;
    private float MicInitializationTimeout = 2;
    private bool IsListening = false;
    private string micName;
    private string langCode = "ja-JP";
    private const string CredentialFileName = "credentials.json";
    private const double NormalizedFloatTo16BitConversionFactor = 0x7FFF + 0.4999999999999999;
    private const int StreamingLimit = 290000; // 約5分. この時間なら継続してSTTできるっぽい

    void Awake()
    {
        // 環境変数を設定
        string credentialsPath = Path.Combine(Application.streamingAssetsPath, CredentialFileName);
        Environment.SetEnvironmentVariable("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath);

        // オーディオ設定 (ProjectSettings -> Audio から変更可能)
        audioConfig = AudioSettings.GetConfiguration();

        // sampleRate = 1秒間のサンプリング数
        // dspBufferSize = レイテンシに関わるもの. 小さい値ほど遅延が少なくなるが,性能が求められる
        // speakerMode = MonoやStereo
        Debug.Log(String.Format("sampleRate, dspBufferSize, speakerMode -> {0}, {1}, {2}\n", audioConfig.sampleRate, audioConfig.dspBufferSize, audioConfig.speakerMode));

        // マイクのアクセス許可
        StartCoroutine(nameof(RequestMicAuthorization));

        audioSource = gameObject.GetComponent<AudioSource>();

        foreach (string device in Microphone.devices) { Debug.Log(device); } //使用可能なデバイス一覧
        micName = Microphone.devices[0];

        startButton.onClick.AddListener(async () => await StartSTT());
        stopButton.onClick.AddListener(async () => await StopSTT());
    }

    private IEnumerator RequestMicAuthorization()
    {
        while (!Application.HasUserAuthorization(UserAuthorization.Microphone))
        {
            yield return Application.RequestUserAuthorization(UserAuthorization.Microphone);
        }
    }

    private async Task StartSTT()
    {
        SpeechClient speechClient = SpeechClient.Create();
        response = speechClient.StreamingRecognize();

        // 初期設定リクエスト
        StreamingRecognizeRequest request = new StreamingRecognizeRequest
        {
            StreamingConfig = new StreamingRecognitionConfig()
            {
                Config = new RecognitionConfig()
                {
                    Encoding = RecognitionConfig.Types.AudioEncoding.Linear16,
                    SampleRateHertz = audioConfig.sampleRate,
                    LanguageCode = langCode,
                },
                InterimResults = true,
            }
        };
        await response.WriteAsync(request);

        // マイクをONにしてauiodClipを生成
        AudioClip audioClip = Microphone.Start(micName, true, 20, audioConfig.sampleRate);
        audioSource.clip = audioClip;

        // マイクが初期化されるまで待つ. これがないとリスタート時にマイクが初期化されない時がある.
        float timerStartTime = Time.realtimeSinceStartup;
        bool timedOut = false;
        while (!(Microphone.GetPosition(micName) > 0))
        {
            timedOut = Time.realtimeSinceStartup - timerStartTime >= MicInitializationTimeout;
            if (timedOut)
            {
                Debug.LogError("マイクが初期化できませんでした");
                return;
            }
        }

        // audioSopurceから再生(AudioMixerで-80dBにしているのでハウリングしない)
        audioSource.loop = true;
        audioSource.Play();

        // STTを止めた時に, streamingLimit秒まで待つtaskをキャンセルしたい
        cancellationTokenSource = new CancellationTokenSource();

        // 非同期処理をセット. コールバック的な感じ
        responseHandler = ResponseHandlerTask();

        IsListening = true;
        Debug.Log("喋ってください");

        await RestartAfterStreamingLimit();
    }

    private async Task StopSTT()
    {
        if (!IsListening) return;

        IsListening = false;
        Microphone.End(micName);
        audioSource.Stop();

        await response.WriteCompleteAsync(); //完了リクエスト
        await responseHandler; //終わるまで待つ

        cancellationTokenSource?.Cancel(); //RestartAfterStreamingLimitのTask.Delayをキャンセル

        Debug.Log("STTを終了");
    }

    private async Task RestartAfterStreamingLimit()
    {
        try
        {
            // streamingLimit秒待つ. 非同期で別スレッドで動くので,そんなに重くないはず
            await Task.Delay(StreamingLimit, cancellationTokenSource.Token);
            Debug.Log("Streaming limit reached, restarting");
            await StopSTT();
            await StartSTT();
        }
        catch (TaskCanceledException e)
        {
            // STTの途中でStopするとTaskがキャンセルされ, ここを通る
            Debug.Log(e.Message);
        }
    }

    private async Task ResponseHandlerTask()
    {
        AsyncResponseStream<StreamingRecognizeResponse> responseStream = response.GetResponseStream();
        while (await responseStream.MoveNextAsync())
        {
            StreamingRecognizeResponse responseItem = responseStream.Current;
            if (responseItem.Results.Count <= 0) continue;

            StreamingRecognitionResult result = responseStream.Current.Results[0];

            string transcript = result.Alternatives[0].Transcript.Trim();
            if (result.IsFinal)
            {
                Debug.Log("Final Result: " + transcript);
            }
            else
            {
                Debug.Log("Interim Result: " + transcript);
            }
        }

        // response.WriteCompleteAsync()が呼ばれると,ループを抜けれる.
        Debug.Log("ループ抜けた！");
    }

    private async void OnAudioFilterRead(float[] data, int channels)
    {
        if (!IsListening) return;

        // channelsは, Monoだと1, Stereoだと2
        // Debug.Log(String.Format("data.length, channels, dspBufferSize -> {0}, {1}, {2}\n", data.Length, channels, audioConfig.dspBufferSize));

        byte[] buffer = new byte[audioConfig.dspBufferSize * 2];

        // convert 1st channel of audio from floating point to 16 bit packed into a byte array
        // reference: https://github.com/naudio/NAudio/blob/ec5266ca90e33809b2c0ceccd5fdbbf54e819568/Docs/RawSourceWaveStream.md#playing-from-a-byte-array
        for (int i = 0; i < data.Length / channels; i++)
        {
            short sample = (short)(data[i * channels] * NormalizedFloatTo16BitConversionFactor);
            byte[] bytes = BitConverter.GetBytes(sample);
            buffer[i * 2] = bytes[0];
            buffer[i * 2 + 1] = bytes[1];
        }

        ByteString chunk = ByteString.CopyFrom(buffer, 0, buffer.Length);
        await response.WriteAsync(new StreamingRecognizeRequest() { AudioContent = chunk });
    }

    async void OnApplicationQuit()
    {
        await StopSTT();
    }
}
```

### 2つ目の方法 ( AudioClip.GetData()を使用 )

先程のスクリプトにおいて, メンバ変数に

```GoogleSTT2.cs
private int lastSample = 0;
```

を追加し, OnAudioFilterRead()を削除してください. さらに, 以下のコードを追加して下さい.

```GoogleSTT2.cs
    async void Update()
    {
        if (!(Microphone.IsRecording(micName) && IsListening)) return;

        int pos = Microphone.GetPosition(micName);
        int diff = pos - lastSample;

        // Debug.Log(String.Format("pos, lastSample, diff -> {0}, {1}, {2}\n", pos, lastSample, diff));

        if (diff > 0)
        {
            float[] samples = new float[diff * audioSource.clip.channels];
            audioSource.clip.GetData(samples, lastSample);
            byte[] bytearray = ConvertAudioClipDataToInt16ByteArray(samples);
            if (bytearray.Length != 0)
            {
                ByteString chunk = ByteString.CopyFrom(bytearray, 0, bytearray.Length);
                await response.WriteAsync(new StreamingRecognizeRequest() { AudioContent = chunk });
            }
        }
        lastSample = pos;
    }

    private byte[] ConvertAudioClipDataToInt16ByteArray(float[] data)
    {
        MemoryStream dataStream = new MemoryStream();
        int x = sizeof(Int16);
        Int16 maxValue = Int16.MaxValue;
        int i = 0;
        while (i < data.Length)
        {
            dataStream.Write(BitConverter.GetBytes(Convert.ToInt16(data[i] * maxValue)), 0, x);
            ++i;
        }
        byte[] bytes = dataStream.ToArray();
        dataStream.Dispose();
        return bytes;
    }
```

**補足**
もし文字起こしが不安定だったり,うまくいかない場合は ProjectSettings -> Audio で DefaultSpeakerModeやDSPBufferSizeなどをいじってみてください.

#4. おわりに

STTの体感としては, どちらの方法も低遅延で文字起こしが返ってくるできた印象です.
ご自身のPC性能によると思いますが, 僕の場合, 長時間STTを実行していると, 若干重くなると感じました.
また, Profilerで両者を比較しても, そこまで差異は見られなかったです.

# 参考

- https://github.com/oshoham/UnityGoogleStreamingSpeechToText

- https://googleapis.github.io/google-cloud-dotnet/docs/Google.Cloud.Speech.V1/api/Google.Cloud.Speech.V1.SpeechClient.html

- https://qiita.com/TyounanMOTI/items/5306336823316b0b289c

- https://github.com/Azure-Samples/cognitive-services-speech-sdk/blob/2d35d4cf839867e7d76d6b2738843cdc1ad72979/samples/unity/from-unitymicrophone/Assets/Scripts/HelloWorld.cs#L43