凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity の WebGL ビルドでマイク入力を扱えるライブラリを作ってみた

はじめに

Unity には Microphone というクラスがあり、これを通じてマイクの情報や入力を取得できます。しかしながら MicrophoneWebGL では利用することが出来ません。

docs.unity3d.com

Unity では FMOD をオーディオ周りとして利用しているようで、基本的にスレッド上で動くためスレッドが(部分的にしか)利用できない WebGL とは相性がよくなく、結果的に Web Audio API をベースに自前で実装する、という選択をしたようです。その上で多くのオーディオ系 API は再実装されたものの、幾つか Web Audio API との相性が良くないものは利用不可、となっているようです。そして Microphone はごっそり「不対応」となったようですね。

私は uLipSync というリップシンクのためのライブラリを作っていまして、この中の機能の一つとしてマイク入力によるリップシンクのサポートを行っているのですが、WebGL 上でのマイク入力がサポートできていなかったのでなんとかしたいと思った次第です。直接 uLipSync 側の機能として作るより、再利用性も高いので WebGL 上でマイクを取り扱うためのモジュールとして切り出して設計することにしました。

本記事では、このあたりの調査と、実際に Microphone クラスの代替となるようなものを設計・作成してみましたので、その内容および使い方をご紹介します。

ダウンロード / ユースケース

GitHub

github.com

録音

バイスを選択して録音・再生が出来ます。

リアルタイムアクセス

コールバックを通じてマイク入力へリアルタイムにアクセスが出来ます。

詳しい使い方については、後述の「使い方」の項をご参照ください。

事前調査

WebGL でマイクを使えるようにする、という範囲の機能なら、すでに世の中にありそうな気がしたので調べてみましたら、実際に幾つか見つかりました。

Microphone Pro

assetstore.unity.com

こちらは Unity 互換の Microphone を実現するアセットです。評判もよくデモも用意されております。

unitydemos.frostweepgames.com

Frostweep Games さんは、その他にも Goolgle の音声認識周りの API とのつなぎなど Unity x WebGL 用途でのアセットをリリースしているようです。

UnityWebGLMicrophone

github.com

こちらは AudioClip 生成機能やバッファの取得はありませんが、デバイス情報を取ってこれるようです。面白いのは [Root]/Assets/Plugins/ 下に UnityEngine.Microphone クラスを置くことで、そのままマイクを使えるようにしている点ですね。これによって通常のマイクアクセス系の API のコード変更無しに使えるようです。

方針

Microphone クラス互換を作るのは面白そうですが、挙動を合わせるための様々な制限との戦いや、拡張性のしにくさ、メンテナンス性などを考慮して、従来の API はいったん無視して WebGL 専用のマイククラスという形にしてみます。また、最終目標はいい感じに uLipSync へ統合することなので、その方針を忘れずにシンプルな設計にしてみようと思います。最低限、次のような機能は取り揃えようと思います:

  • マイクの選択
  • マイクの情報取得
  • 開始と終了
  • バッファへのリアルタイムアクセス(今回作るものの大事なところ)
  • エディタ実行

最後の「エディタ実行」ですが、これはリアルタイムアクセスの特性上、開発イテレーションがとても大事なので、WebGL ビルド時だけではなく普通にエディタ上でも結果が確認できるように Unity の Microphone クラスをラップして、同様に扱えるようにするインターフェースも用意する、というものです。

設計と実装

様々なメディア入力機器へのアクセスを可能にする MediaDevicesAPI である getUserMedia を使うと、マイクへのアクセスが出来ます。

developer.mozilla.org

基本的には、この getUserMedia の初期化・終了の指示、およびこれによって開かれたストリームからのマイク入力データへのアクセスを Unity C# 側へ jslib を使ってつなぎ込みます。Unity C# から jslib 側への情報の通知は DllImport を通じた従来の方法で可能です。jslib 側から Unity C# への情報の通知は、前回の記事でも書きましたが、Module.dynCall_v(...) のような Emscripten の機能を使って行います。

tips.hecomi.com

前回の経験から、jslib と C# 側での取り扱いはなんとなく固まってきました。今回は以下のようにしてみました。

jslib

const uMicrophoneWebGLPlugin = {
    // 複数の場所から参照する必要のある
    // グローバルへと展開してほしくない変数や関数をまとめておく
    $uMicrophoneWebGL: {
        startCallback: null,
        dataCallback: null,
        devices: [],
        ...
        
        // 初期化でコールバックを保持しておく
        initialize: async function(startCallback, dataCallback, ...) {
            this.startCallback = startCallback;
            this.dataCallback = dataCallback;
            ...
            await this.updateDeviceList();
        },
        
        updateDeviceList: async function() {
            devices = ...
        },

        ...
        
        // 基本的には async で非同期実行となる
        // 結果は登録しておいたコールバックを経由して返す
        start: async function() {
            ...
            const stream = await navigator.mediaDevices.getUserMedia({ 
                audio: { 
                    device: device.deviceId,
                    sampleRate: { ideal: 44100 },
                },
                video: false,
            });
            this.audioContext = new AudioContext();
            this.source = this.audioContext.createMediaStreamSource(stream);
            this.scriptProcessor = this.audioContext.createScriptProcessor(this.samples, 1, 1);
            this.scriptProcessor.onaudioprocess = this.onAudioProcess.bind(this);
            this.source.connect(this.scriptProcessor);
            this.scriptProcessor.connect(this.audioContext.destination);
            Module.dynCall_v(this.startCallback);
            ...
        },
        
        // JS -> C# への float[] の受け渡しは HEAPF32 を通じて行う
        onAudioProcess: function(event) {
            const data = event.inputBuffer.getChannelData(0);
            const len = data.length;
            const ptr = _malloc(len * 4);
            HEAPF32.set(data, ptr >> 2);
            Module.dynCall_vii(this.dataCallback, ptr, len);
        },
        
        // その他諸々の API を用意
        stop: function() {
            ...
        },
        
        isRecording: function() {
            ...
        },
    },

    // Unity 向けに DllImport して使える API としての関数は
    // 広いスコープへと展開されてしまうため、他のライブラリと名前被りしないように
    // ライブラリ名を付与した関数として定義しておく
    uMicrophoneWebGL_Initialize: async function(startCallback, stopCallback, ...) {
        await uMicrophoneWebGL.initialize(...);
    },
    
    // ↑で用意しておいた API を叩く
    uMicrophoneWebGL_GetDeviceCount: function() {
        return uMicrophoneWebGL.getDeviceCount();
    },
    
    uMicrophoneWebGL_GetLabel: function(index) {
        const device = uMicrophoneWebGL.getDevice(index);
        return device ? device.labelBuffer : "";
    },

    ...
};

autoAddDeps(uMicrophoneWebGLPlugin, '$uMicrophoneWebGL');
mergeInto(LibraryManager.library, uMicrophoneWebGLPlugin);

どのように展開されてしまうかについては前回の記事で詳しく書いてあります。

Unity C#

[Serializable]
public class TimingEvent : UnityEvent
{
}
    
[Serializable]
public class DataEvent : UnityEvent<float[]> 
{
}

public static class Lib
{
    ...
    // 非同期で返ってくるのでイベントにしておく
    public static TimingEvent startEvent { get; } = new();
    public static DataEvent dataEvent { get; } = new();
    ...

    // DllImport では EntryPoint を指定して冗長になった名前を短くする
    // エディタ上でも同じインターフェースで動くように処理を書いておく
#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal", EntryPoint = "uMicrophoneWebGL_Initialize")]
    private static extern void Initialize(
        Action startCallback,
        Action<IntPtr, int> dataCallback, ...);
#else
    private static void Initialize(
        Action startCallback,
        Action<IntPtr, int> dataCallback, ...)
    {
        ...
    }
#endif

    // これで JavaScript 側へ関数登録ができる
    public static void Initialize()
    {
        ...
        Initialize(OnStarted, OnDataReceived, ...);
    }
    
    // JavaScript 側から呼び出されるコールバックには PInvokeCallback 属性を付けておく
    [AOT.MonoPInvokeCallback(typeof(Action))]
    static void OnStarted()
    {
        startEvent.Invoke();
    }

    // HEAPF32 で渡された配列は取り出して使う
    [AOT.MonoPInvokeCallback(typeof(Action<IntPtr, int>))]
    static void OnDataReceived(IntPtr ptr, int length)
    {
        ...
        _dataBuffer = new float[length];
        Marshal.Copy(ptr, _dataBuffer, 0, length);
        dataEvent.Invoke(_dataBuffer);
    }

    // エディタでも動くように書くのは 2 重実装になるのでまぁまぁ大変
#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal", EntryPoint = "uMicrophoneWebGL_Start")]
    public static extern void Start();
#else
    ...
    public static void Start()
    {
        _recordGameObj = new GameObject("Microphone");
        // こういった別途等価に扱うためのコンポーネントを用意する必要がある
        _micInputRetriever = _recordGameObj.AddComponent<EditorMicrophoneDataRetriever>();
        _micInputRetriever.dataEvent.AddListener(x => dataEvent.Invoke(x));
        var deviceId = GetDeviceId(_deviceIndex);
        var freq = GetSampleRate(_deviceIndex);
        _micInputRetriever.Begin(deviceId, freq);
        
        startEvent.Invoke();
    }
#endif

    // 同じように色々な API を用意していく
#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal", EntryPoint = "uMicrophoneWebGL_GetDeviceCount")]
    public static extern int GetDeviceCount();
#else
    public static int GetDeviceCount() => Microphone.devices.Length;
#endif

#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal", EntryPoint = "uMicrophoneWebGL_GetDeviceId")]
    public static extern string GetDeviceId(int index);
#else
    public static string GetDeviceId(int index) => 
        index >= 0 && index < Microphone.devices.Length ?
            Microphone.devices[index] : 
            "";
#endif

#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal", EntryPoint = "uMicrophoneWebGL_GetLabel")]
    public static extern string GetLabel(int index);
#else
    public static string GetLabel(int index) => GetDeviceId(index);
#endif

    ...
}

}

JavaScriptC# 側での配列のやり取りについては、以下のフォーラムで gtk2k さんが素晴らしい返答をしてくださっているので参考にさせていただきました。

forum.unity.com

3 種類の方法が紹介されていますが、今回は継続的にデータを受け取るためのコールバックを登録しているので 2 番目の方式を使っています。面白いので少し詳しく見てみましょう。

const data = event.inputBuffer.getChannelData(0);
const len = data.length;
const ptr = _malloc(len * 4);
HEAPF32.set(data, ptr >> 2); // float の番地をセット
Module.dynCall_vii(this.dataCallback, ptr, len);

_malloc でメモリを必要なメモリ領域を確保していますが、float は 4 byte なので、その個数分の領域を要求しています。HEAPF32Emscripten の提供してくれる型付き配列ビューで、この確保した領域に float 配列をセットしています。ptr >> 2ptr / 4 と等価で 4 byte を考慮して float としてのインデックスを返している感じですね。Wasm ではメモリはリニアなので、このように単純に割り算でインデックスが求められているのだと理解してます(たぶん)。

使い方

さて、こうして作成したものを uMicrophoneWebGL というパッケージにまとめました。

github.com

UPM にてパッケージ追加あるいは GitHub にあげてある .unitypackage をダウンロード・インポートしてください。そのうえで MicrophoneWebGL というコンポーネントをアタッチすることで使えるようになります。UI は以下のような感じになります:

UI 項目

  • Is Auto Start

    • チェックされている場合、起動時に自動的に録音開始になります(後述の Begin() が呼ばれます)
  • Devices

    • 内部的にはインデックス(int)で管理されています。
    • どのユーザー環境基本的には 0 番を選択しておいてください(デバイス選択はデバッグ目的の使用のみ)
  • Events

    • 様々なイベントのコールバックを設定できます。
    • Ready Event
      • JavaScript 側で初期化が完了し、準備ができた後に 1 回呼び出されます。
    • Device List Event
      • バイスリストが構築されたときに呼び出されます。
      • バイス情報を含むリストが引数として渡されます。
    • Start Event
      • マイクが開始されたときに呼び出されます。
      • 内部的には navigator.mediaDevices.getUserMedia()の完了に対応しています。
    • End Event
      • マイクが停止したときに呼び出されます。
    • Data Event
      • マイク入力データが取得されたときに呼び出されます。
      • float 配列の波形データの配列が引数として渡されます。

API リファレンス

MicrophoneWebGL コンポーネントは以下のプロパティとメソッドを提供します:

変数 / プロパティ
  • bool isAutoStart

    • UI の Is Auto Start オプションに対応します。true の場合、起動時にマイクが自動的に開始されます。
  • int micIndex

    • 選択されたマイクデバイスのインデックス。
  • TimingEvent readyEvent

    • 初期化後、マイクが準備完了したときにトリガーされるイベント。
  • TimingEvent startEvent

    • マイクが録音を開始するときにトリガーされるイベント。
  • TimingEvent stopEvent

    • マイクが録音を停止するときにトリガーされるイベント。
  • DeviceListEvent deviceListEvent

    • 使用可能なデバイスのリストが更新されたときにトリガーされるイベント。
  • DataEvent dataEvent

    • マイクデータが取得されたときにトリガーされるイベント。
  • bool isValid { get; }

    • マイクが使用可能な状態であるかどうかを示す。
  • List<Device> devices { get; }

    • 利用可能なマイクデバイスのリスト。
    • Device 構造体は次のような情報を含む
      • deviceId はマイクを識別する ID
      • label はデバイスの名前
      • sampleRate はマイクのサンプルレート
  • Device selectedDevice { get; }

    • 現在選択されているマイクに関する情報。
  • bool isRecording { get; }

    • マイクロフォンが現在録音中であるかどうかを示します。
メソッド
  • void Begin()

    • マイクを開始。録音中の場合は何もしない。
  • void End()

    • マイクを停止。録音中でない場合は何もしない。
  • void RefreshDeviceList()

    • マイクのリストの更新要求。
    • 初期化時には自動で呼ばれる。
    • 更新の終了時に再度 deviceListEvent がトリガーされる。

サンプル解説

サンプルでは録音を行う 01. Recorder と、波形を表示する 02. Buffer が含まれています。冒頭の GIF がこれらに対応しています。用意したイベントに登録する関数を用意し、適切なタイミングで Begin()End() を呼んで後は各コールバックでイベントのハンドルを行っています。

より色々なイベントを扱う Recorder の方のコードの重要な部分だけ抜き出してコメントを書いてみました。

public class MicrophoneRecorder : MonoBehaviour
{
    public float maxDuration = 10f;

    private float[] _buffer = null;
    private int _bufferSize = 0;
    private AudioClip _clip;

    ...

    public void ToggleRecord()
    {
        // この関数を UI のボタン押下に紐づけておく
        ...
        if (!isRecording)
        {
            Begin();
        }
        else
        {
            End();
        }
        ...
    }
    
    private void Begin()
    {
        // マイクを選択して録音開始
        microphoneWebGL.micIndex = dropdown.value;
        microphoneWebGL.Begin();
    }
    
    private void End()
    {
        // 録音終了
        microphoneWebGL.End();
    }

    public void OnBegin()
    {
        // 録音開始イベント
        // UI からこの関数を startEvent に登録しておく
        // 必要なバッファのサイズを計算して領域確保
        int freq = microphoneWebGL.selectedDevice.sampleRate;
        int n = (int)(freq * maxDuration);
        _buffer = new float[n];
        _bufferSize = 0;
    }

    public void OnData(float[] input)
    {
        // データ取得イベント
        // UI からこの関数を dataEvent に登録しておく
        // 先程確保した _buffer に来たデータをつめておく
        int n = input.Length;
        if (_bufferSize + n >= _buffer.Length) return;
        System.Array.Copy(input, 0, _buffer, _bufferSize, n);
        _bufferSize += n;
    }

    public void OnEnd()
    {
        // 録音終了イベント
        // UI からこの関数を stopEvent に登録しておく
        // AudioClip を作成し
        var freq = microphoneWebGL.selectedDevice.sampleRate;
        _clip = AudioClip.Create("Recorded", _bufferSize, 1, freq, false);
        var data = new float[_bufferSize];
        System.Array.Copy(_buffer, data, _bufferSize);
        _clip.SetData(data, 0);
    }

    public void TogglePlay()
    {
        // Play ボタンを押したら先程の AudioClip をセットして再生
        ...
        if (audioSource.isPlaying)
        {
            audioSource.Stop();
        }
        else
        {
            audioSource.clip = _clip;
            audioSource.Play();
        }
    }
}

関連

以前、Unity 側のバッファを Web Audio API 側で再生する方法の記事は書きましたので、よければこちらもご参照ください(本記事中は float 配列を JavaScript 側から C# へ、という流れでしたが、こちらの記事は逆)。

tips.hecomi.com

おわりに

当初は AudioClip に直接流し込みながらリアルタイムで音声再生、とした上でそのバッファを uLipSync に渡す、という設計をしていました(これだと AudioClip に対応してリップシンクする仕組みに何も手を入れなくて良い)。しかしながら SetData() を毎フレーム行うのが WebGL と相性が悪いのか余りうまく行かず…、結局今回の形にしました。ただこれによって生じる遅延が最小化出来たので結果的には良かったかなと思っています。次回は uLipSync とのつなぎ込みを行います。