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-dotenvexpo-av の権限設定
app.json に以下を追加してください。
{
"expo": {
"plugins": [
[
"expo-av",
{
"microphonePermission": "音声アシスタント機能のためにマイクを使用します。"
}
]
]
}
}iOS では Info.plist への記載が自動で行われますが、Android の場合は app.json の android.permissions に RECORD_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-av の Audio.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-crypto や buffer パッケージの使用も検討してください。
APIキーが漏洩するリスク
クライアントサイドに API キーを直接置くのはセキュリティ上のリスクがあります。本番環境では、自前のバックエンドサーバーを経由して WebSocket をプロキシする構成を検討してください。エフェメラルトークンを使う方法については Gemini Live API WebSocket 本番ガイド に詳しく書かれています。
まずは Expo Go で動かしてみましょう
ここで紹介したコードは、npx expo start でそのまま動く最小構成です。マイクで話しかけると Gemini が音声で返してくれる体験は、テキストチャットとはまた違う感覚があります。
次のステップとしては、会話履歴の保持・ウェイクワード対応・UI のブラッシュアップあたりが面白いと思います。より本格的な Live API の実装パターンは Gemini API × React Native AIモバイルアプリ本番構築ガイド も参考にしてみてください。