はじめに
本実装は、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のopacityやscaleにそのまま掛け算できる便利な係数(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)へ反映させることで、視覚的な同期を実現しています。」