言語を切り替える
テーマを切り替える

実践チュートリアル:Gemini Multimodal Live API で低遅延の音声・動画 AI アシスタントを構築する

Gemini Live API は音声の入出力をネイティブでサポートし、ASR や TTS を挟まないエンドツーエンド構成で遅延を 500 ms 以内に抑えられます。本記事では、この API を使ってリアルタイムに会話できる AI アシスタントを組み立てる手順をまとめます。

Gemini Multimodal Live API とは?

まず前提を整理しましょう。従来の Gemini API は、テキストを送ってテキストが返る、というシンプルな流れでした。音声でやり取りしたい場合は、自分で ASR(音声認識)と TTS(音声合成)を挟み、変換のたびにレイテンシが積み上がります。

Gemini Multimodal Live API の違いは、音声の入出力を最初から扱えることです。マイクで拾った音声をそのまま送れ、返ってくるのもオーディオストリーム。中間でフォーマット変換を挟む必要はありません。このエンドツーエンド設計で、遅延を 500 ms 以内に抑えられます。

スマートホームのプロトタイプで試したとき、「リビングのライトを少し暗くして」と言い終わるほぼ同時に応答が返り、相手がプログラムだと忘れさせるほどの滑らかさでした。

現在サポートされているモデルは gemini-2.0-flash-native-audio-preview です。Google は更新が速いので、バージョン名は定期的に確認してください。

アーキテクチャ設計と技術選定

システム構成は、フロントとバックエンドを分けるのがおすすめです。理由は単純で、API キーをフロントに置いてはいけないからです。

データフローは次のとおりです。

[ブラウザ] --WebSocket--> [Python バックエンドプロキシ] --WebSocket--> [Gemini Live API]
   |                           |                           |
マイク収集                 転送 + ビジネスロジック              AI 処理
スピーカー再生             VAD 検知 / 割り込み制御            音声生成

ブラウザから直接 Gemini に繋ぐことも技術的には可能ですが、API キーを JavaScript に書くことになり、開発者ツールを開けば誰でも取得できます。私は一度これをやって、翌日の請求で痛い目にしました。

採用した技術スタックは次の表のとおりです。

レイヤー技術用途
フロント素の JavaScript + Web Audio API音声収集・再生、AudioWorklet によるリアルタイム処理
バックPython 3.9+ + websocketsWebSocket プロキシ、VAD、セッション管理
プロトコルWebSocket + JSONGemini との双方向通信

Web Audio API の AudioWorklet は、メインスレッドをブロックせず独立スレッドで音声を処理できるので有用です。実装例は後述します。

WebSocket 接続の確立とセッション管理

まず Gemini への接続方法からです。

Live API の WebSocket エンドポイントは次のとおりです。

wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=YOUR_API_KEY

v1alpha はプレビュー版の印です。本番利用ではインターフェース変更に備えてください。

接続後、最初に Setup メッセージで対話条件を送ります。

import asyncio
import json
import websockets

GEMINI_API_KEY = "your-api-key-here"
GEMINI_WS_URL = (
    f"wss://generativelanguage.googleapis.com/ws/"
    f"google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent"
    f"?key={GEMINI_API_KEY}"
)

CONFIG = {
    "setup": {
        "model": "models/gemini-2.0-flash-native-audio-preview",
        "generation_config": {
            "response_modalities": ["AUDIO"],
            "speech_config": {
                "voice_config": {
                    "prebuilt_voice_config": {
                        "voice_name": "Charon"  # 例: Charon, Aoede など
                    }
                }
            }
        },
        "system_instruction": {
            "parts": [{"text": "あなたは helpful な AI アシスタントです。簡潔で自然に答えてください。"}]
        }
    }
}

async def connect():
    async with websockets.connect(GEMINI_WS_URL) as ws:
        # setup 設定を送信
        await ws.send(json.dumps(CONFIG))

        # setup complete を待つ
        response = await ws.recv()
        data = json.loads(response)

        if "setupComplete" in data:
            print("✅ 接続成功。対話を開始できます")
            return ws
        else:
            raise Exception(f"Setup 失敗: {data}")

注目ポイントは次の 2 点です。

  • response_modalities: ["AUDIO"] なら音声のみ。テキストも欲しければ ["AUDIO", "TEXT"]
  • voice_name: プリセット音色のひとつ。私は落ち着いた Charon が好みです

切断後の再接続は、指数バックオフが安全です。いきなり連打するとサービスに負荷がかかります。

async def connect_with_retry(max_retries=5):
    for attempt in range(max_retries):
        try:
            return await connect()
        except Exception as e:
            wait_time = min(2 ** attempt, 30)  # 最大 30 秒
            print(f"接続失敗 ({e})。{wait_time} 秒後に再試行...")
            await asyncio.sleep(wait_time)
    raise Exception("再試行上限に達しました")

16kHz PCM オーディオストリームの収集と転送

接続できたら、音声の取得と送信です。

16kHz を選ぶ理由は、人の声の主要帯域(男性は低め、女性は高めでおおよそ 85〜255 Hz)に対し、ナイキストの定理では 8kHz でも理論上は足ります。ただしディテールを残すなら 16kHz が音質とデータ量のバランス点(sweet spot)になります。Gemini 公式もこのレートを推奨しています。

フロントの収集コード例です。

class AudioRecorder {
  constructor() {
    this.sampleRate = 16000;
    this.bufferSize = 1024;
    this.audioContext = null;
    this.workletNode = null;
    this.stream = null;
    this.onAudioData = null; // コールバック
  }

  async start() {
    // マイク権限
    this.stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        sampleRate: 16000,
        channelCount: 1,
        echoCancellation: true,
        noiseSuppression: true
      }
    });

    // サンプルレートを 16kHz に固定
    this.audioContext = new AudioContext({
      sampleRate: 16000
    });

    await this.audioContext.audioWorklet.addModule('pcm-processor.js');

    const source = this.audioContext.createMediaStreamSource(this.stream);
    this.workletNode = new AudioWorkletNode(this.audioContext, 'pcm-processor');

    this.workletNode.port.onmessage = (event) => {
      const float32Data = event.data;
      const int16Data = this.float32ToInt16(float32Data);
      const base64Data = btoa(String.fromCharCode(...new Uint8Array(int16Data.buffer)));

      if (this.onAudioData) {
        this.onAudioData(base64Data);
      }
    };

    source.connect(this.workletNode);
    console.log('🎤 音声収集を開始');
  }

  float32ToInt16(float32Array) {
    const int16Array = new Int16Array(float32Array.length);
    for (let i = 0; i < float32Array.length; i++) {
      const s = Math.max(-1, Math.min(1, float32Array[i]));
      int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
    }
    return int16Array;
  }

  stop() {
    if (this.workletNode) {
      this.workletNode.disconnect();
    }
    if (this.audioContext) {
      this.audioContext.close();
    }
    if (this.stream) {
      this.stream.getTracks().forEach(track => track.stop());
    }
    console.log('🛑 音声収集を停止');
  }
}

AudioWorklet 用の pcm-processor.js です。

// pcm-processor.js
class PCMProcessor extends AudioWorkletProcessor {
  process(inputs, outputs, parameters) {
    const input = inputs[0];
    if (input && input[0]) {
      this.port.postMessage(input[0].slice());
    }
    return true;
  }
}

registerProcessor('pcm-processor', PCMProcessor);

バックエンドは受け取ったデータを Gemini へ転送します。

async def send_audio(ws, base64_pcm_data):
    """オーディオを Gemini へ送信"""
    message = {
        "realtime_input": {
            "media_chunks": [{
                "mime_type": "audio/pcm;rate=16000",
                "data": base64_pcm_data
            }]
        }
    }
    await ws.send(json.dumps(message))

注意点として、一部ブラウザの getUserMediasampleRate を無視し、44.1kHz や 48kHz で返すことがあります。AudioContext 側でリサンプリングするか、audiobuffer-to-wav などのライブラリで揃えると安心です。

VAD(音声活動検知)の実装

無音のまま送り続けると、帯域もコストも無駄になります。ここで VAD(Voice Activity Detection)が役立ちます。

役割は単純で、「人が話しているか」を判定し、話しているときだけ送ることです。

Google の WebRTC VAD は軽量で速く、Python では webrtcvad が使えます。

import webrtcvad
import collections
import numpy as np

class VADProcessor:
    def __init__(self, aggressiveness=2, frame_duration_ms=20):
        """
        aggressiveness: 0-3。高いほど厳格
        frame_duration_ms: 10, 20, 30 のいずれか
        """
        self.vad = webrtcvad.Vad(aggressiveness)
        self.frame_duration_ms = frame_duration_ms
        self.sample_rate = 16000

        self.ring_buffer = collections.deque(maxlen=30)  # 600 ms
        self.triggered = False

    def process_frame(self, pcm_bytes):
        """1 フレームを処理し、送信すべきか返す"""
        is_speech = self.vad.is_speech(pcm_bytes, self.sample_rate)

        if not self.triggered:
            self.ring_buffer.append((pcm_bytes, is_speech))
            num_voiced = sum(1 for _, speech in self.ring_buffer if speech)

            if num_voiced > 0.9 * self.ring_buffer.maxlen:
                self.triggered = True
                return b''.join([f for f, _ in self.ring_buffer])
            return None
        else:
            if is_speech:
                self.ring_buffer.append((pcm_bytes, True))
                return pcm_bytes
            else:
                self.ring_buffer.append((pcm_bytes, False))
                num_unvoiced = sum(1 for _, speech in self.ring_buffer if not speech)

                if num_unvoiced > 0.9 * self.ring_buffer.maxlen:
                    self.triggered = False
                    self.ring_buffer.clear()
                return pcm_bytes

利用例です。

vad = VADProcessor(aggressiveness=2)

async def handle_client_audio(websocket, gemini_ws):
    async for message in websocket:
        data = json.loads(message)

        if 'audio' in data:
            pcm_bytes = base64.b64decode(data['audio'])
            result = vad.process_frame(pcm_bytes)

            if result:
                await send_audio(gemini_ws, base64.b64encode(result).decode())

aggressiveness はまず 2 から。低すぎるとノイズを音声と誤認し、高すぎると小声を取りこぼします。

webrtcvad が使えない環境では、RMS エネルギーによる簡易検知も選択肢です。

// フロントの代替: RMS ベース
function detectVoiceActivity(audioData, threshold = 0.015) {
    const sum = audioData.reduce((acc, val) => acc + val * val, 0);
    const rms = Math.sqrt(sum / audioData.length);
    return rms > threshold;
}

Barge-in(割り込み)の実装

AI が長く話しているあいだ、こちらの言葉を挟めない——そんな経験はありませんか。

Barge-in は、AI 発話中にユーザーが話し始めたら、AI がすぐ止まって聞き直す機能です。Gemini Live API はネイティブで対応しており、自動活動検知を有効にするだけで使えます。

CONFIG = {
    "setup": {
        "model": "models/gemini-2.0-flash-native-audio-preview",
        "generation_config": {
            "response_modalities": ["AUDIO"],
        },
        "realtime_input_config": {
            "automatic_activity_detection": {
                "disabled": False,
                "start_of_speech_sensitivity": "START_SENSITIVITY_HIGH",
                "end_of_speech_sensitivity": "END_SENSITIVITY_LOW"
            }
        }
    }
}

sensitivity の意味は次のとおりです。

  • start_of_speech_sensitivityHIGH にすると、話し始めに敏感になり割り込みが起きやすい
  • end_of_speech_sensitivityLOW にすると、本当に話し終えたかを慎重に判断し、誤検知を減らせる

クライアントは interrupted を受け取ったら再生を止めます。

class GeminiClient {
  constructor() {
    this.audioQueue = [];
    this.isPlaying = false;
    this.currentSource = null;
  }

  async handleMessage(event) {
    const message = JSON.parse(event.data);

    if (message.server_content?.interrupted) {
      console.log('⚡ 割り込み。再生を停止');
      this.stopPlayback();
      return;
    }

    if (message.server_content?.model_turn) {
      const parts = message.server_content.model_turn.parts;

      for (const part of parts) {
        if (part.inline_data?.mime_type.startsWith('audio/')) {
          const audioData = base64ToArrayBuffer(part.inline_data.data);
          this.queueAudio(audioData);
        }
      }
    }
  }

  stopPlayback() {
    this.audioQueue = [];
    this.isPlaying = false;

    if (this.currentSource) {
      try {
        this.currentSource.stop();
      } catch (e) {
        // すでに停止済みの場合あり
      }
      this.currentSource = null;
    }
  }

  async queueAudio(audioData) {
    this.audioQueue.push(audioData);
    if (!this.isPlaying) {
      this.playNext();
    }
  }

  async playNext() {
    if (this.audioQueue.length === 0) {
      this.isPlaying = false;
      return;
    }

    this.isPlaying = true;
    const audioData = this.audioQueue.shift();

    const audioBuffer = await this.audioContext.decodeAudioData(audioData.slice());
    this.currentSource = this.audioContext.createBufferSource();
    this.currentSource.buffer = audioBuffer;
    this.currentSource.connect(this.audioContext.destination);

    this.currentSource.onended = () => {
      this.playNext();
    };

    this.currentSource.start();
  }
}

stop() は再生完了後に呼ぶと例外になることがあるので、try-catch で囲んでおくとコンソールが荒れません。

パフォーマンス最適化と遅延制御

遅延の主な要因は次の 3 つです。

  1. ネットワーク:ブラウザ → サーバー → Gemini の往復
  2. エンコード/デコード:PCM は無損失なのでコストは小さい
  3. バッファ:滑らかな再生のための蓄積

対策の要点です。

バッファを浅くする

再生バッファは 100〜200 ms 程度で十分なことが多いです。

const audioContext = new AudioContext({
  sampleRate: 16000,
  latencyHint: 'interactive'  // 低遅延モード
});

アダプティブバッファ

ジッターが大きいときはバッファを少し増やし、安定時は減らす、という制御が有効です。

エコーキャンセル

スピーカー利用時は AI の声がマイクに入りループしやすいです。getUserMedia のオプションを有効にしてください。

navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  }
})

メトリクス監視

Performance API で計測します。

class LatencyMonitor {
  constructor() {
    this.metrics = [];
  }

  recordSendTime() {
    this.lastSendTime = performance.now();
  }

  recordReceiveTime() {
    const latency = performance.now() - this.lastSendTime;
    this.metrics.push(latency);

    if (this.metrics.length > 100) {
      this.metrics.shift();
    }

    const avg = this.metrics.reduce((a, b) => a + b, 0) / this.metrics.length;
    console.log(`📊 平均遅延: ${avg.toFixed(2)} ms`);
  }
}

テスト環境での目安です。

  • エンドツーエンド:300〜500 ms(ネットワーク依存)
  • 初回応答:200〜400 ms
  • 連続対話:150〜300 ms

これより明らかに遅い場合は、次を確認してください。

  • WebSocket は WSS か(HTTP よりオーバーヘッドが増える)
  • サーバーは Google のデータセンターに近いか
  • VAD のフレーム長が長すぎないか
  • フロントの再生バッファが大きすぎないか

Chrome などはユーザー操作なしの自動再生を制限するため、「対話を開始」ボタンで AudioContext を起動するのが無難です。

まとめ

概念の整理から、アーキテクチャ、WebSocket、音声収集、VAD、Barge-in、遅延最適化まで、Gemini Live API でリアルタイム音声アシスタントを作る流れを一通り見てきました。実装でつまずきやすい点も、できるだけ具体的に書いてあります。

この分野はまだ速く進化しており、API も更新が続きます。ただ、ここで紹介した分離アーキテクチャとストリーミングの基本は、しばらく使える土台になるはずです。私のプロジェクトでも数ヶ月、安定して動いています。

開発中に詰まったら、コミュニティで相談するのも手です。一人で抱え込むより、議論した方が早く前に進めます。

FAQ

なぜフロントエンドとバックエンドを分離した構成が必要なのですか?
API キーはバックエンドに置き、フロントの JavaScript には露出させてはいけません。ブラウザから直接 Gemini に接続すると、開発者ツールで誰でもキーを取得でき、不正利用による高額請求のリスクがあります。フロントは Python の WebSocket プロキシに接続し、バックエンドが Gemini Live API へ転送するのが安全です。
サンプリングレートに 16kHz を選ぶ理由は?
人の声はおおよそ 85〜255 Hz の帯域です。ナイキストの定理では 8kHz でも理論上は足りますが、16kHz ならディテールを残しつつデータ量も抑えられ、音質と転送量のバランスが取りやすいです。Gemini 公式も 16kHz を推奨しており、音声認識精度と帯域コストの両立に向いています。
VAD の aggressiveness パラメータはどう調整する?
0〜3 の範囲で、数値が高いほど厳格(音声を静音とみなしやすい)です。まず 2 から試し、低すぎると背景ノイズを音声と誤認して帯域を消費し、高すぎると小声を取りこぼします。利用環境の騒音レベルに合わせて微調整してください。
Barge-in 機能は追加開発が必要?
Gemini Live API がネイティブでサポートしています。設定で automatic_activity_detection を有効にするだけで足ります。クライアント側では interrupted イベントを受け取ったら、再生キューを空にして現在の音声を即座に止める処理が必要です。

3分で読めます · 公開日: 2026年2月27日 · 更新日: 2026年6月1日

シリーズの読書導線 第 1 / 7 記事

Google AI マスタークラス

このページはシリーズの最初の記事です。次の記事へ進むか、シリーズ全体ページで全体像を確認できます。

シリーズ全体を見る

関連記事

コメント

GitHubアカウントでログインしてコメントできます