Help us understand the problem. What is going on with this article?

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

鬼道で微塵に圧し潰すだけだ!!

滲み出す混濁の紋章 不遜なる狂気の器
湧き上がり・否定し・痺れ・瞬き 眠りを妨げる
爬行する鉄の王女 絶えず自壊する泥の人形
結合せよ 反発せよ 地に満ち 己の無力を知れ!!
破道の九十 『黒棺』!!!!

はじめに

本記事は八耐というイベントで作成したデモアプリの紹介と解説になります。
冒頭の動画のように、「完全詠唱の黒棺を放つ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及びUniSpeechを導入します。
それぞれのリンク先からレポジトリをクローン or ダウンロードし、画像のように配置したら完了です。UnitySwiftはUniSpeechに含まれているので忘れずに配置します。
スクリーンショット 2019-03-22 15.38.36.png

ビルド設定

マイクを使用するため、
Edit > Project Settings > Player > iOS > Other SettingsMicrophone Usage Descriptionに適当な文言を入力します。
カメラ使用のためのCamera Usage Descriptionは、Requires ARKit supportにチェックを入れると自動で文言が入力されます。
また、ARKitはiOS11からサポートのため、Target minimum iOS Version11.0以降にします。
スクリーンショット 2019-03-22 16.50.29.png

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

実装

音声認識

UniSpeechはサンプルが用意されているため、そちらを参考にして簡単に実装することができます。

まずは鬼道を扱うクラス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

画面をタップしながらマイクに向かって喋ると、認識された文章が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を出現させるだけでも良いのですが、せっかくなので勉強中のシェーダーを使ってみました。
最初に枠が表示され、黒い部分が徐々に上に伸びてくるイメージだったので、こちらのディゾルブシェーダーをほぼそのまま使用し、結果こうなりました。
bc.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

Dissolve.shaderから側面用と上下用のマテリアルを生成して、以下のようなテクスチャを各マテリアルの_DissolveTexにそれぞれ設定します。
tex.png
ちなみにこれらのテクスチャはPowerPoint(!?)とphotopeaを使用して作成しました。
photopeaは無料で使えるため、photoshopなどを持っていないエンジニアにオススメです!

上記のディゾルブシェーダーでは、_DissolveTexの黒い(明度が低い)部分ほど_Thresholdの値が小さい場合にも表示されるため、側面は直方体の枠から先に描画されます。また、枠の内部は下が濃いグラデーションになっているため、_Thresholdの増加に従って下側から表示されていきます。
diso.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);
    }
}

あとはゲームオブジェクトBlackCoffinBlackCoffin.csをアタッチし、直方体の面のQuadとアニメーションパラメータを設定します。dissolveTimeSecondは黒棺の出現アニメーションにかかる時間、floatTimeSecondは黒棺が消失まで空間に浮かんでいる時間です。
エディターを実行すると黒棺の出現アニメーションが自動で再生されます!
bcplay.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);
    }
~~

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

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

ここまでできたら、再度ビルドして実機で確認してみましょう。
画面タップ中に「黒棺」と叫ぶと、カメラ前方に黒棺が出現します!
kuro.gif

AR表示

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

平面認識と認識位置の表示にはUnityARKitPlugin > Examples > FocusSquare > FocusSquareSceneをほぼそのまま使用します。
作業中のMainSceneにFocusSquareSceneを追加で読み込み、CameraParentARCameraManagerFocusSquareをMainSceneに移動して、不要になったMainSceneのMain Cameraは削除します。
必要なオブジェクトを移動したら、FocusSquareSceneは削除して構いません。
edit.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

完全詠唱

ここまでの音声認識黒棺作成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

最後にスクリプトの全文を記載します。上記で説明した内容に加え、詠唱を暗記していなくても遊べるよう画面には常に詠唱文を表示し、読まれた厨二漢字の文字色を赤に変更する機能、及び、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...

ippo
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away