音量連動アニメーションの実装 – レベル1:Web Audio APIによる動的プロパティ制御

AI 未分類

はじめに

本実装は、AIから届く音声波形をリアルタイムに解析し、その振幅(音量)を数値化してUIのスタイル属性へ反映させる手法である。画像ファイル自体には一切変更を加えず、CSSやアニメーションライブラリを用いて、外郭の視覚的パラメータを操作する。

画像そのものを動かすのではなく、画像を配置したdivなどのコンテナに対して、以下の3つの変化を「音量(0.0〜1.0)」に比例させて適用する。

  • Scale(拡大・縮小): 脈動感を出す。
  • Shadow / Glow(影・発光): 声のエネルギーを視覚化する。
  • Opacity(不透明度): 存在感の強弱を表現する。

実装

1. 音声データのデコードと再生準備

Geminiから届くBase64形式のL16(16bit PCM)データを、ブラウザが再生できる形式に変換する箇所です。

// L16(Binary) -> Float32Array(Web Audio形式) への変換
const enqueueAudio = useCallback((pcmBase64: string) => {
  const bytes = base64ToArrayBuffer(pcmBase64);
  const pcm16 = new Int16Array(bytes); // 16bit整数として解釈
  const float32 = new Float32Array(pcm16.length);

  for (let i = 0; i < pcm16.length; i++) {
    // L16の範囲(-32768〜32767)を、Web Audioの範囲(-1.0〜1.0)に正規化
    float32[i] = pcm16[i] / 32768;
  }
  
  // 再生待ち行列(Queue)に追加
  playQueueRef.current.push(float32);
  playNextChunk();
}, []);

解説: Int16Arrayを使うことで、バイナリデータをL16の仕様通り「2バイトずつの整数」として正確に読み取っています。これを32768で割ることで、ブラウザのスピーカーが理解できる浮動小数点数に変換しています。

2. AnalyserNodeのセットアップ

音量を抽出するための「センサー」を設置する箇所です。

const getPlaybackContext = useCallback(() => {
  if (!audioContextRef.current) {
    // Geminiの出力レート(24kHz)に合わせてContextを作成
    const ctx = new AudioContext({ sampleRate: 24000 });
    audioContextRef.current = ctx;

    // 音量解析用のノードを作成
    const analyser = ctx.createAnalyser();
    analyser.fftSize = 256;             // 短い時間枠で解析(反応速度優先)
    analyser.smoothingTimeConstant = 0.5; // 動きを滑らかにする(0.0〜1.0)
    
    // スピーカー(destination)に接続
    analyser.connect(ctx.destination);
    analyserRef.current = analyser;
    
    // 定期的に音量を取得するループを開始
    startLevelPolling(analyser);
  }
  return audioContextRef.current;
}, []);

解説: fftSizeを小さく(256)設定することで、リアルタイム性を高めています。smoothingTimeConstantは、数値が激しく変動してアバターがチカチカするのを防ぐための「重し」の役割を果たします。

3. 音量情報の抽出(ポーリング)

解析したデータから、UIで使いやすい「0〜1」の数値を作る箇所です。

const startLevelPolling = (analyser: AnalyserNode) => {
  const dataArray = new Uint8Array(analyser.frequencyBinCount);
  
  const poll = () => {
    // 現在の周波数データを取得
    analyser.getByteFrequencyData(dataArray);
    
    // 全周波数の平均振幅を計算
    let sum = 0;
    for (let i = 0; i < dataArray.length; i++) {
      sum += dataArray[i];
    }
    const avg = sum / dataArray.length / 255; // 0.0 〜 1.0に正規化
    
    setAudioLevel(avg); // Reactの状態を更新
    levelTimerRef.current = requestAnimationFrame(poll);
  };
  
  levelTimerRef.current = requestAnimationFrame(poll);
};

解説: getByteFrequencyDataは、その瞬間の音の大きさを0〜255の値で配列に格納します。その平均値をとり、最後に255で割ることで、CSSのopacityscaleにそのまま掛け算できる便利な係数(0〜1)に変換しています。

4. UIへのマッピング:パーティクル・エフェクト

抽出した audioLevel を使い、声に合わせて「粒子」が飛び出す演出を実装している箇所です。

{/* 音声に反応するパーティクル(粒子)の生成 */}
{voice.status === "connected" && voice.audioLevel > 0.05 && (
  <>
    {Array.from({ length: 12 }).map((_, i) => (
      <motion.div
        key={`particle-${i}`}
        className="absolute rounded-full pointer-events-none z-20"
        style={{
          background: accentColor, // スタッフのアクセントカラーに同期
          left: `${30 + Math.random() * 40}%`,
          bottom: "20%",
        }}
        animate={{
          // 音量(audioLevel)に応じて、粒子の「不透明度」や「飛び出す高さ」を動的に変化させる
          y: [0, -(60 + Math.random() * 120)],
          opacity: [voice.audioLevel, 0], 
          scale: [1, 0.3],
        }}
        transition={{
          duration: 1 + Math.random() * 1.5,
          repeat: Infinity,
          ease: "easeOut",
        }}
      />
    ))}
  </>
)}

解説: ここでは opacity に直接 voice.audioLevel をマッピングしています。これにより、声が大きくなると粒子がハッキリと見え、声が小さくなると消えていくという、直感的な視覚効果を生んでいます。

5. マイク入力のリサンプリング(送信側)

ブラウザのマイク(48kHz)からGemini(16kHz)へ変換して送る「入り口」の処理です。

processor.onaudioprocess = (e) => {
  const input = e.inputBuffer.getChannelData(0); // 48kHzの生データ
  
  // 3つに1つピックアップして16kHzに間引く(簡易リサンプリング)
  const resampled = resample(input, 48000, 16000);
  
  // Float32 -> L16(Int16) へ逆変換
  const pcm16 = float32ToPcm16(resampled);
  
  // Base64化して送信
  const base64 = arrayBufferToBase64(pcm16.buffer);
  wsRef.current?.send(JSON.stringify({ type: "audio", data: base64 }));
};

解説: 送信時も同様に、浮動小数点数をL16の整数範囲に変換して送ります。resample関数でデータを間引くことで、ネットワーク帯域を節約しつつGeminiの仕様に適合させています。

まとめ

レベル1の実装では、音声再生のルートに『観測地点(AnalyserNode)』を設けることが最大のポイントです。画像自体を加工する複雑な計算は一切行わず、再生される音のエネルギーを数値化し、それをDOM要素の外郭(ScaleやShadow)へ反映させることで、視覚的な同期を実現しています。」

関連記事

カテゴリー

アーカイブ

Lang »