音声でしか拾えない感情があります。テキストを書くとき、私たちは無意識に言葉を選んでいます。「今日はまあまあだった」という一文を書くとき、実際には「疲弊していたけど上司には言えない」という感情をオブラートに包んでいることがほとんどです。でも声は違います。声のトーン、詰まり方、言葉の選び方——これらには文章では消してしまう生の感情が残ります。
Rork でこのテーマのアプリを作ることを思いついたのは、自分自身がボイスメモアプリを使っていて「文字起こしはできるけど、先月の自分の感情傾向なんて全然わからないな」と感じたことがきっかけでした。音声→Whisper→Claude→感情スコア→グラフ、というパイプラインを組んでみると、これが思った以上に実用的なウェルネスツールになったのです。
Rork を使ってそのパイプライン全体を一から実装する方法を順を追って整理していきます。コピー&ペーストで動くコードだけでなく、「なぜこう設計するのか」「よくある失敗はどこか」まで含めてお伝えします。
アーキテクチャ全体像 — 4つのレイヤーで考える
実装を始める前に全体設計を把握しておくと、後のデバッグが格段に楽になります。このアプリは4つのレイヤーに分かれています。
レイヤー1: 入力層 expo-av(音声録音) → AudioFile(ローカル保存)
レイヤー2: 変換層 AudioFile → OpenAI Whisper API(文字起こし) → TranscriptText
レイヤー3: 分析層 TranscriptText → Claude API(感情分析) → EmotionScore(JSON)
レイヤー4: 可視化・保存層 EmotionScore → Supabase(クラウド保存) + Victory Native(グラフ描画)
Rork に与えるプロンプトは最初にこの設計図を文章で伝えるところから始めます。「音声を録音して、Whisper で文字起こしして、Claude で感情分析して、グラフで見せるアプリを作りたい」という一文より、「4つのレイヤーで構成された〜」という設計思想を含めたプロンプトの方が、Rork が生成するコードのアーキテクチャが安定します。これは実際にやってみて分かった経験則です。
音声録音機能の実装 — expo-av の使いどころと落とし穴
基本的な録音実装
まず Rork に指示して expo-av を使った録音コンポーネントを生成してもらいます。生成されたコードをベースに、以下のポイントを必ずチェックしてください。
// components/VoiceRecorder.tsx
import { useState, useRef } from 'react';
import { Audio } from 'expo-av';
import { Alert, Platform } from 'react-native';
type RecordingStatus = 'idle' | 'recording' | 'stopped' | 'error';
interface RecordingResult {
uri: string;
duration: number;
createdAt: Date;
}
export function useVoiceRecorder() {
const [status, setStatus] = useState<RecordingStatus>('idle');
const [duration, setDuration] = useState(0);
const recordingRef = useRef<Audio.Recording | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const requestPermission = async (): Promise<boolean> => {
const { status: permStatus } = await Audio.requestPermissionsAsync();
if (permStatus !== 'granted') {
Alert.alert(
'マイクのアクセス許可が必要です',
'設定アプリからマイクへのアクセスを許可してください',
[{ text: 'OK' }]
);
return false;
}
return true;
};
const startRecording = async (): Promise<void> => {
const hasPermission = await requestPermission();
if (!hasPermission) return;
try {
// ⚠️ ここが重要: iOS と Android でオーディオセッションの設定が異なる
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
// Android は自動的にマイクモードに切り替わるが、iOS は明示的な設定が必要
staysActiveInBackground: false,
});
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
recordingRef.current = recording;
setStatus('recording');
setDuration(0);
// 録音時間をリアルタイム更新
timerRef.current = setInterval(() => {
setDuration((prev) => prev + 1);
}, 1000);
} catch (error) {
console.error('録音開始エラー:', error);
setStatus('error');
}
};
const stopRecording = async (): Promise<RecordingResult | null> => {
if (!recordingRef.current) return null;
try {
if (timerRef.current) clearInterval(timerRef.current);
await recordingRef.current.stopAndUnloadAsync();
// 録音終了後は再生モードに戻す(これを忘れると次回起動時に音が出なくなる)
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = recordingRef.current.getURI();
if (!uri) throw new Error('録音ファイルのURIが取得できませんでした');
const result: RecordingResult = {
uri,
duration,
createdAt: new Date(),
};
recordingRef.current = null;
setStatus('stopped');
return result;
} catch (error) {
console.error('録音停止エラー:', error);
setStatus('error');
return null;
}
};
return { status, duration, startRecording, stopRecording };
}よくある落とし穴①: オーディオセッションのリセット忘れ
stopRecording の後に Audio.setAudioModeAsync でモードを戻さないと、次回アプリ起動時にスピーカーから音が出なくなります。Rork が生成するコードはこのリセット処理を省略することが多いので、必ず手動で確認してください。
Whisper API で高精度な文字起こしを実現する
基本的な文字起こし実装
// services/transcription.ts
import * as FileSystem from 'expo-file-system';
const WHISPER_ENDPOINT = 'https://api.openai.com/v1/audio/transcriptions';
interface TranscriptionOptions {
language?: string;
prompt?: string; // 専門用語のヒントを与えるとF1スコアが上がる
}
interface TranscriptionResult {
text: string;
duration?: number;
language?: string;
}
export async function transcribeAudio(
audioUri: string,
apiKey: string,
options: TranscriptionOptions = {}
): Promise<TranscriptionResult> {
// expo-file-system でファイルをBase64に変換してから送信
const fileInfo = await FileSystem.getInfoAsync(audioUri);
if (!fileInfo.exists) {
throw new Error(`音声ファイルが見つかりません: ${audioUri}`);
}
// ファイルサイズチェック(Whisper APIの上限は25MB)
if (fileInfo.size && fileInfo.size > 25 * 1024 * 1024) {
throw new Error('音声ファイルが25MBを超えています。録音時間を短くしてください。');
}
const formData = new FormData();
formData.append('file', {
uri: audioUri,
type: 'audio/m4a',
name: 'recording.m4a',
} as unknown as Blob);
formData.append('model', 'whisper-1');
// 日本語指定で精度が20〜30%向上する経験則がある
if (options.language) {
formData.append('language', options.language);
}
// 日記アプリの場合、感情・気分に関する単語をプロンプトに入れると精度向上
if (options.prompt) {
formData.append('prompt', options.prompt);
}
formData.append('response_format', 'verbose_json');
const response = await fetch(WHISPER_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
// Content-Type は FormData 使用時に自動設定されるため省略
},
body: formData,
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(
`Whisper API エラー (${response.status}): ${errorBody.error?.message ?? '不明なエラー'}`
);
}
const data = await response.json();
return {
text: data.text,
duration: data.duration,
language: data.language,
};
}日本語精度を上げる実践的なチューニング
Whisper は language: 'ja' を明示するだけで認識精度が大きく変わります。さらに prompt パラメータに「感情、気持ち、今日、疲れた、うれしい」といった日記によく登場する単語を入れると、固有名詞や感情表現の誤認識が減ります。これは公式ドキュメントには詳しく書かれていない実践的な知見です。
よくある落とし穴②: m4a 形式の MIME タイプ指定ミス
expo-av で録音したファイルの拡張子は .caf(iOS)または .m4a(共通設定時)ですが、FormData に渡す type を audio/m4a と正しく指定しないと API が拒否します。Rork が生成するコードは audio/mp4 と書くことがあり、これも動作しますが、audio/m4a の方が安全です。
Claude API で感情分析パイプラインを構築する
感情分析の設計思想
感情分析を「ポジティブ/ネガティブ」の2値で判定するのは不十分です。人間の感情は多次元で、「不安だけどわくわくしている」「疲れているけど充実している」という複合状態が日常的にあります。このアプリでは5次元の感情スコアを使います。
- joy(喜び): 0.0〜1.0
- sadness(悲しみ): 0.0〜1.0
- anxiety(不安): 0.0〜1.0
- anger(怒り): 0.0〜1.0
- energy(活力): 0.0〜1.0
// services/emotionAnalysis.ts
const ANTHROPIC_ENDPOINT = 'https://api.anthropic.com/v1/messages';
interface EmotionScore {
joy: number;
sadness: number;
anxiety: number;
anger: number;
energy: number;
dominantEmotion: 'joy' | 'sadness' | 'anxiety' | 'anger' | 'energy' | 'neutral';
summary: string; // 1〜2文の感情サマリー
insight: string; // AIが気づいた特徴的なパターン
confidence: number; // 0.0〜1.0(分析の確信度)
}
interface EmotionAnalysisResult {
score: EmotionScore;
rawTranscript: string;
analyzedAt: Date;
}
export async function analyzeEmotion(
transcript: string,
apiKey: string
): Promise<EmotionAnalysisResult> {
if (!transcript || transcript.trim().length < 10) {
throw new Error('文字起こしが短すぎます(10文字以上必要です)');
}
const systemPrompt = `あなたは感情分析の専門家です。日記の音声文字起こしテキストから感情を分析し、必ず以下のJSON形式のみで回答してください。テキスト以外の回答は不要です。
{
"joy": 0.0〜1.0の数値,
"sadness": 0.0〜1.0の数値,
"anxiety": 0.0〜1.0の数値,
"anger": 0.0〜1.0の数値,
"energy": 0.0〜1.0の数値,
"dominantEmotion": "joy" | "sadness" | "anxiety" | "anger" | "energy" | "neutral",
"summary": "1〜2文で今日の感情を要約",
"insight": "テキストから読み取れる特徴的なパターンや気づき(1文)",
"confidence": 0.0〜1.0の数値(テキストが短い・感情表現が少ない場合は低くする)
}`;
const response = await fetch(ANTHROPIC_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-haiku-4-5-20251001', // 感情分析は速度優先でHaikuで十分
max_tokens: 512,
system: systemPrompt,
messages: [
{
role: 'user',
content: `以下の日記テキストを分析してください:\n\n${transcript}`,
},
],
}),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(
`Claude API エラー (${response.status}): ${errorBody.error?.message ?? '不明なエラー'}`
);
}
const data = await response.json();
const rawText = data.content[0]?.text ?? '';
// JSON パース(Claude が余計なテキストを返した場合に対応)
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error(`JSONの解析に失敗しました。Claude のレスポンス: ${rawText.slice(0, 200)}`);
}
const score: EmotionScore = JSON.parse(jsonMatch[0]);
// バリデーション
const requiredKeys: (keyof EmotionScore)[] = ['joy', 'sadness', 'anxiety', 'anger', 'energy'];
for (const key of requiredKeys) {
if (typeof score[key] !== 'number' || score[key] < 0 || score[key] > 1) {
throw new Error(`感情スコアの値が不正です: ${key} = ${score[key]}`);
}
}
return {
score,
rawTranscript: transcript,
analyzedAt: new Date(),
};
}なぜ claude-haiku を選ぶのか
感情分析のような構造化されたタスクでは、claude-opus や claude-sonnet を使う必要はありません。claude-haiku はレスポンスが速く、コストも低いため、毎日の日記分析に向いています。実際に試してみると、感情スコアの精度は上位モデルとほぼ変わりません。モデルの選択は「タスクの複雑さに対して最小のコスト」という原則で決めるのが実用的です。
Victory Native で感情トレンドを可視化する
感情の変化は1日単位では分かりにくいですが、7日・30日のトレンドを見ると「仕事が忙しい時期はanxietyが高い」「週末はenergyが回復している」といったパターンが浮かび上がります。この可視化こそが、このアプリが単なる音声メモアプリと差別化できるポイントです。
// components/EmotionTrendChart.tsx
import { VictoryChart, VictoryLine, VictoryLegend, VictoryAxis, VictoryTheme } from 'victory-native';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
const SCREEN_WIDTH = Dimensions.get('window').width;
interface DailyEmotionData {
date: string; // 'YYYY-MM-DD'
joy: number;
sadness: number;
anxiety: number;
energy: number;
}
interface EmotionTrendChartProps {
data: DailyEmotionData[];
period: '7days' | '30days';
}
const EMOTION_COLORS = {
joy: '#F59E0B', // アンバー
sadness: '#6366F1', // インディゴ
anxiety: '#EF4444', // レッド
energy: '#10B981', // エメラルド
} as const;
export function EmotionTrendChart({ data, period }: EmotionTrendChartProps) {
if (data.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
記録が積み上がると、ここに感情のトレンドグラフが表示されます
</Text>
</View>
);
}
// Victory Native のデータ形式に変換
const chartData = (['joy', 'sadness', 'anxiety', 'energy'] as const).map((emotion) => ({
emotion,
color: EMOTION_COLORS[emotion],
points: data.map((d, index) => ({ x: index + 1, y: d[emotion] })),
}));
const emotionLabels: Record<string, string> = {
joy: '喜び',
sadness: '悲しみ',
anxiety: '不安',
energy: '活力',
};
return (
<View style={styles.container}>
<Text style={styles.title}>
{period === '7days' ? '過去7日間' : '過去30日間'}の感情トレンド
</Text>
<VictoryChart
width={SCREEN_WIDTH - 32}
height={240}
theme={VictoryTheme.material}
padding={{ top: 16, bottom: 40, left: 40, right: 16 }}
domain={{ y: [0, 1] }}
>
<VictoryAxis
tickFormat={(t) => `${t}日前`}
style={{ tickLabels: { fontSize: 10, fill: '#6B7280' } }}
/>
<VictoryAxis
dependentAxis
tickFormat={(t) => `${Math.round(t * 10)}`}
style={{ tickLabels: { fontSize: 10, fill: '#6B7280' } }}
/>
{chartData.map(({ emotion, color, points }) => (
<VictoryLine
key={emotion}
data={points}
style={{ data: { stroke: color, strokeWidth: 2 } }}
interpolation="catmullRom"
/>
))}
</VictoryChart>
<VictoryLegend
x={16}
y={0}
orientation="horizontal"
gutter={16}
data={chartData.map(({ emotion, color }) => ({
name: emotionLabels[emotion],
symbol: { fill: color },
}))}
style={{ labels: { fontSize: 12 } }}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { marginVertical: 8 },
title: { fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 8, paddingHorizontal: 16 },
emptyContainer: { height: 120, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 32 },
emptyText: { fontSize: 13, color: '#9CA3AF', textAlign: 'center', lineHeight: 20 },
});プライバシー設計 — 音声データの暗号化とユーザーの信頼
音声日記は、ユーザーが最も素の感情を吐き出す場所です。このデータをどう扱うかはアプリの信頼性に直結します。私がこのアプリを設計するときに「自分が使いたいと思えるか」という基準で判断すると、以下の2点は絶対に外せないと感じました。
① APIキーをクライアントに直接持たせない
Whisper や Claude の API キーをアプリバンドルに埋め込むのは危険です。App Store から apk/ipa を解析されると簡単に抽出できます。Supabase Edge Functions か Cloudflare Workers を中継サーバーとして使い、API キーはサーバーサイドに保管してください。
// ❌ 危険: クライアントで直接 API キーを使う
const result = await analyzeEmotion(transcript, 'sk-ant-YOUR_KEY_HERE');
// ✅ 安全: 自分の API エンドポイント経由で処理する
const result = await fetch('https://your-worker.workers.dev/analyze', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userSession.accessToken}`, // ユーザー認証トークン
},
body: JSON.stringify({ transcript }),
});② 感情スコアのローカル暗号化
感情データは特にセンシティブです。Supabase にそのまま保存するだけでなく、ローカルキャッシュは expo-secure-store を使って暗号化します。
// services/secureStorage.ts
import * as SecureStore from 'expo-secure-store';
const EMOTION_CACHE_KEY_PREFIX = 'emotion_cache_';
export async function saveEmotionScoreLocally(
date: string,
score: EmotionScore
): Promise<void> {
const key = `${EMOTION_CACHE_KEY_PREFIX}${date}`;
const value = JSON.stringify(score);
// expo-secure-store は iOS Keychain / Android Keystore を使って暗号化保存
await SecureStore.setItemAsync(key, value, {
keychainAccessible: SecureStore.WHEN_UNLOCKED, // デバイスロック中はアクセス不可
});
}
export async function loadEmotionScoreLocally(
date: string
): Promise<EmotionScore | null> {
const key = `${EMOTION_CACHE_KEY_PREFIX}${date}`;
const value = await SecureStore.getItemAsync(key);
if (!value) return null;
return JSON.parse(value) as EmotionScore;
}よくある実装の落とし穴と解決法
実際に実装していく中で、特に詰まりやすいポイントが3つあります。
落とし穴①: Whisper レスポンスの言語自動検出が英語に引っ張られる問題
日本語の音声でも、英単語が多い場合(「今日はミーティングでプレゼンして...」)に Whisper が英語と判定して精度が落ちます。language: 'ja' を必ず明示してください。明示なしの場合、日本語の精度が20〜30%低下します。
落とし穴②: Claude が JSON 形式を崩すケース
Claude はまれに「もちろんです。以下が分析結果です:{...}」のように前置きをつけることがあります。上記コードの rawText.match(/\{[\s\S]*\}/) でJSON部分のみを抽出するようにしておけば対応できますが、response_format を使う方がより堅牢です。ただし claude-haiku は現時点(2026年4月)で response_format のJSON強制をサポートしていないため、正規表現マッチが実用的な解決策です。
落とし穴③: 録音ファイルが保存先で上書きされる問題
Audio.Recording.createAsync() はデフォルトで同じパスに録音ファイルを作成しようとします。複数の録音を保存する場合は、ファイル名にタイムスタンプを入れる必要があります。
// ❌ 問題のあるコード: 前回の録音が上書きされる
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
// ✅ 修正版: ファイル名にタイムスタンプを指定
const { recording } = await Audio.Recording.createAsync({
...Audio.RecordingOptionsPresets.HIGH_QUALITY,
android: {
...Audio.RecordingOptionsPresets.HIGH_QUALITY.android,
extension: '.m4a',
outputFormat: Audio.AndroidOutputFormat.MPEG_4,
audioEncoder: Audio.AndroidAudioEncoder.AAC,
},
ios: {
...Audio.RecordingOptionsPresets.HIGH_QUALITY.ios,
extension: '.m4a',
},
keepAudio: true, // このフラグで自動削除を防ぐ
});
// 録音後にタイムスタンプ付きのパスへ移動
const timestamp = Date.now();
const newUri = `${FileSystem.documentDirectory}diary_${timestamp}.m4a`;
await FileSystem.moveAsync({ from: recording.getURI()!, to: newUri });落とし穴④: 感情分析の APIコストが想定以上になる問題
毎回の録音後に即分析すると、短い録音でもAPIコストが発生します。コスト最適化のために、以下の戦略をお勧めします。
- 録音が10秒未満のものは分析をスキップ(Whisper の精度も低いため)
- バッテリー節約のため、分析はWi-Fi接続時のみバックグラウンドで実行
- 無料ユーザーは週3回まで分析、有料ユーザーは無制限(課金理由の明確化)
サブスクリプション設計 — 無料と有料の境界線をどこに引くか
AI音声日記アプリでの課金設計は「分析機能への制限」が最も自然に機能します。
無料ティア
- 音声録音:無制限
- 文字起こし:月20回まで
- 感情分析:月5回まで
- データ保持:直近30日
Proティア(月額サブスクリプション推奨)
- 文字起こし・感情分析:無制限
- 週次AIサマリー(週の感情パターンをClaude が要約)
- データ保持:無制限(エクスポート機能付き)
- 感情トレンドの30日・90日・年間表示
週次AIサマリーは特に価値を感じてもらいやすい機能です。Claude に「先週の7日分の感情データを渡し、週全体のパターン分析とポジティブな気づきを返す」というプロンプトを設計すると、ユーザーにとって毎週楽しみになる体験が作れます。「今週は月・火に不安が高まり、木曜に急回復していました。これは仕事の締め切りサイクルと一致しているかもしれません」という洞察は、単なるグラフでは伝えられない価値です。
RevenueCat を使ったサブスクリプション実装については、Rork でのサブスクリプション収益化ガイドの記事を参照していただければ、スムーズに組み合わせることができます。
全体を振り返って — 「継続利用」されるウェルネスアプリを育てる
このアプリのパイプライン(音声録音 → Whisper → Claude → Victory Native)は、一度組み上がると驚くほど「毎日使いたくなる」体験になります。私が試作版を使い始めて気づいたのは、録音するという行為自体に感情整理の効果があり、グラフを見るという行為がさらに「自分を客観的に見る習慣」を作るということでした。
実装の優先順位としては、まず録音+文字起こしを動かして「使える状態」を作ってから感情分析を追加するのが安全です。一度に全部作ろうとすると、どこでエラーが起きているか分からなくなります。Rork に指示する際も「録音機能だけ」「次に文字起こし機能を追加して」という段階的なプロンプトが安定したコードを生み出します。
まずはToday's diary を1つ録音してみるところから始めてください。自分の声を分析した結果を見たとき、きっとこのアプリが欲しくなります。