この記事の内容
音声波形の解析はせず口パク用データを作成し、それを再生する方法 になります。
ですので、マイクの音を拾ってリアルタイムで口パクさせたい方とかが使える内容ではありません。
はじめに
"lipsync" とは何かについては、他に良い記事を書かれている方が沢山いらっしゃいますので割愛します。
"lipsync" を Google先生で検索すると、音声波形を解析して口パクさせている方法が多くヒットするのですが(そして、普通は当然それを使うと思います。)、自身が開発している「ACUAH β」というアプリでは、音声波形の解析はせず、こんなアナログな方法でやってますという話になります。(逆に、音声波形を解析しない口パクはどうやって実装されているのか、まったく分かりません...アニメーションにキーを打っている?)
また、アプリ開発経験なしの素人プログラマの記事です。口パク品質もそれなりですので諸々ご容赦ください。
Lipsyncライブラリ といえば
(2021/7/16時点)
Oculus Lipsync(Oculus社)
AniLipSync(yoshitaka-xvi 様)
AniLipSync-VRM(sh-akira 様)
ADX Lipsync(CRIWARE社)
uLipSync(hecomi 様)
でしょうか。どれも凄いライブラリです。
何で Lipsyncライブラリ を使わないの?
主に以下の理由でした。
最初に試した「Oculus Lipsync」が日本語では思ったような口の形にならなかった。(思ったより口の動きが小さかった。ソースコード内のパラメータ値を変更すれば多少改善した)
開発中のアプリにとっては、将来、何かあった時に自作のソースコードに切り替えできた方が良い機能(開発能力が足りないのは一旦置いておく)
Android向けアプリなので、処理が軽いほうが良さそう。
ADX Lipsync は有料・法人向けなので使いたいけど使えない。
(まだ uLipSync はリリース前でした。)
自作するしかない
「ACUAH β」 は決まった音声データしか使わないので、事前に口パクデータが用意できていればOK。
CSV形式でリップシンクデータを作る
母音だけではなく、子音もきちんと表現したい。
音声波形の解析とか、無理だ。
「口の形のデータ」「その形にするタイミング」「その形にしておく時間(秒)」の3つで、データ作れる はず?
実装方針(日本語)
悩んで、以下の方法にする事にしました。
言葉のひらがな各1文字をアルファベット2文字(ローマ字)に表す。(一部3文字になるものは2文字に簡略化する)
母音、子音の全アルファベット1文字(a, i, u, e, o, k, s, t, n, h, m, y, ...)の口の形をBlendShapeで作る。母音(a, i, u, e, o)の形も 単純に 'A' のプリセット値のみを弄る訳ではなくて、'I', 'U', 'E', 'O' の値も調整する。
そのひらがなが発音されている時間(秒)を調査・記録する。
発音時間の取得方法は Adobe Audition とかで波形データから調べる (後日、後述の音声を聴きながら時間を取得する方法に変更)。 CeVIOで作成したデータについては、CeVIO内で発音時間を確認することができるのでその値をメモする。
アルファベット最初の1文字の発音時間は0.05秒で固定してしまい、残りの時間を母音の発音時間とする。
このデータを事前に読み込んでおき、音声再生と併せてCoroutineで口パクさせる。
(例)「こんにちは」
(1) 「こんにちは」⇒ ko,nn,ni,ti,wa
(2) Adobe Audition等で、波形データから 各ひらがなの発音時間(秒)を取得(メモする)
0.15,0.2,0.22,0.2,0.18
「ACUAH β」の音声データは短い文しかないので、この方法で1音声ファイルについてだいたい5分~10分でデータが作れます。 ただそれでも数が多いのと波形データをにらめっこするので精神的にきつい作業ではあります。
できたデータの再生はこんな感じにやります。(すみません、このままコピペでは動きません。)
using VRM;
private VRMBlendShapeProxy vrmBlendShapeProxy;
private AudioSource audioSource;
void Start()
{
vrmBlendShapeProxy = this.GetComponent<VRMBlendShapeProxy>();
string[] letters = new string[5] { "ko","nn","ni","ti","wa" }
float[] dts = new float[5] { 0.15f, 0.2f, 0.22f, 0.2f, 0.18f }
// 上記の他、口の形のデータを Dictionary<string, float[]> で lipShape で読み込んでおきます。
//(例)lipShape["k"] = { 0.1f, 0.0f, 0.1f, 0.0f, 0.0f } "A","I","U","E","O" の各 BlendShape.Value
// AudioSource での音声データ再生
// 実際には、以下の4行は PlayVoice というコルーチンで処理しています。
// PlayVoice内から PlayLipSync(letters, dts) を実行しています。
audioSource = this.GetComponent<AudioSource>();
audioSource.clip = "こんにちは";
audioSource.Play();
StartCoroutine(PlayLipSync(letters, dts));
}
IEnumerator PlayLipSync(string[] letters, string[] dts)
{
// 【注】この例ではリミテッドアニメーションぽい処理になっています。
// 実際は Mathf.Lerp, Time.deltaTime等 を使ってフルアニメーションぽい感じにしています。
// 口の大きさを AudioSourceのvolumeで取得する。
// リアルタイムでClipの音量を取得し口の大きさに反映させる方法は試したものの、思ったほど効果がなかった。
float vv = 0.5f * audioSoure.volume + 0.5f;
for (int i = 0; i < letters.Length; i++ )
{
string let1 = letters[i].Substring(0, 1);
string let2 = letters[i].Substring(1, 1);
// 1文字目の時間は0.05秒で固定
// そもそも 1文字目で 0.05秒未満の発話時間しかない場合には、以下の処理を行わないようにしておきます。
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.A), lipShapeTable[let1][0] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.I), lipShapeTable[let1][1] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.U), lipShapeTable[let1][2] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.E), lipShapeTable[let1][3] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.O), lipShapeTable[let1][4] * vv);
vrmBlendShapeProxy.Apply();
// 1文字目の口の形で 0.05秒
yield return new WaitForSecondsRealtime(0.05f);
// 2文字目
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.A), lipShapeTable[let2][0] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.I), lipShapeTable[let2][1] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.U), lipShapeTable[let2][2] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.E), lipShapeTable[let2][3] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.O), lipShapeTable[let2][4] * vv);
vrmBlendShapeProxy.Apply();
// 2文字目の口の形で (dts[i] - 0.05)秒
yield return new WaitForSecondsRealtime(dts[i] - 0.05f);
}
yield break;
}
実装方針(英語)
英語の音声データに対するリップシンクは更に悩みました。
日本語と同じ処理方式でやるためにはという事で、発音記号を使う というアイデアになりました。
英文を入れると、発音記号に変換してくれるサイトがあった。
IPA(国際音声記号)で音声記号は決まってる。
IPAの音声記号文字は UTF-8 でちゃんと読み込みできた。(SAMPA, X-SAMPA でなくて良い)
英語については音声記号毎に口の形の解説がされている。(大きさとか、舌の位置とか)
(例)"hello"
(1) "hello" ⇒ h,ə,l,o,ʊ
(2) Adobe Audition等で、波形データから 各発音記号の発音時間(秒)を取得(メモする)→ 無理!!
日本語と違って発音記号毎の時間を取得するのは流石に作業大変すぎる...
どうしようと悩んだところで、かなり大雑把なやり方を思いつきました。
音節(単語ごと)の発音時間が取得できれば十分かも? 例えば、hello [həloʊ] が0.5秒で発音されるのが分かれば、音声記号文字数でその時間を等分すればそれなりのものができるような気がする。
(例)həloʊ,0.5 → h,0.1,ə,0.1,l,0.1,o,0.1,ʊ,0.1
ここまでできたら、Adobe Auditionで 音声データの波形から hello の発音時間を調べて・・・
やっぱり面倒。
そこで、更にUnity上で音声データを聴きながら、単語(音節)に合わせてリズムを取る方法を思いつきました。
これならデータを作る人が音を聞き取るタイミングはほぼ一定と思われるし、それほどタイミングずれないはず。
例えば、"What can I do for you?" という音声データなら、リズム良く、"What" "can" "I" "do" "for" "you" と聞こえてくるタイミングで 画面上のボタン を押して ボタンの押された間隔(秒)をTime.time で取得すれば、各単語の発音時間とおよそ同じになるはず。
とは言え、再生速度は落とさないと無理なので、ピッチ(再生速度)をスライダーで調整できるようにしました。
また、1音節しかない場合には、音声データの再生時間と同じになります。
という事で完成。
(例)"hello world" [həˈloʊ wɜrld]
(1) "həloʊ wɜrld" ⇒ h,ə,l,o,ʊ,w,ɜ,r,l,d
(2) 音声を再生して、həloʊ と wɜrld の発声タイミングに合わせて 各1回ボタンを押す。
həloʊ,0.5,wɜrld,0.8
(3) 各単語、発音記号5文字ずつなので、取得した発音時間を等分する
h,ə,l,o,ʊ,w,ɜ,r,l,d
0.1,0.1,0.1,0.1,0.1,0.16,0.16,0.16,0.16,0.16
IEnumerator PlayLipSync(string[] letters, string[] dts)
{
// 【注】この例ではリミテッドアニメーションぽい処理になっていますが、
// 実際は Mathf.Lerp, Time.deltaTime等 を使ってフルアニメーションぽい感じにしています。
// 口の大きさを AudioSourceのvolumeで取得する。(音声波形の解析はしない)
// リアルタイムでClipの音量を取得し口の大きさに反映させる方法は試したものの、思ったほど効果がなかった。
float vv = 0.5f * audioSoure.volume + 0.5f;
for (int i = 0; i < letters.Length; i++ )
{
// 英語は発音記号1文字毎の処理で良い
string let = letters[i];
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.A), lipShapeTable[let][0] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.I), lipShapeTable[let][1] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.U), lipShapeTable[let][2] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.E), lipShapeTable[let][3] * vv);
vrmBlendShapeProxy.AccumulateValue(BlendShapeKey.CreateFromPreset(BlendShapePreset.O), lipShapeTable[let][4] * vv);
vrmBlendShapeProxy.Apply();
yield return new WaitForSecondsRealtime(dts[i]);
}
yield break;
}
LipSyncEditor
音節に合わせてボタンポチポチするだけでリップシンクデータを作ってくれるようにしたら割と有能。(音声データはAh-ya(@aya_voicer)様)#ACUAH β pic.twitter.com/H1DOL0KibF— riemgoshawk (@project_ACUAH) June 15, 2021
まとめ
口パクデータを事前に用意しておける程々実装でよければ。
英語のリップシンクは思ったより有能だった(なんとなくそれっぽい)。
発音記号と口の形が分かれば、他の言語も同じ仕組みで実装できるはず。
BlendShapeに口の横幅を任意に狭められるものがあるともっと良い感じにできそう。(preset.U ではなくて)
やっぱり長い文章の音声データとかはこの方法では大変。
リアルタイムでの音声波形解析が必要な場合は当然これは使えない。
参考
↧