GEMINI LABEN
NANOLITE — Nano Banana 2 Liteが登場しました。Googleで最も速く、最もコスト効率の高いGemini Imageモデルで、軽量な画像生成を安く回したい用途に向いていますOMNIFLASH — Gemini Omni Flashがpublic previewになりました。ネイティブにマルチモーダルなモデルで、企業や開発者が独自の動的な動画ワークフローを構築できますAGENTS — Managed Agentsが拡張されました。background: trueでサーバー側の非同期実行とポーリング、リモートMCPサーバー連携、対話をまたぐ認証情報のリフレッシュに対応しますMEMORY — Memory BankのIngestEvents APIが一般提供になりました。イベントの取り込みとメモリ生成を分離し、コンテンツを継続的にストリームできますTHROUGHPUT — Provisioned Throughputで、同一モデル・同一リージョンに対して最大7件の保留オーダーを提出できるようになりましたDEPRECATE — 画像生成モデルは8月17日に、Gemini Enterprise Agent PlatformのGrok 4.1系は8月20日に停止される予定ですNANOLITE — Nano Banana 2 Liteが登場しました。Googleで最も速く、最もコスト効率の高いGemini Imageモデルで、軽量な画像生成を安く回したい用途に向いていますOMNIFLASH — Gemini Omni Flashがpublic previewになりました。ネイティブにマルチモーダルなモデルで、企業や開発者が独自の動的な動画ワークフローを構築できますAGENTS — Managed Agentsが拡張されました。background: trueでサーバー側の非同期実行とポーリング、リモートMCPサーバー連携、対話をまたぐ認証情報のリフレッシュに対応しますMEMORY — Memory BankのIngestEvents APIが一般提供になりました。イベントの取り込みとメモリ生成を分離し、コンテンツを継続的にストリームできますTHROUGHPUT — Provisioned Throughputで、同一モデル・同一リージョンに対して最大7件の保留オーダーを提出できるようになりましたDEPRECATE — 画像生成モデルは8月17日に、Gemini Enterprise Agent PlatformのGrok 4.1系は8月20日に停止される予定です
記事一覧/開発ツール
開発ツール/2026-05-05中級

Gemini Live API を Expo アプリに組み込む — リアルタイム音声会話機能の実装ガイド

Gemini Live API を Expo(React Native)のモバイルアプリに組み込み、リアルタイム音声会話機能を実装する方法を解説します。WebSocket 接続・音声録音・再生の実践的なコード例付き。

Gemini Live API6ExpoReact Native2音声AI2WebSocket3モバイル開発リアルタイム5

Expo で音声 AI を作ろうとして「Gemini Live API、WebSocket は分かったけど、React Native でどうやって繋ぐの?」と詰まった方は少なくないのではないでしょうか。Web なら WebAudio API が使えますが、Expo では事情が違います。expo-av の扱い方、PCM オーディオのバイト列への変換、WebSocket セッションの維持——これを全部一から調べると半日消えます。

ここではGemini Live API を Expo アプリにつなぐ最短ルートを、動くコードとともに紹介します。「試しに会話できる状態」まで30分で到達することを目標にしています。

個人開発で長くモバイルアプリを作ってきた私自身、Expo の音声まわりは毎回つまずくポイントだと感じています。だからこそ、調べ直す時間を省けるよう、実際に手元で動いた構成だけを順番に並べました。

Gemini Live API の仕組みをおさらいする

実装に入る前に、Live API の特徴を簡単に確認しておきます。

通常の Gemini API は「リクエストを送って、レスポンスを待つ」という一方通行の流れです。一方、Live API は WebSocket を使った双方向ストリーミングで動作します。音声を送り続けながら、AI の返答も音声として受け取れる——いわゆる「割り込み」にも対応した、よりインタラクティブな設計になっています。

音声フォーマットは PCM 16 bit / 16 kHz / モノラルが基本です。Expo でマイクから録音した音声をそのまま送ることができれば、あとは Live API 側が音声認識・推論・音声合成を一貫して行ってくれます。

詳しい API の仕様は Gemini Live API 公式ガイド を参照してください。

環境準備

必要なパッケージ

# Expo プロジェクト作成(既存プロジェクトがある場合は不要)
npx create-expo-app@latest gemini-voice-app --template blank-typescript
cd gemini-voice-app
 
# 音声録音・再生
npx expo install expo-av
 
# 環境変数管理(APIキー保護)
npm install react-native-dotenv

expo-av の権限設定

app.json に以下を追加してください。

{
  "expo": {
    "plugins": [
      [
        "expo-av",
        {
          "microphonePermission": "音声アシスタント機能のためにマイクを使用します。"
        }
      ]
    ]
  }
}

iOS では Info.plist への記載が自動で行われますが、Android の場合は app.jsonandroid.permissionsRECORD_AUDIO が含まれていることも確認してください。

WebSocket 接続の実装

Live API への接続は、通常の WebSocket より少し手順が多いです。接続時に「セッション設定」を送り、その後に音声データを流す構造になっています。

// src/hooks/useGeminiLive.ts
import { useRef, useCallback, useState } from 'react';
 
const GEMINI_API_KEY = process.env.EXPO_PUBLIC_GEMINI_API_KEY ?? '';
const MODEL = 'gemini-2.0-flash-live-001';
const WS_URL = `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent?key=${GEMINI_API_KEY}`;
 
type SessionStatus = 'idle' | 'connecting' | 'active' | 'error';
 
export function useGeminiLive() {
  const ws = useRef<WebSocket | null>(null);
  const [status, setStatus] = useState<SessionStatus>('idle');
  const [transcript, setTranscript] = useState<string>('');
 
  const connect = useCallback(() => {
    setStatus('connecting');
    ws.current = new WebSocket(WS_URL);
 
    ws.current.onopen = () => {
      // セッション設定を最初のメッセージとして送信
      const setupMessage = {
        setup: {
          model: `models/${MODEL}`,
          generationConfig: {
            responseModalities: ['AUDIO'],
            speechConfig: {
              voiceConfig: {
                prebuiltVoiceConfig: { voiceName: 'Aoede' },
              },
            },
          },
          systemInstruction: {
            parts: [{ text: 'You are a helpful Japanese assistant. Respond in Japanese.' }],
          },
        },
      };
      ws.current?.send(JSON.stringify(setupMessage));
    };
 
    ws.current.onmessage = (event) => {
      // 返ってきたデータを処理(音声バイト列またはテキスト)
      handleServerMessage(event.data);
    };
 
    ws.current.onerror = () => setStatus('error');
    ws.current.onclose = () => setStatus('idle');
  }, []);
 
  const handleServerMessage = (data: string) => {
    try {
      const msg = JSON.parse(data);
 
      // セッション準備完了
      if (msg.setupComplete) {
        setStatus('active');
        return;
      }
 
      // サーバーからの音声・テキストレスポンス
      const parts = msg.serverContent?.modelTurn?.parts ?? [];
      for (const part of parts) {
        if (part.inlineData?.mimeType === 'audio/pcm;rate=24000') {
          // Base64 エンコードされた PCM 音声データを再生
          playAudioChunk(part.inlineData.data);
        }
        if (part.text) {
          setTranscript((prev) => prev + part.text);
        }
      }
    } catch (e) {
      console.warn('メッセージのパースに失敗:', e);
    }
  };
 
  const sendAudio = useCallback((base64Audio: string) => {
    if (ws.current?.readyState !== WebSocket.OPEN) return;
    const message = {
      realtimeInput: {
        mediaChunks: [
          {
            mimeType: 'audio/pcm;rate=16000',
            data: base64Audio,
          },
        ],
      },
    };
    ws.current.send(JSON.stringify(message));
  }, []);
 
  const disconnect = useCallback(() => {
    ws.current?.close();
    ws.current = null;
    setStatus('idle');
  }, []);
 
  return { connect, disconnect, sendAudio, status, transcript };
}

playAudioChunk 関数は後のセクションで実装します。ひとまず WebSocket 側の骨格はこれで完成です。

音声録音の実装

次に、マイクから録音した音声を Live API に流す部分を作ります。

expo-avAudio.Recording は録音後にファイルを保存するものですが、今回はリアルタイムでチャンクを送りたいので、録音ループを細かく回す方法を使います。

// src/hooks/useAudioRecorder.ts
import { Audio } from 'expo-av';
import { useState, useRef, useCallback } from 'react';
import * as FileSystem from 'expo-file-system';
 
export function useAudioRecorder(onChunk: (base64: string) => void) {
  const recording = useRef<Audio.Recording | null>(null);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const [isRecording, setIsRecording] = useState(false);
 
  const startRecording = useCallback(async () => {
    // マイク権限の確認
    const { status } = await Audio.requestPermissionsAsync();
    if (status !== 'granted') {
      console.warn('マイクの使用が許可されていません');
      return;
    }
 
    await Audio.setAudioModeAsync({
      allowsRecordingIOS: true,
      playsInSilentModeIOS: true,
    });
 
    const rec = new Audio.Recording();
    await rec.prepareToRecordAsync({
      android: {
        extension: '.wav',
        outputFormat: Audio.AndroidOutputFormat.DEFAULT,
        audioEncoder: Audio.AndroidAudioEncoder.DEFAULT,
        sampleRate: 16000,
        numberOfChannels: 1,
        bitRate: 256000,
      },
      ios: {
        extension: '.wav',
        audioQuality: Audio.IOSAudioQuality.HIGH,
        sampleRate: 16000,
        numberOfChannels: 1,
        bitRate: 256000,
        linearPCMBitDepth: 16,
        linearPCMIsBigEndian: false,
        linearPCMIsFloat: false,
      },
      web: {},
    });
 
    await rec.startAsync();
    recording.current = rec;
    setIsRecording(true);
 
    // 500ms ごとに録音データを取り出して送信
    intervalRef.current = setInterval(async () => {
      if (!recording.current) return;
      await recording.current.stopAndUnloadAsync();
 
      const uri = recording.current.getURI();
      if (uri) {
        const base64 = await FileSystem.readAsStringAsync(uri, {
          encoding: FileSystem.EncodingType.Base64,
        });
        // WAV ヘッダー(44バイト)を除いた PCM データのみ送信
        const pcmBase64 = stripWavHeader(base64);
        onChunk(pcmBase64);
      }
 
      // 次のチャンクの録音を開始
      const next = new Audio.Recording();
      await next.prepareToRecordAsync({ /* 同じ設定 */ } as any);
      await next.startAsync();
      recording.current = next;
    }, 500);
  }, [onChunk]);
 
  const stopRecording = useCallback(async () => {
    if (intervalRef.current) clearInterval(intervalRef.current);
    await recording.current?.stopAndUnloadAsync();
    recording.current = null;
    setIsRecording(false);
  }, []);
 
  return { startRecording, stopRecording, isRecording };
}
 
// WAV ヘッダー(44バイト)を除去してPCMバイト列のみ取り出す
function stripWavHeader(base64wav: string): string {
  const binary = atob(base64wav);
  const pcmBinary = binary.slice(44); // WAV ヘッダーは固定44バイト
  return btoa(pcmBinary);
}

500ms ごとにファイルへ書き出して Base64 に変換するのは少し回りくどいように見えますが、現状の expo-av でストリーミング録音を実現する現実的な方法の一つです。チャンクが小さいほどレイテンシは下がりますが、250ms 以下にすると Android でファイル I/O が追いつかないことがあります。私は実機テストで 300〜500ms が安定する範囲だと感じました。

音声再生の実装

サーバーから返ってくる音声は Base64 エンコードされた PCM / 24 kHz です。expo-av で再生するには、一時ファイルに書き出してから再生する方法がシンプルです。

// src/utils/audioPlayer.ts
import { Audio } from 'expo-av';
import * as FileSystem from 'expo-file-system';
 
let soundRef: Audio.Sound | null = null;
 
export async function playAudioChunk(base64pcm: string) {
  try {
    // PCM を WAV に変換(44バイトのヘッダーを付加)
    const wav = addWavHeader(base64pcm, 24000);
    const tmpUri = FileSystem.cacheDirectory + `chunk_${Date.now()}.wav`;
 
    await FileSystem.writeAsStringAsync(tmpUri, wav, {
      encoding: FileSystem.EncodingType.Base64,
    });
 
    // 前のサウンドを解放
    if (soundRef) {
      await soundRef.unloadAsync();
      soundRef = null;
    }
 
    const { sound } = await Audio.Sound.createAsync({ uri: tmpUri });
    soundRef = sound;
    await sound.playAsync();
  } catch (e) {
    console.warn('音声再生エラー:', e);
  }
}
 
function addWavHeader(base64pcm: string, sampleRate: number): string {
  const pcm = atob(base64pcm);
  const dataLength = pcm.length;
  const buffer = new ArrayBuffer(44 + dataLength);
  const view = new DataView(buffer);
 
  // WAVヘッダーを書き込む
  const writeString = (offset: number, str: string) => {
    for (let i = 0; i < str.length; i++) {
      view.setUint8(offset + i, str.charCodeAt(i));
    }
  };
 
  writeString(0, 'RIFF');
  view.setUint32(4, 36 + dataLength, true);
  writeString(8, 'WAVE');
  writeString(12, 'fmt ');
  view.setUint32(16, 16, true);        // チャンクサイズ
  view.setUint16(20, 1, true);         // PCM形式
  view.setUint16(22, 1, true);         // モノラル
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * 2, true); // バイトレート
  view.setUint16(32, 2, true);         // ブロックアライン
  view.setUint16(34, 16, true);        // ビット深度
  writeString(36, 'data');
  view.setUint32(40, dataLength, true);
 
  for (let i = 0; i < dataLength; i++) {
    view.setUint8(44 + i, pcm.charCodeAt(i));
  }
 
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

UIコンポーネントの組み立て

ここまでのフックとユーティリティを組み合わせて、シンプルなチャット画面を作ります。

// App.tsx
import React, { useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native';
import { useGeminiLive } from './src/hooks/useGeminiLive';
import { useAudioRecorder } from './src/hooks/useAudioRecorder';
import { playAudioChunk } from './src/utils/audioPlayer';
 
export default function App() {
  const { connect, disconnect, sendAudio, status, transcript } = useGeminiLive();
  const { startRecording, stopRecording, isRecording } = useAudioRecorder(sendAudio);
 
  // アプリ起動時に接続
  useEffect(() => {
    connect();
    return () => disconnect();
  }, []);
 
  const handlePress = () => {
    if (isRecording) {
      stopRecording();
    } else {
      startRecording();
    }
  };
 
  const statusLabel: Record<string, string> = {
    idle: '未接続',
    connecting: '接続中...',
    active: '準備完了',
    error: '接続エラー',
  };
 
  return (
    <View style={styles.container}>
      <Text style={styles.status}>ステータス: {statusLabel[status]}</Text>
 
      <ScrollView style={styles.transcript}>
        <Text style={styles.transcriptText}>{transcript || '会話が始まると、ここに文字起こしが表示されます'}</Text>
      </ScrollView>
 
      <TouchableOpacity
        style={[styles.button, isRecording && styles.buttonActive]}
        onPress={handlePress}
        disabled={status !== 'active'}
      >
        <Text style={styles.buttonText}>
          {isRecording ? '● 話し中(タップして終了)' : 'マイクをタップして話す'}
        </Text>
      </TouchableOpacity>
    </View>
  );
}
 
const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#1a1a2e', padding: 20, paddingTop: 60 },
  status: { color: '#888', fontSize: 13, marginBottom: 10 },
  transcript: { flex: 1, backgroundColor: '#16213e', borderRadius: 12, padding: 16, marginBottom: 20 },
  transcriptText: { color: '#e0e0e0', fontSize: 15, lineHeight: 24 },
  button: { backgroundColor: '#0f3460', padding: 20, borderRadius: 50, alignItems: 'center' },
  buttonActive: { backgroundColor: '#e94560' },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

npx expo start で起動し、Expo Go アプリから読み込むと動作確認できます。

よくある落とし穴

実装してみて気づいた点をいくつか挙げておきます。

WebSocket の接続タイミングがずれる

onopen が発火する前に send しようとするとエラーになります。setupComplete レスポンスを受け取ってから音声送信を開始するよう、status フラグで制御しています。

音声が途切れて不自然に聞こえる

チャンクが短すぎるとモデルが文脈をつかみにくくなります。また、チャンクの末尾に無音が多いと誤認識の原因になります。私が試した範囲では 300〜500ms のチャンクサイズが安定していました。

Android で WAV ヘッダーの付加が崩れる

DataView を使った WAV ヘッダー生成は、Android の JS エンジン(Hermes)でも動作しますが、ArrayBuffer から Base64 に変換するループが遅い場合があります。音声が多くなる場合は expo-cryptobuffer パッケージの使用も検討してください。

APIキーが漏洩するリスク

クライアントサイドに API キーを直接置くのはセキュリティ上のリスクがあります。本番環境では、自前のバックエンドサーバーを経由して WebSocket をプロキシする構成を検討してください。エフェメラルトークンを使う方法については Gemini Live API WebSocket 本番ガイド に詳しく書かれています。

まずは Expo Go で動かしてみましょう

ここで紹介したコードは、npx expo start でそのまま動く最小構成です。マイクで話しかけると Gemini が音声で返してくれる体験は、テキストチャットとはまた違う感覚があります。

次のステップとしては、会話履歴の保持・ウェイクワード対応・UI のブラッシュアップあたりが面白いと思います。より本格的な Live API の実装パターンは Gemini API × React Native AIモバイルアプリ本番構築ガイド も参考にしてみてください。

シェア

お読みいただきありがとうございます

Gemini Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

開発ツール2026-05-03
Gemini Live API で音声 SaaS を構築する実装 — リアルタイム会話と Stripe 課金の連動
Gemini Live API を使ったリアルタイム音声 SaaS の本番実装ガイド。WebSocket 接続から Stripe 連動の利用時間課金まで、個人開発者がゼロから作り切るための完全な設計と実装コードを公開します。
API / SDK2026-06-16
Gemini Live API のセッションが切れるたびに会話が消える — 期限切れトークンの再取得とセッション再開の実装メモ
Gemini Live API の WebSocket が再接続するたびに会話履歴とユーザーの発話を取りこぼす問題を、エフェメラルトークンの単発消費・セッション再開ハンドル・goAway 予告の3点から実装で塞ぐ運用メモです。
開発ツール2026-04-01
Gemini × Rork で iOS/Android アプリを AI 生成する — プロンプトひとつでアプリ開発を加速する
Gemini と Rork を組み合わせたiOS/Androidアプリ開発の実践ガイド。AI生成プラットフォームRorkにGemini APIを連携させ、プロンプトからアプリ設計・実装・デバッグまでを一気通貫で進める方法を解説します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →