音量連動アニメーションの実装 – レベル3:感情ステートと音声の同期

AI

はじめに

本稿では、単なる「音量に合わせた口の開閉」を超え、アニメやゲームのような「意思を感じさせるキャラクター表現」をWebブラウザ上で実装する手法について解説します。

3Dモデル(VRM)による精緻な制御も一つの解ですが、計算負荷とアセット制作のコストを抑えつつ、現代的なアニメ調の高品質なビジュアルを維持するためには、「静止画のレイヤー合成」と「ステートマシンによる制御」の組み合わせが極めて有効です。

本実装の目的は、AI(Gemini)から得られる「文脈(感情)」と、リアルタイムな「音声信号(音量)」を独立した軸として扱い、1枚のスプライトシート上でそれらを再合成することにあります。

1. 3×3 ステートマトリックスによる描画ロジック

キャラクターの状態を「感情(行)」と「口の形(列)」の2軸で定義します。

  • 感情軸(Emotion): Geminiのレスポンス内容から「通常・笑顔・真剣」を判定し、Y座標を決定します。
  • 音声軸(MouthState): 音量解析値(RMS)から「閉じ・半開き・全開」を判定し、X座標を決定します。

コードの要点:CSS背景座標の算出

スプライトシート(300%サイズ)内の表示領域を、以下の計算式で特定しています。

// row: 感情 (0: neutral, 1: happy, 2: serious)
// col: 口の形 (0: closed, 1: half, 2: open)
const bgX = col * 50; // 0%, 50%, 100% の3段階
const bgY = row * 50; // 0%, 50%, 100% の3段階
CSSの background-position: ${bgX}% ${bgY}% を適用することで、1枚の画像から瞬時に特定の状態(例:笑顔で口が半開き)を切り出します。

2. 高速な音声同期を実現する audioLevelRef

Reactの通常の state 更新(useState)は非同期であり、毎秒60フレーム(60fps)のリップシンクに適用すると再レンダリングのオーバーヘッドが無視できません。

本実装では、useRef を使用して音声レベルを管理し、requestAnimationFrame 内で直接値を参照しています。

useEffect(() => {
  const update = () => {
    const level = audioLevelRef.current; // Refから最新値をノーレイテンシで取得
    
    // 閾値(Threshold)による口の形状判定
    if (level > 0.3) setMouthState("open");
    else if (level > 0.08) setMouthState("half");
    else setMouthState("closed");

    mouthTimerRef.current = requestAnimationFrame(update);
  };
  // ...
}, [isActive]);

数値の意図:

  • 0.3 / 0.08: 音声信号のノイズを拾わないためのマージンです。この閾値を設けることで、囁き声(半開き)から叫び声(全開)までのメリハリを、デジタルな切り替えでありながら自然に表現します。

3. 生体感を与える「自律動作」の実装

静止画が「古臭く」見える最大の要因は、ドットの変化が完全に止まる瞬間にあります。これを回避するために、音声とは独立した2つの「生命維持」ロジックを走らせています。

A. フローティング(呼吸エフェクト)

framer-motion を使い、画像全体を数秒周期でわずかにスケーリング・移動させます。

animate={{ 
  scale: [1, 1.015, 1], // 1.5%の膨張
  y: [0, -2, 0]        // 2pxの浮遊
}}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}

これにより、喋っていない時でもキャラクターが「その場に存在している」という錯覚を維持します。

B. 非規則なまばたき

人間は一定間隔でまばたきをしません。これを模すために、ランダムなディレイを生成する再帰的なタイマーを実装しています。

const scheduleBlink = () => {
  const delay = 2000 + Math.random() * 4000; // 2〜6秒のランダム性
  blinkTimeoutRef.current = setTimeout(() => {
    setIsBlinking(true);
    setTimeout(() => {
      setIsBlinking(false);
      scheduleBlink(); // 次のまばたきを予約
    }, 150); // まばたき自体の速度は一定
  }, delay);
};

4. 感情の遅延遷移(Smooth Emotion Shift)

音声(リップシンク)は瞬時に反応すべきですが、感情(表情の変化)がパチパチ切り替わると不自然です。

useEffect(() => {
  const timer = setTimeout(() => setCurrentEmotion(emotion), 300);
  return () => clearTimeout(timer);
}, [emotion]);

Geminiから届いた感情タグに対し、意図的にわずかな遅延(300ms)とフェードを加えることで、「ハッとして表情が変わる」ような有機的な遷移を演出しています。

まとめ

この実装の核心は、「AIが理解した意味(感情)」を低速なレイヤーで、「物理的な音の振動(リップシンク)」を高速なレイヤーで処理し、それらを1枚のスプライトシート上で合成した点にあります。

「高解像度なイラストをそのまま動かせる」という2Dの利点を最大限に活かしつつ、コードによる物理的なゆらぎを加えることで、現代的なゲームインターフェースとしてのクオリティを担保しています。

関連記事

カテゴリー

アーカイブ

Lang »