【Unity(C#)】Watson API × OculusQuestで音声認識

## はじめに

VRヘビーユーザーからすれば当たり前のことかもしれませんが、
[OculusQuest](https://www.oculus.com/quest-2/)にマイクが搭載されているのはご存じでしたでしょうか。

ボイスチャットに利用されることがほとんどで、
その他の用途で使われている事例をあまり見たことがありませんでした。
(たぶん世の中にはたくさんある)

しかし、最近目にした記事にボイスチャット以外の用途でマイクを使った事例がありました。
【参考リンク】：[Synamon、ロゼッタと「リアルタイム多言語翻訳システム装備のVRオフィス」を共同開発](https://prtimes.jp/main/html/rd/p/000000053.000025330.html)

一言で説明すると、翻訳VRアプリです。
マイクを音声認識の受け口として利用しています。

そこで、私も勉強がてら"OculusQuestのマイクを利用したVRアプリ作りに挑戦してみたい"と思い、実際に作りました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">勉強がてら作成していた翻訳VRできました！😎<br><br>OculusQuestのマイクで拾った音声を音声認識APIに渡して、認識結果を翻訳APIに渡す、、、というやり方です💪<br><br>次はマルチ対応していきます😆<a href="https://twitter.com/hashtag/OculusQuest?src=hash&amp;ref_src=twsrc%5Etfw">#OculusQuest</a> <a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://t.co/k95D73gEnh">pic.twitter.com/k95D73gEnh</a></p>&mdash; KENTO⚽️XRエンジニア😎Zenn100記事マラソン挑戦中29/100 (@okprogramming) <a href="https://twitter.com/okprogramming/status/1378167158694277120?ref_src=twsrc%5Etfw">April 3, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## Watson API
先ほどのアプリで利用した音声認識の部分はWatson APIを利用しています。

利用にはアカウントの登録とリソースの作成が必要です。
[こちら](https://cloud.ibm.com/)からログイン後、検索欄から`Speech To Text`を選んでリソースを作成します。

![Watson3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/bcb2271e-8ed7-abbc-7132-b63cce4d1842.png)

フリー版だと500分/月分が無料で30日で使用できなくなるようです。

リソースの作成を終えたら下記画面からAPIキーを取得します。

![Watson4.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/39114551-9971-b115-229c-e736516e44c9.png)

##プロジェクトの下準備
続いてプロジェクトの下準備を行います。
まずは、プラットフォームをAndroidに変更しておきます。これは後からでも構いませんが私は最初にしました。

そして、`Api Compatibility Level`を`.NET 4.x`に変更します。
これをしないと後述のSDKの導入でエラーを吐きます。

![Watson1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/b2d6c955-e63b-d2f0-6146-31ee6a38c68b.png)

続いて、公式のGitHubからSDKを取得します。
下記リンク先から2つZipファイルをダウンロードします。
[watson-developer-cloud/unity-sdk](https://github.com/watson-developer-cloud/unity-sdk/releases/tag/v5.0.2)
[IBM/unity-sdk-core](https://github.com/IBM/unity-sdk-core/releases/tag/v1.2.2)

Zipを解凍したらそれぞれ下記のように名前を変更し、プロジェクトのAssets配下に移動します。

![Watson2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/5324c759-8cc1-8530-f17d-d641ed57c326.png)

成功すれば、初回だけIBMのログイン画面に遷移するポップアップダイアログが出現します。
出現しなかったり、ダイアログを消してしまったりしても、
先ほど作成したリソースの画面でAPIキーを確認すればいいだけなので問題ないです。

音声認識ではマイクを使用するのでパーミッションの追記が必要です。
Oculus Integrationを導入後、Unityのツールバーに出現する`Oculus/Tools/Create strore -compatible AndroidManifest.xml`を実行し、AndroidManifestを作成します。
そしてマイクのパーミッションを追加します。`category` に`LAUNCHER`が無いと怒られたのでそちらも追加しました。

```xml
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
  <application android:label="@string/app_name" android:icon="@mipmap/app_icon" android:allowBackup="false">
    <activity android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen" android:configChanges="locale|fontScale|keyboard|keyboardHidden|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode" android:launchMode="singleTask" android:name="com.unity3d.player.UnityPlayerActivity" android:excludeFromRecents="true">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" /> //追記
        <category android:name="android.intent.category.INFO" />
        <category android:name="com.oculus.intent.category.VR" />
      </intent-filter>
      <meta-data android:name="com.oculus.vr.focusaware" android:value="true" />
    </activity>
    <meta-data android:name="unityplayer.SkipPermissionsDialog" android:value="false" />
    <meta-data android:name="com.samsung.android.vr.application.mode" android:value="vr_only" />
    <meta-data android:name="com.oculus.supportedDevices" android:value="quest|quest2" />
  </application>
  <uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="true" />
  <uses-permission android:name="android.permission.RECORD_AUDIO" />　//追記
</manifest>
```

これでビルド環境が整いました。

## バージョン
Unity 2019.4.8f1
Oculus Integration 25.0
watson-developer-cloud/unity-sdk v5.0.2
IBM/unity-sdk-core v1.2.2

##サンプルデモ

音が無いですが、ボタンを押しながらしゃべると音声認識が実行されるサンプルです。
![SpeechSample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244071/518935e6-5b03-2af3-fa5f-a57ee9173920.gif)

##コード

SDK内の`ExampleStreaming`というサンプルシーン内のコードを改変しました。

```C#
using UnityEngine;
using System.Collections;
using UnityEngine.UI;
using IBM.Watson.SpeechToText.V1;
using IBM.Cloud.SDK;
using IBM.Cloud.SDK.Authentication.Iam;
using IBM.Cloud.SDK.Utilities;
using IBM.Cloud.SDK.DataTypes;

public class CustomExampleStreaming : MonoBehaviour
{
    [Space(10)]
    [Tooltip("The service URL (optional). This defaults to \"https://api.us-south.speech-to-text.watson.cloud.ibm.com\"")]
    [SerializeField]
    private string _serviceUrl;

    [Tooltip("Text field to display the results of streaming.")]
    public Text ResultsField;

    [Header("IAM Authentication")] [Tooltip("The IAM apikey.")] [SerializeField]
    private string _iamApikey;

    [Header("Parameters")]
    [Tooltip("The Model to use. This defaults to en-US_BroadbandModel")]
    [SerializeField]
    private string _recognizeModel;


    private int _recordingRoutine = 0;
    private string _microphoneID = null;
    private AudioClip _recording = null;
    private int _recordingBufferSize = 1;
    private int _recordingHZ = 22050;

    private SpeechToTextService _service;

    void Start()
    {
        LogSystem.InstallDefaultReactors();
        Runnable.Run(CreateService());
    }

    private void Update()
    {
        if (OVRInput.GetDown(OVRInput.RawButton.A))
        {
            StartRecording();
        }

        if (OVRInput.GetUp(OVRInput.RawButton.A))
        {
            StopRecording();
        }
    }

    private IEnumerator CreateService()
    {
        if (string.IsNullOrEmpty(_iamApikey))
        {
            throw new IBMException("Plesae provide IAM ApiKey for the service.");
        }

        IamAuthenticator authenticator = new IamAuthenticator(apikey: _iamApikey);
        
        while (!authenticator.CanAuthenticate()) yield return null;

        _service = new SpeechToTextService(authenticator);
        if (!string.IsNullOrEmpty(_serviceUrl))
        {
            _service.SetServiceUrl(_serviceUrl);
        }

        _service.StreamMultipart = true;

        Active = true;
    }

    private bool Active
    {
        get => _service.IsListening;
        set
        {
            if (value && !_service.IsListening)
            {
                _service.RecognizeModel =
                    (string.IsNullOrEmpty(_recognizeModel) ? "en-US_BroadbandModel" : _recognizeModel);
                _service.DetectSilence = true;
                _service.EnableWordConfidence = true;
                _service.EnableTimestamps = true;
                _service.SilenceThreshold = 0.01f;
                _service.MaxAlternatives = 1;
                _service.EnableInterimResults = true;
                _service.OnError = OnError;
                _service.InactivityTimeout = -1;
                _service.ProfanityFilter = false;
                _service.SmartFormatting = true;
                _service.SpeakerLabels = false;
                _service.WordAlternativesThreshold = null;
                _service.EndOfPhraseSilenceTime = null;
                _service.StartListening(OnRecognize, OnRecognizeSpeaker);
            }
            else if (!value && _service.IsListening)
            {
                _service.StopListening();
            }
        }
    }

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

    private void StopRecording()
    {
        if (_recordingRoutine != 0)
        {
            Microphone.End(_microphoneID);
            Runnable.Stop(_recordingRoutine);
            _recordingRoutine = 0;
        }
    }

    private void OnError(string error)
    {
        Active = false;

        Debug.Log(error);
    }

    private IEnumerator RecordingHandler()
    {
        _recording = Microphone.Start(_microphoneID, true, _recordingBufferSize, _recordingHZ);
        yield return null;

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

        var bFirstBlock = true;
        var 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.Log("Microphone disconnected.");

                StopRecording();
                yield break;
            }

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

                var 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);

                _service.OnListen(record);

                bFirstBlock = !bFirstBlock;
            }
            else
            {
                var remaining = bFirstBlock ? (midPoint - writePos) : (_recording.samples - writePos);
                var timeRemaining = (float) remaining / (float) _recordingHZ;

                yield return new WaitForSeconds(timeRemaining);
            }
        }
    }

    private void OnRecognize(SpeechRecognitionEvent result)
    {
        if (result != null && result.results.Length > 0)
        {
            foreach (var res in result.results)
            {
                foreach (var alt in res.alternatives)
                {
                    ResultsField.text = alt.transcript;
                }
            }
        }
    }

    private void OnRecognizeSpeaker(SpeakerRecognitionEvent result)
    {
    }
}
```


音声認識のコールバックに登録してある`OnRecognize`の内部で認識した音声をテキストに反映しています。

```C#
    private void OnRecognize(SpeechRecognitionEvent result)
    {
        if (result != null && result.results.Length > 0)
        {
            foreach (var res in result.results)
            {
                foreach (var alt in res.alternatives)
                {
                    ResultsField.text = alt.transcript;
                }
            }
        }
    }
```

下記リンクの手順でエディター上でも音声認識を行うことができます。
【参考リンク】：[【Unity】Oculus Link使ってEditor上でデバッグ]
(https://qiita.com/OKsaiyowa/items/0b1d505219c4b4bdc421)
しかし、まれにQuestのマイクが反応しなくなります。原因不明です。
その際はLinkの接続をいったん切る、Unityを再起動するなどすればだいたい直ります。


認識言語の設定は`SpeechToTextService.RecognizeModel`を変更することで切り替えることが可能です。
下記に一覧があります。
【参考リンク】：[BM Cloud API Docs/Speech to Text](https://cloud.ibm.com/apidocs/speech-to-text)

## おわりに
翻訳VRは最終的に多人数翻訳コミュニケーションアプリに仕立てたいので、Watsonを導入する以前から`PUN2`と`Photon Voice`を導入していました。

しかし、ライブラリ間で同名のDllが存在する？とかなんとかでエラーを取り除くことができず、それに気づくまで動作しないことをWatsonのせいにしてました。(ごめんよWatson)

引き続き多人数対応をしていきたいので、どうすればライブラリ間の干渉によるエラーを取り除けるかも含めて調査します。

(Watson導入の前に、Androidのネイティブの音声認識機能がQuestでも動かないか検証して四苦八苦してました。結果動かずWatsonに助けてもらいました。その記録も供養の意を込めてそのうちメモしようと思っています。)

---
2021/04/05 追記

供養しました→[【Unity(C#)】OculusQuestでAndroidネイティブの音声認識機能を呼び出せるのか検証](https://qiita.com/OKsaiyowa/items/6d9b4115b2945aecb495)


## 参考リンク
[watson-developer-cloud/unity-sdk](https://github.com/watson-developer-cloud/unity-sdk)
[UnityからIBM Watson APIを使う](https://taiponrock.medium.com/unity%E3%81%8B%E3%82%89ibm-watson-api%E3%82%92%E4%BD%BF%E3%81%86-deeecd3f4588)
[UnityでWatsonと雑談APIを利用したChatBotを作る](https://qiita.com/kazuhiro1982/items/fb397902c8870e15f1f3)