はじめに
本稿で解説するのは、Gemini Multimodal Live APIから取得した音量値(RMS)を、VRMモデルのBlendShapeに反映させるリアルタイム・リップシンクの実装です。
現代のアニメやゲームのような高精度な表現とは異なり、ここで行っているのは「音声の内容とは無関係に、裏でループしている複数のサイン波を、音量という包絡線(Envelope)で切り出しているだけ」の極めてプリミティブな手法です。
この実装には、音素解析(Visemeの特定)や感情推論といった高度なレイヤーは一切含まれていません。あくまで「音声データが届いている間、規定の計算式に基づいた『口らしい動き』を自動生成し、静止状態を回避する」という一点に特化した、低コストなハックを詳述します。
実装の要点
- 音素解析の完全なオミット ブラウザ側の負荷と遅延を最小化するため、入力ソースからは振幅(Amplitude)のみを抽出します。
- 非同期サイン波による形状合成 「あ・お・え」の各母音パラメータに対し、異なる周波数と位相のサイン波を割り当て、それらを加算合成することで、単一のパクパク運動を回避し、擬似的な口の遷移をシミュレートします。
- AM(振幅変調)ライクな制御 常に一定周期で計算され続けている「口の動き(キャリア)」に対して、音声の「強度(モジュレーター)」を乗算することで、発話と連動した動きを生成します。
1. 再生と解析の同期(AnalyserNodeの接続)
まず、スピーカーから流れる音声を「数値」として取り出すための下準備です。
const analyser = ctx.createAnalyser();
analyser.fftSize = 256; // 256分割の短い区間で解析し、反応速度を優先
analyser.smoothingTimeConstant = 0.5; // 急激な数値変化を抑えて動きをマイルドにする
// スピーカー出力の直前にアナライザーを接続
source.connect(analyser);
analyser.connect(ctx.destination);
解説: fftSize は解析の解像度です。値を小さくするとリアルタイム性が増し、声と口のズレが最小限になります。逆に smoothingTimeConstant を調整することで、ガタつきのない滑らかな動きを作ります。
2. 自然な口の動きを作る「多層サイン波合成」
レベル2の実装では、音量(level)を直接口の開きにするのではなく、時間軸(t)で変化するサイン波の「強弱」として利用します。
// 異なる周期の波を掛け合わせることで、単純なパクパク運動を回避
const aa = (Math.sin(t * 8.5) * 0.5 + 0.5) * intensity * 0.8;
const oh = (Math.sin(t * 5.3 + 1.2) * 0.5 + 0.5) * intensity * 0.4;
const ee = (Math.sin(t * 6.7 + 2.5) * 0.5 + 0.5) * intensity * 0.2;
// 各母音の表情(Expression)に適用
vrm.expressionManager?.setValue('aa', aa);
vrm.expressionManager?.setValue('oh', oh);
vrm.expressionManager?.setValue('ee', ee);
解説: 人間が喋るとき、口は常に同じ形(Aaだけ等)で動くわけではありません。複数の母音パラメータに、それぞれ異なる周期の揺らぎを与えることで、視覚的な「喋っている感」を複雑化させています。
3. 慣性による減衰(Speaking Decay)
音声が途切れた瞬間の「不自然な静止」を防ぐための処理です。
// level > 0.05(発話中)でなければ、係数を毎フレーム0.92倍にする
speakingDecayRef.current *= 0.92;
if (speakingDecayRef.current < 0.01) {
speakingDecayRef.current = 0; // 完全に停止
}
// この係数を、口の開きや頭の揺れの振幅に掛ける
mouthCurrentRef.current += (aa - mouthCurrentRef.current) * 0.15;解説: 0.92 という減衰率を掛けることで、指数関数的な滑らかな停止を実現しています。また、mouthCurrentRef.current の計算にある * 0.15 は「前回の値との補完」であり、口が瞬時に開閉するのを防ぐローパスフィルタの役割を果たします。
4. ボーン操作による頭部の連動
表情だけでなく、首(Humanoid Bone)を動かして「身振り」を加えます。
// VRMのボーン構造から直接「head」を取得して回転をかける
const headNode = vrm.humanoid.getNormalizedBoneNode('head');
if (headNode) {
// 左右(Z軸)と上下(X軸)に、発話中のみ微細な振動を加える
headNode.rotation.x = Math.sin(t * 1.3) * 0.03 * speakingDecayRef.current;
headNode.rotation.z = Math.sin(t * 0.9) * 0.02 * speakingDecayRef.current;
}解説: 表情の変化に加えて、頭部がわずかに揺れることで「声を出そうとする物理的なエネルギー」を表現しています。ここでも speakingDecayRef を掛けることで、喋り終わると自然に首の動きも止まるように設計しています。
5. 自律動作(Blink & Breathing)
最後に、音声に依存しない「生命維持」のためのコードです。
// 呼吸:Y軸をサイン波で極小に上下させる
vrm.scene.position.y = Math.sin(Date.now() * 0.001) * 0.002;
// まばたき:タイマーが一定値を超えたら瞬きを実行
blinkTimerRef.current += delta;
if (blinkTimerRef.current > 閾値) {
const blinkValue = /* 0→1→0のカーブ */;
vrm.expressionManager?.setValue('blink', blinkValue);
}
解説: vrm.scene.position.y の微細な変動は、直立不動の「人形感」を消すのに非常に効果的です。これらは音声の有無にかかわらず常に実行し続けます。