凹みTips

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

uLipSync の WebGL 対応をしてみた

はじめに

前回調査記事を書きました。

tips.hecomi.com

今回は続きで、調査を元に実際に WebGL のサポートを行いました(マイクサポートのみまた後日)。実装しながら気づいたことや、問題に対してどのように対応したかなどについてまとめましたのでご紹介します。

ダウンロード

github.com

実験と問題解決

前回は WebGL では解析の入口となっていたオーディオバッファを取得できる OnAudioFilterRead() が動作しないことから、代替手段として Web Audio API からバッファをもらってくる方式と AudioClip.GetData() によって取ってくる方式のメリット・デメリットを比較しました。前回の記事の結論としては、汎用性とコード変更性の少なさから AudioClip.GetData() を選択することにした、というものでした。

それではまずはシンプルにコードを書いて試してみます。AudioClip.GetData() が動作するので、あとは AudioSource から現在の再生位置を取ってくる形にします。オーディオバッファの取得部分はメインスレッドでの実行になってしまいますが、API 自体は動作します。

#if UNITY_WEBGL && !UNITY_EDITOR
float[] _audioBuffer = null;
#endif
...
void Update()
{
    ...
#if UNITY_WEBGL && !UNITY_EDITOR
    UpdateWebGL();
#endif
    ...
}
...
#if UNITY_WEBGL && !UNITY_EDITOR
void UpdateWebGL()
{
    if (!_audioSource) return;
    
    var clip = _audioSource.clip;
    if (!clip || clip.loadState != AudioDataLoadState.Loaded) return;

    int ch = clip.channels;
    int fps = 60;
    int n = AudioSettings.outputSampleRate / fps * ch;
    if (_audioBuffer == null || _audioBuffer.Length != n)
    {
        _audioBuffer = new float[n];
    }

    int offset = _audioSource.timeSamples;
    offset = math.min(clip.samples - n - 1, offset);
    clip.GetData(_audioBuffer, offset);
    OnDataReceived(_audioBuffer, ch);
}
#endif
...
public void OnDataReceived(float[] input, int channels)
{
    // 解析
    ...
}
...
void OnAudioFilterRead(float[] input, int channels)
{
    ...
    OnDataReceived(input, channels);
}
...

本当は前回の timeSamples を覚えておき、そこからの差分を取得するべきです。そうしないと前回与えた音声バッファとズレが起きてしまいます。ただ一方これだとパフォーマンスのブレでバッファ(_audioBuffer)の数が可変になってしまい、頻繁に new float[] する必要があるため、パフォーマンス観点から望ましくありません。そのためバッファの大きさが一定になるよう仮の FPS を設定し、最新の再生部分から一定の量ずつバッファを取ってくる形としました。ただこれだとぶつ切りのバッファを解析のリングバッファに突っ込んでいる形になっています。この関係でバッファの接続部は不連続になっているのですが、基本的には母音の解析なのでそれぞれのチャンク程度の長さがあれば十分なのでこれでも実際は大きな問題なく動作すると思われます。

Load Type による制限

しかしながらこのままではビルドしても動作しません。原因を見ていきましょう。

GetData()リファレンス によると、現在の制限としては、AudioClip の Load TypeCompressed In Memory になっている場合、AudioClip.GetData() が使用できません(同様にストリーミングの設定の際も使用できません)。圧縮された音声に対して GetData() すると次のようにエラーとなります。

確かに生バッファの特定位置にアクセスしたいわけですから、圧縮済みのものからはそのままでは持ってこれないわけですね。代わりに Decompressed On Load をすると、その名の通りロード時に圧縮解除してくれるため、バッファを取得することができるようになります。

この設定をすることで口パクしてくれるようになります。しかしながら次の問題が出てきます。

音の同期ズレ問題

Chrome などの Web ブラウザでは、ユーザの入力なしに勝手にメディアを再生することに対して制限を設けています。例えば Chrome では Autoplay Policy という名前で紹介されています。

developer.chrome.com

qiita.com

これは Web の規格ではないようですが、これを許すと勝手にバックグラウンドで音やビデオがドコドコ鳴ってしまいますし、勝手に計算リソースも消費されてしまうので、エンドユーザ視点だとありがたい仕様です。ただ開発者側は少し面倒で、次のような手順で再生済みオーディオを再開する必要があります。

// AudioContext を生成する
window.onload = function() {
  var context = new AudioContext();
  ...
}

// なにかしらのユーザインタラクション(ここでは click イベント)が
// 発生したら AudioContext を resume する
document.querySelector('button').addEventListener('click', function() {
  context.resume().then(() => {
    console.log('ドコドコ');
  });
});

さて、このレジューム処理により単に音を再開して鳴らすだけなら問題ないのですが、再生している音声と何かしら同期したいとなると、ズレが起きてしまうようです。どうやら GetData() では通常通り最初から再生されていると認識されバッファが降ってくるのですが、実際の再生されている音声はレジュームしたタイミング(ユーザ操作のタイミング)で頭から再生開始される、といった状態になるようです。Unity 側の実装で、このあたりうまく処理してほしいところですが...、出来ていないことに対する深い理由は調べましたがわかりませんでした(内部的には音声発音がされている形にしないと、確かに困る局面は出てきそうなので、どっちを取るか問題なのかもしれません)。一応、Unity での Autoplay Policy に対する実装を見てみると次のようにユーザ入力を見てコンテキストを resume() していました。

mousedown は主に PC 向け、touchstart はタッチ可能なデバイススマホなど)向けのようです。基本的には、アプリ制作者向けのベストプラクティスとしては、何かしらのスタート画面を設け、クリックやタッチを促してからゲームやアプリを開始してもらい、その後に音を鳴らし始める、という感じのようです。ただ、ライブラリ制作者としては色々なユースケースを拾うために、ユーザ操作前から鳴っている音も対応したいです。

対策検討

さて、同期ズレを直す方法はないかと調べていたところ少し主旨は違いますが Unity の WebGL での知見の面白い記事がありました。

note.com

記事によると AudioSource.timeSamples はどうやら代入も効くようなので、次の謎コードを書いてみます。

void UpdateWebGL()
{
    ...
    _audioSource.timeSamples = _audioSource.timeSamples
    ...
}

なんとこうすると再生位置がバッファの取得場所と一致して、タイミングが合うリップシンクになります(プロパティの get / set は中身は関数なので、WebGL の場合は内部の実装が非対称になっていそうですね)。ただ、上のコードでは毎フレーム Update() タイミングで位置調整をしてしまっている関係で、オーディオスレッドとメインスレッドによる更新タイミングの誤差(オーディオ更新のスレッドの周期は Unity のメインスレッドの更新周期と異なる)で、音がガビガビします。

どこかにフックしたり、コールバックなどで AudioContext のレジュームタイミングにこの処理を実行してあげれば良いかと思いましたが、Unity が吐き出すコード中の _JS_Sound_Init を覗いてみてもあまり良い仕組みは無さそうです。もちろん吐き出し後の .js を書き換えてあげれば色々できますが、毎度ビルド後に書き換えるのはライブラリ使用者側の手間もかかりますし避けたいところです。

C# だけでなるべく完結したかったですが、ここは jslib プラグインを書いてユーザイベントにフックして上記コードを実行できるようにしてみます。

実装の流れ

設計

方針としては JavaScript 上で window.addEventListner したコールバックを、再生済みの複数の uLipSync インスタンスが受け取れるようにしたいです。少し図にすると流れが複雑ですが次のような設計をしてみました。

  1. static なクラスを作成(ここでは WebGL)し、この中で RuntimeInitializeOnLoadMethod アトリビュートを利用して起動時に同クラス内のコールバックを(ここでは OnAuidoContextInitiallyResumed を jslib 側に登録
  2. 同時に window.addEventListener で種々のイベントが呼ばれたらこのコールバックが呼ばれるよう登録
  3. uLipSync コンポーネントAwakeWebGL クラスに自身を登録
  4. ユーザーイベントが発火されると jslib 側から登録しておいた C# 側の OnAuidoContextInitiallyResumed を発火
  5. 登録しておいた uLipSync コンポーネントに通知

この流れです。

実装

では具体的なコードを覗いてみましょう。

WebGL.cs

まずは C# 側からです。

public class uLipSync : MonoBehaviour
{
    ...
    void Awake()
    {
        ...
#if UNITY_WEBGL && !UNITY_EDITOR
        InitializeWebGL();
#endif
    }
    ...

#if UNITY_WEBGL && !UNITY_EDITOR
    public void InitializeWebGL()
    {
        WebGL.Register(this);
    }

    public void OnAuidoContextInitiallyResumed()
    {
        _audioSource.timeSamples = _audioSource.timeSamples;
        ...
    }

    ...
#endif

    ...
}

public static class WebGL
{
    static List<uLipSync> instances = new List<uLipSync>();
    static bool isAudioContextResumed = false;

    public static void Register(uLipSync instance)
    {
        if (isAudioContextResumed) return;
        instances.Add(instance);
    }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Init()
    {
        OnLoad(OnAuidoContextInitiallyResumed);
    }
    
    [DllImport("__Internal")]
    public static extern void OnLoad(System.Action callback);
    
    [AOT.MonoPInvokeCallback(typeof(System.Action))]
    public static void OnAuidoContextInitiallyResumed()
    {
        foreach (var instance in instances)
        {
            instance.OnAuidoContextInitiallyResumed();
        }
        instances.Clear();
        isAudioContextResumed = true;
    }
}

DllImport で宣言している OnLoad が後で見る jslib 側の関数になります(WebGL では DllImport("__Internal") をつけることで呼び出しが可能)。MonoPInvokeCallback アトリビュートをつけた OnAuidoContextInitiallyResumed をこの OnLoad に渡して初期化しています。後でこれを jslib 側から呼ぶイメージです(jslib 側から呼ぶ関数にはこのアトリビュートを付与する必要があります)。uLipSync コンポーネントからは Register が呼ばれて各 uLipSync インスタンスがリストに登録され、後で jslib 側から OnAuidoContextInitiallyResumed をコールされた時にすべてのインスタンスに通知が飛ぶ、というイメージです。

uLipSync.jslib

次は jslib 側です。

const uLipSyncPlugin =
{
    $uLipSync:
    {
        unityCsharpCallback: null,
        resumeEventNames: ['keydown', 'mousedown', 'touchstart'],
        userEventCallback: function() {
            Module.dynCall_v(uLipSync.unityCsharpCallback);
            for (const ev of uLipSync.resumeEventNames) {
                window.removeEventListener(ev, uLipSync.userEventCallback);
            }
        }
    },

    OnLoad: function(callback) {
        if (WEBAudio.audioContext.state !== 'suspended') return;
        uLipSync.unityCsharpCallback = callback;
        for (const ev of uLipSync.resumeEventNames) {
            window.addEventListener(ev, uLipSync.userEventCallback);
        }
    },
};

autoAddDeps(uLipSyncPlugin, '$uLipSync');
mergeInto(LibraryManager.library, uLipSyncPlugin);

jslib 側はちょっと癖がありますが…、とても詳しい解説が gtk2k さんによって書かれているのでご一読ください。

qiita.com

細かいところを見ていきます。autoAddDeps を行うと JavaScript 側に $ を外したオブジェクトが(Unity のスコープ内に)生成されます。一方で通常通り mergeInto するとアンダースコア付きの関数として展開されます(その名の通り merge into として含まれるイメージ)。なので一時的に変数を保存したいときは autoAddDeps しています。具体的には以下のようなコードへと展開されます。

OnLoad が呼ばれたタイミングではコンテキストが suspended のときだけ処理します(Autoplay Policy が働き、Unity 側からは再生しているように見えるけど音声は再生されていない状態)。与えられたコールバックを保存し、イベントリスナを登録しておきます。そして userEventCallback がユーザ操作によって呼ばれたとき、Module.dynCall_v でそのコールバックを呼び出し、イベントリスナを解除します。

この Module.dynCall_v ですがドキュメントがあまりありません…。以下のサイトの解説がとてもわかりやすかったです。

qiita.com

Unity 公式では製品の一つである Untiy Forma のドキュメントに記載があります。

docs.unity3d.com

Module.dynCall_v(...) の代わりに dynCall('v', ...) でも動作しますが、dynCallLegacy に接続されてしまっていたので、手元のコードでは Module を使用する方向でひとまず書いておくことにします。

結果

さて、これで uLipSyncOnAuidoContextInitiallyResumed がユーザ操作時によって呼ばれ、AudioSourcetimeSamplestimeSamples を代入する、という謎コードを経て同期が取れるようになりました。これで WebGL でのサポートが出来た形になります。

タイミング調整

それでもちょっとタイミングがズレている(遅れている)ように見えました。今回は任意の場所のバッファを取ってくる形になっているので、この取ってくる位置にオフセットをかけることが可能で、これにより良い感じに口が合うように調整できます(逆にリアルタイムのバッファベースだと先読みが出来ません、なのでこの方式の唯一の利点だと思います)。

[Range(-0.2f, 0.2f)] pulbic float audioSyncOffsetTime = 0.1f;
...
void UpdateWebGL()
{
    ...
    int offset = _audioSource.timeSamples;
    offset += (int)(audioSyncOffsetTime * AudioSettings.outputSampleRate * ch);
    ...
    clip.GetData(_audioBuffer, offset);
    OnDataReceived(_audioBuffer, ch);
}

パフォーマンス

最後に現状のパフォーマンスを見ていきましょう。Chrome のプロファイラで見てみます。

uLipSync では取得したバッファは JobSystem によって生成されたジョブがワーカスレッド 1 つを使い裏で計算が行われ、次フレームで解析結果が回収されます。ただ WebGL ではデフォルトでは上図のようにメインスレッド上でジョブが実行されます。

Unity 上ではまだ WebGL はメインスレッドのみの実行なので、現状のコードのままではパフォーマンス改善はしばらく待つことになるかもしれません。JobSystem 計算部分を切り出し、JavaScript 側の Web Worker 上で実行できるよう jslib を使ってつなぐことができればパフォーマンス改善の余地はあるかもしれません。これは今後の課題としたいと思います。

上記画像を見ていただくとわかるように、私の MacBook Pro (2021 / M1) だと 4 ms もかかってしまっています。。より細かく見ていくと、ダウンサンプルのための前処理として行っているローパスフィルタ処理に時間がかかっています。

複数キャラの会話などのユースケースでは、可能な際は事前ベイクを積極的に利用していったほうが良さそうです。

おわりに

次回はマイクのサポートを行いたいと思います。マイク側は今回のように Unity 側の取り扱いを変えるだけでは実現できず、JavaScript 側で getUserMedia() して取得したバッファを Unity 側へ渡す必要があるので、既存の仕組みとの互換性を保つために少しかかる予定です。