Whisper 音声認識の実装 — リアルタイム文字起こしと発音評価
expo-av による音声録音
Rork Max で音声を録音し、Whisper API に送信する基本実装は以下の通りです。Rork + OpenAI Whisper API で音声認識アプリを作る完全ガイドで解説した内容をベースに、語学学習に特化した拡張を加えます。
// hooks/useAudioRecorder.ts
import { Audio } from 'expo-av' ;
import { useState, useRef } from 'react' ;
export function useAudioRecorder () {
const [ isRecording , setIsRecording ] = useState ( false );
const recordingRef = useRef < Audio . Recording | null >( null );
const startRecording = async () => {
// オーディオセッションの設定(語学学習向けに最適化)
await Audio. setAudioModeAsync ({
allowsRecordingIOS: true ,
playsInSilentModeIOS: true ,
// 他のアプリの音声を中断して録音品質を確保
interruptionModeIOS: 1 , // DoNotMix
interruptionModeAndroid: 1 ,
});
const { recording } = await Audio.Recording. createAsync (
// Whisper に最適な録音設定
{
android: {
extension: '.m4a' ,
outputFormat: 3 , // MPEG_4
audioEncoder: 3 , // AAC
sampleRate: 16000 , // Whisper推奨サンプルレート
numberOfChannels: 1 ,
bitRate: 128000 ,
},
ios: {
extension: '.m4a' ,
audioQuality: 127 , // Max quality
sampleRate: 16000 ,
numberOfChannels: 1 ,
bitRate: 128000 ,
outputFormat: 'aac' ,
},
web: { mimeType: 'audio/webm' , bitsPerSecond: 128000 },
}
);
recordingRef.current = recording;
setIsRecording ( true );
};
const stopRecording = async () : Promise < Blob | null > => {
if ( ! recordingRef.current) return null ;
await recordingRef.current. stopAndUnloadAsync ();
const uri = recordingRef.current. getURI ();
recordingRef.current = null ;
setIsRecording ( false );
if ( ! uri) return null ;
// ファイルをBlobに変換してAPI送信用に準備
const response = await fetch (uri);
return response. blob ();
};
return { isRecording, startRecording, stopRecording };
}
Whisper API 呼び出しと発音データの取得
Whisper の verbose_json レスポンス形式を活用すると、単語レベルのタイムスタンプと信頼度スコアが取得できます。これを発音評価に活用します。
// services/whisperService.ts
interface WhisperDetailedResponse {
text : string ;
language : string ;
segments : Array <{
text : string ;
start : number ;
end : number ;
// 各単語の認識信頼度(発音品質の指標として利用)
avg_logprob : number ;
no_speech_prob : number ;
words ?: Array <{
word : string ;
start : number ;
end : number ;
probability : number ; // 0.0〜1.0 — 発音明瞭度の指標
}>;
}>;
}
export async function transcribeWithWhisper (
audioBlob : Blob ,
expectedLanguage : string = 'en'
) : Promise < WhisperDetailedResponse > {
const formData = new FormData ();
formData. append ( 'file' , audioBlob, 'recording.m4a' );
formData. append ( 'model' , 'whisper-1' );
formData. append ( 'language' , expectedLanguage);
formData. append ( 'response_format' , 'verbose_json' );
formData. append ( 'timestamp_granularities[]' , 'word' );
const response = await fetch (
'https://api.openai.com/v1/audio/transcriptions' ,
{
method: 'POST' ,
headers: {
Authorization: `Bearer ${ process . env . OPENAI_API_KEY }` ,
},
body: formData,
}
);
if ( ! response.ok) {
throw new Error ( `Whisper API error: ${ response . status }` );
}
return response. json ();
}
// 発音スコアの算出(単語レベルの信頼度から導出)
export function calculatePronunciationScore (
response : WhisperDetailedResponse ,
expectedText : string
) : { overall : number ; wordScores : Array <{ word : string ; score : number }> } {
const words = response.segments. flatMap ( s => s.words || []);
const wordScores = words. map ( w => ({
word: w.word. trim (),
// probability を 0-100 スコアに変換
score: Math. round (w.probability * 100 ),
}));
// 全単語の平均スコア
const overall = wordScores. length > 0
? Math. round (
wordScores. reduce (( sum , w ) => sum + w.score, 0 ) / wordScores. length
)
: 0 ;
return { overall, wordScores };
}
Claude 会話AI の実装 — 適応型カリキュラムと文法フィードバック
会話シミュレーションの設計
Claude API を語学学習用に最適化するには、システムプロンプトの設計 が最も重要です。学習者のレベル(CEFR A1〜C2)に応じて、語彙の難易度・発話速度のガイダンス・文法フォーカスを動的に調整します。
// services/claudeConversationService.ts
interface ConversationContext {
userText : string ;
expectedPhrase ?: string ;
history : Message [];
learnerLevel : 'A1' | 'A2' | 'B1' | 'B2' | 'C1' | 'C2' ;
targetLanguage : string ;
nativeLanguage : string ;
lessonTopic : string ;
}
const LEVEL_GUIDELINES : Record < string , string > = {
A1: `
- 現在形のみ使用。1文5語以内で応答
- 基礎語彙500語以内(家族・食事・天気・挨拶)
- 間違いは1レスポンスにつき1つだけ指摘
- 常に励ましのトーンで、正解部分を先に褒める
` ,
A2: `
- 現在形・過去形を使用。1文8語以内
- 語彙1,500語程度(買い物・旅行・日常会話)
- 文法ミスは2つまで指摘。代替表現を1つ提示
` ,
B1: `
- 全時制使用可。複文OK。自然な会話速度
- イディオム・句動詞を積極的に導入
- 文法だけでなくニュアンスの違いも指摘
` ,
B2: `
- ビジネス・アカデミック語彙を含む
- 論理的な議論・意見表明を促す質問
- コロケーション・レジスターの適切さもフィードバック
` ,
C1: `
- ネイティブ同等の会話。専門用語・スラング含む
- 微妙なニュアンス・文化的文脈の違いを指摘
- 修辞技法・説得力のある表現を教える
` ,
C2: `
- マスタリーレベル。洗練された表現を追求
- 文体・トーンの使い分け、暗喩・皮肉の理解
- ネイティブが犯す間違いとの区別も解説
` ,
};
export async function generateConversationResponse (
context : ConversationContext
) : Promise < ConversationFeedback > {
const systemPrompt = `You are an expert language tutor conducting an immersive ${ context . targetLanguage } conversation lesson.
LEARNER PROFILE:
- Native language: ${ context . nativeLanguage }
- Current level: ${ context . learnerLevel } (CEFR)
- Lesson topic: ${ context . lessonTopic }
LEVEL-SPECIFIC GUIDELINES:
${ LEVEL_GUIDELINES [ context . learnerLevel ] }
RESPONSE FORMAT (JSON):
{
"responseText": "Your conversational response in ${ context . targetLanguage }",
"pronunciationScore": 0-100,
"grammarFeedback": [
{
"original": "what the learner said",
"corrected": "the correct version",
"explanation": "brief explanation in ${ context . nativeLanguage }",
"rule": "grammar rule name"
}
],
"vocabularyTip": {
"word": "a new word used in your response",
"meaning": "meaning in ${ context . nativeLanguage }",
"exampleSentence": "another example"
},
"nextPrompt": "a follow-up question to continue the conversation",
"encouragement": "a brief positive comment in ${ context . nativeLanguage }"
}
IMPORTANT:
- Always respond naturally as a conversation partner FIRST, tutor SECOND
- Keep corrections constructive and non-intimidating
- Gradually increase complexity when the learner performs well
- If pronunciation score is below 60, suggest specific phonemes to practice` ;
const messages = [
... context.history. map ( m => ({
role: m.role as 'user' | 'assistant' ,
content: m.content,
})),
{
role: 'user' as const ,
content: `The learner said: "${ context . userText }"${
context . expectedPhrase
? ` \n (Expected response was similar to: "${ context . expectedPhrase }")`
: ''
}` ,
},
];
const response = await fetch ( 'https://api.anthropic.com/v1/messages' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-api-key' : process.env. ANTHROPIC_API_KEY ! ,
'anthropic-version' : '2023-06-01' ,
},
body: JSON . stringify ({
model: 'claude-sonnet-4-6' ,
max_tokens: 1024 ,
system: systemPrompt,
messages,
}),
});
const data = await response. json ();
const content = data.content[ 0 ].text;
// JSONレスポンスをパース
return JSON . parse (content);
}
適応型カリキュラムエンジン
学習者の正答率・発音スコア・セッション頻度に基づいて、次のレッスン内容を自動で調整するエンジンを構築します。
// engine/AdaptiveCurriculumEngine.ts
interface LearnerMetrics {
averagePronunciationScore : number ;
grammarAccuracy : number ; // 0-1
vocabularyRetention : number ; // 0-1
sessionsThisWeek : number ;
streakDays : number ;
weakTopics : string [];
strongTopics : string [];
}
interface LessonPlan {
topic : string ;
difficulty : number ; // 1-10
focusAreas : ( 'pronunciation' | 'grammar' | 'vocabulary' | 'fluency' )[];
estimatedDuration : number ; // minutes
warmupActivity : string ;
mainActivity : string ;
reviewActivity : string ;
}
export function generateNextLesson (
metrics : LearnerMetrics ,
completedLessons : string []
) : LessonPlan {
// スコアに基づく難易度調整
// 発音が弱い → 発音フォーカス、文法が弱い → 文法フォーカス
const focusAreas : LessonPlan [ 'focusAreas' ] = [];
if (metrics.averagePronunciationScore < 70 ) {
focusAreas. push ( 'pronunciation' );
}
if (metrics.grammarAccuracy < 0.7 ) {
focusAreas. push ( 'grammar' );
}
if (metrics.vocabularyRetention < 0.6 ) {
focusAreas. push ( 'vocabulary' );
}
if (focusAreas. length === 0 ) {
// 全体的に良好 → 流暢さ向上にフォーカス
focusAreas. push ( 'fluency' );
}
// 弱点トピックを優先的に出題(間隔反復の原理)
const topic = metrics.weakTopics. length > 0
? metrics.weakTopics[ 0 ]
: selectNewTopic (completedLessons);
// 連続学習日数に応じてボーナスコンテンツを追加
const bonusDuration = metrics.streakDays >= 7 ? 5 : 0 ;
return {
topic,
difficulty: calculateDifficulty (metrics),
focusAreas,
estimatedDuration: 15 + bonusDuration,
warmupActivity: generateWarmup (topic, focusAreas),
mainActivity: generateMainActivity (topic, focusAreas),
reviewActivity: generateReview (metrics.weakTopics),
};
}
function calculateDifficulty ( metrics : LearnerMetrics ) : number {
// 総合スコアから難易度を算出(1-10)
const composite =
metrics.averagePronunciationScore * 0.3 +
metrics.grammarAccuracy * 100 * 0.3 +
metrics.vocabularyRetention * 100 * 0.4 ;
// 80以上 → 難易度アップ、50以下 → 難易度ダウン
if (composite > 80 ) return Math. min ( 10 , Math. ceil (composite / 10 ));
if (composite < 50 ) return Math. max ( 1 , Math. floor (composite / 15 ));
return Math. round (composite / 12 );
}
ElevenLabs TTS の実装 — ネイティブ品質の音声出力
音声合成サービスの構築
RorkアプリにTTS(テキスト読み上げ)を実装する完全ガイドでは expo-speech を使った基本的な読み上げを解説しましたが、語学学習アプリではネイティブに近い自然な発音 が不可欠です。ElevenLabs API を使えば、感情やイントネーションまで再現した高品質な音声を生成できます。
// services/elevenLabsService.ts
import { Audio } from 'expo-av' ;
import * as FileSystem from 'expo-file-system' ;
// 語学学習に適した音声IDの定義
const VOICE_PROFILES = {
en: {
male: 'pNInz6obpgDQGcFmaJgB' , // Adam — クリアな発音
female: 'EXAVITQu4vr4xnSDxMaL' , // Bella — 穏やかな発音
},
ja: {
male: 'bIHbv24MWmeRgasZH58o' ,
female: 'jsCqWAovK2LkecY7zXl4' ,
},
es: {
male: 'onwK4e9ZLuTAKqWW03F9' ,
female: 'XB0fDUnXU5powFXDhCwa' ,
},
} as const ;
interface TTSOptions {
text : string ;
language : string ;
voiceGender : 'male' | 'female' ;
speed : 'slow' | 'normal' | 'fast' ; // 学習レベルに応じた速度
stability : number ; // 0-1: 低いと感情豊か、高いと安定
}
export async function synthesizeSpeech (
options : TTSOptions
) : Promise < Audio . Sound > {
const voiceId =
VOICE_PROFILES [options.language]?.[options.voiceGender] ||
VOICE_PROFILES .en.female;
// 速度パラメータの変換
const similarityBoost = options.speed === 'slow' ? 0.9 : 0.75 ;
const response = await fetch (
`https://api.elevenlabs.io/v1/text-to-speech/${ voiceId }` ,
{
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'xi-api-key' : process.env. ELEVENLABS_API_KEY ! ,
},
body: JSON . stringify ({
text: options.text,
model_id: 'eleven_multilingual_v2' ,
voice_settings: {
stability: options.stability,
similarity_boost: similarityBoost,
style: 0.3 , // 感情表現の度合い
use_speaker_boost: true ,
},
}),
}
);
if ( ! response.ok) {
throw new Error ( `ElevenLabs API error: ${ response . status }` );
}
// 音声データをローカルファイルに保存して再生
const audioData = await response. arrayBuffer ();
const base64 = btoa (
String. fromCharCode ( ...new Uint8Array (audioData))
);
const fileUri = `${ FileSystem . cacheDirectory }tts_${ Date . now () }.mp3` ;
await FileSystem. writeAsStringAsync (fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
const { sound } = await Audio.Sound. createAsync ({ uri: fileUri });
return sound;
}
// 学習レベルに応じた音声パラメータの自動調整
export function getTTSParamsForLevel (
level : string
) : Partial < TTSOptions > {
switch (level) {
case 'A1' :
case 'A2' :
return { speed: 'slow' , stability: 0.9 };
case 'B1' :
case 'B2' :
return { speed: 'normal' , stability: 0.75 };
case 'C1' :
case 'C2' :
return { speed: 'fast' , stability: 0.5 };
default :
return { speed: 'normal' , stability: 0.75 };
}
}
音声キャッシュとオフライン対応
API 呼び出しコストを削減するため、よく使われるフレーズの音声はローカルにキャッシュします。
// utils/audioCache.ts
import * as FileSystem from 'expo-file-system' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
const CACHE_DIR = `${ FileSystem . cacheDirectory }tts_cache/` ;
const CACHE_INDEX_KEY = 'tts_cache_index' ;
const MAX_CACHE_SIZE_MB = 100 ;
interface CacheEntry {
key : string ;
filePath : string ;
createdAt : number ;
sizeBytes : number ;
accessCount : number ;
}
export class AudioCache {
private index : Map < string , CacheEntry > = new Map ();
async initialize () {
// キャッシュディレクトリの作成
const dirInfo = await FileSystem. getInfoAsync ( CACHE_DIR );
if ( ! dirInfo.exists) {
await FileSystem. makeDirectoryAsync ( CACHE_DIR , {
intermediates: true ,
});
}
// インデックスの復元
const stored = await AsyncStorage. getItem ( CACHE_INDEX_KEY );
if (stored) {
const entries : CacheEntry [] = JSON . parse (stored);
entries. forEach ( e => this .index. set (e.key, e));
}
}
// テキスト+音声設定からキャッシュキーを生成
private getCacheKey ( text : string , voiceId : string ) : string {
// シンプルなハッシュ関数
const input = `${ text }:${ voiceId }` ;
let hash = 0 ;
for ( let i = 0 ; i < input. length ; i ++ ) {
const char = input. charCodeAt (i);
hash = (hash << 5 ) - hash + char;
hash |= 0 ;
}
return `tts_${ Math . abs ( hash ). toString ( 36 ) }` ;
}
async get ( text : string , voiceId : string ) : Promise < string | null > {
const key = this . getCacheKey (text, voiceId);
const entry = this .index. get (key);
if ( ! entry) return null ;
// ファイルの存在確認
const info = await FileSystem. getInfoAsync (entry.filePath);
if ( ! info.exists) {
this .index. delete (key);
return null ;
}
// アクセスカウント更新(LRU的な管理)
entry.accessCount ++ ;
return entry.filePath;
}
async store (
text : string ,
voiceId : string ,
audioBase64 : string
) : Promise < string > {
const key = this . getCacheKey (text, voiceId);
const filePath = `${ CACHE_DIR }${ key }.mp3` ;
await FileSystem. writeAsStringAsync (filePath, audioBase64, {
encoding: FileSystem.EncodingType.Base64,
});
const info = await FileSystem. getInfoAsync (filePath);
this .index. set (key, {
key,
filePath,
createdAt: Date. now (),
sizeBytes: info.exists ? (info.size ?? 0 ) : 0 ,
accessCount: 1 ,
});
// キャッシュサイズ超過時のクリーンアップ
await this . evictIfNeeded ();
await this . persistIndex ();
return filePath;
}
private async evictIfNeeded () {
const totalSize = Array. from ( this .index. values ()). reduce (
( sum , e ) => sum + e.sizeBytes, 0
);
if (totalSize <= MAX_CACHE_SIZE_MB * 1024 * 1024 ) return ;
// アクセス頻度の低いものから削除
const sorted = Array. from ( this .index. values ()). sort (
( a , b ) => a.accessCount - b.accessCount
);
let currentSize = totalSize;
for ( const entry of sorted) {
if (currentSize <= MAX_CACHE_SIZE_MB * 1024 * 1024 * 0.8 ) break ;
await FileSystem. deleteAsync (entry.filePath, { idempotent: true });
this .index. delete (entry.key);
currentSize -= entry.sizeBytes;
}
}
private async persistIndex () {
const entries = Array. from ( this .index. values ());
await AsyncStorage. setItem ( CACHE_INDEX_KEY , JSON . stringify (entries));
}
}
会話レッスン画面の UI 実装
メインの会話インターフェース
ユーザーが自然に音声対話できるよう、チャットUI風のレッスン画面を構築します。
// screens/ConversationLessonScreen.tsx
import React, { useState, useCallback, useRef, useEffect } from 'react' ;
import {
View, Text, ScrollView, TouchableOpacity,
StyleSheet, Animated, ActivityIndicator,
} from 'react-native' ;
import { useAudioRecorder } from '../hooks/useAudioRecorder' ;
import { processUserUtterance } from '../pipeline/LessonPipeline' ;
import { Audio } from 'expo-av' ;
interface ChatMessage {
id : string ;
role : 'tutor' | 'learner' ;
text : string ;
audioUri ?: string ;
pronunciationScore ?: number ;
feedback ?: {
corrections : Array <{
original : string ;
corrected : string ;
explanation : string ;
}>;
vocabularyTip ?: { word : string ; meaning : string };
encouragement : string ;
};
}
export default function ConversationLessonScreen () {
const [ messages , setMessages ] = useState < ChatMessage []>([]);
const [ isProcessing , setIsProcessing ] = useState ( false );
const [ showFeedback , setShowFeedback ] = useState < string | null >( null );
const { isRecording , startRecording , stopRecording } = useAudioRecorder ();
const scrollViewRef = useRef < ScrollView >( null );
const pulseAnim = useRef ( new Animated. Value ( 1 )).current;
// 録音中のパルスアニメーション
useEffect (() => {
if (isRecording) {
Animated. loop (
Animated. sequence ([
Animated. timing (pulseAnim, {
toValue: 1.3 ,
duration: 600 ,
useNativeDriver: true ,
}),
Animated. timing (pulseAnim, {
toValue: 1 ,
duration: 600 ,
useNativeDriver: true ,
}),
])
). start ();
} else {
pulseAnim. setValue ( 1 );
}
}, [isRecording]);
const handleRecordToggle = useCallback ( async () => {
if (isRecording) {
const audioBlob = await stopRecording ();
if ( ! audioBlob) return ;
setIsProcessing ( true );
try {
const result = await processUserUtterance (
audioBlob,
messages. map ( m => ({
role: m.role === 'tutor' ? 'assistant' : 'user' ,
content: m.text,
})),
{ /* learner profile from context */ } as any
);
// 学習者の発話を追加
const learnerMessage : ChatMessage = {
id: `learner-${ Date . now () }` ,
role: 'learner' ,
text: result.transcription,
pronunciationScore: result.pronunciationScore,
feedback: {
corrections: result.feedback.grammarFeedback || [],
vocabularyTip: result.feedback.vocabularyTip,
encouragement: result.feedback.encouragement,
},
};
// チューターの応答を追加
const tutorMessage : ChatMessage = {
id: `tutor-${ Date . now () }` ,
role: 'tutor' ,
text: result.feedback.responseText,
audioUri: result.audioResponse,
};
setMessages ( prev => [ ... prev, learnerMessage, tutorMessage]);
// チューターの音声を自動再生
if (result.audioResponse) {
const { sound } = await Audio.Sound. createAsync ({
uri: result.audioResponse,
});
await sound. playAsync ();
}
} catch (error) {
console. error ( 'Pipeline error:' , error);
} finally {
setIsProcessing ( false );
}
} else {
await startRecording ();
}
}, [isRecording, messages]);
return (
< View style = { styles.container } >
< ScrollView
ref = { scrollViewRef }
style = { styles.messageList }
onContentSizeChange = { () =>
scrollViewRef.current?. scrollToEnd ({ animated: true })
}
>
{ messages. map ( msg => (
< View
key = { msg.id }
style = { [
styles.messageBubble,
msg.role === 'learner'
? styles.learnerBubble
: styles.tutorBubble,
] }
>
< Text style = { styles.messageText } > { msg.text } </ Text >
{ /* 発音スコアバッジ */ }
{ msg.pronunciationScore !== undefined && (
< TouchableOpacity
style = { [
styles.scoreBadge,
{
backgroundColor:
msg.pronunciationScore >= 80
? '#22c55e'
: msg.pronunciationScore >= 60
? '#f59e0b'
: '#ef4444' ,
},
] }
onPress = { () => setShowFeedback (msg.id) }
>
< Text style = { styles.scoreText } >
{ msg.pronunciationScore } 点
</ Text >
</ TouchableOpacity >
) }
{ /* フィードバック展開 */ }
{ showFeedback === msg.id && msg.feedback && (
< View style = { styles.feedbackPanel } >
< Text style = { styles.encouragement } >
{ msg.feedback.encouragement }
</ Text >
{ msg.feedback.corrections. map (( c , i ) => (
< View key = { i } style = { styles.correctionItem } >
< Text style = { styles.original } > { c.original } </ Text >
< Text style = { styles.arrow } >→</ Text >
< Text style = { styles.corrected } > { c.corrected } </ Text >
< Text style = { styles.explanation } > { c.explanation } </ Text >
</ View >
)) }
</ View >
) }
</ View >
)) }
</ ScrollView >
{ /* 録音ボタン */ }
< View style = { styles.recordArea } >
{ isProcessing ? (
< ActivityIndicator size = "large" color = "#6366f1" />
) : (
< TouchableOpacity onPress = { handleRecordToggle } >
< Animated.View
style = { [
styles.recordButton,
isRecording && styles.recordingActive,
{ transform: [{ scale: pulseAnim }] },
] }
>
< Text style = { styles.recordIcon } >
{ isRecording ? '⏹' : '🎙' }
</ Text >
</ Animated.View >
</ TouchableOpacity >
) }
< Text style = { styles.recordHint } >
{ isRecording
? 'タップして送信'
: isProcessing
? 'AI が分析中...'
: 'タップして話す' }
</ Text >
</ View >
</ View >
);
}
const styles = StyleSheet. create ({
container: { flex: 1 , backgroundColor: '#f8fafc' },
messageList: { flex: 1 , padding: 16 },
messageBubble: {
maxWidth: '80%' ,
padding: 14 ,
borderRadius: 18 ,
marginBottom: 12 ,
},
learnerBubble: {
alignSelf: 'flex-end' ,
backgroundColor: '#6366f1' ,
},
tutorBubble: {
alignSelf: 'flex-start' ,
backgroundColor: '#ffffff' ,
borderWidth: 1 ,
borderColor: '#e2e8f0' ,
},
messageText: { fontSize: 16 , lineHeight: 22 },
scoreBadge: {
alignSelf: 'flex-end' ,
paddingHorizontal: 10 ,
paddingVertical: 4 ,
borderRadius: 12 ,
marginTop: 6 ,
},
scoreText: { color: '#fff' , fontSize: 13 , fontWeight: '700' },
feedbackPanel: {
marginTop: 10 ,
padding: 12 ,
backgroundColor: '#f1f5f9' ,
borderRadius: 12 ,
},
encouragement: {
fontSize: 14 ,
color: '#22c55e' ,
fontWeight: '600' ,
marginBottom: 8 ,
},
correctionItem: { marginBottom: 8 },
original: { color: '#ef4444' , textDecorationLine: 'line-through' },
arrow: { color: '#94a3b8' },
corrected: { color: '#22c55e' , fontWeight: '600' },
explanation: { color: '#64748b' , fontSize: 13 , marginTop: 2 },
recordArea: { alignItems: 'center' , paddingVertical: 24 },
recordButton: {
width: 72 ,
height: 72 ,
borderRadius: 36 ,
backgroundColor: '#6366f1' ,
alignItems: 'center' ,
justifyContent: 'center' ,
},
recordingActive: { backgroundColor: '#ef4444' },
recordIcon: { fontSize: 28 },
recordHint: { marginTop: 8 , color: '#94a3b8' , fontSize: 14 },
});
学習進捗の永続化 — Supabase との統合
データモデル設計
学習者のプロファイル・セッション履歴・発音スコアの推移を Supabase に保存します。
-- Supabase でのテーブル設計
-- 学習者プロファイル
CREATE TABLE learner_profiles (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
user_id UUID REFERENCES auth . users (id),
native_language TEXT NOT NULL DEFAULT 'ja' ,
target_language TEXT NOT NULL DEFAULT 'en' ,
cefr_level TEXT NOT NULL DEFAULT 'A1' ,
total_sessions INTEGER DEFAULT 0 ,
total_minutes REAL DEFAULT 0 ,
streak_days INTEGER DEFAULT 0 ,
last_session_at TIMESTAMPTZ ,
created_at TIMESTAMPTZ DEFAULT NOW ()
);
-- 学習セッション
CREATE TABLE learning_sessions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
learner_id UUID REFERENCES learner_profiles(id),
topic TEXT NOT NULL ,
duration_seconds INTEGER ,
avg_pronunciation_score REAL ,
grammar_accuracy REAL ,
new_vocabulary_count INTEGER ,
messages_count INTEGER ,
started_at TIMESTAMPTZ DEFAULT NOW (),
ended_at TIMESTAMPTZ
);
-- 間隔反復用の語彙カード
CREATE TABLE vocabulary_cards (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
learner_id UUID REFERENCES learner_profiles(id),
word TEXT NOT NULL ,
meaning TEXT NOT NULL ,
example_sentence TEXT ,
ease_factor REAL DEFAULT 2 . 5 , -- SM-2 アルゴリズム
interval_days INTEGER DEFAULT 1 ,
repetitions INTEGER DEFAULT 0 ,
next_review_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW ()
);
-- 発音スコア推移(グラフ用)
CREATE TABLE pronunciation_history (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY ,
learner_id UUID REFERENCES learner_profiles(id),
session_id UUID REFERENCES learning_sessions(id),
word TEXT NOT NULL ,
score INTEGER NOT NULL ,
recorded_at TIMESTAMPTZ DEFAULT NOW ()
);
SM-2 間隔反復アルゴリズムの実装
語彙学習の定着には、科学的に裏付けられた間隔反復(Spaced Repetition)が効果的です。SuperMemo の SM-2 アルゴリズムを実装します。
// engine/SpacedRepetition.ts
interface ReviewResult {
quality : number ; // 0-5 (0=完全忘却, 5=完璧)
}
interface CardSchedule {
easeFactor : number ;
interval : number ; // days
repetitions : number ;
nextReviewAt : Date ;
}
export function calculateNextReview (
card : {
easeFactor : number ;
interval : number ;
repetitions : number ;
},
review : ReviewResult
) : CardSchedule {
let { easeFactor, interval, repetitions } = card;
const { quality } = review;
if (quality < 3 ) {
// 不正解 → リセット
repetitions = 0 ;
interval = 1 ;
} else {
// 正解 → 間隔を拡張
if (repetitions === 0 ) {
interval = 1 ;
} else if (repetitions === 1 ) {
interval = 6 ;
} else {
interval = Math. round (interval * easeFactor);
}
repetitions ++ ;
}
// Ease Factor の更新
easeFactor = Math. max (
1.3 ,
easeFactor + ( 0.1 - ( 5 - quality) * ( 0.08 + ( 5 - quality) * 0.02 ))
);
const nextReviewAt = new Date ();
nextReviewAt. setDate (nextReviewAt. getDate () + interval);
return { easeFactor, interval, repetitions, nextReviewAt };
}
レイテンシ最適化 — ストリーミングと並列処理
Claude ストリーミング応答
Whisper→Claude→TTS のシーケンシャルパイプラインでは、ユーザーが発話してから応答音声が再生されるまで3〜5秒のレイテンシが生じます。Claude のストリーミング応答を使えば、テキスト生成と同時に TTS 変換を開始 できます。
// pipeline/StreamingPipeline.ts
export async function processWithStreaming (
audioBlob : Blob ,
context : ConversationContext ,
callbacks : {
onTranscription : ( text : string ) => void ;
onPartialResponse : ( text : string ) => void ;
onAudioReady : ( uri : string ) => void ;
onFeedback : ( feedback : ConversationFeedback ) => void ;
}
) {
// Step 1: Whisper(これは一括処理のみ)
const transcription = await transcribeWithWhisper (audioBlob);
callbacks. onTranscription (transcription.text);
// Step 2: Claude ストリーミング
let fullResponse = '' ;
let firstSentenceProcessed = false ;
const stream = await fetch ( 'https://api.anthropic.com/v1/messages' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'x-api-key' : process.env. ANTHROPIC_API_KEY ! ,
'anthropic-version' : '2023-06-01' ,
},
body: JSON . stringify ({
model: 'claude-sonnet-4-6' ,
max_tokens: 1024 ,
stream: true ,
system: buildSystemPrompt (context),
messages: buildMessages (context, transcription.text),
}),
});
const reader = stream.body?. getReader ();
const decoder = new TextDecoder ();
while (reader) {
const { done , value } = await reader. read ();
if (done) break ;
const chunk = decoder. decode (value);
const lines = chunk. split ( ' \n ' );
for ( const line of lines) {
if (line. startsWith ( 'data: ' )) {
const data = JSON . parse (line. slice ( 6 ));
if (data.type === 'content_block_delta' ) {
fullResponse += data.delta.text;
callbacks. onPartialResponse (fullResponse);
// 最初の文が完成したら、TTS を先行開始
if ( ! firstSentenceProcessed && fullResponse. includes ( '.' )) {
const firstSentence = fullResponse. split ( '.' )[ 0 ] + '.' ;
firstSentenceProcessed = true ;
// 非同期で TTS 変換を開始
synthesizeSpeech ({
text: firstSentence,
language: context.targetLanguage,
voiceGender: 'female' ,
speed: 'normal' ,
stability: 0.75 ,
}). then ( sound => {
callbacks. onAudioReady (firstSentence);
sound. playAsync ();
});
}
}
}
}
}
}
サブスクリプション課金の設計
フリーミアムモデルの設計
語学学習アプリの収益化には、フリーミアムモデルが最も効果的です。
無料プラン : 1日3回のAI会話セッション(各5分)、基本フラッシュカード機能、進捗トラッキング
プロプラン(月額¥980) : 無制限のAI会話セッション、全音声バリエーション、詳細な発音レポート、オフラインキャッシュ、広告なし
プレミアムプラン(年額¥7,800) : プロの全機能 + パーソナルカリキュラム生成、ネイティブ講師による月1回のフィードバック
// config/subscriptionTiers.ts
export const SUBSCRIPTION_TIERS = {
free: {
dailySessions: 3 ,
sessionDurationMinutes: 5 ,
voiceOptions: [ 'default' ],
pronunciationReport: 'basic' , // スコアのみ
offlineCache: false ,
adaptiveCurriculum: false ,
},
pro: {
dailySessions: Infinity ,
sessionDurationMinutes: 30 ,
voiceOptions: [ 'male' , 'female' , 'british' , 'australian' ],
pronunciationReport: 'detailed' , // 音素レベルの分析
offlineCache: true ,
adaptiveCurriculum: true ,
},
premium: {
dailySessions: Infinity ,
sessionDurationMinutes: 60 ,
voiceOptions: 'all' ,
pronunciationReport: 'expert' , // ネイティブ比較付き
offlineCache: true ,
adaptiveCurriculum: true ,
nativeFeedback: true , // 月1回
},
} as const ;
API コスト試算の目安として、1セッション(5分・約10往復の会話)あたりの API 費用は以下の通りです。
Whisper: 約$0.006(5分の音声)
Claude Sonnet: 約$0.015(入力3K + 出力1K トークン × 10回)
ElevenLabs: 約$0.03(1,000文字 × 10回)
合計: 約$0.05/セッション
月額¥980で1日3セッション(90セッション/月)利用された場合、API コストは約$4.50/月。粗利率は約55%と十分な収益性があります。
個人開発で運用してみてわかった3つの落とし穴
設計図の段階では綺麗に動くはずのパイプラインも、実機で1日100セッション分回してみると、ドキュメントには書かれていない地味な問題が出てきます。私が累計5,000万ダウンロードのアプリ運用で培ったログ分析の流儀に当てはめて、語学学習アプリでよく踏む落とし穴を3つだけ抽出しておきます。
落とし穴1: ElevenLabs の月間文字数を「会話往復」で見積もると2倍ハズれる
Pro プランの利用者を想定して文字数を見積もるとき、「1セッション=10往復=平均1,000文字」と単純に計算したら、実測値は約2,000文字でした。原因は、システムプロンプト内の例文や、grammarFeedback の corrected/explanation フィールドまで TTS に投げる実装にしていたから。リリース前にこの分岐を入れておかないと、3ヶ月目に API 請求が想定の2倍になります。
// services/ttsRouter.ts — どのテキストを TTS に投げるかの判断ロジック
// 「読み上げる必要があるテキスト」と「画面で読む補助情報」を必ず分離する
interface TTSCandidate {
text : string ;
purpose : 'main_response' | 'pronunciation_hint' | 'grammar_explanation' | 'vocab_example' ;
}
const TTS_BUDGET_PER_SESSION = 1200 ; // 文字数の上限。超過分は画面表示のみ
export function selectTTSCandidates (
candidates : TTSCandidate [],
spentCharsSoFar : number
) : TTSCandidate [] {
// main_response は必ず TTS。それ以外は予算が残っていれば優先順で
const main = candidates. filter ( c => c.purpose === 'main_response' );
const others = candidates. filter ( c => c.purpose !== 'main_response' );
let budget = TTS_BUDGET_PER_SESSION - spentCharsSoFar;
const result = [ ... main];
budget -= main. reduce (( s , c ) => s + c.text. length , 0 );
// 優先順: pronunciation_hint > vocab_example > grammar_explanation
const priorityOrder : TTSCandidate [ 'purpose' ][] = [
'pronunciation_hint' , 'vocab_example' , 'grammar_explanation' ,
];
for ( const purpose of priorityOrder) {
const items = others. filter ( c => c.purpose === purpose);
for ( const item of items) {
if (budget >= item.text. length ) {
result. push (item);
budget -= item.text. length ;
}
}
}
return result;
}
これを入れておくと、ユーザーごとの文字数消費が中央値で40%ほど下がり、Pro プラン1人あたりのコストがメンタル試算と一致するようになります。
落とし穴2: Whisper の word probability は「無音区間」で過大評価される
発音スコアを word probability の平均で算出していると、ユーザーが「えー」「えーっと」と無音に近い発話をした単語が0.95などの高い値で返ってきて、全体スコアを不当に押し上げます。対処は単純で、no_speech_prob > 0.3 と avg_logprob < -1.0 のセグメントを発音スコア算出から除外することです。
// services/whisperService.ts に追加するフィルタ
export function calculateRobustPronunciationScore (
response : WhisperDetailedResponse
) : { overall : number ; reliableWordCount : number } {
const reliableSegments = response.segments. filter ( s =>
s.no_speech_prob < 0.3 && s.avg_logprob > - 1.0
);
const words = reliableSegments. flatMap ( s => s.words || []);
// probability < 0.3 の単語も除外(ノイズや咳の可能性)
const reliable = words. filter ( w => w.probability >= 0.3 );
if (reliable. length === 0 ) {
return { overall: 0 , reliableWordCount: 0 };
}
const overall = Math. round (
(reliable. reduce (( s , w ) => s + w.probability, 0 ) / reliable. length ) * 100
);
return { overall, reliableWordCount: reliable. length };
}
私の場合、このフィルタを入れる前後で「平均スコア 82 → 71」と一度下がりましたが、ユーザーフィードバックでは「自分の発音と合っている感じがする」というコメントが増えました。スコアは高く見せるものではなく、信頼できる指標として設計するほうが、最終的な継続率に効きます。
落とし穴3: Rork Max の Expo SDK バージョン固定を放置すると iOS17 起動クラッシュが出る
Rork Max の自動生成プロジェクトは Expo SDK のメジャーバージョンが固定で出てきます。AdMob・ElevenLabs・expo-av の組み合わせは、SDK のマイナー更新で Audio.Recording.createAsync の引数仕様が変わったことがあり、私の壁紙アプリでも同じ罠で iOS17 起動直後クラッシュを2日間追いかけました。語学アプリの場合は録音モジュールが心臓部なので、CI に以下の検証を組み込んでおくのがお勧めです。
# .github/workflows/audio-smoke-test.yml の核心部
- name: Smoke test audio recording API
run: |
npx expo install --check
npx expo-doctor
# expo-av のメジャーバージョン整合を確認
node -e "
const pkg = require('./node_modules/expo-av/package.json');
const expoPkg = require('./node_modules/expo/package.json');
const expoMajor = parseInt(expoPkg.version.split('.')[0]);
const avMajor = parseInt(pkg.version.split('.')[0]);
if (Math.abs(expoMajor - avMajor * 5) > 5) {
console.error('expo-av major version drift detected');
process.exit(1);
}
"
CI に組み込んでおくと、Rork Max が裏側で Expo SDK をアップデートしたタイミングを必ず捕捉できます。
運用ノウハウ — 個人開発者のための運用チェックリスト
ここまでで実装の主要なピースは揃いました。最後に、運用フェーズで毎週見るべき指標と、Rork Lab の他記事に分散している運用知見へのリンクをまとめておきます。
毎週見るべき指標(個人開発でも省略しない)
API コストの実測値 ÷ 課金ユーザー数(ARPU の比較対象) :API コスト ≥ プラン価格の60%を超えたら、要因(ElevenLabs 文字数 / Whisper 秒数 / Claude トークン)の内訳を必ず確認します
Day 7・Day 30 リテンション :通常アプリと違って、語学アプリは習慣化に時間がかかります。私の経験では Day 7 で35%、Day 30 で15% を最低ラインに設定しています
「平均発音スコア」と「セッション継続秒数」の散布図 :発音スコアが低い人がセッションを早く切り上げる傾向があれば、レベル設定が高すぎる可能性が高いです
AdMob のリワード広告完視率(無料プラン) :無料プランで広告挿入する場合、完視率60%未満ならインタースティシャル形式や挿入位置の再検討が必要です
Claude / Whisper / ElevenLabs 各 API の P95 レイテンシ :体感速度は P95 で決まります。3社のうち1社でも 4秒を超えたら、ストリーミング化や並列化を検討します
Rork Lab 他記事との関係
このガイドの内容は単体でも完結していますが、運用フェーズでは以下の記事も組み合わせると効果的です。
次に書くコード
ここまでを読んでくださってありがとうございました。本ガイドのコードはどれも、明日の通勤時間中にコピペしてビルドできるレベルまで具体的に書いたつもりです。最初の一歩としてお勧めなのは、Whisper の verbose_json レスポンスからスコアを算出する calculateRobustPronunciationScore を、すでに作っている自分のサンドボックスに貼り付けて、自分の発音で実測値を見てみることです。
語学アプリは、ユーザーが「下手な自分」と向き合うアプリです。だからこそ、スコアは厳密で、フィードバックは温かく、レイテンシは短くあるべきだと考えています。読者のみなさまの語学アプリが、誰かの「世界が広がる体験」につながることを願っています。
本ガイドのテーマ