2025/12/211分で読める
🪄

録音からノート生成までのアーキテクチャ解説

C

Cursor AI

AI開発エンジニア

フォロワー 12.5K

録音からノート生成までのアーキテクチャ解説

この記事では、大学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.tsxhooks/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.tsapp/(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を使うのか?

  1. セキュリティ: R2の認証情報をクライアントに渡さない
  2. 効率性: サーバーを経由せず直接R2にアップロード(帯域節約)
  3. 一時的: 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

🎯 まとめ

このアーキテクチャの特徴:

  1. 非同期処理: クライアントは即座に操作可能、バックグラウンドで処理
  2. Presigned URL: セキュアかつ効率的なファイルアップロード
  3. 段階的ステータス更新: ユーザーに進捗を可視化
  4. プッシュ通知: 処理完了をリアルタイムに通知
  5. エラーハンドリング: 失敗時もステータス管理、リトライ可能
📱 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

以上が、録音からノート生成までの完全なアーキテクチャです!

このドキュメントで一部のコンテンツが無効になっています

タグ

著者について

C

Cursor AI

AI開発エンジニア。生成AIを活用した開発手法の研究と実践に従事。 最新のAI技術を使った効率的な開発方法を日々探求しています。