2644 文字
13 分

会議の録音を ElevenLabs Scribe で自動 transcribe する CLI を Go で作った — vmt

概要#

macOS Voice Memos に溜まった会議録音を ElevenLabs Scribe で一気に文字起こしできる CLI vmt を作りました。

  • 録音 DB (CloudRecordings.db) を直接読み、ファイルを一覧・バッチ transcribe
  • 話者分離 (diarize) 込みで txt / md / json / csv / xml を同時出力
  • bubbletea TUI、cobra CLI、Alfred / Raycast 連携、fsnotify + launchd で録音→自動 transcribe
  • シングルバイナリ (go build)、macOS 専用、MIT License

vmt TUI の録音一覧画面

きっかけ:会議のたびに ElevenLabs に手動アップロードする作業が面倒#

手元の Mac で会議を録音することが多いのですが、録音後のフローが毎回同じでダルい。

  1. Voice Memos を開く
  2. 録音を選んでエクスポート
  3. ElevenLabs の Web UI にアップロード
  4. transcribe が終わったら結果を DL
  5. 次の録音で同じことを繰り返す

会議の文字起こしに限れば、ElevenLabs Scribe の話者分離 (diarize) は現状最強クラスです。日本語でも speaker_0 / speaker_1 をちゃんとグループ化してくれて、他の STT サービスを何個か試した結果、会議用途では ElevenLabs 一択、という結論でした。

それだけ頼りにしているのに、使うたびに手動アップロードしてるのが馬鹿らしい。「録音した瞬間に、ローカルに .md が落ちてる」状態にしたい。これが動機です。

本当にやりたかったこと — AI エージェントから一発で議事録共有#

「ローカルに .md を落とす」のはあくまで第一歩で、本命のゴールは AI エージェント (Claude Code / Raycast AI / MCP 経由の LLM) から自然言語で議事録を共有する ことでした。具体的にはこういうワークフロー:

「最新の Voice Memo の録音を会議参加者に Google Docs で共有しておいて」

と言うだけで、エージェントが:

  1. vmt で最新の録音を transcribe (話者分離込みの .md を生成)
  2. gws (Google Workspace CLI) で Google Docs を新規作成・書き込み
  3. Google Calendar の該当イベントから参加者リストを引く (録音時刻の前後30分に自分が参加者のイベントを探せば特定できる)
  4. そのままイベント参加者全員に Docs を share、リンクを Slack or メールで送信

ここまで全部自動で終わる。人間は「会議が終わったので共有しといて」と言うだけ。参加者リストは Google Calendar が持っているので、共有範囲の判断も自動化できます。

この全体像の中で、入口である Voice Memos → テキスト化のステップだけ「自動化されていないボトルネック」だったのが vmt を作った理由です。CLI として叩ける形で出しておけば、AI エージェント側から見ると「コマンドが 1 個増えただけ」で、あとは LLM の tool use で他の CLI (gws, gh, slack-cli 等) と自由に組み合わせられる。

vmt watch で常駐させて録音→自動 transcribe まで回しておけば、AI エージェントに話しかけた時点で既に .md は存在していて、残タスクは「共有」だけ、というところまで短縮できます。

最初は Python で書いていた#

実は最初は Python で書いていました。requests + click で ElevenLabs API を叩くだけのスクリプト。30行ぐらいで動いたので、しばらくはこれで満足していました。

不満が出てきたのはエコシステム連携のとき:

  • Raycast の Script Command から呼びたい — Python スクリプトだと venv や依存パッケージのロード時間でもっさりする
  • Alfred Workflow のトリガーに繋ぎたい — 依存パッケージ周りの問題をユーザー環境で起こしたくない
  • launchd の Agent として常駐させたい — Python daemon は管理が面倒

「インストールも一発で終わらせたい」「依存関係をゼロにしたい」「どこから呼んでも即レスポンスが欲しい」—— この3点を同時に満たすには シングルバイナリ が必要で、Go に書き直しました。

結果: go build 1発で 8MB の Mach-O が出て、Raycast でも Alfred でも launchd でも起動コスト ~20ms。正解でした。

設計のポイント#

Voice Memos の SQLite DB を直接読む#

Voice Memos は録音メタデータを Core Data の SQLite DB (~/Library/Group Containers/group.com.apple.VoiceMemos.shared/Recordings/CloudRecordings.db) に持っています。テーブルは ZCLOUDRECORDING:

// 主要な列だけ
// Z_PK INTEGER — primary key
// ZENCRYPTEDTITLE TEXT — タイトル(平文、命名が紛らわしい)
// ZPATH TEXT — "20260415 113326-ABCD1234.m4a"
// ZDURATION REAL — 秒
// ZDATE REAL — Core Data timestamp (Unix + 978307200)
// ZEVICTIONDATE REAL — NULL なら生存、値ありなら「Recently Deleted」

modernc.org/sqlite (pure Go、CGo 不要) でアクセス。Core Data タイムスタンプのオフセット 978307200 は 2001-01-01 00:00<00> UTC の Unix 秒。

削除済み録音の扱いだけは罠で、ZEVICTIONDATE が NULL でないものは .m4a が残っていても「削除予定」扱いなので、WHERE ZEVICTIONDATE IS NULL で除外が必須でした。これを入れ忘れると、削除したはずの録音が vmt の一覧にゾンビ表示されます。

Pluggable な Engine interface#

ElevenLabs 固有のロジックを外に追い出せるよう、STT エンジンは interface で抽象化:

type Engine interface {
Name() string
Transcribe(ctx context.Context, audioPath string, opts TranscribeOptions) (*TranscribeResult, error)
EstimateCost(durationSeconds float64) float64
}

将来 Whisper や Google Cloud STT を追加するときは internal/engine/<name>/client.go を書いて registry に Register するだけ。

現状は ElevenLabs Scribe v1 ($0.40/hour) と v2 ($0.22/hour) のみ対応。v2 は新しい分安いのでデフォルト。

マルチフォーマット同時出力#

1回の API 呼び出しから txt / md / json / csv / xml を同時生成:

Meeting.txt:
[00:15] speaker_0: こんにちは
[01:23] speaker_1: どうも
Meeting.md:
- **00:15** speaker_0: こんにちは
- **01:23** speaker_1: どうも
Meeting.json:
{ "segments": [...], "engine": "elevenlabs", ... }

どれか1個あれば他を使わない人もいるし、人間用 (md) と機械用 (json) の両方欲しい人もいる。実装コストは低いので全部出す方針にしました。

TUI (bubbletea)#

vmt tui で対話的にアクセスできます。画面は 5つ:

  • list — 録音一覧。 マーク付きで transcribe 済みかどうか一目で分かる
  • confirm — 選択した録音の料金見積りと y/n 確認
  • progress — spinner + 経過秒
  • preview — transcribe 結果を表示、c キーで pbcopy、←/→ でフォーマット切替
  • settings — エンジン・モデル・言語の確認
  • quit confirmq / Ctrl+C で「Quit vmt? [y/n]」

bubbletea は状態遷移が綺麗に書けて気持ちよかったです。画面遷移は message-passing なので、Update() に集約して managing の headache が少ない。

エコシステム連携#

Alfred Script Filter#

$ vmt alfred [query]
{"items":[{"uid":"...","title":"会議 4/22","subtitle":"2026-04-22 14:30 (1h06m)","arg":"20260422_143000.m4a","icon":{"path":"icons/transcribed.png"}, ...}]}

Alfred Workflow から /usr/local/bin/vmt alfred {query} を Script Filter として呼ぶと、録音一覧がキーワード検索可能な形で返ります。Enter で transcribe、Cmd+Enter で preview。

Raycast Script Commands#

Raycast ユーザー向けに 5 つの Script Command を同梱 (raycast/ ディレクトリ):

Command機能
Transcribe All Pending未 transcribe な録音をバッチ処理
Copy Latest Transcription最新の .txt をクリップボードへ
List Voice Memos Recordings録音一覧を表示
Open Voice Memos TUITerminal.app で vmt tui 起動
Toggle Watch Agentlaunchd watch agent の install / uninstall

Raycast → Settings → Extensions → Script Commands → Add Directory で raycast/ を指定するだけで使えます。

vmt watch + launchd#

これが一番の目玉機能。fsnotify~/Library/Group Containers/group.com.apple.VoiceMemos.shared/Recordings/ を監視して、新しい .m4a が出現したら 2秒 debounce (Voice Memos は incremental write) 後に自動 transcribe → macOS 通知で知らせる。

Terminal window
$ vmt watch --install
Installed: /Users/matsu/Library/LaunchAgents/com.matsubo.vmt.watch.plist
vmt watch will start automatically on login.

Mac 起動時に自動で走り、会議が終わって録音を止めた数分後には ~/Downloads/voice-memo-transcription/.md.json が落ちてる、という状態。これ最高に便利です。

ハマりどころ#

ZEVICTIONDATE 列の存在#

上に書いた通り、Voice Memos の「削除」はソフトデリート。実体 .m4a は残り、DB 行には削除予定時刻がセットされるだけ。初回実装では WHERE ZEVICTIONDATE IS NULL を入れ忘れていて、削除したはずの録音が一覧に出続けるバグがありました。

テストで ZEVICTIONDATE 列を持つ fixture を用意して、削除行が List 結果に含まれないことを確認しています。

DB が一時的に消える現象#

ある日 vmt list を動かしたら no Voice Memos recordings found が出たので調べると、CloudRecordings.db 本体は消えていて .db-shm.db-wal だけ残っている状態。macOS Voice Memos が DB 再構築中だったようです。.m4a は普通に残っている。

仕方ないので、DB がなくても .m4a をスキャンしてファイル名から日時をパース (20220215 184033-HASH.m4a 形式) するフォールバックを実装しました。duration は不明になるのと、ユーザー命名タイトルは失われますが、少なくとも「録音があるのに表示されない」状態は避けられます。

golangci-lint が Go 1.25 に対応していない#

CI で golangci-lint を回していたら the Go language version (go1.24) used to build golangci-lint is lower than the targeted Go version (1.25.0) で失敗。golangci-lint v1.64.8 が Go 1.24 ビルドで、go.modgo 1.25.0 を解析できない。

深追いせず、CI の lint ステップは go vet ./... に置き換えました。go vet は stdlib 同梱なので Go バージョン問題は起きません。

開発プロセス — Claude Code で計画→実装→レビューを自律実行#

余談として開発手法も残しておきます。このプロジェクトは実装の大部分を Claude Codesuperpowers プラグイン で進めました。ざっくり:

  1. superpowers:writing-plans で仕様から詳細な実装プランを生成 (12 タスク、各タスクに TDD ステップと完全なコード例が入る)
  2. superpowers:subagent-driven-development でタスクごとに subagent を dispatch → 実装 → 自動で spec compliance review と code quality review の2段階でチェック
  3. レビューで指摘された issue はそのまま次のラウンドで fix に回される

手動で書いたのは、設計判断と方向性の修正 (ZEVICTIONDATE に気づいた、filesystem scan fallback を入れる、etc) が中心で、実装コードのほとんどは subagent が書きました。22 コミットできたものを最終的に sekkei / jissou / test の 3 コミットに squash して main に force push、v0.0.1 としてリリース。

全体を通して「人間は要件と設計に集中、実装は委譲、レビューで品質担保」という流れが綺麗に回りました。もちろん Voice Memos の SQLite スキーマみたいなドメイン知識は subagent に事前に仕込んで渡す必要があったり、CI 失敗のデバッグは自分でやる必要があったりはしますが。

リポジトリ#

ソース: github.com/matsubo/voice-memo-stt

Terminal window
git clone https://github.com/matsubo/voice-memo-stt.git
cd voice-memo-stt
make build
sudo cp bin/vmt /usr/local/bin/
export ELEVENLABS_API_KEY=sk-xxxxx
vmt tui

MIT License、macOS 専用。ElevenLabs API key が必要です。

Homebrew tap は未公開 (brew install matsubo/tap/vmt は今後)。Issue / PR 歓迎します。

この記事が役に立ったら
GitHub Sponsorsで応援できます

コメント