取り組みの背景 — 単一入力で詰まる前に整えておく層
個人でアプリ開発を続けて13年、累計5,000万ダウンロードを超えたあたりから、私が抱えるアプリのなかで「次の機能を足すとUIが破綻する」と感じる瞬間が増えました。カメラを呼び出すボタンを置き、音声入力のマイクボタンを置き、テキスト欄を置く——3つそろえた瞬間に、画面はもう成立しなくなります。
問題は「複数の入力を並べた」ことではなく、入力を統合する「層」を持たずに UI 側で繋ごうとしたことでした。マルチモーダルAIアプリで実際に難しいのは、Gemini や Whisper を呼ぶことではありません。入力の意味的な順序、優先度、コスト境界、フォールバックを、UIから離れた1か所に閉じ込めること です。
この記事は、Rork と Rork Max でカメラ・音声・テキストを束ねる設計パターンを、私自身が壁紙系・癒し系・引き寄せ系アプリで踏んできた失敗のうえで書き直したものです。コードは実運用で動いている形を、判断基準は実測値を、それぞれ可能な範囲で開示します。
この記事の対象読者:
Rork でAI機能を実装した経験があり、次のステップを模索している方
複数のAIモデル(Gemini、Claude、Core ML)を組み合わせたアプリを作りたい方
マルチモーダル入力の状態管理やUX設計に課題を感じている方
従来の記事では個別のモダリティ(カメラ・ギャラリー機能ガイド 、Whisper × Claude による会議メモアプリ、Claude API連携 )を取り上げてきましたが、ここではこれらを統合するアーキテクチャ に焦点を当てます。
マルチモーダル入力統合レイヤーの設計
なぜ統合レイヤーが必要なのか
カメラ、音声、テキストの各入力をそれぞれ独立に処理すると、以下の問題が発生します。
コンテキストの断絶 : ユーザーが画像を撮影した後に音声で質問しても、画像の文脈が音声処理に渡らない
状態管理の複雑化 : 3種類の入力状態が分散し、UIの整合性が崩れやすくなる
AIモデルへの入力構築が煩雑 : 各モダリティのデータを手動で組み立ててプロンプトに詰め込む必要がある
これらを解決するのが Unified Input Layer(統合入力レイヤー) パターンです。
Unified Input Layer の構造
// types/multimodal.ts — マルチモーダル入力の型定義
export interface ModalityInput {
type : 'image' | 'audio' | 'text' ;
timestamp : number ;
data : ImageInput | AudioInput | TextInput ;
metadata ?: Record < string , unknown >;
}
export interface ImageInput {
uri : string ;
base64 ?: string ;
width : number ;
height : number ;
mimeType : string ;
}
export interface AudioInput {
uri : string ;
transcription ?: string ; // Whisper で変換済みのテキスト
durationMs : number ;
language ?: string ;
}
export interface TextInput {
content : string ;
intent ?: 'question' | 'command' | 'context' ;
}
// 統合コンテキスト — AI に渡す最終的な入力
export interface MultimodalContext {
sessionId : string ;
inputs : ModalityInput [];
conversationHistory : ConversationTurn [];
userPreferences : UserPreferences ;
}
このように、すべての入力を ModalityInput として正規化し、MultimodalContext に集約します。AIモデルはこの統合コンテキストだけを受け取れば、入力の種類を意識せずに処理できます。
入力キューとバッチ処理
ユーザーが短時間に複数のモダリティを使う場合(画像を撮影→即座に音声質問)、入力をキューに溜めてバッチ処理する設計が有効です。
// hooks/useMultimodalQueue.ts
import { useState, useCallback, useRef } from 'react' ;
import { ModalityInput, MultimodalContext } from '../types/multimodal' ;
const BATCH_DELAY_MS = 1500 ; // 1.5秒の猶予期間
export function useMultimodalQueue ( sessionId : string ) {
const [ queue , setQueue ] = useState < ModalityInput []>([]);
const [ isProcessing , setIsProcessing ] = useState ( false );
const timerRef = useRef < NodeJS . Timeout | null >( null );
const enqueue = useCallback (( input : ModalityInput ) => {
setQueue ( prev => [ ... prev, input]);
// タイマーをリセット — 連続入力を待つ
if (timerRef.current) clearTimeout (timerRef.current);
timerRef.current = setTimeout (() => {
flushQueue ();
}, BATCH_DELAY_MS );
}, []);
const flushQueue = useCallback ( async () => {
setIsProcessing ( true );
const currentQueue = [ ... queue];
setQueue ([]);
const context : MultimodalContext = {
sessionId,
inputs: currentQueue,
conversationHistory: [], // 実装時は会話履歴を注入
userPreferences: {}, // 実装時はユーザー設定を注入
};
try {
const response = await processMultimodalInput (context);
// AI 応答の処理
return response;
} finally {
setIsProcessing ( false );
}
}, [queue, sessionId]);
return { enqueue, isProcessing, queueSize: queue. length };
}
BATCH_DELAY_MS(1.5秒)は、ユーザーが画像を撮影した直後に音声で補足する典型的な操作パターンに基づいた値です。実際のアプリではA/Bテストで調整してください。
AIオーケストレーション — 適材適所のモデル選択
マルチモデル戦略の必要性
一つのAIモデルですべてをこなそうとすると、レイテンシ・コスト・精度のトレードオフに悩まされます。マルチモーダルアプリでは、入力の種類と処理目的に応じてモデルを使い分ける オーケストレーション戦略が不可欠です。
モデル選択マトリクス
処理内容 推奨モデル 理由
画像認識(リアルタイム) Core ML(オンデバイス) 低レイテンシ・オフライン対応
画像+テキストの総合分析 Gemini 2.0 Flash マルチモーダルネイティブ対応・コスト効率
複雑な推論・長文生成 Claude Opus 4.6 推論精度・文脈理解が最高水準
音声→テキスト変換 Whisper API 多言語対応・高精度
テキスト分類・意図抽出 Core ML(NLModel) オンデバイスで即時処理
画像キャプション生成 Gemini 2.0 Flash 視覚理解力が高い
オーケストレーターの実装
// services/aiOrchestrator.ts
import { MultimodalContext, ModalityInput } from '../types/multimodal' ;
type AIProvider = 'coreml' | 'gemini' | 'claude' | 'whisper' ;
interface OrchestratorConfig {
preferOnDevice : boolean ; // オンデバイス処理を優先するか
maxLatencyMs : number ; // 許容最大レイテンシ
costBudgetPerRequest : number ; // 1リクエストあたりの予算(USD)
}
const DEFAULT_CONFIG : OrchestratorConfig = {
preferOnDevice: true ,
maxLatencyMs: 3000 ,
costBudgetPerRequest: 0.05 ,
};
export class AIOrchestrator {
private config : OrchestratorConfig ;
constructor ( config : Partial < OrchestratorConfig > = {}) {
this .config = { ... DEFAULT_CONFIG , ... config };
}
// 入力内容に応じて最適なモデルを選択
selectProvider ( inputs : ModalityInput []) : AIProvider {
const hasImage = inputs. some ( i => i.type === 'image' );
const hasAudio = inputs. some ( i => i.type === 'audio' );
const hasText = inputs. some ( i => i.type === 'text' );
// 音声入力がある場合、まず Whisper で変換
if (hasAudio) {
return 'whisper' ; // まずテキスト化 → 再度ルーティング
}
// 画像のみ(テキストなし)→ オンデバイス処理
if (hasImage && ! hasText && this .config.preferOnDevice) {
return 'coreml' ;
}
// 画像+テキスト → Gemini(マルチモーダルネイティブ)
if (hasImage && hasText) {
return 'gemini' ;
}
// テキストのみで複雑な推論が必要 → Claude
const textInputs = inputs. filter ( i => i.type === 'text' );
const totalLength = textInputs. reduce (( sum , i ) => {
return sum + (i.data as { content : string }).content. length ;
}, 0 );
if (totalLength > 500 ) {
return 'claude' ;
}
// 短いテキストのみ → Core ML でオンデバイス処理
return this .config.preferOnDevice ? 'coreml' : 'gemini' ;
}
// メイン処理パイプライン
async process ( context : MultimodalContext ) : Promise < AIResponse > {
const pipeline = this . buildPipeline (context.inputs);
let result : AIResponse = { text: '' , confidence: 0 };
for ( const step of pipeline) {
result = await this . executeStep (step, context, result);
}
return result;
}
// 入力に応じた処理パイプラインを構築
private buildPipeline ( inputs : ModalityInput []) : PipelineStep [] {
const steps : PipelineStep [] = [];
// Step 1: 音声をテキストに変換
const audioInputs = inputs. filter ( i => i.type === 'audio' );
if (audioInputs. length > 0 ) {
steps. push ({ provider: 'whisper' , task: 'transcribe' , inputs: audioInputs });
}
// Step 2: 画像の前処理(オンデバイス分類)
const imageInputs = inputs. filter ( i => i.type === 'image' );
if (imageInputs. length > 0 && this .config.preferOnDevice) {
steps. push ({ provider: 'coreml' , task: 'classify' , inputs: imageInputs });
}
// Step 3: 統合分析(全入力をまとめてクラウドAIへ)
const mainProvider = this . selectProvider (inputs);
steps. push ({ provider: mainProvider, task: 'analyze' , inputs });
return steps;
}
}
// 期待する出力:
// Pipeline: [whisper:transcribe] → [coreml:classify] → [gemini:analyze]
// 音声入力が "この花の名前は?" → テキスト化
// 画像 → Core ML で植物分類 → ラベル: "バラ科"
// 全データを Gemini に送信 → "この画像にはバラ科の花が写っています。品種は..."
このオーケストレーターの設計ポイントは以下の3つです。
段階的処理 : 音声→テキスト変換を最初に行い、以降の処理では統一的にテキストとして扱える
オンデバイス優先 : レイテンシとコストを抑えるため、可能な限り Core ML で前処理を行う
フォールバック対応 : オフライン時は自動的にオンデバイスモデルのみで処理を完結させる
カメラ入力の高度な統合パターン
リアルタイムプレビュー+AI分析
カメラのプレビュー画面でリアルタイムにAI分析を走らせるパターンは、商品検索アプリや翻訳アプリで広く使われています。
// components/SmartCamera.tsx
import React, { useState, useCallback, useRef } from 'react' ;
import { View, Text, StyleSheet, TouchableOpacity, Image } from 'react-native' ;
interface SmartCameraProps {
onCapture : ( image : ImageInput ) => void ;
onRealtimeResult ?: ( result : string ) => void ;
}
export function SmartCamera ({ onCapture , onRealtimeResult } : SmartCameraProps ) {
const [ preview , setPreview ] = useState < string | null >( null );
const [ analyzing , setAnalyzing ] = useState ( false );
const frameCountRef = useRef ( 0 );
// フレーム分析(5フレームに1回実行してパフォーマンスを確保)
const handleFrame = useCallback ( async ( frameData : string ) => {
frameCountRef.current += 1 ;
if (frameCountRef.current % 5 !== 0 ) return ;
// Core ML でオンデバイス分類
try {
const result = await classifyWithCoreML (frameData);
onRealtimeResult ?.(result.label);
} catch (error) {
console. warn ( 'Frame analysis failed:' , error);
}
}, [onRealtimeResult]);
// 撮影ボタン押下
const handleCapture = useCallback ( async () => {
setAnalyzing ( true );
try {
const photo = await capturePhoto ();
setPreview (photo.uri);
const imageInput : ImageInput = {
uri: photo.uri,
base64: photo.base64,
width: photo.width,
height: photo.height,
mimeType: 'image/jpeg' ,
};
onCapture (imageInput);
} finally {
setAnalyzing ( false );
}
}, [onCapture]);
return (
< View style = {styles.container} >
{ /* カメラプレビュー領域 */ }
< View style = {styles.cameraPreview} >
{ preview ? (
< Image source = {{ uri : preview }} style = {styles.previewImage} />
) : (
< Text style = {styles.placeholder} > カメラプレビュー </ Text >
)}
</ View >
{ /* 撮影ボタン */ }
< TouchableOpacity
style = {styles.captureButton}
onPress = {handleCapture}
disabled = {analyzing}
>
< Text style = {styles.captureText} >
{ analyzing ? '分析中...' : '撮影' }
</ Text >
</ TouchableOpacity >
</ View >
);
}
// 期待する動作:
// 1. カメラプレビュー中 → 5フレームごとに Core ML で分類
// 2. 撮影ボタン押下 → 高解像度画像をキャプチャ → onCapture コールバック
// 3. 統合レイヤーを通じて Gemini/Claude に画像を送信
画像前処理の最適化
AIモデルに画像を送信する前に、適切な前処理を行うことでレイテンシとコストを大幅に削減できます。
// utils/imagePreprocessor.ts
interface PreprocessConfig {
maxWidth : number ;
maxHeight : number ;
quality : number ; // 0.0 - 1.0
format : 'jpeg' | 'png' ;
}
const PREPROCESS_CONFIGS : Record < string , PreprocessConfig > = {
// Core ML: 高解像度は不要
coreml: { maxWidth: 224 , maxHeight: 224 , quality: 0.8 , format: 'jpeg' },
// Gemini: 中解像度で十分
gemini: { maxWidth: 1024 , maxHeight: 1024 , quality: 0.85 , format: 'jpeg' },
// Claude: テキスト読み取り時は高解像度
claude: { maxWidth: 2048 , maxHeight: 2048 , quality: 0.9 , format: 'png' },
};
export async function preprocessImage (
imageUri : string ,
targetProvider : string
) : Promise <{ uri : string ; base64 : string ; sizeKB : number }> {
const config = PREPROCESS_CONFIGS [targetProvider] || PREPROCESS_CONFIGS .gemini;
// リサイズ + 圧縮
const resized = await resizeImage (imageUri, config.maxWidth, config.maxHeight);
const compressed = await compressImage (resized, config.quality, config.format);
return {
uri: compressed.uri,
base64: compressed.base64,
sizeKB: Math. round (compressed.size / 1024 ),
};
}
// 期待する出力:
// preprocessImage('photo.jpg', 'gemini')
// → { uri: 'resized_photo.jpg', base64: '...', sizeKB: 180 }
// 元画像 3MB → Gemini向け 180KB に圧縮
音声入力とリアルタイムトランスクリプション
ストリーミング音声認識の実装
音声入力では、ユーザーが話し終わるのを待ってから処理するのではなく、話している最中にリアルタイムでテキスト変換 するストリーミング方式が体験として優れています。
// hooks/useVoiceInput.ts
import { useState, useCallback, useRef } from 'react' ;
interface VoiceInputState {
isListening : boolean ;
interimText : string ; // 認識途中のテキスト
finalText : string ; // 確定したテキスト
confidence : number ;
}
export function useVoiceInput () {
const [ state , setState ] = useState < VoiceInputState >({
isListening: false ,
interimText: '' ,
finalText: '' ,
confidence: 0 ,
});
const audioChunksRef = useRef < ArrayBuffer []>([]);
const startListening = useCallback ( async () => {
setState ( prev => ({ ... prev, isListening: true , interimText: '' }));
audioChunksRef.current = [];
// マイク入力のストリーム処理
const stream = await startAudioStream ({
sampleRate: 16000 ,
channels: 1 ,
encoding: 'pcm_16bit' ,
});
stream. on ( 'data' , async ( chunk : ArrayBuffer ) => {
audioChunksRef.current. push (chunk);
// 500msごとに中間結果を取得
if (audioChunksRef.current. length % 8 === 0 ) {
const interim = await transcribeChunk (audioChunksRef.current);
setState ( prev => ({ ... prev, interimText: interim.text }));
}
});
stream. on ( 'silence' , async () => {
// 無音を検出 → 最終結果を確定
const final = await transcribeFull (audioChunksRef.current);
setState ( prev => ({
... prev,
isListening: false ,
finalText: final.text,
confidence: final.confidence,
interimText: '' ,
}));
});
}, []);
const stopListening = useCallback (() => {
setState ( prev => ({ ... prev, isListening: false }));
// オーディオストリームを停止
stopAudioStream ();
}, []);
return { ... state, startListening, stopListening };
}
// 期待する動作:
// 1. startListening() → マイク起動
// 2. ユーザーが "この写真に写っている建物は何ですか" と話す
// 3. interimText: "この写真に" → "この写真に写って" → "この写真に写っている建物は..."
// 4. 無音検出 → finalText: "この写真に写っている建物は何ですか"
// 5. confidence: 0.95
音声からインテントを抽出する
音声をテキスト化した後、そのテキストが「質問」なのか「コマンド」なのか「補足情報」なのかを判定することで、AIの応答品質が大幅に向上します。
// services/intentClassifier.ts
export type Intent = 'question' | 'command' | 'context' | 'correction' ;
interface IntentResult {
intent : Intent ;
confidence : number ;
entities : Record < string , string >; // 抽出されたエンティティ
}
// ルールベース + ML ハイブリッド分類
export async function classifyIntent ( text : string ) : Promise < IntentResult > {
// 1. ルールベースの高速判定(疑問詞・命令形)
const questionPatterns = / ^ (何 | どう | なぜ | いつ | どこ | 誰 | what | how | why | when | where | who)/ i ;
const commandPatterns = / ^ (して | やって | 変えて | 教えて | 見せて | show | change | do | tell)/ i ;
const correctionPatterns = / ^ (違う | ちがう | そうじゃない | not that | wrong | no,)/ i ;
if (correctionPatterns. test (text)) {
return { intent: 'correction' , confidence: 0.9 , entities: {} };
}
if (questionPatterns. test (text)) {
return { intent: 'question' , confidence: 0.85 , entities: {} };
}
if (commandPatterns. test (text)) {
return { intent: 'command' , confidence: 0.85 , entities: {} };
}
// 2. ML モデルで詳細判定(Core ML NLModel)
const mlResult = await classifyWithNLModel (text);
return {
intent: mlResult.label as Intent ,
confidence: mlResult.confidence,
entities: mlResult.entities,
};
}
// 期待する出力:
// classifyIntent("この花の名前は?")
// → { intent: 'question', confidence: 0.92, entities: { target: '花' } }
// classifyIntent("もっと明るくして")
// → { intent: 'command', confidence: 0.88, entities: { action: '明るく' } }
テキスト入力の高度な統合
コンテキスト対応テキスト入力
マルチモーダルアプリでは、テキスト入力が画像や音声と連動する必要があります。たとえば、画像を撮影した直後にテキストを入力すると、そのテキストは画像に対するコメントとして解釈されるべきです。
// hooks/useContextualTextInput.ts
import { useState, useCallback, useEffect } from 'react' ;
interface ContextualTextState {
text : string ;
relatedModality : 'image' | 'audio' | 'none' ;
suggestions : string []; // コンテキストに応じた入力候補
}
export function useContextualTextInput (
recentInputs : ModalityInput []
) {
const [ state , setState ] = useState < ContextualTextState >({
text: '' ,
relatedModality: 'none' ,
suggestions: [],
});
// 直近の入力モダリティに応じてサジェストを更新
useEffect (() => {
const lastInput = recentInputs[recentInputs. length - 1 ];
if ( ! lastInput) return ;
if (lastInput.type === 'image' ) {
setState ( prev => ({
... prev,
relatedModality: 'image' ,
suggestions: [
'これは何ですか?' ,
'この写真の詳細を教えてください' ,
'この商品の価格を調べて' ,
'テキストを読み取って' ,
],
}));
} else if (lastInput.type === 'audio' ) {
setState ( prev => ({
... prev,
relatedModality: 'audio' ,
suggestions: [
'もう少し詳しく' ,
'それについて画像を見せてください' ,
'要約してください' ,
],
}));
}
}, [recentInputs]);
const setText = useCallback (( text : string ) => {
setState ( prev => ({ ... prev, text }));
}, []);
return { ... state, setText };
}
マルチモーダルUXフローの設計
入力モード切替のインタラクション設計
ユーザーがカメラ・音声・テキストをスムーズに切り替えられるUIは、マルチモーダルアプリの成否を分ける重要な要素です。
// components/MultimodalInputBar.tsx
import React, { useState, useCallback } from 'react' ;
import { View, TextInput, TouchableOpacity, Text, StyleSheet, Animated } from 'react-native' ;
type InputMode = 'text' | 'camera' | 'voice' ;
interface MultimodalInputBarProps {
onSubmit : ( input : ModalityInput ) => void ;
isProcessing : boolean ;
}
export function MultimodalInputBar ({ onSubmit , isProcessing } : MultimodalInputBarProps ) {
const [ mode , setMode ] = useState < InputMode >( 'text' );
const [ text , setText ] = useState ( '' );
const slideAnim = useState ( new Animated. Value ( 0 ))[ 0 ];
const switchMode = useCallback (( newMode : InputMode ) => {
// スライドアニメーションでモード切替
Animated. spring (slideAnim, {
toValue: newMode === 'text' ? 0 : newMode === 'camera' ? 1 : 2 ,
useNativeDriver: true ,
}). start ();
setMode (newMode);
}, [slideAnim]);
const handleTextSubmit = useCallback (() => {
if ( ! text. trim ()) return ;
onSubmit ({
type: 'text' ,
timestamp: Date. now (),
data: { content: text. trim (), intent: 'question' },
});
setText ( '' );
}, [text, onSubmit]);
return (
< View style = {styles.container} >
{ /* モード切替ボタン */ }
< View style = {styles.modeSelector} >
< TouchableOpacity
onPress = {() => switchMode ( 'camera' )}
style = {[styles.modeButton, mode === 'camera' && styles.activeMode]}
>
<Text>📷</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => switchMode('voice')}
style={[styles.modeButton, mode === 'voice' && styles.activeMode]}
>
<Text>🎤</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => switchMode('text')}
style={[styles.modeButton, mode === 'text' && styles.activeMode]}
>
<Text>⌨️</Text>
</TouchableOpacity>
</View>
{ /* 入力エリア — モードに応じて切替 */ }
{mode === 'text' && (
<View style={styles.textInputContainer}>
<TextInput
style={styles.textInput}
value={text}
onChangeText={setText}
onSubmitEditing={handleTextSubmit}
placeholder="メッセージを入力..."
editable={!isProcessing}
/>
<TouchableOpacity onPress={handleTextSubmit} disabled={isProcessing}>
<Text style={styles.sendButton}>送信</Text>
</TouchableOpacity>
</View>
)}
{mode === 'voice' && (
<View style={styles.voiceContainer}>
<Text style={styles.voiceText}>
{isProcessing ? '処理中...' : 'タップして話してください' }
</ Text >
</ View >
)}
{mode === 'camera' && (
< View style = {styles.cameraContainer} >
< Text style = {styles.cameraText} >
{isProcessing ? '分析中...' : 'タップして撮影' }
</ Text >
</ View >
)}
</ View >
);
}
const styles = StyleSheet. create ({
container: { padding: 12 , backgroundColor: '#fff' , borderTopWidth: 1 , borderColor: '#e0e0e0' },
modeSelector: { flexDirection: 'row' , justifyContent: 'center' , marginBottom: 8 , gap: 16 },
modeButton: { padding: 8 , borderRadius: 20 , backgroundColor: '#f0f0f0' },
activeMode: { backgroundColor: '#e0e7ff' },
textInputContainer: { flexDirection: 'row' , alignItems: 'center' },
textInput: { flex: 1 , borderWidth: 1 , borderColor: '#ddd' , borderRadius: 20 , paddingHorizontal: 16 , paddingVertical: 8 , fontSize: 16 },
sendButton: { marginLeft: 8 , color: '#4f46e5' , fontWeight: '600' , fontSize: 16 },
voiceContainer: { alignItems: 'center' , padding: 20 },
voiceText: { fontSize: 16 , color: '#666' },
cameraContainer: { alignItems: 'center' , padding: 20 },
cameraText: { fontSize: 16 , color: '#666' },
});
状態管理のベストプラクティス
マルチモーダル入力の状態管理は、有限状態マシン(Finite State Machine) パターンで整理すると見通しが良くなります。
// state/multimodalStateMachine.ts
type AppState =
| 'idle' // 待機中
| 'capturing' // カメラ撮影中
| 'listening' // 音声入力中
| 'typing' // テキスト入力中
| 'processing' // AI処理中
| 'displaying' // 結果表示中
| 'error' ; // エラー
type AppEvent =
| { type : 'START_CAMERA' }
| { type : 'START_VOICE' }
| { type : 'START_TEXT' }
| { type : 'INPUT_RECEIVED' ; payload : ModalityInput }
| { type : 'AI_RESPONSE' ; payload : AIResponse }
| { type : 'ERROR' ; payload : Error }
| { type : 'RESET' };
export function multimodalReducer ( state : AppState , event : AppEvent ) : AppState {
switch (state) {
case 'idle' :
if (event.type === 'START_CAMERA' ) return 'capturing' ;
if (event.type === 'START_VOICE' ) return 'listening' ;
if (event.type === 'START_TEXT' ) return 'typing' ;
return state;
case 'capturing' :
case 'listening' :
case 'typing' :
if (event.type === 'INPUT_RECEIVED' ) return 'processing' ;
if (event.type === 'ERROR' ) return 'error' ;
if (event.type === 'RESET' ) return 'idle' ;
return state;
case 'processing' :
if (event.type === 'AI_RESPONSE' ) return 'displaying' ;
if (event.type === 'ERROR' ) return 'error' ;
return state;
case 'displaying' :
if (event.type === 'START_CAMERA' ) return 'capturing' ;
if (event.type === 'START_VOICE' ) return 'listening' ;
if (event.type === 'START_TEXT' ) return 'typing' ;
if (event.type === 'RESET' ) return 'idle' ;
return state;
case 'error' :
if (event.type === 'RESET' ) return 'idle' ;
return state;
default :
return state;
}
}
// 期待する遷移:
// idle → START_CAMERA → capturing → INPUT_RECEIVED → processing → AI_RESPONSE → displaying
// displaying → START_VOICE → listening → INPUT_RECEIVED → processing → ...
エラーハンドリングとフォールバック戦略
グレースフルデグラデーション
マルチモーダルアプリでは、一部のモダリティやAIモデルが利用できない状況(オフライン、APIエラー、権限拒否)でもアプリが動作し続ける グレースフルデグラデーション が重要です。
// services/fallbackStrategy.ts
interface FallbackChain {
primary : AIProvider ;
fallbacks : AIProvider [];
offlineOnly : AIProvider | null ;
}
const FALLBACK_CHAINS : Record < string , FallbackChain > = {
imageAnalysis: {
primary: 'gemini' ,
fallbacks: [ 'claude' , 'coreml' ],
offlineOnly: 'coreml' ,
},
textGeneration: {
primary: 'claude' ,
fallbacks: [ 'gemini' ],
offlineOnly: null , // テキスト生成はオフラインでは不可
},
speechToText: {
primary: 'whisper' ,
fallbacks: [ 'coreml' ], // iOS の Speech Framework
offlineOnly: 'coreml' ,
},
};
export async function executeWithFallback < T >(
task : string ,
execute : ( provider : AIProvider ) => Promise < T >
) : Promise < T > {
const chain = FALLBACK_CHAINS [task];
if ( ! chain) throw new Error ( `Unknown task: ${ task }` );
// オフラインチェック
const isOnline = await checkNetworkStatus ();
if ( ! isOnline && chain.offlineOnly) {
return execute (chain.offlineOnly);
}
if ( ! isOnline && ! chain.offlineOnly) {
throw new Error ( 'この機能にはインターネット接続が必要です' );
}
// プライマリ → フォールバック の順に試行
const providers = [chain.primary, ... chain.fallbacks];
let lastError : Error | null = null ;
for ( const provider of providers) {
try {
return await execute (provider);
} catch (error) {
lastError = error as Error ;
console. warn ( `${ provider } failed for ${ task }:` , error);
continue ;
}
}
throw lastError || new Error ( `All providers failed for ${ task }` );
}
// 使用例:
// const result = await executeWithFallback('imageAnalysis', async (provider) => {
// return await analyzeImage(imageData, provider);
// });
// → gemini 失敗 → claude にフォールバック → それも失敗 → coreml で処理
パフォーマンス最適化とコスト管理
レイテンシバジェットの管理
マルチモーダル処理では、複数のAIモデルを順番に呼び出すため、全体のレイテンシが膨らみがちです。レイテンシバジェット を設定し、各ステップにタイムアウトを設けることで、ユーザー体験を守ります。
// utils/latencyBudget.ts
interface BudgetAllocation {
preprocessing : number ; // 画像リサイズ・音声チャンク化
onDeviceML : number ; // Core ML 推論
networkTransfer : number ; // APIへのデータ送信
cloudInference : number ; // クラウドAI推論
postprocessing : number ; // 結果の整形・表示
}
// 合計3秒の予算配分
const DEFAULT_BUDGET : BudgetAllocation = {
preprocessing: 200 , // 200ms
onDeviceML: 300 , // 300ms
networkTransfer: 500 , // 500ms
cloudInference: 1500 , // 1500ms
postprocessing: 100 , // 100ms
};
// 残り: 400ms をバッファとして確保
export function createTimedExecution < T >(
budgetMs : number ,
fallback : T
) : ( fn : () => Promise < T >) => Promise < T > {
return async ( fn ) => {
const controller = new AbortController ();
const timeout = setTimeout (() => controller. abort (), budgetMs);
try {
const result = await fn ();
clearTimeout (timeout);
return result;
} catch (error) {
clearTimeout (timeout);
if ((error as Error ).name === 'AbortError' ) {
console. warn ( `Budget exceeded: ${ budgetMs }ms` );
return fallback;
}
throw error;
}
};
}
APIコストの最適化
マルチモーダル処理はAPIコストが高くなりがちです。以下の戦略でコストを抑えます。
オンデバイス前処理 : 画像分類やテキスト分類はCore MLで行い、クラウドAPIへの不要なリクエストを削減
画像圧縮 : AIモデルに送信する前に適切なサイズに圧縮(前述の preprocessImage)
キャッシュ : 同一の画像や質問に対する応答をローカルキャッシュ
バッチ処理 : 複数の入力をまとめて1回のAPIリクエストに集約
// utils/costTracker.ts — APIコストの追跡
interface CostEntry {
provider : string ;
inputTokens : number ;
outputTokens : number ;
imageCount : number ;
estimatedCostUSD : number ;
timestamp : number ;
}
// 月間コスト上限の管理
const MONTHLY_BUDGET_USD = 50 ;
export class CostTracker {
private entries : CostEntry [] = [];
addEntry ( entry : CostEntry ) {
this .entries. push (entry);
}
getMonthlyTotal () : number {
const now = new Date ();
const monthStart = new Date (now. getFullYear (), now. getMonth (), 1 ). getTime ();
return this .entries
. filter ( e => e.timestamp >= monthStart)
. reduce (( sum , e ) => sum + e.estimatedCostUSD, 0 );
}
isWithinBudget () : boolean {
return this . getMonthlyTotal () < MONTHLY_BUDGET_USD ;
}
// 予算超過時はオンデバイスモデルのみに制限
shouldUseCloudAI () : boolean {
return this . isWithinBudget ();
}
}
公式ドキュメントには書かれていない、本番運用で見えた落とし穴
ここまでの設計パターンは、公式ドキュメントとサンプルを丁寧に読めば組み立てられるものです。一方、私が壁紙系・癒し系・引き寄せ系アプリでマルチモーダル機能を実運用してきたなかで、ドキュメントには書かれていない判断や落とし穴がいくつもありました。読者が同じ罠を踏まずに済むよう、可能な範囲で開示します。
1. 「マルチモーダル」を盛り込みすぎたUIは、Day7 リテンションを確実に下げる
私が運営している壁紙系アプリで、生成系の機能を入れた直後、Day7 リテンションが 34% から 21% まで 落ちたことがあります。原因は機能不足ではなく、初回起動でカメラ・マイク・テキスト入力のすべてを順番にユーザーに見せたことでした。
学びは単純で、「初回セッションで触らせる入力は1つに絞る 」です。私の場合、最初のセッションではテキストか画像のどちらか1つだけを提示し、結果がよかった人にだけマイクの存在を出します。これに変更したあと、Day7 は 31% まで戻り 、Day30 は約 12% で安定するようになりました。
// プログレッシブにモダリティを開示するヘルパー
class ModalityProgressionManager {
// セッション数と直近の成功体験で「次に見せていい入力」を決める
static getEnabledModalities (
sessionCount : number ,
lastResultRating : number | null
) : InputModality [] {
if (sessionCount === 0 ) return [ "text" ]; // 初回はテキストだけ
if (sessionCount < 3 ) return [ "text" , "camera" ]; // 数回はテキスト+カメラ
// 直近の結果評価が3以上のユーザーだけ、音声まで開放する
if (lastResultRating !== null && lastResultRating >= 3 ) {
return [ "text" , "camera" , "voice" ];
}
return [ "text" , "camera" ];
}
}
maxSimultaneousInputs を最初から3にしないだけで、初回離脱は確実に減ります。私の場合、初回セッションのアクション完了率が 38% → 61% に上がりました。
2. Gemini と Claude を「常時両方走らせる」のは、原則やめる
AIOrchestrator の解説で、画像入力時は Gemini、複雑な推論は Claude、という使い分けを書きました。これは正しいのですが、ドキュメントどおりに実装すると 両方のAPIに同時投げするフォールバック を書きがちです。これをやると月のAPI費用が 2.3 倍 になり、結果の整合性も逆に下がります。
私の場合、本番では次の3段ルールに収束しました。
第1段: Gemini 2.0 Flash でとりあえず投げる(画像があるなら強制 Gemini)。p50 で 800ms、p95 で 2.4 秒、課金は1リクエストあたり 約 ¥0.4
第2段: Gemini の応答に confidence < 0.6 のシグナルが入ったときだけ、Claude Haiku に切り替えて再生成。約 5% のリクエストだけが Claude に流れる
第3段: Claude も「分からない」を返したときに、ユーザーへ「もう少し情報を教えてください」と返す(ここで2回目の入力を促すだけで再現率が23%上がります )
「常時両方走らせる」を捨てて、この3段に切り替えただけで、月のAPI費用は ¥45,000 → ¥18,000 程度 まで下がりました。
// 3段オーケストレーション
async function tieredMultimodalCall ( ctx : MultimodalContext ) : Promise < AIResponse > {
// 1段目: Gemini 2.0 Flash
const firstPass = await callGemini (ctx);
if (firstPass.confidence >= 0.6 ) return firstPass;
// 2段目: Claude Haiku(テキスト中心の問題に強い)
const secondPass = await callClaudeHaiku (ctx, { previousAttempt: firstPass });
if (secondPass.confidence >= 0.6 ) return secondPass;
// 3段目: ユーザーに追加情報を求める(API呼び出しなし)
return {
type: "clarification_needed" ,
suggestedQuestions: secondPass.clarifyingQuestions ?? defaultClarifyingQuestions,
};
}
3. 音声入力は「無音検出」より「沈黙が3秒続いたら確定」のほうが体感が良い
Whisper や Gemini Live で音声を扱うとき、ドキュメントは VAD(Voice Activity Detection)のしきい値を細かく調整する手順を案内します。実装はできますが、私が普通に使っていて「話し終わって3秒沈黙が続いたら自動的に確定する 」というシンプルな実装のほうが、ユーザーの満足度が高いことが多かったです。
理由はおそらく、ユーザーは話している途中で「えっと…」と止まる時間が想像より長く、VADのしきい値を厳しめにすると 発話の途中でカットされる事故 が起きるからです。3秒沈黙ルールは「気持ちよく話し終わってから一拍置く」という人間の自然なリズムに沿っています。
class SilenceConfirmAudioRecorder {
private silenceThresholdMs = 3000 ; // 3秒
private lastVoiceAt = Date. now ();
private silenceTimer : NodeJS . Timeout | null = null ;
onAudioFrame ( volume : number , onFinish : () => void ) {
if (volume > 0.05 ) {
// 話し始めたらタイマーをクリア
this .lastVoiceAt = Date. now ();
if ( this .silenceTimer) {
clearTimeout ( this .silenceTimer);
this .silenceTimer = null ;
}
return ;
}
// 沈黙が始まったら3秒タイマー
if ( ! this .silenceTimer) {
this .silenceTimer = setTimeout (() => {
onFinish (); // 確定して送信
}, this .silenceThresholdMs);
}
}
}
iPhone SE 第3世代でも CPU 負荷はほぼゼロで、複雑な VAD を入れるよりずっと安定しています。
4. 画像はクライアントで「短辺 1024px」にリサイズしておくとコストが約 40% 下がる
これは Gemini のドキュメントに「リサイズすると速くなります」程度の記述はありますが、具体的なしきい値や費用への影響は書かれていません。私の壁紙アプリで実測すると、次のような結果になりました。
送信画像サイズ Gemini 応答時間(p50) 1枚あたり課金 認識精度(壁紙シーン分類)
4032×3024(無加工) 2.8 秒 ¥0.62 92%
短辺 1536px 1.5 秒 ¥0.41 91%
短辺 1024px 1.1 秒 ¥0.37 91%
短辺 512px 0.9 秒 ¥0.35 78%
短辺 1024px が「精度を落とさずに費用と時間を最小化できる境界」でした。512px まで落とすと、雲や夕焼けのような細部が必要なシーンで精度が露骨に落ちます。
import { manipulateAsync, SaveFormat } from "expo-image-manipulator" ;
// 短辺 1024px を保つリサイズ
async function resizeForGemini ( localUri : string ) : Promise < string > {
const result = await manipulateAsync (
localUri,
[{ resize: { width: 1024 } }], // Expoは width だけ指定すれば短辺扱いではなく幅、ただし大半の写真は横長なので短辺になる
{ compress: 0.85 , format: SaveFormat. JPEG }
);
return result.uri;
}
5. Core ML フォールバックは「常時動くか」を CI で監視しないと、いつのまにか壊れる
オンデバイスのフォールバックは「最後の砦」として設計するのに、皮肉なことに CI で壊れている期間が一番長い のが現実です。私の場合、Expo SDK アップグレード時に expo-camera の API シグネチャが変わって、フォールバック経路だけ落ちていることに1か月半気づきませんでした 。クラウド経路が問題なく動いていたからです。
学びは「フォールバックは飛行機の脱出装置と同じで、定期的に動作確認しないと意味がない 」です。私はいま、毎日深夜の CI で Core ML フォールバック経路を強制的に通すスモークテストを走らせています。
// __tests__/smoke/coreml-fallback.smoke.test.ts
describe ( "Core ML fallback smoke" , () => {
beforeAll (() => {
// ネットワークを意図的に切る(クラウドが死んでいる状況を模す)
jest. spyOn (global, "fetch" ). mockRejectedValue ( new Error ( "offline" ));
});
it ( "画像分類がオンデバイスで完走する" , async () => {
const result = await classifyImage ( "./__fixtures__/sample-landscape.jpg" );
expect (result.source). toBe ( "on-device" );
expect (result.labels. length ). toBeGreaterThan ( 0 );
});
it ( "テキスト分類がオンデバイスで完走する" , async () => {
const result = await classifyText ( "こんにちは、今日はいい天気ですね" );
expect (result.source). toBe ( "on-device" );
expect (result.sentiment). toBeDefined ();
});
});
CI が緑である限り、私は「フォールバックは存在する」と信じられるようになりました。これは精神衛生としても効果が大きく、本番で雷雨でクラウドが落ちた日も、ユーザー体験が大きく崩れずに済みました。
6. マルチモーダルアプリのリテンションは「最初の30秒で1回成功体験を出せたか」で決まる
これは数値の話というより、私が13年やってきて確信に変わってきた感覚です。マルチモーダルAIアプリは、初回起動の30秒以内に「おお、これは動くんだ 」とユーザーが感じる瞬間が1つだけあるかどうかで、Day7 リテンションが2倍以上違います。
私が運営しているアプリ群で測った範囲では、次のしきい値が安定しています。
初回 30 秒以内に「成功体験」を1つ提示できたユーザー: Day7 約 41% 、Day30 約 15%
初回 30 秒以内に提示できなかったユーザー: Day7 約 18% 、Day30 約 5%
ここでいう「成功体験」は、AIモデルからの完璧な回答ではなく、「自分が入れたものに対して、ちゃんと反応が返ってきた」という最小単位の手応えです。クラウドが遅れて成功体験が後ろにずれるくらいなら、Core ML のオンデバイス分類で 0.8 秒で何かを返す方が体験として勝つ ことがほとんどでした。
これは、設計の優先順位を「クラウドの精度 > オンデバイスの速度」から「最初の応答までの速度 > その後の精度 」に逆転させる、というドキュメントには書かれない判断です。
実装でつまずきやすい2点だけ補足
マルチモーダル入力の順序は処理結果に効いてくる
ドキュメントには明示されていませんが、画像→音声の順序は「画像について質問している」と解釈され、テキスト→画像の順序は「テキストの内容を画像で補足している」と解釈される傾向があります。MultimodalContext で timestamp を保持し、その順序のまま AI モデルに渡すと、回答の的中率が体感で1割程度上がります。実機テストで違いを確認するときは、同じ素材を順序だけ入れ替えて投げ、応答を並べてみるのが手っ取り早いです。
オンデバイス処理が動く端末の現実的なしきい値
Core ML の推論は iPhone 12 以降であれば実用的な速度で動作します。Neural Engine を搭載した A14 Bionic 以降のチップが理想です。画像前処理パイプライン(リサイズ・Base64エンコード)で一時的に 50〜100MB 程度メモリを消費するため、低メモリデバイスでは maxWidth / maxHeight を控えめにしてください。私の場合、対応端末を「iPhone 11 以降」と決め、それ未満では多モーダル機能のメニュー自体を出さない方針にしています。
次の一歩 — 1つの「層」から始める
ここまで4つの層(入力統合・オーケストレーション・UXフロー・エラーハンドリング)を順に扱ってきましたが、最初から全部きれいに作ろうとすると、私のように途中で機能が破綻します。
私が今アドバイスするとしたら、まずは 入力統合レイヤー だけを MultimodalContext として既存アプリに足してみることです。中身は1つのモダリティ(カメラだけ、音声だけ)からでもよくて、「あとから別のモダリティを差し込める形」だけ整えておきます。これだけで、次にAIモデルを足したとき、UI側のコードを書き換えずに済むようになります。
13年やってみて思うのは、マルチモーダルAIは「全部入りにする技術」ではなく、「ユーザーが必要としたときに、必要なモダリティだけを、自然に差し出せるアーキテクチャ 」です。最初の機能を実装するときから、その器を用意できるかどうかで、半年後のアプリの姿は変わってきます。
私自身、いまもこの設計を壁紙系・癒し系のアプリに少しずつ移植している最中で、まだ正解にたどり着いた感覚はありません。一緒に試行錯誤しながら、よりよい体験を作っていけたら嬉しいです。最後までお読みいただき、ありがとうございました。