Unityで音声認識を使って完全詠唱の黒棺を放つARアプリを作る

##鬼道で微塵に圧し潰すだけだ!!
```
滲み出す混濁の紋章 不遜なる狂気の器
湧き上がり・否定し・痺れ・瞬き 眠りを妨げる
爬行する鉄の王女 絶えず自壊する泥の人形
結合せよ 反発せよ 地に満ち 己の無力を知れ!!
破道の九十 『黒棺』!!!!
```
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">AR×音声認識で「完全詠唱の黒棺」 <a href="https://t.co/BYL0g7htOz">pic.twitter.com/BYL0g7htOz</a></p>&mdash; 1pp0 (@1pp0qq1) <a href="https://twitter.com/1pp0qq1/status/1110139483263889409?ref_src=twsrc%5Etfw">March 25, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

##はじめに
本記事は[八耐](https://daihachitai.connpass.com/event/121432/)というイベントで作成したデモアプリの紹介と解説になります。
冒頭の動画のように、「完全詠唱の黒棺を放つARアプリ」を作成しました。
作成の経緯としては、仕事や趣味でARに関わっている中で、VUI(音声ユーザーインターフェース)を取り入れたアプリを作ってみたいという思いと、オサレな鬼道をARで出せたら面白いのでは、とお酒を飲んでいる時に思ってしまったからです。

本アプリはUnityARKitPluginとUniSpeechという音声認識のライブラリを使用しています。
題材はネタよりですが、iOSで音声認識のARアプリを作る際の参考になれば嬉しいです。
また、プロジェクトはGitHubで公開しておりますので、興味がある方は合わせてご覧ください！
https://github.com/k1pp0/BlackCoffinForQiita

##準備
###環境
・Unity 2018.3.4
・ARKit 2.0
・Xcode 10.1
・iOS 12.0.1

###UniSpeech
Unityを使った音声認識の実現には、UniSpeechという素晴らしいライブラリがあります。
https://github.com/noir-neo/UniSpeech

UniSpeechはiOSのSpeech Recognition APIをUnityから使用するためのネイティブプラグインです。
Speech Recognition API自体に以下のような使用制限がありますが、私がiOSユーザかつデモの範囲では支障がなかったため、今回はこちらを使用させていただきました。
・iOS10以降のiOSデバイスのみで動作
・端末、アプリ毎に使用回数の制限がある
・一度の最大認識時間は1分まで

Speech Recognition APIの詳細については公式をご参照ください。
https://developer.apple.com/documentation/speech
https://developer.apple.com/videos/play/wwdc2016/509/

また、こちらも参考にさせていただきました。
https://dev.classmethod.jp/smartphone/iphone/try-ios10-speech-recognizer/

### プラグイン追加
それでは早速、プラグインの追加から説明していきます。
まずは、Unityで新規プロジェクトを作成し、[UnityARKitPlugin](https://bitbucket.org/Unity-Technologies/unity-arkit-plugin/downloads/)及び[UniSpeech](https://github.com/noir-neo/UniSpeech)を導入します。
それぞれのリンク先からレポジトリをクローン or ダウンロードし、画像のように配置したら完了です。UnitySwiftはUniSpeechに含まれているので忘れずに配置します。
<img width="200" alt="スクリーンショット 2019-03-22 15.38.36.png" src="https://qiita-image-store.s3.amazonaws.com/0/174948/169df9d2-3a46-37f7-6f6d-618130e601c0.png">

###ビルド設定
マイクを使用するため、
`Edit > Project Settings > Player > iOS > Other Settings`の`Microphone Usage Description`に適当な文言を入力します。
カメラ使用のための`Camera Usage Description`は、`Requires ARKit support`にチェックを入れると自動で文言が入力されます。
また、ARKitはiOS11からサポートのため、`Target minimum iOS Version`を`11.0`以降にします。
<img width="387" alt="スクリーンショット 2019-03-22 16.50.29.png" src="https://qiita-image-store.s3.amazonaws.com/0/174948/635ccd02-b6e0-5b44-31b3-cf310e65cdc4.png">

以上で準備は完了ですので、早速実装について説明していきます！

##実装
###音声認識
UniSpeechは[サンプル](https://github.com/noir-neo/UniSpeech/tree/master/Assets/UniSpeech/Sample)が用意されているため、そちらを参考にして簡単に実装することができます。

まずは鬼道を扱うクラス`KidoManager`を作成し、`UniSpeech.ISpeechRecognizer`を実装します。
以下のスクリプトでは画面をタップしている間の音声を認識してログに出力します。
`OnRecognized`は認識停止のタイミングで呼ばれるのではなく、認識途中の音声を都度返している事に注意してください。

```KidoManager.cs
using UnityEngine;
using UniSpeech;

public class KidoManager : MonoBehaviour, ISpeechRecognizer
{    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            if (SpeechRecognizer.StartRecord())
            {
                Debug.Log("StartRecord");
            }
        }
        if (Input.GetMouseButtonUp(0))
        {
            if (SpeechRecognizer.StopRecord())
            {
                Debug.Log("StopRecord");
            }
        }
    }

    public void OnRecognized(string transcription)
    {
        Debug.Log($"Recognized: {transcription}");
    }

    public void OnError(string description)
    {
        Debug.LogWarning($"Error: {description}");
    }

    public void OnAuthorized()
    {
        Debug.Log("Authorized");
    }

    public void OnUnauthorized()
    {
        Debug.LogWarning("Unauthorized");
    }

    public void OnAvailable()
    {
        Debug.Log("Available");
    }

    public void OnUnavailable()
    {
        Debug.LogWarning("Unavailable");
    }
}

```
ここまでできたら、適当なシーン（ここではMainSceneとします）に*Create Empty*で空のゲームオブジェクトを作成します。
ゲームオブジェクトの名前を*SpeechRecognizer*に変更し、*KidoManager.cs*をアタッチしたら、音声認識の準備は完了です！
ビルドして実機で確認してみましょう。

ビルドが完了してアプリが起動すると、SkyBoxだけが映ったシーンが表示されます。
その状態で画面をタップすると、次の二つの権限が要求されますが、どちらも許可してください。
![auth.png](https://qiita-image-store.s3.amazonaws.com/0/174948/d2a28bda-3615-5998-6b87-0a0edff74d5e.png)

画面をタップしながらマイクに向かって喋ると、認識された文章がXcodeのログに出力されます！

```
Recognized: にじみ出す
 
(Filename: ./Runtime/Export/Debug.bindings.h Line: 45)

Recognized: にじみ出す混濁
 
(Filename: ./Runtime/Export/Debug.bindings.h Line: 45)

Recognized: にじみ出す混濁の
 
(Filename: ./Runtime/Export/Debug.bindings.h Line: 45)

Recognized: にじみ出す混濁の紋章
 
(Filename: ./Runtime/Export/Debug.bindings.h Line: 45)

Recognized: にじみ出す混濁の紋章不遜
 
(Filename: ./Runtime/Export/Debug.bindings.h Line: 45)

Recognized: にじみ出す混濁の紋章不遜なる
 
(Filename: ./Runtime/Export/Debug.bindings.h Line: 45)

Recognized: にじみ出す混濁の紋章不遜なる狂気
 
(Filename: ./Runtime/Export/Debug.bindings.h Line: 45)

Recognized: にじみ出す混濁の紋章不遜なる狂気の
 
(Filename: ./Runtime/Export/Debug.bindings.h Line: 45)

Recognized: にじみ出す混濁の紋章不遜なる狂気の器
```

###黒棺作成
詠唱はできたので今度は黒棺を作成していきます。
黒いCubeを出現させるだけでも良いのですが、せっかくなので勉強中のシェーダーを使ってみました。
最初に枠が表示され、黒い部分が徐々に上に伸びてくるイメージだったので、[こちら](http://nn-hokuson.hatenablog.com/entry/2017/04/14/204822)のディゾルブシェーダーをほぼそのまま使用し、結果こうなりました。
![bc.gif](https://qiita-image-store.s3.amazonaws.com/0/174948/1b6aa18e-fdf7-98ce-b42f-33487cde8d33.gif)

```Dissolve.shader
Shader "Custom/dissolve" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_DissolveTex ("DissolveTex (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
		_Threshold("Threshold", Range(0,1))= 0.0
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200
		
		CGPROGRAM
		#pragma surface surf Standard fullforwardshadows
		#pragma target 3.0

		sampler2D _MainTex;
		sampler2D _DissolveTex;

		struct Input {
			float2 uv_MainTex;
		};

		half _Glossiness;
		half _Metallic;
		half _Threshold;
		fixed4 _Color;

		UNITY_INSTANCING_BUFFER_START(Props)
		UNITY_INSTANCING_BUFFER_END(Props)

		void surf (Input IN, inout SurfaceOutputStandard o) {
		
			// Albedo comes from a texture tinted by color
			fixed4 m = tex2D (_DissolveTex, IN.uv_MainTex);
			half g = m.r * 0.2 + m.g * 0.7 + m.b * 0.1;
			if( g > _Threshold ){
				discard;
			} 
			fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
			o.Albedo = c.rgb;
			
			// Metallic and smoothness come from slider variables
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}
		ENDCG
	}
	FallBack "Diffuse"
}
```

ディゾルブシェーダーはディゾルブ用に設定されたテクスチャ`_DissolveTex`の明度が、パラメータ`_Threshold`より大きいか小さいかで表示非表示を切り替える事ができます。したがって、パラメータ`_Threshold`の値を動的に変更する事で、徐々に出現 or 消失する表現ができます。

黒棺のオブジェクトは、空のゲームオブジェクト*BlackCoffin*の子に、[(側面:4 + 上下:2) × 裏表]の計12個の*Quad*を直方体の面として作成します。
![quad.gif](https://qiita-image-store.s3.amazonaws.com/0/174948/1e61d1f5-e393-c4d9-ab17-3d2e90e812be.gif)

*Dissolve.shader*から側面用と上下用のマテリアルを生成して、以下のようなテクスチャを各マテリアルの`_DissolveTex`にそれぞれ設定します。
![tex.png](https://qiita-image-store.s3.amazonaws.com/0/174948/3875f38d-4e18-6de9-4a88-58d3be9ad12c.png)
ちなみにこれらのテクスチャはPowerPoint(!?)と[photopea](https://www.photopea.com/)を使用して作成しました。
photopeaは無料で使えるため、photoshopなどを持っていないエンジニアにオススメです！

上記のディゾルブシェーダーでは、`_DissolveTex`の黒い(明度が低い)部分ほど`_Threshold`の値が小さい場合にも表示されるため、側面は直方体の枠から先に描画されます。また、枠の内部は下が濃いグラデーションになっているため、`_Threshold`の増加に従って下側から表示されていきます。
![diso.gif](https://qiita-image-store.s3.amazonaws.com/0/174948/3898bff5-cc86-a1ae-3e45-1eb7e7bd734d.gif)

これで黒棺のオブジェクトは完成です！
最後に`_Threshold`の値を動的に変更するクラス`BlackCoffin`を作成します。

```BlackCoffin.cs
using System.Collections;
using UnityEngine;

public class BlackCoffin : MonoBehaviour
{
    [SerializeField] private GameObject[] quads;
    private Material[] quadMats;

    // 黒棺アニメーションパラメータ
    [SerializeField] private float dissolveTimeSecond = 2.0f;
    [SerializeField] private float floatTimeSecond = 1.0f;
    private static readonly int Threshold = Shader.PropertyToID("_Threshold");

    private void Start()
    {
        // 初期化時に透明にしておく
        quadMats = new Material[quads.Length];
        for (var i = 0; i < quads.Length; i++)
        {
            quadMats[i] = quads[i].GetComponent<Renderer>().material;
            quadMats[i].SetFloat(Threshold, 0);
        }
        
        StartCoroutine(PlayAnimation());
    }

    private IEnumerator PlayAnimation()
    {
        var t = 0f;
        while (t < dissolveTimeSecond)
        {
            t += Time.deltaTime;
            foreach (var mat in quadMats)
            {
                mat.SetFloat(Threshold, t / dissolveTimeSecond);
            }
            yield return null;
        }
        yield return new WaitForSeconds(floatTimeSecond);
        Destroy(gameObject);
    }
}
```

あとはゲームオブジェクト*BlackCoffin*に*BlackCoffin.cs*をアタッチし、直方体の面の*Quad*とアニメーションパラメータを設定します。`dissolveTimeSecond`は黒棺の出現アニメーションにかかる時間、`floatTimeSecond`は黒棺が消失まで空間に浮かんでいる時間です。
エディターを実行すると黒棺の出現アニメーションが自動で再生されます！
![bcplay.gif](https://qiita-image-store.s3.amazonaws.com/0/174948/77f97599-799b-31c5-aaac-e9af2d6a91f3.gif)

###詠唱で黒棺を出現させる
黒棺は完成したので、`KidoManager`に戻って詠唱で黒棺を出現させる処理を追加します。
ここでは単純に、認識された文章に**「黒棺」**という単語が含まれていたら、カメラの正面に黒棺を出現するようにスクリプトを修正します。

```KidoManager.cs
using UnityEngine;
using UniSpeech;
using UnityEngine.UI;

public class KidoManager : MonoBehaviour, ISpeechRecognizer
{
    [SerializeField] private BlackCoffin blackCoffin;
    [SerializeField] private Transform cameraTransform;
    [SerializeField] private Text transcriptionText;
    private bool inAria;
    
    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            if (SpeechRecognizer.StartRecord())
            {
                Debug.Log("StartRecord");
                inAria = true;
            }
        }
        if (Input.GetMouseButtonUp(0))
        {
            if (SpeechRecognizer.StopRecord())
            {
                Debug.Log("StopRecord");
                inAria = false;
                transcriptionText.text = "";
            }
        }
    }

    public void OnRecognized(string transcription)
    {
        Debug.Log($"Recognized: {transcription}");
        transcriptionText.text = transcription;
        if (transcription.Contains("黒棺"))
        {
            CreateBlackCoffin();
        }
    }
    
    private void CreateBlackCoffin()
    {
        Debug.Log("CreateBlackCoffin");
        if (!inAria) return;
        inAria = false;
        Instantiate(blackCoffin, cameraTransform.position + cameraTransform.forward * 3, Quaternion.identity);
    }
~略~
```

**「黒棺」**という単語がトリガーになっているか確認するため、シーンに適当な*Canvas*と*Text*を追加して音声認識の結果をUIに表示するようにしています。
Inspectorから、`BlackCoffin`に*BlackCoffin*のPrefab、`CameraTransform`に*Main Camera*、`TranscriptionText`に追加した*Text*をセットします。

画面をタップすると音声認識を開始し、詠唱中フラグをオン`inAria=true`とします。`OnRecognized`で取得された文字列に**「黒棺」**という単語が含まれていた場合、`CreateBlackCoffin()`が実行され、*Main Camera*の前方3mの位置に黒棺が出現します。
1回の詠唱で黒棺が複数生成されないよう、詠唱中フラグをオフ`inAria=false`としています。画面から指を離すと音声認識を終了し、詠唱中フラグをオフになります。

ここまでできたら、再度ビルドして実機で確認してみましょう。
画面タップ中に**「黒棺」**と叫ぶと、カメラ前方に黒棺が出現します！
![kuro.gif](https://qiita-image-store.s3.amazonaws.com/0/174948/f4c7e268-fbdc-e2ac-c4ad-b580c6ef42eb.gif)

###AR表示
ここまでできたら、ようやくタイトル通り「ARアプリ」にしていきます。
今回は床の平面を検出し、黒棺が床から出現するようにします。

平面認識と認識位置の表示には`UnityARKitPlugin > Examples > FocusSquare > FocusSquareScene`をほぼそのまま使用します。
作業中のMainSceneにFocusSquareSceneを追加で読み込み、*CameraParent*、*ARCameraManager*、*FocusSquare*をMainSceneに移動して、不要になったMainSceneの*Main Camera*は削除します。
必要なオブジェクトを移動したら、FocusSquareSceneは削除して構いません。
![edit.gif](https://qiita-image-store.s3.amazonaws.com/0/174948/98a217ff-61ee-a6d2-bc86-6f2427c561a9.gif)

ゲームオブジェクト*FocusSquare*は、画面中央からRayを飛ばした先に平面があれば、その位置に黄色の四角を表示する機能を持っています。
なので、**「黒棺」**という単語を認識した際に、`focusedSquare`の位置に黒棺が出現するようスクリプトを修正します。

```KidoManager.cs
public class KidoManager : MonoBehaviour, ISpeechRecognizer
{
    [SerializeField] private BlackCoffin blackCoffin;
    [SerializeField] private Text transcriptionText;
    // 変更
    [SerializeField] private Transform focusedSquare;
    private bool inAria;
~略~    
    private void CreateBlackCoffin()
    {
        Debug.Log("CreateBlackCoffin");
        if (!inAria) return;
        inAria = false;
        // 変更
        Instantiate(blackCoffin, focusedSquare.position, focusedSquare.rotation); 
    }
~略~    
```

変更は`Transform`に設定するオブジェクトを*Main Camera*から*FocusSquareFocused*に変更し、それに伴って`Instantiate`のPositionとRotationの引数を修正するだけです。

実機で実行した様子は以下のようになります。これで、黒棺を放つARアプリの完成です！
![demo0.gif](https://qiita-image-store.s3.amazonaws.com/0/174948/3b6355c2-8c09-09a4-ceff-ade9c38389f8.gif)

###完全詠唱
ここまでの**音声認識**、**黒棺作成**、**AR表示**でアプリとしての機能は完成なのですが、現状はただ**「黒棺」**と言うだけでオブジェクトが出現しているので、全然**完全詠唱**じゃないですね。
と言う事で、最後に詠唱の要素を追加します。

そもそも鬼道は「詠唱をする事でオサレポイントが溜まり威力が上がる」という設定だと考え、今回は「詠唱中のオサレな漢字を発話するほどオサレポイントが溜まり、出現する黒棺が大きくなる」という仕様で実装しました。
本当は詠唱の愛染らしさを数値化したかったのですがとりあえず簡単な方法で...

方法としては単純で、詠唱に含まれる漢字を厨二漢字リスト`twoWordList`として定義し、認識された文字列に含まれている個数をオサレポイント`score`として算出します。そして、文字列**「黒棺」**を認識した際に、出現させる黒棺の大きさを`score`に応じて変更します。

```KidoManager(該当箇所のみ抜粋).cs
// 詠唱分と厨二漢字リストの定義
private const string Spell90 = "滲み出す混濁の紋章\n不遜なる狂気の器\n湧き上がり・否定し・痺れ・瞬き\n眠りを妨げる\n爬行する鉄の王女\n絶えず自壊する泥の人形\n結合せよ 反発せよ\n地に満ち 己の無力を知れ";
private readonly char[] twoWordList =
{
    '滲', '出', '混', '濁', '紋', '章', '不', '遜', '狂', '気', 
    '器', '湧', '上', '否', '定', '痺', '瞬', '眠', '妨', '爬',
    '行', '鉄', '王', '女', '絶', '自', '壊', '泥', '人', '形', 
    '結', '合', '反', '発', '地', '満', '己', '無', '力', '知'
};

// 音声認識の結果からスコアを算出
var score = twoWordList.Count(transcription.Contains);

// スコアに応じて黒棺のサイズを変更
var bc = Instantiate(blackCoffin, focusedSquare.position, focusedSquare.rotation);
bc.transform.localScale *= score / 10f + 1;
```
この辺りのパラメータと黒棺のPrefab自体の大きさは、黒棺の高さが詠唱破棄(40cm)〜完全詠唱(200cm)になるよう調整しました。
詠唱破棄と完全詠唱の黒棺の比較です！

![kurohitsugi3.gif](https://qiita-image-store.s3.amazonaws.com/0/174948/7ea53cba-92b2-06e5-d053-e93fff09c736.gif)

最後にスクリプトの全文を記載します。上記で説明した内容に加え、詠唱を暗記していなくても遊べるよう画面には常に詠唱文を表示し、読まれた厨二漢字の文字色を赤に変更する機能、及び、Speech Recognition APIでは漢字に変換されない文字列（今回は「滲み」のみ）を漢字に変換する機能を追加しています。

```KidoManager.cs
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UniSpeech;
using UnityEngine.UI;

public class KidoManager : MonoBehaviour, ISpeechRecognizer
{
    [SerializeField] private BlackCoffin blackCoffin;
    [SerializeField] private Text transcriptionText;
    [SerializeField] private Transform focusedSquare;
    private bool inAria;
    
    private const string Spell90 = "滲み出す混濁の紋章\n不遜なる狂気の器\n湧き上がり・否定し・痺れ・瞬き\n眠りを妨げる\n爬行する鉄の王女\n絶えず自壊する泥の人形\n結合せよ 反発せよ\n地に満ち 己の無力を知れ";
    private readonly char[] twoWordList =
    {
        '滲', '出', '混', '濁', '紋', '章', '不', '遜', '狂', '気', 
        '器', '湧', '上', '否', '定', '痺', '瞬', '眠', '妨', '爬',
        '行', '鉄', '王', '女', '絶', '自', '壊', '泥', '人', '形', 
        '結', '合', '反', '発', '地', '満', '己', '無', '力', '知'
    };
    private readonly Dictionary<string, string> replaceWordList = new Dictionary<string, string>()
    {
        {"にじみ", "滲み"}
    };
    
    private void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            if (SpeechRecognizer.StartRecord())
            {
                Debug.Log("StartRecord");
                inAria = true;
            }
        }
        if (Input.GetMouseButtonUp(0))
        {
            if (SpeechRecognizer.StopRecord())
            {
                Debug.Log("StopRecord");
                inAria = false;
                transcriptionText.text = Spell90;
            }
        }
    }

    public void OnRecognized(string transcription)
    {
        Debug.Log($"Recognized: {transcription}");
        foreach (var pair in replaceWordList)
        {
            transcription = transcription.Replace(pair.Key, pair.Value);
        }

        var score = twoWordList.Count(transcription.Contains);
        if (transcription.Contains("黒棺"))
        {
            CreateBlackCoffin(score);
        }
        
        var tmp = "";
        foreach (var word in Spell90)
        {
            if (twoWordList.Contains(word) && transcription.Contains(word))
            {
                tmp += $"<color=red>{word}</color>";
            }
            else
            {
                tmp += word;
            }
        }
        transcriptionText.text = tmp;
    }
    
    private void CreateBlackCoffin(int score)
    {
        Debug.Log("CreateBlackCoffin");
        if (!inAria) return;
        inAria = false;
        var bc = Instantiate(blackCoffin, focusedSquare.position, focusedSquare.rotation);
        bc.transform.localScale *= score / 10f + 1;
    }

    public void OnError(string description)
    {
        Debug.LogWarning($"Error: {description}");
        transcriptionText.text = Spell90;
    }

    public void OnAuthorized()
    {
        Debug.Log("Authorized");
    }

    public void OnUnauthorized()
    {
        Debug.LogWarning("Unauthorized");
    }

    public void OnAvailable()
    {
        Debug.Log("Available");
    }

    public void OnUnavailable()
    {
        Debug.LogWarning("Unavailable");
    }
}
```

##おわりに
「完全詠唱の黒棺を放つARアプリ」の説明は以上となります。
Unityのプロジェクトは下記レポジトリになりますので、AR×VUIの参考にしていただければ幸いです。
https://github.com/k1pp0/BlackCoffinForQiita

...ところでBLEACHには黒棺以外にもオサレな鬼道がいっぱいありましたね？
次回は鬼道を打ち分ける機能を実装したいと思います！
to be continued...