この記事では、大学AI講義ノートアプリにおいて、録音開始からノート閲覧までの一連の処理フローを詳しく解説します。
🏗️ 全体アーキテクチャ図
┌─────────────────────────────────────────────────────────────────────────┐
│ 📱 Expo React Native App │
├─────────────────────────────────────────────────────────────────────────┤
│ ① 録音画面 ② アップロード処理 ⑥ ノート閲覧画面 │
│ (record.tsx) (lib/upload.ts) (note/[id].tsx) │
│ │ │ ↑ │
│ │ useRecording │ useUpload │ useNotes │
│ │ useNotes │ │ │
│ ↓ ↓ │ │
└───────┼────────────────────┼───────────────────────┼───────────────────┘
│ │ │
│ Audio.Recording │ PUT to Presigned URL │ SELECT notes
↓ ↓ ↑
┌───────────────────────────────────────────────────────────────────────┐
│ 🌐 Backend Services │
├───────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌────────────────────┐ │
│ │ ③ get-upload-url │ │ ④ process-audio │ │
│ │ (Edge Function) │ │ (Edge Function) │ │
│ └────────┬─────────┘ └─────────┬──────────┘ │
│ │ │ │
│ │ aws4fetch │ バックグラウンド処理 │
│ ↓ ↓ │
│ ┌──────────────────┐ ┌────────────────────┐ ┌────────────────┐ │
│ │ ☁️ Cloudflare R2 │────│ 🎤 Deepgram API │───→│ 🤖 OpenAI API │ │
│ │ (音声ストレージ) │ │ (文字起こし) │ │ (ノート構造化) │ │
│ └──────────────────┘ └────────────────────┘ └────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 🗄️ Supabase PostgreSQL │ │
│ │ notes テーブル: id, title, status, transcript, structured_content │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ 📲 Expo Push │ ← ⑤ 完了通知 │
│ └──────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
plain text
📝 処理フロー詳細
① 録音開始・停止(クライアント)
ファイル: app/(tabs)/record.tsx, hooks/use-recording.ts
// 録音フックの使用const {
state: recordingState,// 'idle' | 'recording' | 'paused'
duration,// 録音時間(ミリ秒)
metering,// 音量レベル(0-1)
startRecording,
stopRecording,
} = useRecording();
typescript
1.1 マイク権限の取得
// hooks/use-recording.tsconst requestPermission = async () => {
const { granted } = await Audio.requestPermissionsAsync();
if (granted) {
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
staysActiveInBackground: true,// バックグラウンド録音対応
});
}
};
typescript
1.2 録音の開始
const startRecording = async () => {
const recording = new Audio.Recording();
await recording.prepareToRecordAsync({
...Audio.RecordingOptionsPresets.HIGH_QUALITY,
isMeteringEnabled: true,// 音量取得を有効化
});
await recording.startAsync();
};
typescript
1.3 録音の停止とファイル取得
const stopRecording = async (): Promise<RecordingResult> => {
await recordingRef.current.stopAndUnloadAsync();
const uri = recordingRef.current.getURI();// ローカルファイルパス
return {
uri,// file:///path/to/recording.m4adurationMs: status.durationMillis,
};
};
typescript
② ノート作成とアップロード準備(クライアント)
ファイル: hooks/use-notes.ts, app/(tabs)/record.tsx
録音停止後、以下の処理が連続して実行されます:
// record.tsx - handleStop関数const handleStop = async () => {
// 1. 録音を停止してファイルURIを取得const result = await stopRecording();
// 2. Supabaseにノートのメタデータを作成const note = await createNote(title, selectedSubjectId, durationSeconds);
// 3. アップロードモーダルを表示setShowUploadModal(true);
// 4. 音声ファイルをR2にアップロードconst uploadResult = await upload(result.uri, note.id);
// 5. 音声URLをノートに紐付けawait updateNoteAudioUrl(note.id, uploadResult.audioUrl);
// 6. 音声処理をトリガーawait startProcessing(note.id);
};
typescript
2.1 ノート作成(Supabase INSERT)
// hooks/use-notes.tsconst createNote = async (title, subjectId, durationSeconds) => {
const { data } = await supabase
.from('notes')
.insert({
user_id: user.id,
title,
subject_id: subjectId,
audio_duration_seconds: durationSeconds,
status: 'uploading',// 初期ステータス
})
.select()
.single();
return data;
};
typescript
ノートのステータス遷移:
uploading → pending → transcribing → structuring → completed
↓
failed
plain text
③ 署名付きURL取得(Edge Function)
ファイル: supabase/functions/get-upload-url/index.ts
クライアントから直接R2にアップロードするため、Presigned URLを発行します。
// lib/upload.ts(クライアント側)const { data: urlData } = await supabase.functions.invoke('get-upload-url', {
body: {
note_id: noteId,
file_name: `${noteId}.m4a`,
content_type: 'audio/mp4',
file_size: fileInfo.size,
},
});
typescript
Edge Function側の処理:
// get-upload-url/index.tsserve(async (req) => {
// 1. ユーザー認証const { data: { user } } = await supabase.auth.getUser();
// 2. プラン制限チェックconst { data: limits } = await supabase.rpc('check_plan_limits', { p_user_id: user.id });
// 3. aws4fetchでPresigned URLを生成const aws = new AwsClient({
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
});
const key = `recordings/${note_id}.m4a`;
const url = `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${R2_BUCKET}/${key}`;
const signedRequest = await aws.sign(url, {
method: 'PUT',
headers: { 'Content-Type': 'audio/mp4' },
aws: { signQuery: true, expiresIn: 3600 },
});
return Response.json({
upload_url: signedRequest.url,// クライアントがPUTするURLaudio_url: url,// 永続的なファイルURL
});
});
typescript
なぜPresigned URLを使うのか?
- セキュリティ: R2の認証情報をクライアントに渡さない
- 効率性: サーバーを経由せず直接R2にアップロード(帯域節約)
- 一時的: URLは1時間で有効期限切れ
④ 音声ファイルのアップロード(クライアント → R2)
ファイル: lib/upload.ts
Expo FileSystemを使用して、署名付きURLにPUTリクエストを送信します。
export function createCancellableUpload(fileUri, noteId, onProgress) {
const upload = async () => {
// 1. Presigned URLを取得const { data: urlData } = await supabase.functions.invoke('get-upload-url', { ... });
// 2. FileSystem.createUploadTaskでアップロードconst uploadTask = FileSystem.createUploadTask(
urlData.upload_url,// Presigned URL
fileUri,// ローカルファイルパス
{
httpMethod: 'PUT',
headers: { 'Content-Type': 'audio/mp4' },
},
({ totalBytesSent, totalBytesExpectedToSend }) => {
// 進捗コールバックonProgress({
progress: totalBytesSent / totalBytesExpectedToSend,
});
}
);
const result = await uploadTask.uploadAsync();
return { audioUrl: urlData.audio_url, noteId };
};
const cancel = () => {
uploadTask?.cancelAsync();// キャンセル可能
};
return { upload, cancel };
}
typescript
ポイント:
createUploadTaskはバックグラウンドでも動作- 進捗をリアルタイムでUI更新(モーダルのプログレスバー)
- キャンセル機能付き
⑤ 音声処理(Edge Function)
ファイル: supabase/functions/process-audio/index.ts
アップロード完了後、クライアントがprocess-audioを呼び出します。
// クライアント側(hooks/use-notes.ts)const startProcessing = async (noteId) => {
await supabase.functions.invoke('process-audio', {
body: { note_id: noteId },
});
};
typescript
Edge Function側は非同期で処理:
serve(async (req) => {
// 1. 認証・プラン制限チェック
// 2. バックグラウンドで処理開始(即座にレスポンスを返す)EdgeRuntime.waitUntil(processNoteAsync(supabase, note, user));
return Response.json({ success: true, message: 'Processing started' });
});
async function processNoteAsync(supabase, note, user) {
try {
// ステップ1: 文字起こし開始await supabase.from('notes').update({ status: 'transcribing' }).eq('id', note.id);
// ステップ2: Deepgram APIで文字起こしconst transcript = await transcribeAudio(note.audio_url);
// ステップ3: 構造化開始await supabase.from('notes').update({ status: 'structuring', transcript }).eq('id', note.id);
// ステップ4: GPT-4o-miniでノート生成const structuredContent = await structureNote(transcript);
// ステップ5: 完了await supabase.from('notes').update({
status: 'completed',
structured_content: structuredContent,
}).eq('id', note.id);
// ステップ6: プッシュ通知await sendPushNotification(user.id, note.id, note.title);
} catch (error) {
await supabase.from('notes').update({
status: 'failed',
error_message: error.message,
}).eq('id', note.id);
}
}
typescript
5.1 Deepgramによる文字起こし
async function transcribeAudio(audioUrl) {
const params = new URLSearchParams({
model: 'nova-2',// 最新モデルlanguage: 'ja',// 日本語punctuate: 'true',// 句読点付与diarize: 'true',// 話者分離paragraphs: 'true',// 段落分けsmart_format: 'true',// スマートフォーマット
});
const response = await fetch(`https://api.deepgram.com/v1/listen?${params}`, {
method: 'POST',
headers: {
'Authorization': `Token ${DEEPGRAM_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: audioUrl }),
});
const result = await response.json();
return result.results.channels[0].alternatives[0].transcript;
}
typescript
5.2 GPT-4o-miniによるノート構造化
async function structureNote(transcript) {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: `あなたは大学の講義ノートを整理する専門家です。
以下の文字起こしテキストを、構造化されたノートに変換してください。
出力形式(Markdown):
# 講義タイトル(推測)
## セクション1
- 重要ポイント
### サブセクション
- 詳細
## 重要ポイントまとめ
⭐ 特に重要な点
ルール:
- 見出しは階層的に整理
- 重要な用語は**太字**
- 具体例は引用形式で
- 専門用語には簡単な説明を追加`,
},
{ role: 'user', content: transcript },
],
max_tokens: 4000,
temperature: 0.3,
}),
});
const result = await response.json();
return result.choices[0].message.content;
}
typescript
5.3 長い講義の分割処理
30分を超える講義は分割して処理し、最後に統合します:
function splitTranscript(transcript, durationSeconds) {
const CHUNK_DURATION = 30 * 60;// 30分if (durationSeconds <= CHUNK_DURATION) {
return [transcript];
}
// 文字数ベースで分割const chunkCount = Math.ceil(durationSeconds / CHUNK_DURATION);
const charsPerChunk = Math.ceil(transcript.length / chunkCount);
// ...
}
async function mergeChunks(chunks) {
// GPT-4o-miniで複数チャンクを統合
}
typescript
⑥ ノート閲覧(クライアント)
ファイル: app/note/[id].tsx
export default function NoteDetailScreen() {
const { id } = useLocalSearchParams();
const [note, setNote] = useState<Note | null>(null);
const loadNote = async () => {
const data = await fetchNote(id);
setNote(data);
};
useEffect(() => {
loadNote();
// 処理中のノートは5秒ごとに自動更新if (note && note.status !== 'completed' && note.status !== 'failed') {
const interval = setInterval(loadNote, 5000);
return () => clearInterval(interval);
}
}, [note?.status]);
return (
<ScrollView>
{/* 処理ステータス表示 */}
<ProcessingStatus status={note.status} />
{/* タブ: ノート / 文字起こし / 音声 */}
<Tabs activeTab={activeTab} />
{/* Markdownレンダリング */}
{note.structured_content && (
<Markdown>{note.structured_content}</Markdown>
)}
</ScrollView>
);
}
typescript
処理ステータスの表示:
const statusConfig = {
uploading: { label: 'アップロード中...', progress: 25 },
pending: { label: '処理待ち...', progress: 10 },
transcribing: { label: '文字起こし中...', progress: 50 },
structuring: { label: 'ノートを構造化中...', progress: 75 },
completed: { label: '完了', progress: 100 },
failed: { label: '処理に失敗しました', progress: 0 },
};
typescript
🗄️ データベーススキーマ
notes テーブル
未対応のブロックタイプ: table
🔐 セキュリティ設計
認証フロー
1. Supabase Auth でJWTトークン発行
2. 全てのEdge Functionリクエストにトークン添付
3. Edge Function内で auth.getUser() で検証
4. RLSポリシーで user_id によるアクセス制御
plain text
R2へのアクセス制御
1. クライアント → Edge Function(JWT認証)
2. Edge Function → R2(Presigned URL発行、有効期限1時間)
3. クライアント → R2(署名付きURLでPUT)
4. Edge Function → R2(サーバーサイドで署名付きURLでGET)
plain text
📊 処理時間の目安
未対応のブロックタイプ: table
🎯 まとめ
このアーキテクチャの特徴:
- 非同期処理: クライアントは即座に操作可能、バックグラウンドで処理
- Presigned URL: セキュアかつ効率的なファイルアップロード
- 段階的ステータス更新: ユーザーに進捗を可視化
- プッシュ通知: 処理完了をリアルタイムに通知
- エラーハンドリング: 失敗時もステータス管理、リトライ可能
📱 App 🌐 Edge Functions ☁️ External Services
│ │ │
│──① 録音───────────────────→│ │
│ │ │
│──② ノート作成──→ Supabase DB │
│ │ │
│──③ URL取得────→ get-upload-url ──→ R2 (Presigned URL) │
│ │ │
│──④ 音声UP─────────────────────────→ R2 │
│ │ │
│──⑤ 処理開始───→ process-audio ────→ Deepgram → OpenAI │
│ │ │
│←─⑥ ポーリング──← Supabase DB │
│ │ │
│←─⑦ プッシュ通知─────────────────← Expo Push │
│ │ │
└──⑧ ノート閲覧 │
plain text
以上が、録音からノート生成までの完全なアーキテクチャです!
このドキュメントで一部のコンテンツが無効になっています
