Unityで音を扱うということ

みなさんこんにちは
T.C.と申します。
  この記事はCCS裏アドベントカレンダー2020の21日目の記事です。
 

adventar.org

前回はいかろちゃんのGitトークでした。

hogespace.hatenablog.jp

 
  お久しぶりです。
今回は珍しく真面目な記事を書こうと思います。
ただ、例年通り当日になるまで書くのを忘れていたので突貫工事な記事になります。
 
この記事は以下の前提があります。
* Unityについてある程度理解している。
* 音を扱うとか言っておきながら扱うのは音声入力
以下よろしくおねがいします。
 

Unityの音声入力

突然ですがこちらのゲームを見てください。

github.com

これはこの秋冬に色々あって自分が作ったゲームです。
音声入力でキャラを操作します。
これはとあるゲームをインスパイアさせていただいたゲームになります。
 
今回はこちらを作成していたときにわかった知見になります。
 

Unityのデフォルト音声 操作コンポーネント「AudioSource」について

Unityにはデフォルトで音声を取り扱うコンポーネントAudioSourceが存在します。
こちらは取り扱うAudioClip(音声データのようなもの)を設定し、そのSourceの簡単なミキシングができるコンポーネントになります。

docs.unity3d.com

 
Unityで音声入力を行う際、こちらのAudioSourceのAudioClipにMicrophone.Start()関数の返り値を渡してやることになります。

docs.unity3d.com

 
ただ、こちらのMicrophoneには以下の欠点があります。
 

レイテンシがひどい

レイテンシーとは「遅延」になります。
音声系において、遅延というものは切っても切り離せない関係にあります。
これはどこかで聞いた話ですが、「マイクなどで録音した音声をスピーカーなどから出力する場合、0.5sec以上の遅延は人の耳に違和感を与える」という話があります。 リアルタイムな音声入出力を考えるときに必ず直面する問題であり、この問題により音声処理では重く、複雑な処理を簡単に行うことが出来ません。 そして問題のUnityデフォルトの音声入力系ですが、以前色々と試行錯誤したところレイテンシが猛烈に悪いです。
今回ブログを書くにあたって計測をかけた結果、以前レイテンシーがひどいように感じたのは自分が原因説が出てきました。以後調査します。。。
 

メインスレッド

これはそもそもUnity自体の問題になります。
Unityは元々の思想として、基本的に大体のコンポーネントをメインスレッドにて動作しています。
つまりフリーズの影響を大きく受けます。
複雑な処理・重い処理が発生し、メインスレッドが重くなると、この音声入力機構が影響を受けます。
ではそれらの複雑な処理を別スレッドで動かせば?という話になりますが、Unityのコンポーネントを利用したUI部分などで重くなってしまった場合アウトです。
 
ではこれらの問題をどう回避するか?ということについて考えます。
 
今回3パターン用意しました。

また、計測を行うためにこちらのページからSavWav.csをお借りして、改造したものを使っております。

twinklesmile.blog42.fc2.com

   

パターン1 : 普通にAudioSourceを用いた場合

まずこちらから行きましょう。

using System.Collections.Generic;
using UnityEngine;

public class AudioExample : MonoBehaviour
{
    public float volumeShiftSize = 2.0f;
    AudioSource aud;
    float[] samples;
    const int timeLength = 1;
    const int samplingRate = 48000;
    int currentPosition = 0;
    List<float> data;
    string deviceName;
    // Start is called before the first frame update
    async void Start()
    {
        data = new List<float>();
        aud = this.GetComponent<AudioSource>();
        string[] tmp = Microphone.devices;
        deviceName = tmp[0];
        // マイク名、ループするかどうか、AudioClipの秒数、サンプリングレート を指定する
        aud.clip = Microphone.Start(deviceName, true, timeLength, samplingRate);
        samples = new float[aud.clip.samples * aud.clip.channels * timeLength];
    }

    public void  FixedUpdate()
    {
        int pos = Microphone.GetPosition(deviceName);
        aud.clip.GetData(samples, 0);
        if (pos > currentPosition)
        {
            for(int i = currentPosition; i < pos; i++)
            {
                data.Add(samples[i]);
            }
        }
        else if(pos < currentPosition)
        {
            for(int i = currentPosition; i < aud.clip.samples * aud.clip.channels; i++)
            {
                data.Add(samples[i]);
            }
            for(int i=0; i<pos; i++)
            {
                data.Add(samples[i]);
            }
        }
        currentPosition = pos;
    }

    private void OnDestroy()
    {
        SavWav.Save("1.wav", data, aud.clip.samples, aud.clip.channels, data.Count / aud.clip.channels);
    }
}
  • メリット
      • 書く行数が少ないです。
      • Unity側で用意されているものなので
  • デメリット
    • スレッド分け
      • 前述のとおりです。
    • データの扱いについて
      • Microphone.Start()では指定した時間長分のバッファが作成され、内部でリングバッファのように音声データが格納されていく
      • 時系列に沿ってデータを取得する場合、バッファ内でのpositionを取得した上で定期的にデータを取得して別のバッファに入れるなどの工夫を行う必要がある。
    • レイテンシーが悪い

UDPにて別プロセスで録音した音をbyteデータにして送信する。

C#にはNAudioという.NETにて動作するオーディオライブラリが存在します。
https://github.com/naudio/NAudio
 
こちらのライブラリは、複雑な音声系のもろもろを使いやすくされたものになっており、非常に使いやすいです。
ただし、こちらのライブラリはUnityではサポートされていません。
調べたとき見つけた理由としては「Unityで禁止しているSystem.Windows.Formを利用しているため」などありました。
とにかくUnityでNAudioは正常に動作しません。
ではNAudioでをUnityと関係のないところで動かし、そこで取得したデータをUDPにて送ればいいのでは?といった発想にて用いられた方式になります。
  UDPを飛ばす側のコード

using System;
using NAudio.Wave;
using System.Net.Sockets;

namespace naudio_test
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(WasapiLoopbackCapture.GetDefaultCaptureDevice());
            var devCnt = WaveInEvent.DeviceCount;
            for(int i=0; i<devCnt; i++)
            {
                Console.WriteLine(i + ": " + WaveInEvent.GetCapabilities(i).ProductName);
            }

            Console.Write("inputDeviceNumber : ");
            var line = System.Console.ReadLine();
            var deviceNumber = Int32.Parse(line);

            var waveIn = new WaveInEvent();
            int samplingRate = 48000;
            waveIn.DeviceNumber = deviceNumber;
            //waveIn.WaveFormat = new WaveFormat(samplingRate, WaveIn.GetCapabilities(deviceNumber).Channels);
            waveIn.WaveFormat = new WaveFormat(samplingRate, 1);
            waveIn.BufferMilliseconds = 16;
            int bufferSize = (int)((waveIn.BufferMilliseconds / 1000.0f) * samplingRate);

            var waveBuffer1ch = new float[bufferSize];

            UdpClient udp = new UdpClient();
            string hostip = "127.0.0.1";
            int hostPort = 2222;
            waveIn.DataAvailable += (_, ee) =>
            {
                udp.Send(ee.Buffer, ee.BytesRecorded, hostip, hostPort);
            };
            waveIn.RecordingStopped += (_, __) =>
            {
            };

            waveIn.StartRecording();
            Console.Write("何らかの文字を入力したら終了");
            Console.ReadLine();
            waveIn?.StopRecording();
            waveIn?.Dispose();
            waveIn = null;
        }
    }
}

飛ばされる側のコード(Unity側)

using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using UnityEngine;
using System.Threading;

namespace Assets
{
    class audioManajor : MonoBehaviour
    {
        //入力デバイスの設定はここで入力 
        public int inputChannels = 1;
        public int samplingRate = 48000;
        public int udpTimeout = 1000;
        public enum BitRate 
        {
            Short = 16,
            Int = 32,
            Long = 64,
            Float = 32,
        } ;

        public BitRate inputBitRate = BitRate.Short;

        int LOCAL_PORT = 2222;
        static UdpClient udp;
        Thread rcv_wave_thread;
        int bufferSize;
        float[] dataSamples;
        const int timeLength = 1;
        List<byte> dataBuffer;
        
        public audioManajor()
        {
            bufferSize = 1024;
            dataSamples = new float[bufferSize];
        }

        void Start()
        {
            dataBuffer = new List<byte>();
            //udpスレッドスタート
            udp = new UdpClient(LOCAL_PORT);
            udp.Client.ReceiveTimeout = udpTimeout;
            rcv_wave_thread = new Thread(new ThreadStart(waveUdpRcv));
            rcv_wave_thread.Start();
        }

        private void OnApplicationQuit()
        {
            rcv_wave_thread.Abort();
            SavWav.Save("2.wav", dataBuffer, 48000, inputChannels, dataBuffer.Count);
        }

        private void waveUdpRcv()
        {
            while(true)
            {
                IPEndPoint remoteEP = null;
                if(udp.Available != 0)
                {
                    byte[] data = udp.Receive(ref remoteEP);
                    dataBuffer.AddRange(data);
                }
            }
        }

        public float[] getSamples()
        {
            return dataSamples;
        }

        public int getTimeLength()
        {
            return timeLength;
        }

        public int getSamplingRate()
        {
            return samplingRate;
        }
    }
}
  • メリット
    • レイテンシーがとてもいい
      • UDPのデータ送信自体には遅延はほぼないため
    • スレッドわけが容易
      • UDPの受信自体はC#の機能の範囲内で実装できるため、別スレッドにて行うことが可能
    • Unity自体が軽い
      • UDPを受信する口だけを用意すればいいため、Unity自体はとても軽くなる
  • デメリット
    • UDPの送信側を別実装、かつ別プロセスでの動作になる
      • 2プロセスにて動作させる必要があり、面倒
      • また、RPCなどによりきれいに録音開始、終了をハンドリングしないと起動時に無駄な手間がいる。

C++にてDirectSoundでの録音処理をDLL化し、C#側で読み込む

読んで字のごとくです。
これは説明するよりこちらに書いたほうがわかりやすいかと思います。
 
実際の処理コード

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
using lib_audio_analysis;
using System.Threading;

namespace audio_app
{
    class AudioManager : MonoBehaviour
    {
        InputCaptureFuncs inputCap;

        //入力デバイスの設定はここで入力 
        ushort inputChannels = 1;
        public uint SamplingRate { get; private set; } 
        public float[] DataSamples { get; private set; }
        int frameBufferSize = 512;

        int captureBufferSize;
        byte[] captureData;
        IntPtr captureDataPtr;

        List<float> waveBuffer;

        bool cap_now;

        Thread rcv_wave_thread;

        void Start()
        {
            inputCap = new InputCaptureFuncs();

            cap_now = false;
            //自環境で試した結果、8.0ms取得、512サンプル処理が一番遅延が少ないため、今回はこの設定で行う
            long hr = inputCap.initInputCapture(48000, inputChannels, 16, 8, 0);
            waveBuffer = new List<float>();
            DataSamples = new float[frameBufferSize];

            captureBufferSize = inputCap.getDataBufferSize();
            captureData = new byte[captureBufferSize];
            captureDataPtr = new IntPtr();
            captureDataPtr = Marshal.AllocCoTaskMem(captureBufferSize);
            inputCap.startCapture();
            rcv_wave_thread = new Thread(new ThreadStart(capture));
            rcv_wave_thread.Start();
            cap_now = true;
        }
        private async void capture()
        {
            while(true)
            {
                int size = 0;
                long hr = inputCap.getCaptureData(ref captureDataPtr, ref size);
                if (hr == 0)
                {
                    Marshal.Copy(captureDataPtr, captureData, 0, size);
                    inputSoundDataConvert(captureData, size);
                }
                else
                {
                    if(!cap_now) 
                        break;
                }
            }
        }

        private void OnDestroy()
        {
            cap_now = false;
            rcv_wave_thread.Join();
            rcv_wave_thread = null;
            inputCap.stopCapture();
            inputCap = null;
            SavWav.Save("3.wav", waveBuffer, 48000, inputChannels, waveBuffer.Count);
        }

        private void inputSoundDataConvert(byte[] captureData, int size)
        {
            int captureDataIncremation = 16 / 8 * inputChannels;
            for (int i = 0; i < size; i += captureDataIncremation)
            {
                byte[] tmp = new byte[] { captureData[i], captureData[i + 1] };
                waveBuffer.Add((float)BitConverter.ToInt16(tmp, 0) / 32767.0f);
            }
        }

    }
}

    DirectSoundによる音声キャプチャコード

using System;
using System.Text;
using System.Runtime.InteropServices;


namespace lib_audio_analysis
{
    public class InputCaptureFuncs
    {
        [DllImport("lib_audio_analysis", EntryPoint = "create_input_capture", CallingConvention = CallingConvention.StdCall)]
        static extern void create_input_capture(ref IntPtr func_object);

        [DllImport("lib_audio_analysis", EntryPoint = "delete_input_capture", CallingConvention = CallingConvention.StdCall)]
        static extern void delete_input_capture(ref IntPtr func_object);

        [DllImport("lib_audio_analysis", EntryPoint = "get_input_devices_list", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
        static extern void get_input_devices_list(int index, StringBuilder tmp, IntPtr func_object);

        [DllImport("lib_audio_analysis", EntryPoint = "get_input_devices_list_size", CallingConvention = CallingConvention.StdCall)]
        static extern int get_input_devices_list_size(IntPtr func_object);

        [DllImport("lib_audio_analysis", EntryPoint = "init_input_capture", CallingConvention = CallingConvention.StdCall)]
        static extern long init_input_capture(UInt32 sample_rate, UInt16 channels, UInt16 bits_per_sample, Int32 frame_ms, int device_index, IntPtr func_object);

        [DllImport("lib_audio_analysis", EntryPoint = "get_buf_size", CallingConvention = CallingConvention.StdCall)]
        static extern int get_buf_size(IntPtr func_object);

        [DllImport("lib_audio_analysis", EntryPoint = "start", CallingConvention = CallingConvention.StdCall)]
        static extern long start(IntPtr func_object);

        [DllImport("lib_audio_analysis", EntryPoint = "caputre_data", CallingConvention = CallingConvention.StdCall)]
        static extern long caputre_data(ref IntPtr data, ref int capture_length, IntPtr func_object);

        [DllImport("lib_audio_analysis", EntryPoint = "stop", CallingConvention = CallingConvention.StdCall)]
        static extern long stop(IntPtr func_object);

        private IntPtr mInputCap;

        public InputCaptureFuncs()
        {
            mInputCap = new IntPtr();
            create_input_capture(ref mInputCap);
        }

        ~InputCaptureFuncs()
        {
            delete_input_capture(ref mInputCap);
        }

        public long initInputCapture(UInt32 sampleRate, UInt16 channels, UInt16 bitsPerSample, Int32 frameMs, int deviceIndex)
        {
            return init_input_capture(sampleRate, channels, bitsPerSample, frameMs, deviceIndex, mInputCap);
        }

        public long startCapture()
        {
            return start(mInputCap);
        }

        public long getCaptureData(ref IntPtr data, ref int capture_length)
        {
            return caputre_data(ref data, ref capture_length, mInputCap);
        }

        public long stopCapture()
        {
            return stop(mInputCap);
        }

        public int getDataBufferSize()
        {
            return get_buf_size(mInputCap);
        }

        public void getInputDevicesList(int index, StringBuilder tmp)
        {
            get_input_devices_list(index, tmp, mInputCap);
        }

        public int getInputDevicesListSize()
        {
            return get_input_devices_list_size(mInputCap);
        }
    }
}

 
こちらで用いているlib_audio_analysis.dllは以下のGithubにいて公開しています。
内部的にはworldによる音声解析処理とDirectSoundの音声キャプチャ機能のラッパーになっています。
単純に自分でここらへんの処理まとめて置きたいと思ったので作りました。
 
これはC++にて作成したDirectSoundの音声キャプチャ処理をDLL化し、C#にてDLLを読み込んで実行するものになっています。

  • メリット
    • レイテンシーがとてもいい
      • DirectSoundは遅延が少ないです。
    • スレッド分け
      • こちらもUnityのコンポーネントを使わず実装しているため、別スレッドにて動かすことが出来ます。
    • 無駄に処理が別れていない
      • 2つ目の方法と違い、レイテンシを維持したまま1プロセスで完結しているため、取り回しはいいです。
  • デメリット
    • DLL作成の手間
      • C++を用いることになるため、DLLを作成し、そこから読み込むなどの手間がかかってしまう。
    • win環境のみの動作
      • DirectXに依存しているため

それぞれのレイテンシーについて

レイテンシーを簡易計測しました。
こちらは厳密な計測を行ったわけではないです。適当に音声をスピーカー再生してUnity内で取得したデータを書き出しています。 上から
* リファレンス音
* AudioSourceによる録音
* UDPによる録音
* DirectSoundによる録音

f:id:yoooomaruuuu:20201221230144p:plain
それぞれのレイテンシ

まとめ

もともとAudioSourceによる録音はレイテンシーがひどい!!!みたいな記事書こうと思ったんですけどなんか計測している間に事情が変わってきたりしてとりあえず知見紹介記事となりました。このあともう少し調べてみようとは思います。
Unityの音声周りは触った感じではあまり強くはないです。例えばAudioSourceで取得した音はPlay()関数を叩くことで再生できますが、Microphone.Start()を用いて再生した場合、いくらか遅延が生じます。
そのため、色々と音声処理をやるのであればここらへんを参考にしてみてください。