Expo React Nativeを使ったクロスプラットフォーム音声エージェント
Expo React Nativeを使ったクロスプラットフォーム音声エージェント
WebRTC対応のExpoとElevenLabs React Native SDKを使用して、iOSとAndroidで動作するElevenLabsエージェントを構築します。
チュートリアル · ElevenAgents クイックスタートを完了し、Expo開発環境をセットアップ済みであることを前提としています。
はじめに
このチュートリアルでは、Expo React NativeとWebRTC対応のElevenLabs React Native SDKを使用して、iOSとAndroidの両方で動作する音声エージェントを構築する方法を学びます。
必要なもの
- APIキーを持つElevenLabsアカウント。
- マシンにインストールされたNode.js v18以降。
セットアップ
新しいExpoプロジェクトを作成する
create-expo-appを使用して、新しい空のExpoプロジェクトを作成します。
npx create-expo-app@latest --template blank-typescript
依存関係をインストールする
ElevenLabs React Native SDKとその依存関係をインストールします。
npx expo install @elevenlabs/react-native @livekit/react-native @livekit/react-native-webrtc @config-plugins/react-native-webrtc @livekit/react-native-expo-plugin @livekit/react-native-expo-plugin livekit-client
ピア依存関係で問題が発生した場合は、プロジェクトのルートに.npmrcファイルを追加し、
次の内容を記述してください:legacy-peer-deps=true。
マイク権限を有効にしてExpoプラグインを追加する
app.jsonファイルに、以下の権限を追加します。
{"expo": {"scheme": "elevenlabs",// ..."ios": {"infoPlist": {"NSMicrophoneUsageDescription": "This app uses the microphone to record audio."},"supportsTablet": true,"bundleIdentifier": "YOUR.BUNDLE.ID"},"android": {"permissions": ["android.permission.RECORD_AUDIO","android.permission.ACCESS_NETWORK_STATE","android.permission.CAMERA","android.permission.INTERNET","android.permission.MODIFY_AUDIO_SETTINGS","android.permission.SYSTEM_ALERT_WINDOW","android.permission.WAKE_LOCK","android.permission.BLUETOOTH"],"adaptiveIcon": {"foregroundImage": "./assets/adaptive-icon.png","backgroundColor": "#ffffff"},"package": "YOUR.PACKAGE.ID"},"plugins": ["@livekit/react-native-expo-plugin", "@config-plugins/react-native-webrtc"]// ...}}
これにより、会話の開始時にReact Nativeがマイク権限をリクエストできるようになります。
Androidエミュレーターでは、エミュレーターのマイク設定で「Virtual microphone uses host audio input」を 有効にする必要があります。
アプリにElevenLabs Agentsを追加する
./App.tsxファイルに以下のコードを追加して、アプリにElevenLabs Agentsを追加します。
import { ConversationProvider, useConversation } from "@elevenlabs/react-native";import type { ConversationStatus, ConversationEvent, Role } from "@elevenlabs/react-native";import React, { useState } from "react";import {View,Text,StyleSheet,TouchableOpacity,Keyboard,TouchableWithoutFeedback,Platform,} from "react-native";import { TextInput } from "react-native";import { getBatteryLevel, changeBrightness, flashScreen } from "./utils/tools";const ConversationScreen = () => {const conversation = useConversation({clientTools: {getBatteryLevel,changeBrightness,flashScreen,},onConnect: ({ conversationId }: { conversationId: string }) => {console.log("✅ Connected to conversation", conversationId);},onDisconnect: (details: string) => {console.log("❌ Disconnected from conversation", details);},onError: (message: string, context?: Record<string, unknown>) => {console.error("❌ Conversation error:", message, context);},onMessage: ({ message, source }: { message: ConversationEvent; source: Role }) => {console.log(`💬 Message from ${source}:`, message);},onModeChange: ({ mode }: { mode: "speaking" | "listening" }) => {console.log(`🔊 Mode: ${mode}`);},onStatusChange: ({ status }: { status: ConversationStatus }) => {console.log(`📡 Status: ${status}`);},onCanSendFeedbackChange: ({ canSendFeedback }: { canSendFeedback: boolean }) => {console.log(`🔊 Can send feedback: ${canSendFeedback}`);},});const [isStarting, setIsStarting] = useState(false);const [textInput, setTextInput] = useState("");const handleSubmitText = () => {if (textInput.trim()) {conversation.sendUserMessage(textInput.trim());setTextInput("");Keyboard.dismiss();}};const startConversation = async () => {if (isStarting) return;setIsStarting(true);try {await conversation.startSession({agentId: process.env.EXPO_PUBLIC_AGENT_ID,dynamicVariables: {platform: Platform.OS,},});} catch (error) {console.error("Failed to start conversation:", error);} finally {setIsStarting(false);}};const endConversation = async () => {try {await conversation.endSession();} catch (error) {console.error("Failed to end conversation:", error);}};const getStatusColor = (status: ConversationStatus): string => {switch (status) {case "connected":return "#10B981";case "connecting":return "#F59E0B";case "disconnected":return "#EF4444";default:return "#6B7280";}};const getStatusText = (status: ConversationStatus): string => {return status[0].toUpperCase() + status.slice(1);};const canStart = conversation.status === "disconnected" && !isStarting;const canEnd = conversation.status === "connected";return (<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}><View style={styles.container}><Text style={styles.title}>ElevenLabs React Native Example</Text><Text style={styles.subtitle}>Remember to set the agentId in the .env file!</Text><View style={styles.statusContainer}><Viewstyle={[styles.statusDot, { backgroundColor: getStatusColor(conversation.status) }]}/><Text style={styles.statusText}>{getStatusText(conversation.status)}</Text></View>{/* Speaking Indicator */}{conversation.status === "connected" && (<View style={styles.speakingContainer}><Viewstyle={[styles.speakingDot,{backgroundColor: conversation.isSpeaking ? "#8B5CF6" : "#D1D5DB",},]}/><Textstyle={[styles.speakingText,{ color: conversation.isSpeaking ? "#8B5CF6" : "#9CA3AF" },]}>{conversation.isSpeaking ? "🎤 AI Speaking" : "👂 AI Listening"}</Text></View>)}<View style={styles.buttonContainer}><TouchableOpacitystyle={[styles.button, styles.startButton, !canStart && styles.disabledButton]}onPress={startConversation}disabled={!canStart}><Text style={styles.buttonText}>{isStarting ? "Starting..." : "Start Conversation"}</Text></TouchableOpacity><TouchableOpacitystyle={[styles.button, styles.endButton, !canEnd && styles.disabledButton]}onPress={endConversation}disabled={!canEnd}><Text style={styles.buttonText}>End Conversation</Text></TouchableOpacity></View>{/* Feedback Buttons */}{conversation.status === "connected" && conversation.canSendFeedback && (<View style={styles.feedbackContainer}><Text style={styles.feedbackLabel}>How was that response?</Text><View style={styles.feedbackButtons}><TouchableOpacitystyle={[styles.button, styles.likeButton]}onPress={() => conversation.sendFeedback(true)}><Text style={styles.buttonText}>👍 Like</Text></TouchableOpacity><TouchableOpacitystyle={[styles.button, styles.dislikeButton]}onPress={() => conversation.sendFeedback(false)}><Text style={styles.buttonText}>👎 Dislike</Text></TouchableOpacity></View></View>)}{/* Text Input and Messaging */}{conversation.status === "connected" && (<View style={styles.messagingContainer}><Text style={styles.messagingLabel}>Send Text Message</Text><TextInputstyle={styles.textInput}value={textInput}onChangeText={(text) => {setTextInput(text);// Prevent agent from interrupting while user is typingif (text.length > 0) {conversation.sendUserActivity();}}}placeholder="Type your message or context... (Press Enter to send)"multilineonSubmitEditing={handleSubmitText}returnKeyType="send"blurOnSubmit={true}/><View style={styles.messageButtons}><TouchableOpacitystyle={[styles.button, styles.messageButton]}onPress={handleSubmitText}disabled={!textInput.trim()}><Text style={styles.buttonText}>💬 Send Message</Text></TouchableOpacity><TouchableOpacitystyle={[styles.button, styles.contextButton]}onPress={() => {if (textInput.trim()) {conversation.sendContextualUpdate(textInput.trim());setTextInput("");Keyboard.dismiss();}}}disabled={!textInput.trim()}><Text style={styles.buttonText}>📝 Send Context</Text></TouchableOpacity></View></View>)}</View></TouchableWithoutFeedback>);};export default function App() {return (<ConversationProvider><ConversationScreen /></ConversationProvider>);}const styles = StyleSheet.create({container: {flex: 1,justifyContent: "center",alignItems: "center",backgroundColor: "#F3F4F6",padding: 20,},title: {fontSize: 24,fontWeight: "bold",marginBottom: 8,color: "#1F2937",},subtitle: {fontSize: 16,color: "#6B7280",marginBottom: 32,},statusContainer: {flexDirection: "row",alignItems: "center",marginBottom: 24,},statusDot: {width: 12,height: 12,borderRadius: 6,marginRight: 8,},statusText: {fontSize: 16,fontWeight: "500",color: "#374151",},speakingContainer: {flexDirection: "row",alignItems: "center",marginBottom: 24,},speakingDot: {width: 12,height: 12,borderRadius: 6,marginRight: 8,},speakingText: {fontSize: 14,fontWeight: "500",},toolsContainer: {backgroundColor: "#E5E7EB",padding: 16,borderRadius: 8,marginBottom: 24,width: "100%",},toolsTitle: {fontSize: 14,fontWeight: "600",color: "#374151",marginBottom: 8,},toolItem: {fontSize: 12,color: "#6B7280",fontFamily: "monospace",marginBottom: 4,},buttonContainer: {width: "100%",gap: 16,},button: {backgroundColor: "#3B82F6",paddingVertical: 16,paddingHorizontal: 32,borderRadius: 8,alignItems: "center",},startButton: {backgroundColor: "#10B981",},endButton: {backgroundColor: "#EF4444",},disabledButton: {backgroundColor: "#9CA3AF",},buttonText: {color: "white",fontSize: 16,fontWeight: "600",},instructions: {marginTop: 24,fontSize: 14,color: "#6B7280",textAlign: "center",lineHeight: 20,},feedbackContainer: {marginTop: 24,alignItems: "center",},feedbackLabel: {fontSize: 16,fontWeight: "500",color: "#374151",marginBottom: 12,},feedbackButtons: {flexDirection: "row",gap: 16,},likeButton: {backgroundColor: "#10B981",},dislikeButton: {backgroundColor: "#EF4444",},messagingContainer: {marginTop: 24,width: "100%",},messagingLabel: {fontSize: 16,fontWeight: "500",color: "#374151",marginBottom: 8,},textInput: {backgroundColor: "#FFFFFF",borderRadius: 8,padding: 16,minHeight: 100,textAlignVertical: "top",borderWidth: 1,borderColor: "#D1D5DB",marginBottom: 16,},messageButtons: {flexDirection: "row",gap: 16,},messageButton: {backgroundColor: "#3B82F6",flex: 1,},contextButton: {backgroundColor: "#4F46E5",flex: 1,},activityContainer: {marginTop: 24,alignItems: "center",},activityLabel: {fontSize: 14,color: "#6B7280",marginBottom: 8,textAlign: "center",},activityButton: {backgroundColor: "#F59E0B",},});
ネイティブクライアントツール
ElevenLabsエージェントの構築では、エージェントが機能にアクセスし、動的に実行できるようにすることが重要です。これはクライアントツールで実現できます。
クライアントツールを格納する新しいファイル./utils/tools.tsを作成し、以下のコードを追加します。
import * as Battery from "expo-battery";import * as Brightness from "expo-brightness";const getBatteryLevel = async () => {const batteryLevel = await Battery.getBatteryLevelAsync();console.log("batteryLevel", batteryLevel);if (batteryLevel === -1) {return "Error: Device does not support retrieving the battery level.";}return batteryLevel;};const changeBrightness = ({ brightness }: { brightness: number }) => {console.log("changeBrightness", brightness);Brightness.setSystemBrightnessAsync(brightness);return brightness;};const flashScreen = () => {Brightness.setSystemBrightnessAsync(1);setTimeout(() => {Brightness.setSystemBrightnessAsync(0);}, 200);return "Successfully flashed the screen.";};export { getBatteryLevel, changeBrightness, flashScreen };
動的変数
クライアントツールに加え、プラットフォーム(web、iOS、Android)を動的変数として、最初のメッセージとプロンプトの両方に挿入します。
// ...const startConversation = async () => {if (isStarting) return;setIsStarting(true);try {await conversation.startSession({agentId: process.env.EXPO_PUBLIC_AGENT_ID,dynamicVariables: {platform: Platform.OS,},});} catch (error) {console.error("Failed to start conversation:", error);} finally {setIsStarting(false);}};// ...
エージェント設定
最初のメッセージを設定
最初のメッセージを設定し、プラットフォーム用の動的変数を指定します。
Hi there, woah, so cool that I'm running on {{platform}}. What can I help you with?
システムプロンプトを設定
システムプロンプトを設定します。ここにも動的変数を含めることができます。
You are a helpful assistant running on {{platform}}. You have access to certain tools that allow you to check the user device battery level and change the display brightness. Use these tools if the user asks about them. Otherwise, just answer the question.
クライアントツールを設定
次のクライアントツールを設定します。
- 名前:
getBatteryLevel- 説明:デバイスのバッテリーレベルを小数のパーセンテージとして取得します。
- 応答を待機:
true - 応答タイムアウト(秒):3
- 名前:
changeBrightness- 説明:デバイス画面の明るさを変更します。
- 応答を待機:
true - 応答タイムアウト(秒):3
- パラメーター:
- データ型:
number - 識別子:
brightness - 必須:
true - 値の型:
LLM Prompt - 説明:希望する画面の明るさを表す、0から1までの数値(両端を含む)。
- データ型:
- 名前:
flashScreen- 説明:画面を素早く点滅させます。
- 応答を待機:
true - 応答タイムアウト(秒):3
アプリを実行する
このアプリにはExpo Goでサポートされていないネイティブ依存関係が含まれているため、アプリをプリビルドしてからネイティブデバイスで実行する必要があります。
- ターミナル1:
npx expo prebuild --cleanを実行します。
npx expo prebuild --clean
npx expo start --tunnelを実行して、https経由でExpo開発サーバーを起動します。
npx expo start --tunnel
- ターミナル2:
npx expo run:ios --deviceを実行して、iOSデバイスでアプリを実行します。
npx expo run:ios --device