凹みTips

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

Unity でリアルタイムにリップシンクを行うプラグインを作り直してみた

はじめに

Unity リップシンクを行う手段としては、事前にデータを作るものからリアルタイムに解析するものまで色々あります。本ブログでも以前、自分で作ったり Oculus 製のものを解説したりしました。

tips.hecomi.com

tips.hecomi.com

前者の記事で自分が 7 年程前に作ったものは様々な依存(外部ライブラリやツールなど)が多く扱いづらいものでした。そこで今回これらの依存を廃してリップシンク単機能に絞り、またもう少しナウい実装にして uLipSync という名前で作り直してみました。

  • Job と Burst コンパイラを利用して処理を裏に回しつつ高速化
  • Unity のパッケージ以外の依存を排除

本エントリでは使い方をちょっとした解説を交えて紹介したいと思います。

追記(2021/02/27)

新しいバージョンの説明を書きました。

tips.hecomi.com

デモ

GIF

Youtube

ダウンロード

github.com

Releases ページから最新のパッケージを自身のプロジェクトへインポートしてください(執筆時は 0.0.1 です)。

また、Unity.Burst および Unity.Mathematics を必要とするので、プロジェクトにインストールされていない場合は Package Manager から追加してください。

f:id:hecomi:20210110003113p:plain

使い方

uLipSync は AudioSource と連携して動作します。ボイスを喋らせる AudioSource のアタッチされた GameObject に uLipSync コンポーネントをアタッチしてください。すると次のような UI が見えると思います。

f:id:hecomi:20210106010605p:plain

次にブレンドシェイプを持つキャラクタのルートに uLipSyncBlendShape コンポーネントをアタッチしてください(テクスチャでリップシンクする便利コンポーネントは未作成ですが、対応方法については後述します)。Find From Children にチェックを入れて対象の口パクのブレンドシェイプを持つ Skinned Mesh Renderer を探し、A ~ O の母音に対応するブレンドシェイプをプルダウンから割り当てます。

f:id:hecomi:20210106233359p:plain

uLipSyncコンポーネントに戻り、セットアップした uLipSyncBlendShapeOnLipSyncUpdate を Callback セクション内にある On Lip Sync Update (LipSyncInfo) に割り当てます。

f:id:hecomi:20210106233550p:plain

最後に喋る声のキャラクタに応じて男性(Man)か女性(Woman)を LipSync Profile セクションの中で選択します(カスタマイズする方法は後述します)。

f:id:hecomi:20210110030655p:plain

これでゲームを開始し、音声を再生すると口パクするようになります。

コンポーネントについて

uLipSync

リップシンクのための計算を行うコンポーネントです。MonoBehaviour には同じゲームオブジェクトで再生されている音(サウンドのバッファ)を OnAudioFilterRead() という関数で受け取るための仕組みが用意されており、これを利用するので AudioSource と同じゲームオブジェクトにアタッチする必要があります。

uLipSyncBlendShape

SkinnedMeshrenderer に設定された口パクのブレンドシェイプを動作させるコンポーネントです。子以下の SkinnedMeshRendererブレンドシェイプを探しやすいようにプルダウンでサポートしてます。uLipSyncOnLipSyncUpdate() を登録することによって動作します。

uLipSyncMicrophone

f:id:hecomi:20210107013152p:plain

事前録音データの代わりにマイクを使いたいときにこのコンポーネントを利用します。マイク入力を流し込む AudioClip を作成し AudioSource へ登録します。なので uLipSync と同じく AudioSource がついているゲームオブジェクトにアタッチしてください。エディタ上からはテスト用にボタンでスタート・ストップできますが、スクリプトからも StartRecord()StopRecord() することで制御できます。また、index でマイクを指定することが出来ます。どのマイクがどのインデックスかは uLipSync.MicUtil.GetDeviceList() から調べてください。

パラメタ

f:id:hecomi:20210110025752p:plain

Parameters セクションでは次のような設定ができます。主に口の開きかたに関連したパラメタが用意されています。

  • Volume
    • Normalized Volume
      • Min VolumeMax Volume で 0.0 ~ 1.0 にスケールされたボリュームがビジュアライズされます
    • Min Volume
      • これよりも小さい音量の音は無視されます
    • Max Volume
      • このボリュームが口を最大開く(ブレンドシェイプの値が 100)値になります
    • Auto Volume
      • チェックすると Max Volume が現在の入力から自動計算されます
    • Auto Volume Amp
      • Max Volume = 現在の入力ボリューム * Amp
    • Auto Volume Filter
      • 毎フレーム Max Volume がこの値だけ減少 (MaxVolume *= Filter)
  • Smoothness
    • Open Smoothness
      • 口を開くスピード (1.0 に近づくほどスムーズ)
    • Close Smoothness
      • 口を閉じるスピード
    • Vowel Transition Smoothness
      • 母音によって口の形が変化するスピード
  • Output
    • Output Sound Gain
      • サウンドの出力のゲイン
      • マイク入力を利用していてマイクの声を再生したくないとき(別口で再生したいとき)は 0.0 にしてください

プロファイル

声は色々な周波数の波が重なってできています。それぞれの母音は周波数の組み合わせ具合に特徴を持っており、大体この付近の周波数の成分が多いみたいな形になっています。

この山の 1 番目と 2 番目の周波数を第 1 フォルマント、第 2 フォルマントと呼びます。

ja.wikipedia.org

例えば男性の声で「あ」の第 1 フォルマントは他の母音よりも高い 900 kHz 付近で、第 2 フォルマントは 1400 kHz くらいに位置する、といった形になっています。現在解析している音声が、この第 1 フォルマント、第 2 フォルマントをプロットしたグラフ上でどの母音に近いかを見てあげれば大体今喋っている母音を判別できます。この設定を行うのが LipSync Profile セクションです。

f:id:hecomi:20210107235105p:plain

Profile はアセットになっており、プリセットで男性用の Man、女性用の Woman を用意しています(ボタンを押すとセットされます)。プリセットは編集できなくしていますが、人によってフォルマントの周波数は異なるので個人ごとに調整できるよう Create ボタンを押すと Profile アセットを作成できるようになっています。

f:id:hecomi:20210107235840p:plain

後述するビジュアライザセクションと組み合わせてフォルマントを確認しながら調整してください。以下、各パラメタ詳細です:

  • Formant
    • F1
      • 第 1 フォルマントの周波数(Hz)
    • F2
      • 第 2 フォルマントの周波数(Hz)
  • Tips
    • 平均的なフォルマントの紹介
  • Visualizer
    • X 軸が第 1 フォルマント、Y 軸が第 2 フォルマント
    • 母音同士が近すぎると判定が出来ないのである程度開いていることを確認してください
  • Settings
    • Use Error Range
      • 範囲外の入力を除外したいときはチェックを入れてください
    • Max Error Range
      • ↑でチェックを入れた場合の範囲です
    • Min Log 10H
      • フォルマントの山がこの値よりも低いときはスキップします(そのままをオススメ)

Callback

リップシンクの解析結果は UnityEvent 経由で受け取ることが出来ます。幾つでも登録できるので口パク以外にも音量で光るギミックとかにも使えます。

f:id:hecomi:20210108002158p:plain

登録するためのコールバックは次のように LipSyncInfo を受け取る形で用意しておきます。

using UnityEngine;
using uLipSync;

public class DebugPrintLipSyncInfo : MonoBehaviour
{
    public float threshVolume = 0.01f;
    public bool outputLog = true;

    public void OnLipSyncUpdate(LipSyncInfo info)
    {
        if (info.volume > threshVolume && outputLog) 
        {
            Debug.LogFormat("MAIN VOWEL: {0}, [ A:{1} I:{2}, U:{3} E:{4} O:{5} N:{6} ], VOL: {7}, FORMANT: {8}, {9}",
                info.mainVowel, 
                info.volume, 
                info.vowels[Vowel.A],
                info.vowels[Vowel.I],
                info.vowels[Vowel.U],
                info.vowels[Vowel.E],
                info.vowels[Vowel.O],
                info.vowels[Vowel.None],
                info.formant.f1, 
                info.formant.f2);
        }
    }
}

これをイベントとして登録すれば該当の処理が実行されます。uLipSyncBlendShape コンポーネントも似たようなシンプルな実装で次のように SkinnedMeshRendererブレンドシェイプを設定しているだけです(後は便利設定用のエディタ拡張が付属してます)。

public class uLipSyncBlendShape : MonoBehaviour
{
    public SkinnedMeshRenderer skinnedMeshRenderer;
    public List<BlendShapeInfo> blendShapeList = new List<BlendShapeInfo>();
    ...

    public void OnLipSyncUpdate(LipSyncInfo info)
    {
        foreach (var kv in info.vowels)
        {
            int i = (int)kv.Key;
            blendShapeList[i].blend = kv.Value;
        }
        volume_ = info.volume;
    }

    void LateUpdate()
    {
        if (!skinnedMeshRenderer) return;

        foreach (var info in blendShapeList)
        {
            if (info.index < 0) continue;

            float blend = info.blend * info.factor * volume_ * 100;
            skinnedMeshRenderer.SetBlendShapeWeight(info.index, blend);
        }
    }
}

コンフィグ

f:id:hecomi:20210108013523p:plain

Calculation Config のセクションでは計算のパラメタを設定します。こちらも Profile と同じくアセットになっています。計算の詳細は以前作ったものと同じです:

tips.hecomi.com

  • Config
    • Default
      • 推奨の計算設定です。
    • Calibration
      • 安定するまでに 1 秒くらいかかりますが「あ~」「い~」という入力に対してフォルマントがどの周波数なのかを詳しく見やすいです。
    • Create
      • Config アセットを新規に作成します。
  • Sample Count
    • 解析に用いるバッファのサンプル数です(48000 Hz なら 1 フレームは 800 サンプルです)。
  • Lpc Order
    • LPC(線形予測符号)の係数の数
  • Frequency Resolution
    • 包絡線の分割数(Max Frequency までの周波数の分割数)
  • Max Frequency
    • 解析に用いる最大周波数(大体 3000 Hz 付近が一番高いフォルマント)
  • Window Func
    • 窓関数を幾つか用意してます、デフォルトはハン窓(O が出やすいです)
      • Hann: ハン窓
      • Blackman-Harris: ブラックマンハリス窓
      • Gauss_4_5: ガウス窓 (sigma = 4.5)
  • Check Second Derivative
    • O の第 1 / 2 フォルマントが近く見えづらいケースがあるのでピーク検出の代わりに 2 階微分値のピークを調べます。
  • Check Third Formant
    • ノイズにより低周波領域に不要なフォルマントが見えてしまうことがあるので 3 つ目のフォルマントまで一致具合を調べます。
  • Filter H

ビジュアライザ

f:id:hecomi:20210109165412g:plain

入力が正確にフォルマントとして見えているかグラフで確認できる仕組みとしてビジュアライザを用意しています。

  • Draw On Every Frame
    • エディタが毎フレーム Repaint() を呼ぶことによってリアルタイムに描画が見えますが、ゲームのパフォーマンスを低下させるので確認したいときのみチェックを入れてください(入れないとカクカクと動きます)
  • Formant Map
    • 横軸が第 1 フォルマント、縦軸が第 2 フォルマントです。
    • ラインタイム時は現在の入力が白丸で見えます。
  • LPC Spectral Envelope
    • 入力スペクトルの包絡線や通常の FFT のグラフなどが見えます。
    • LPC
      • 包絡線を描画
    • dLPC
      • 包絡線の 2 階微分を描画
    • FFT
      • FFT の結果を描画
    • Formant
      • 現在の認識されたフォルマントを描画

余談ですがグラフ描画は以下のページで解説している Handle を使っています。

tips.hecomi.com

ジョブについて

計算はジョブ化しメインループの裏で回すことにします。バッファが溜まったら Update() のタイミングでジョブをスケジュールし、解析が終わったタイミングの以降の Update() で結果を回収します(デフォルト設定なら計算は 1ms 程度なので次フレームになります)。ジョブを作るのは簡単で次のようなものを書きます。LPC の計算は Unity の(API の)世界とは関係なく純粋に数値計算なのですが、こういったものはジョブにしてしまって Burst を効くようにするととても速くなります。

using Unity.Jobs;
using Unity.Collections;
using Unity.Mathematics;
using Unity.Burst;

namespace uLipSync
{

[BurstCompile]
public struct LipSyncJob : IJob
{
    public struct Result
    {
        public float f1;
        public float f2;
        public float f3;
        public float volume;
    }

    [ReadOnly] public NativeArray<float> input;
    ...
    [ReadOnly] public int lpcOrder;
    [ReadOnly] public int sampleRate;
    ...
    public NativeArray<Result> result;

    public void Execute()
    {
        ... // input を解析、結果を result につめる
    }
}

}

ジョブの発行側はこんな感じです。

public class uLipSync : MonoBehaviour
{
    ...
    NativeArray<float> inputData_;
    NativeArray<LipSyncJob.Result> jobResult_;
    JobHandle jobHandle_;
    public LipSyncUpdateEvent onLipSyncUpdate = new LipSyncUpdateEvent();
    ...

    void Update()
    {
        if (!jobHandle_.IsCompleted) return;
        GetResult();
        InvokeCallback();
        ScheduleJob();
        ...
    }

    void AllocateBuffers()
    {
        ...
        inputData_ = new NativeArray<float>(sampleCount, Allocator.Persistent); 
        jobResult_ = new NativeArray<LipSyncJob.Result>(2, Allocator.Persistent);
        ...
    }

    void GetResult()
    {
        jobHandle_.Complete();
        ...
    }

    void InvokeCallback()
    {
        ...
        onLipSyncUpdate.Invoke(result);
    }

    void ScheduleJob()
    {
        var lipSyncJob = new LipSyncJob()
        {
            input = inputData_,
            result = jobResult_,
            ...
        };
        jobHandle_ = lipSyncJob.Schedule();
        ...
    }

    [BurstCompile]
    void OnAudioFilterRead(float[] input, int channels)
    {
        // inputData_ をセット
    }
}

Unity.Mathematics の計算は SIMD を有効化してくれて速いですが 1 次元の音声の計算ではどれくらい有効なのかは不明です。。ただ、Burst を ON / OFF では以下のように桁が 1 つ変わるような大きな差が出ます。

Burst ON 時

f:id:hecomi:20210109181748p:plain

Burst OFF 時

f:id:hecomi:20210109182142p:plain

なお、FFT 用のジョブはあくまでエディタ描画用のためエディタだけで走りビルドでは省かれます。

ライセンス

uLipSync 自体は MIT ライセンスです。パッケージでなくプロジェクトを DL した場合はユニティちゃんのアセットをサンプルとしてを含むので利用する場合は UCL にも従ってください。

© Unity Technologies Japan/UCL

おわりに

Unity 5 時代とは使える機能も増え、また自分の理解ももう少し進んだのでまともな実装に出来ました。リップシンク自体の性能はまだまだ改善の余地があると思います(バグもあるかもしれません)。あとはブレンドシェイプだけでなくテクスチャによる口パク向けのサンプルの追加と、オフラインでの解析とアセット保存のような機能も、追々余力があれば入れていきたいです。