🙌

VOICEVOXのAudioQueryを利用した音声合成に対する非音声ベースのLipSyncシステム

2023/04/11に公開

モチベーション

「LipSync」で検索をすると、音声を解析して2D/3Dのモデルの口の形状を発話の音声と同期させる話が出てくると思います。

LipSyncが必要になるケースは、リアルタイムにマイクなどで収音した音声をベースに口を動かしたい場合(VTuberのLive2DやVRSNSのアバター)や、事前に収録した音声に細かい調整をせずに直接アニメーションを生成したい場合などがほとんどだからでしょう。

そのため、そのようなユースケースを想定した音声解析ベースのライブラリやアセットがほとんどです。

ところで、ChatGPTのAPIで生成した会話のテキストは、VOICEVOXなどの音声合成サービスを利用することで音声データに変換し利用することができます。

この場合、音声ではなくリアルタイムに生成されるテキストをベースにLipSyncをしたい、という上記とは異なる需要があります。

もちろん音声合成した音声データを解析して既存のLipSyncを行うことは可能ですが、LipSyncの仕組みを作った経験からして音声解析の処理負荷はそれなりに大きいので可能なら避けたいですし、事前にテキストがあるのだからわざわざ次元の多い音声データに変換してから解析するのも随分遠回りな気がします。

ではテキストベースのLipSyncは容易なのかというとそんなこともなく、任意の多言語のテキストを音素や口形素に変換するにしても巨大な辞書だけではなく話す速度などの話者の個性も考慮する必要があり、それなりに大変そうです。

ところが、音声合成サービスとしてVOICEVOXを利用する際には、事前にAudioQueryというデータで指定したテキストと話者によって生成される音素と発声時間を取得することができます。

このAudioQueryの情報をベースとしてLipSyncする仕組みが作れるなら、音声解析が不要で軽量かつ正確なLipSyncが実現できるのでは、ということで実装してみました。

AudioQueryの構造

AudioQueryの構造の仕様は(Core version 0.14.3では)公式のAPI Referenceから引用すると下記のようになっています。

{
	"accent_phrases": [
		{
			"moras": [
				{
					"text": "string",
					"consonant": "string",
					"consonant_length": 0,
					"vowel": "string",
					"vowel_length": 0,
					"pitch": 0
				}
			],
			"accent": 0,
			"pause_mora": {
				"text": "string",
				"consonant": "string",
				"consonant_length": 0,
				"vowel": "string",
				"vowel_length": 0,
				"pitch": 0
			},
			"is_interrogative": false
		}
	],
	"speedScale": 0,
	"pitchScale": 0,
	"intonationScale": 0,
	"volumeScale": 0,
	"prePhonemeLength": 0,
	"postPhonemeLength": 0,
	"outputSamplingRate": 0,
	"outputStereo": true,
	"kana": "string"
}

ここからLipSyncに必要な情報のみ抽出してみましょう。

  • accent_phrases: Phrase(音句)の配列
    • moras: Mora(音韻)の配列
      • consonant: 子音の音素
      • consonant_length: 子音の発声時間(秒)
      • vowel: 母音の音素
      • vowel_length:母音の発声時間(秒)
    • pause_mora: (Optional)Pauseが入る場合はそのMoraが入る
  • speedScale: 話すスピード
  • prePhonemeLength: 話し始める前の無音時間(秒)
  • postPhonemeLength: 話し終わった後の無音時間(秒)

Moraの中には母音の音素、子音の音素、それぞれの発声時間が格納されているので、事前の無音時間を挟んでからaccent_phrasesの中身を上から順番に舐めていき、pause_moraもnullではない場合にはカウントし、最後に事後の無音時間を挟めば、時系列の音素データが手に入ることになります。

あとはこれをLipSyncに都合の良いデータ形式に加工してあげれば良さそうです。

口のアニメーションの仕組み

LipSyncは最終的には口(正確には唇、Lip)の形状を変え(Morphing)、モデルの見た目を変化させる仕組みです。

ただしここではどのように口の形状を決めるかは置いておき、決められた形状にどのように変形させるのかというアニメーションの仕組み部分を考えます。

モデル側でのセットアップはあくまで口の形状(口形素、Viseme)で決められることが多く、発話の音(音素、Phonome)とは一対一には対応しません。

例えばpとbの音素は別ですが、口の表面的な形状は区別できないため同じVisemeが割り当てられることが多いです。

そのため、音声合成で利用する音素を口形素にマッピングしてあげる必要があります。

口形素の定義は一般的なものを採用したいところですがパッと分からなかったので、OVR LipSyncが参照しているMPEG-4 Face and Body Animationから引用して、次のように定義することにしました。

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions/LipSync/Viseme.cs

この口形素別にBlend Shapeなどでモーフィングされていることを仮定して、最もPrimitiveなLipSyncの操作は次のILipMorpher.MorphIntoのように定義できます。

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions/LipSync/ILipMorpher.cs

LipSampleは次で定義しているように、Visemeとそのweightを指定するためのデータです。
(本当はreadonly struct recordを使いたいところですがまだ使用できないのでreadonly structで実装しています)

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions/LipSync/LipSample.cs

ILipMorpherの実装は好きに用意できますが、Unity標準の機能で使われやすそうなSkinnedMeshRendererAnimatorの実装は標準で用意しています。

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions/LipSync/SkinnedMeshLipMorpher.cs

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions/LipSync/AnimatorLipMorpher.cs

最もPrimitiveなLipSyncの操作はこれだけでも十分ではありますが、実際には時系列のフレームのデータの集合を用意して一連のアニメーションをさせたいケースもあります。

今回のAudioQueryを使用するケースも指定したテキストを話す分のアニメーションを生成したいので、LipSyncのアニメーションの仕組みを用意します。

GIFのデータ構造を参考に、フレームデータを次のように定義をします。

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions/LipSync/LipAnimationFrame.cs

このフレームのCollectionを使用して実際にアニメーションをするインターフェースは次のように定義できます。

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions/LipSync/ILipAnimator.cs

ILipAnimatorの実装も好きに用意できますが、実際に動かしてみると補完をかけて滑らかにVisemeの動きを付けたくなったので、そのためのLipAnimatorの実装も用意しています。

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions/LipSync/FollowingLipAnimator.cs

また、VRMのモデルフォーマットではExpressionという仕様の中で母音のモーフィングが定義されているので、ランタイムライブラリのUniVRMと繋げて簡単に使用できる拡張を別パッケージで用意しています。

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions.Extensions/VRM/VRMLipMorpher.cs

AudioQueryをLipSync用のデータに変換する

最後に、VOICEVOXのAudioQueryから取得できる音素の時系列データをLipAnimationFrameのCollectionに変換する仕組みを用意してあげれば、このデータを元に好きなLipSyncの仕組みを利用できることになります。

https://github.com/mochi-neko/facial-expressions-unity/blob/main/Assets/Mochineko/FacialExpressions.Extensions/VOICEVOX/AudioQueryConverter.cs

VowelとConsonantをVisemeにマッピングしてあげる必要がありますが、VowelとConsonantの一覧はEngineのmora_list.pyから、対応関係は少し大雑把ですがOVR LipSyncを見ながら自分で作成しています。

直近はVowelしか必要ないのですが、念の為。

結果

最終的には次のようなLipSyncの仕組みが実装できました。

  1. 読み上げたいテキストを指定
  2. VOICEVOXで音声合成
  3. 音声合成時のAudioQueryを使ってLipAnimationのデータを生成
  4. UniVRMで読み込んだVRMアバターの口を、VOICEVOXで生成した音声と同期させる(LipSync)

こちらが実際に動かしてみたデモです。

https://twitter.com/mochi_neko_7/status/1645669296163651584?s=20

音声はこちらをお借りしています。

VOICEVOX:四国めたん

https://zunko.jp/con_ongen_kiyaku.html

先日のまばたきのアニメーションも組み合わせています。

https://zenn.dev/mochineko/articles/b39ee8fb8a4824

おわりに

AudioQueryベースだと当然精度も処理負荷も少なく、効率よくLipSyncをすることができました。

VOICEVOXを利用する場合はわざわざ音声ベースのLipSyncの仕組みを入れる必要はなさそうです。

本記事の内容はこちらのRepositoryに実装がありUPMでもデフォルト実装を利用できるようにしてありますので、使用してみたい方は参照してみてください。

https://github.com/mochi-neko/facial-expressions-unity

VOICEVOX、VRMを使わないケースにも応用できるよう抽象化されているので、カスタマイズ次第で使いまわせるかと思います。

Discussion