2026-07-14
議事録アプリを1日で自作した話
はじめに
オンライン会議のたびに議事録を手書きするのが面倒で、Notta や Notion AI のようなミーティングノートサービスも検討しました。ただ、個人で使うには料金プランが割高な上に、文字起こし時間や録音回数に制限があるものが多く、自分の使い方には合いませんでした。それなら同じ体験を自分専用に作ってしまおうと考えました。要件は次の2つです。
- 文字起こしは無料・ローカル完結
- 要約だけ Claude API に投げて、Markdown と Google Docs に議事録として残す
Claude Code に設計書と実装計画を書かせてから実装させる、という普段のやり方で進めたところ、録音・文字起こし・要約までひと通り動くところまで、ほぼ1日で到達しました。この記事ではその中身を振り返ります。
コスパを比較してみる
そもそもの動機が既存サービスは個人用途には割高・制限ありだったので、実際どれくらい差があるのか比較してみます。月20回・1時間ずつ会議がある想定です。
| サービス | 料金 | 月20会議(20時間)での実質コスト | 1時間あたり |
|---|---|---|---|
| Notta プレミアム(年払い) | 月額約1,185円 | 定額1,185円(文字起こし月1,800分までの範囲内) | 約40円(30時間枠を使い切った場合) |
| Notion AI | Business以上が必須(年払いで月額3,150円〜) | 定額3,150円〜 | 約158円(月20時間利用の場合) |
| 自作パイプライン | 文字起こしは無料(ローカル)、要約のみClaude API従量課金 | 約180〜260円 | 9〜13円(実測) |
文字起こしをローカルの whisper.cpp で無料にし、コストがかかる要約だけを Claude API に従量課金で任せたことで、月20会議でも数百円以内に収まっています。NottaやNotion AIは月額固定なので使わない月でも同額かかりますが、自作なら使った分しか払いません。個人用途でたまにしか会議がない月ほど差が開く計算です。
全体像
最終的な構成はこうなります。
録音アプリ(meeting-recorder)と議事録パイプライン(minutes-pipeline)は、ファイルシステム越しに疎結合にしています。まず Python パイプライン単体を、手動で inbox/ に置いた音声ファイルで動かせる状態にし、それができてから Swift の録音アプリを作って自動化する、という順番で進めました。録音側の不具合が議事録処理に波及しない(逆も同様)というメリットもあります。
進め方
まず設計書を書き、それを実装計画(タスク分割)に落とし込んでから、Claude Code に実装させました。
- 設計書 → 実装計画の作成
- scaffold → 文字起こし → マージ → 要約 → Markdown生成 → オーケストレーター → CLI → Google Docs連携 → 通知/監視デーモンの実装
- 実際に自分の声を録音してパイプラインに通し、見つかった不具合の修正、README整備
設計書と実装計画をタスク単位に細かく切ってあったので、Claude Code が迷わず順番に実装を進められたのが大きかったと思います。実装から動作確認・バグ修正・READMEまで含めた実質的な作業時間は、ほぼ1日分に収まりました。
実装の中身
パッケージ全体は11ファイル・556行というコンパクトな構成です。
pipeline/src/minutes_pipeline/
├── config.py # config.toml読み込み
├── transcribe.py # whisper-cli呼び出し
├── merge.py # 自分/相手の対話マージ
├── summarize.py # Claude構造化出力による要約
├── render.py # Markdown組み立て
├── process.py # 冪等なオーケストレーター
├── cli.py # minutes process/watch コマンド
├── gdocs.py # Google Docsアップロード
├── notify.py # macOS通知
└── watch.py # inbox監視デーモン
話者マージはタイムスタンプの単純な突き合わせ
相手の個人識別(話者分離)まではやらず、「自分」「相手」の2区分に割り切りました。マイク音声とシステム音声をそれぞれ whisper.cpp にかけ、セグメントをタイムスタンプ順に並べ直すだけです。
def merge(mine: list[Segment], theirs: list[Segment]) -> list[Utterance]:
tagged = [("自分", s) for s in mine] + [("相手", s) for s in theirs]
tagged.sort(key=lambda t: t[1].start)
result: list[Utterance] = []
for speaker, seg in tagged:
if result and result[-1].speaker == speaker:
result[-1].text += " " + seg.text
else:
result.append(Utterance(speaker=speaker, start=seg.start, text=seg.text))
return result
pyannote などでの話者分離も検討しましたが、個人用途では自分か相手かがわかれば十分なので設計段階でスコープ外にしました。
要約は Claude の構造化出力に任せる
要約・決定事項・TODO・詳細をパースするコードを自前で書きたくなかったので、Anthropic SDK の messages.parse + Pydantic モデルにそのまま構造化出力させています。
class MeetingSummary(BaseModel):
title: str
summary: str
decisions: list[str]
todos: list[Todo]
details: str
response = client.messages.parse(
model=model,
max_tokens=16000,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": f"...\n\n{dialogue}"}],
output_format=MeetingSummary,
)
output_format に Pydantic モデルを渡すと、返答テキストをパースするのではなく、モデル側の出力生成そのものが指定したスキーマに沿うよう制約されます。返ってくる response.parsed_output は最初から検証済みの MeetingSummary インスタンスです。詳しくは公式ドキュメントStructured outputsを参照してください。
システムプロンプトにはTODOの担当・期限は読み取れる場合のみ設定し、推測で埋めない、決定事項は確定したものだけといった、ハルシネーション対策の指示を入れています。
各ステップを冪等にして失敗に強くする
会議中はまとまった時間録音するので、処理の途中で失敗しても録音をやり直させたくありません。process.py では state.json に各ステップの完了状態を書き込みながら進め、例外発生時は failed/ ディレクトリに移動して、そこから再実行できるようにしています。
if not state["transcript_done"]:
...
state["transcript_done"] = True
_save_state(meeting_dir, state)
...
except Exception as e:
state["error"] = f"{type(e).__name__}: {e}"
_save_state(meeting_dir, state)
_move(meeting_dir, config.base_dir / "failed")
raise
文字起こし済みなのに要約だけ失敗した場合、failed/ から再実行すれば文字起こしをやり直さずに要約からリトライされます。
meeting-recorder(Swift)で録音を自動化する
Python パイプラインの動作を手動の音声ファイルで確認できたら、次は録音そのものを自動化するメニューバー常駐アプリを作りました。
会議の検知はマイクの他プロセス使用中を見る
Zoom や Google Meet を専用に検知するAPIがあるわけではないので、マイクが自分以外のプロセスに使われ始めたことを CoreAudio のプロセス単位API(kAudioHardwarePropertyProcessObjectList / kAudioProcessPropertyIsRunningInput)で検知します。検知したら「会議を検知しました」という通知を出し、クリックで録音開始。誤検知(ボイスメモや音声入力など)を避けるため、検知しただけでは自動録音しません。
static func isMicInUseByOtherProcesses() -> Bool {
// ... プロセス一覧を取得 ...
for process in processes {
guard pidStatus == noErr, pid != ownPID else { continue }
if runningStatus == noErr, running != 0 {
return true
}
}
return false
}
システム音声は Core Audio Process Tap で丸ごと録る
相手の声(システム全体の音声)を録音する方法として、最初は ScreenCaptureKit を検討していました。ただこれは画面収録の権限が必要で、画面を録るわけでもないのに毎回その許可を求めるのは利用者として気持ちよくありません。そこで、音声だけを取り出せる Core Audio Process Tap API(AudioHardwareCreateProcessTap、macOS 14.4以降)に変更しました。こちらはシステムオーディオ録音という専用の軽い権限だけで済みます。
やっていることは単純で、対象アプリを絞らないグローバルタップを作ると、Zoomでも Meet でも問わずMac全体のシステム音声(=相手の声を含む)を1本の音声ストリームとして受け取れます。自分の声は別途 AVAudioEngine でマイクから録音し、最終的に2つの音声ファイルを、それぞれ whisper.cpp が扱いやすい16kHz・モノラルのWAVに変換します。
「タップ」は、あるプロセスが再生しようとしている音声を横取りして受け取る仕組みです。Zoomなど特定のアプリの音声だけを狙うことも、除外対象を指定しないことでMac上の全プロセスの音声をまとめて受け取ることもできます。今回は後者の「グローバルタップ」を使うことで、会議アプリの種類を問わず相手の声を拾えるようにしています(コード上は CATapDescription(stereoGlobalTapButExcludeProcesses: []) のように、除外プロセスを空にして作成します)。
録音終了もポーリングで自動化する
録音開始後は、マイクの使用状況を一定間隔でポーリングし、他プロセスがマイクを使わなくなったら自動的に録音を終了します(AutoStopMonitor)。当初はデバイスレベルの通知だけで判定しようとしましたが、自分が録音中はそのイベントが発火しないため、ポーリング方式に変更しました。
終了時は mic.wav / system.wav / meta.json を保存先フォルダ(デフォルトは ~/Meetings)配下の inbox/<日時>/ へ書き出します。保存先はSettings画面から変更可能です。meta.json を最後に書くことで、Python パイプライン側のファイルが揃ったという合図として使っています。
実際に動かしてみて
手動で mic.wav / system.wav / meta.json を inbox/ に置いて minutes process を実行すると、数分待つだけで notes/ に議事録 Markdown が生成されます。録音アプリが完成したあとは、会議を検知した通知をクリックするだけでここまで自動で進みます。
~/Meetings/notes/2026-07-07_定例MTG.md
サマリ・決定事項・TODO・全文文字起こしが1ファイルにまとまっていて、想像していた通りの体験になりました。
その後も配布用の dmg パッケージ化や議事録一覧の GUI など、使いながら気になった部分を作り込んでいますが、コアの録音→文字起こし→要約→保存という一連の流れは、この1日でひと通り動く形になりました。
まとめ
「SaaSは終わった」という言説をここ最近よく見かけますが、今回それを実感する体験になりました。Notta や Notion AI のようなSaaSは、開発コストを不特定多数のユーザーで割り勘して月額課金にすることで成り立っています。裏を返せば、自分1人分の要件だけに絞ってAIエージェントに実装させられるなら、その割り勘分のコストは丸ごと不要になるということです。今回でいえば、話者分離もリアルタイム表示もいらない、自分と相手の2区分でMarkdownに残せれば十分——そこまで要件を絞れたからこそ、Claude Code にほぼ1日で作らせきれました。
もちろん保守やエッジケース対応は自分の責任になりますし、誰かに配って使ってもらうならまた話は別です。それでも、個人が自分専用に使う道具である限り、欲しい機能だけを最短距離で持つソフトウェアを自分で作る方が、汎用SaaSに合わせて使い方を妥協するより速くて安いという場面は、今後もっと増えていくのだろうと感じています。