はじめに
会議の文字起こしはクラウドの文字起こしサービスを使えば一瞬です。ただ、業務の会議には外部に出したくない情報が普通に含まれます。「便利だけど、この音声をどこかのAPIに送るのはちょっと…」という理由で導入が止まる、というのはよくある話だと思います。
そこで、音声を一切外部に送らず、Mac上だけで完結する文字起こしパイプラインを組みました。最終的には次のところまで自動化しています。
- 会議の録音ファイル(mp3 / m4a / wav / ogg など)を
- ローカルで日本語文字起こしし(faster-whisper / mlx-whisper)
- 「誰が話したか」を付け(pyannote.audio による話者分離)
- ローカルLLM(Ollama)で要約し
- 録音を集約しているNotionページに、要約と全文を自動で追記する
文字起こし・話者分離・要約のすべてがローカル実行で、外部通信はモデルの初回ダウンロードを除けば発生しません(要約も自分のMacのLLMで回します)。
動作環境
- macOS(Apple Silicon。今回は Mac mini M4 Pro / 64GB で運用)
- Python 3.12(後述しますが、GUIのドラッグ&ドロップ都合で3.13より3.12を推奨)
- ffmpeg
- 文字起こし: faster-whisper(CPU)または mlx-whisper(Apple GPU)
- 話者分離: pyannote.audio
- 要約: Ollama + ローカルLLM
全体構成
[録音ファイル]
│ ffmpegで 16kHz/モノラル/wav に正規化
▼
[faster-whisper / mlx-whisper] ── 文字起こし(日本語)
│
▼
[pyannote.audio] ── 話者分離(SPEAKER_00 / 01 …を各セグメントに割当)
│
├─▶ .txt / .srt / .vtt / .json を出力
│
▼
[Ollama(ローカルLLM)] ── 要約
│
▼
[Notion API] ── 要約+全文を元ページに追記
CLI、GUI(ドラッグ&ドロップ)、Notion自動監視(launchdで1時間おき)の3つの入り口を用意しました。
技術選定の理由
文字起こし: faster-whisper と mlx-whisper の二段構え
OpenAIのWhisperをそのまま使うより、推論を最適化した実装を使う方が速いです。代表的な選択肢が2つあります。
- faster-whisper(CTranslate2バックエンド): CPUで動き、どのMacでも動作する。ただしCTranslate2はApple GPU(Metal)に非対応で、Apple SiliconでもCPU実行になります。
- mlx-whisper(AppleのMLXフレームワーク): Apple SiliconのGPUを使えるので、M系チップでは明確に速い。
同じlarge-v3を使う限り精度は同等(同じ重みを動かすだけ)なので、差は速度と対応環境だけです。そこで「全Mac対応のfaster-whisperを既定にしつつ、Apple Siliconならmlxに切り替えられる」二段構えにしました。
話者分離: pyannote.audio
「誰が話したか」を付けないと、複数人の会議の議事録としては使いにくいです。pyannote.audioはローカルで話者分離(diarization)ができる定番ライブラリです。推論はローカルですが、モデルがゲート付きで初回だけHugging Faceの認証が要ります(後述)。
要約: ローカルLLM(Ollama)
要約だけクラウドAPIに投げてしまうと「音声は守ったのにテキストは外に出た」になって本末転倒です。要約もローカルで完結させるため、Ollamaでローカルモデルを使います。64GBのマシンなら、MoE系の30Bクラス(アクティブ3B)が速度と日本語品質のバランスで実用的でした。
セットアップ
1. ffmpegとPython
# Homebrew
brew install ffmpeg
brew install python@3.12 python-tk@3.12 # python-tkはGUI(Tkinter)用
# 仮想環境
cd ~/transcribe-local
python3.12 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
requirements.txt:
faster-whisper>=1.0.0
pyannote.audio>=3.1
tkinterdnd2>=0.4.0
requests>=2.31
# Apple SiliconでGPUを使う場合のみ:
# mlx-whisper
pip install -r requirements.txt
pip install mlx-whisper # Apple Siliconなら
2. 話者分離モデルの利用条件に同意(初回のみ)
pyannoteのモデルはゲート付きです。推論自体はローカルですが、初回ダウンロードにHugging Faceのトークンと利用条件への同意が必要です。以下のページで「Agree and access repository」を押します。
pyannote/speaker-diarization-3.1pyannote/segmentation-3.0-
pyannote/speaker-diarization-community-1(pyannote.audio 4.x系で必要)
トークンは hf auth login で保存します。
hf auth login --token "your_hf_token"
トークンは ~/.cache/huggingface/ に保存され、以後はライブラリが自動で読み込みます(環境変数の永続化は不要)。
補足: 古い
huggingface-cli loginは非推奨で huggingface_hub v1.0 で削除済み。現行コマンドはhf auth loginです。一度ダウンロードされれば以降はオフラインで動きます。
3. Ollama(要約用LLM)
brew install ollama
brew services start ollama
ollama pull qwen3:30b-a3b-instruct-2507-q4_K_M # 例。環境に合うモデルを選ぶ
要約用途では「思考トークンを出さないinstruct系」の方がレイテンシが小さくて扱いやすいです。
実装
前処理: ffmpegで正規化
Whisper系は 16kHz・モノラル・PCM wav を入力にするのが無難です。どんな入力でもまずこれに揃えます。
import subprocess
from pathlib import Path
def convert_to_wav(src: Path, dst: Path) -> None:
"""ffmpegで 16kHz・モノラル・16bit PCM wav に変換する。"""
cmd = [
"ffmpeg", "-y",
"-i", str(src),
"-vn", # 映像トラックを無視(mp4等の場合)
"-ac", "1", # モノラル
"-ar", "16000", # 16kHz
"-c:a", "pcm_s16le", # 16bit PCM
str(dst),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
tail = "\n".join(result.stderr.splitlines()[-10:])
raise RuntimeError(f"ffmpeg変換に失敗: {src}\n{tail}")
文字起こし(faster-whisper)
def load_model(model_name: str, device: str, compute_type: str):
from faster_whisper import WhisperModel
return WhisperModel(model_name, device=device, compute_type=compute_type)
def transcribe_file(model, audio_path, language, initial_prompt, progress_callback=None):
segments_iter, info = model.transcribe(
str(audio_path),
language=language, # "ja"
initial_prompt=initial_prompt, # 固有名詞補正(後述)
vad_filter=True, # 無音区間をスキップ(精度・速度に効く)
vad_parameters={"min_silence_duration_ms": 500},
beam_size=5,
)
total = info.duration or 0.0
results = []
for seg in segments_iter: # ここで実際に推論が走る(ジェネレータ)
results.append({
"id": len(results) + 1,
"start": round(seg.start, 3),
"end": round(seg.end, 3),
"text": seg.text.strip(),
})
if progress_callback:
progress_callback(seg.end, total)
return results, total
Apple SiliconでのCPU設定は device="cpu", compute_type="int8" が現実的な最適解です。int8量子化でメモリと速度が改善し、精度低下はごくわずかです。
文字起こし(mlx-whisper / Apple GPU)
mlx版は同じ形式のセグメントを返すように包んでおくと、後段(出力・話者分離)を共通化できます。
MLX_MODEL_REPOS = {
"small": "mlx-community/whisper-small-mlx",
"medium": "mlx-community/whisper-medium-mlx",
"large-v3": "mlx-community/whisper-large-v3-mlx",
}
def transcribe_file_mlx(audio_path, model_name, language, initial_prompt, verbose=False):
import mlx_whisper
result = mlx_whisper.transcribe(
str(audio_path),
path_or_hf_repo=MLX_MODEL_REPOS[model_name],
language=language,
initial_prompt=initial_prompt,
verbose=verbose or None,
)
segments = [
{"id": i + 1, "start": round(float(s["start"]), 3),
"end": round(float(s["end"]), 3), "text": s["text"].strip()}
for i, s in enumerate(result.get("segments", []))
]
duration = segments[-1]["end"] if segments else 0.0
return segments, duration
注意: mlx-whisperは結果を一括で返すため、faster-whisperのような逐次の進捗%は出せません。
verbose=Trueにすると認識結果が逐次表示されるので、それを進捗代わりにしています。
話者分離(pyannote.audio)
ここはバージョン差でハマりやすいので、後述の「ハマりどころ」と合わせて読んでください。要点だけ先に。
def load_diarization_pipeline(hf_token):
from pyannote.audio import Pipeline
model_id = "pyannote/speaker-diarization-3.1"
try:
pipeline = Pipeline.from_pretrained(model_id, token=hf_token) # 4.x系
except TypeError:
pipeline = Pipeline.from_pretrained(model_id, use_auth_token=hf_token) # 3.x系
return pipeline
def run_diarization(pipeline, wav_path, num_speakers=None):
kwargs = {"num_speakers": num_speakers} if num_speakers else {}
result = pipeline(str(wav_path), **kwargs)
# 4.x系は DiarizeOutput を返し、本体は .speaker_diarization に入る
diarization = getattr(result, "speaker_diarization", result)
return [(t.start, t.end, spk)
for t, _, spk in diarization.itertracks(yield_label=True)]
文字起こしの各セグメントに、時間的に最も重なる話者を割り当てます。
def assign_speakers(segments, turns):
for seg in segments:
start, end = seg["start"], seg["end"]
overlaps = {}
for t_start, t_end, speaker in turns:
ov = min(end, t_end) - max(start, t_start) # 重なり秒
if ov > 0:
overlaps[speaker] = overlaps.get(speaker, 0.0) + ov
if overlaps:
seg["speaker"] = max(overlaps, key=overlaps.get)
elif turns: # 重なりゼロなら中心時刻が最も近い区間を採用
center = (start + end) / 2
nearest = min(turns, key=lambda t: min(abs(center - t[0]), abs(center - t[1])))
seg["speaker"] = nearest[2]
else:
seg["speaker"] = None
return segments
固有名詞辞書で誤変換を減らす
社名・人名・製品名・専門用語は誤変換されがちです。Whisperの initial_prompt にこれらを渡すと改善します。毎回手で打つのは面倒なので、固有名詞.txt(1行1語)を置いて自動で読み込むようにしました。
from pathlib import Path
GLOSSARY_FILE = Path(__file__).resolve().parent / "固有名詞.txt"
def load_glossary():
if not GLOSSARY_FILE.exists():
return None
terms = []
for line in GLOSSARY_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
terms.append(line)
return "、".join(terms) if terms else None
これだけで「専門用語がカタカナの当て字になる」系のミスがかなり減ります。
出力(txt / srt / vtt / json)
用途に合わせて4形式を出します。話者ラベルがあれば各行頭に付けます。
def speaker_prefix(seg):
return f"[{seg['speaker']}] " if seg.get("speaker") else ""
def write_srt(segments, path):
with open(path, "w", encoding="utf-8") as f:
for i, seg in enumerate(segments, 1):
f.write(f"{i}\n{ts(seg['start'])} --> {ts(seg['end'])}\n")
f.write(f"{speaker_prefix(seg)}{seg['text']}\n\n")
失敗してもやり直せるように(中間保存とresume)
最初に痛い目を見たのがここです。**「文字起こしは成功したのに、後段の話者分離でエラー → 全部やり直し」**を一度やると、25分の音声でも地味に堪えます。
対策として「文字起こしが終わった時点でまず保存 → 話者分離 → 成功したら話者付きで上書き」という順序にしました。話者分離で落ちても文字起こし結果は残り、保存済みJSONを再利用して話者分離だけやり直せます。
def try_load_existing_segments(json_path, src):
"""既存JSONがあり、かつ同じ音声由来なら文字起こし結果を再利用する。"""
if not json_path.exists():
return None
data = json.loads(json_path.read_text(encoding="utf-8"))
if data.get("source") != str(src) or not data.get("segments"):
return None
return data["segments"], float(data.get("duration_sec", 0.0))
長時間処理を組むときは「重い処理の直後に中間保存」を入れておくと、後でとても助かります。
GUI: ドラッグ&ドロップで開始、クリックでコピー
CLIだけだと日常使いに面倒なので、Tkinterで簡単なGUIも用意しました。ファイルをドロップすると自動で処理が始まり、完了した項目をクリックすると全文がクリップボードにコピーされます。
Tkinterはスレッドセーフではないので、重い処理は別スレッドに逃がしつつ、GUIウィジェットの値(モデル選択など)はメインスレッドで読んでからワーカーに渡すのがポイントです。これを怠ると main thread is not in main loop で落ちます。
# ❌ ワーカースレッド内で self.model_var.get() を呼ぶと落ちる
# ⭕ メインスレッドで読み取ってdictで渡す
opts = {
"model": self.model_var.get(),
"engine": self.engine_var.get(),
"diarize": self.diarize_var.get(),
}
threading.Thread(target=self._worker, args=(opts,), daemon=True).start()
Notion監視 → 文字起こし → 要約 → 追記
最後に自動化です。録音デバイスのコンパニオンアプリが、録音をNotionの特定ページ配下にサブページとして自動アップロードしてくれるので、それを監視します。1時間おきに新着サブページを探し、未処理ならダウンロードして処理、結果をそのページに追記します。
要約はOllamaのローカルAPIを叩きます。
import requests
def summarize(transcript, model, ollama_url="http://localhost:11434"):
prompt = f"""あなたは優秀な議事録作成者です。以下の文字起こしを日本語で要約してください。
【概要】2〜3文 / 【決定事項】箇条書き / 【TODO】誰が何を / 【主な論点】箇条書き
文字起こしは音声認識由来で誤認識を含みます。文脈から自然に補ってください。
--- 文字起こし ---
{transcript}
"""
r = requests.post(f"{ollama_url}/api/chat", json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"temperature": 0.3},
}, timeout=600)
r.raise_for_status()
content = r.json()["message"]["content"].strip()
# 思考モデル対策: <think>...</think> が付く場合は除去
if "</think>" in content:
content = content.split("</think>", 1)[1].strip()
return content
二重処理を防ぐため、処理済みページIDを状態ファイルに記録しつつ、Notionページ側にも「要約(自動生成)」見出しがあるかをチェックしています(状態ファイルを消しても二重投稿されないように)。
Notion APIには「1リクエストにつき子ブロック100件まで」「1テキスト2000字まで」という制限があるので、長文は分割して送ります。
def chunk_text(text, size=1800):
chunks, buf = [], ""
for line in text.splitlines(keepends=True):
if len(buf) + len(line) > size:
if buf:
chunks.append(buf); buf = ""
while len(line) > size: # 1行がsize超なら強制分割
chunks.append(line[:size]); line = line[size:]
buf += line
if buf:
chunks.append(buf)
return chunks
定期実行(launchd)
cronでもいいですが、macOSなのでlaunchdを使いました。~/Library/LaunchAgents/ にplistを置いて StartInterval を3600にするだけです。
<key>StartInterval</key>
<integer>3600</integer>
<key>RunAtLoad</key>
<true/>
cp com.example.notion-watch.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.example.notion-watch.plist
注意: launchdはMacがスリープ中は実行されません。常時動かすなら「システム設定 → エネルギー」でスリープしない設定にしておきます。
ハマりどころ(ここが本記事の本体かもしれない)
1. Apple SiliconでGPUを使いたいなら faster-whisper ではなく mlx
faster-whisperのバックエンドCTranslate2はMetal非対応です。「Apple Siliconだから速いはず」と思っても、faster-whisperではCPU実行になります。GPUを使いたいなら mlx-whisper(または whisper.cpp)に切り替える必要があります。精度は同じ重みなので変わりません。
2. Python 3.13 + tkinterdnd2 でドラッグ&ドロップが死ぬ
Python 3.13系はTcl/Tk 9.0にリンクされており、tkinterdnd2同梱のtkdnd(Tcl 8.6向けバイナリ)が
RuntimeError: Unable to load tkdnd library
(interpreter uses an incompatible stubs mechanism)
で読み込めません。回避策は、
- D&Dを諦めてファイル選択ボタンにフォールバックする(コード側で握りつぶす)
- もしくは Tcl/Tk 8.6系のPython(3.12など)で環境を作り直す
GUIを使うなら素直に3.12を選んだ方が楽でした。
3. pyannote.audio 4.x系のAPI変更
3.x系の記事を見ながら書くと、4.x系で2か所詰まります。
-
Pipeline.from_pretrained(..., use_auth_token=...)→ 4.xはtoken=... -
pipeline(wav)の戻り値がAnnotationではなくDiarizeOutputになり、.itertracks()が無い。本体は.speaker_diarization属性に入っている。 - 4.x系は追加で
pyannote/speaker-diarization-community-1のゲート同意も必要。
前掲のコードのように、両バージョンに対応するフォールバックを書いておくと安定します。
4. メモリと量子化
large-v3はint8でも数GB、話者分離も同時に走らせるとさらに積み増しになります。メモリが厳しいマシンでは、文字起こしと話者分離を分けて実行する、モデルをmedium/smallに落とす、といった調整が要ります。要約用ローカルLLMも含めて、同時に何を載せるかでメモリ設計が変わります。
5. 録音の音質が結局いちばん効く
身も蓋もないですが、文字起こし精度は録音の音質で大きく変わります。同じ会議を別々のデバイスで録ったものを比較したところ、不明瞭な録音では同じ文を何度も繰り返す(いわゆるハルシネーション)が顕著に増えました。マイクを話者の近くに置く、音量を確保する、といった基本がモデル選定より効くことは多いです。
まとめ
- 文字起こし・話者分離・要約をすべてローカルで完結させれば、機密の会議音声を外部に出さずに議事録化できます。
- Apple SiliconでGPUを活かすなら faster-whisper ではなく mlx-whisper。精度は同じで速度だけ変わります。
- pyannote.audioはバージョン差(特に4.x)でAPIが変わるので、フォールバックを書いておくと安定します。
- 長時間処理は「重い処理の直後に中間保存」を入れておくと、後段の失敗で全部やり直す悲劇を防げます。
- 自動化はNotion API + ローカルLLM + launchdで、録音アップロードから議事録投稿まで人手ゼロにできます。
ローカル完結なので、外に出せない音声でも安心して文字起こしできるのが一番のメリットでした。同じ悩みを持っている人の参考になれば。