はじめに
こんにちは、開発部のCLです。
音声から文字起こしをするツールはたくさんありますが、
- オフライン環境だと利用できない
- 有料である
- クラウドに音声データを送信することに抵抗がある
など課題があります。
そこで本記事では、Macのローカル環境のみで完結する無料の文字起こし環境を、whisper.cpp を用いて構築する方法を紹介します。
本記事のゴール
- 完全無料・ローカル完結(音声データを外部に送信しない)
- 高精度(OpenAIのWhisper large-v3モデルを使用)
- 長時間音声にも対応(1〜2時間の会議音声でも安定動作)
- 無音区間でのハルシネーション(幻聴)対策を実装
なぜ whisper.cpp を選ぶのか
文字起こしにはOpenAIの Whisper が広く知られていますが、本家はPythonおよびPyTorch環境を必要とし、セットアップがやや煩雑です。
そこで採用したのが whisper.cpp です。
whisper.cppのメリット
- C/C++実装で軽量・高速:Python環境が不要で、Apple Silicon(M1以降)に最適化されている
- 完全ローカル動作:ネットワーク接続が不要で、音声データが外部に出ないためプライバシー面で安心
- 無料:モデル・ツールともにすべて無料
- large-v3に対応:精度面でクラウドサービスにも引けを取らない
セットアップ
1. クローンおよびビルド
cd ~/Documents
git clone https://github.com/ggerganov/whisper.cpp
cd whisper.cpp
# ビルドに必要なcmakeと、音声変換用のffmpegをインストール
brew install cmake ffmpeg
# ビルド
cmake -B build
cmake --build build --config Release
2. モデルのダウンロード
# 文字起こし用モデル(large-v3:高精度)
./models/download-ggml-model.sh large-v3
# VAD(無音検出)用モデル ※後述のハルシネーション対策で使用します
./models/download-vad-model.sh silero-v6.2.0
モデルの種類について
whisper.cppでは、用途やマシンスペックに応じて複数のモデルを選択できます。
-
tiny/base:最軽量・高速。精度は控えめ(約75MB〜140MB) -
small:軽さと精度のバランス型(約460MB) -
medium:実用十分な精度(約1.5GB) -
large-v3:最高精度。その分サイズが大きく処理も重い(約2.9GB)
モデルが大きいほど精度は向上しますが、ダウンロードサイズ・メモリ消費・処理時間も増加します。本記事では、日本語の精度を重視して large-v3を使用します。
3. whisper コマンドとして関数を登録
毎回長いコマンドを入力する手間を省くため、~/.zshrc にシェル関数として登録します。後述する「分割処理」「ハルシネーション対策」まで含めた構成です。
#文字起こしwhisper(30分ごとに分割→文字起こし→連結)
whisper() {
local input="${1:-$HOME/Downloads/output.wav}"
local model="$HOME/Documents/whisper.cpp/models/ggml-large-v3.bin"
local bin="$HOME/Documents/whisper.cpp/build/bin/whisper-cli"
local vadmodel="$HOME/Documents/whisper.cpp/models/for-tests-silero-v6.2.0-ggml.bin"
local stamp="$(date +%Y%m%d%H%M)"
local out="$HOME/Downloads/文字起こし_${stamp}"
if [ ! -f "$input" ]; then
echo "ファイルが見つかりません: $input"
return 1
fi
# 作業用一時ディレクトリ
local tmpdir="$HOME/Downloads/whisper_tmp_${stamp}"
mkdir -p "$tmpdir"
# 16kHz/モノラルwavに変換+音量正規化(dynaudnorm)しつつ30分(1800秒)ごとに分割
echo "音量正規化・30分ごとに分割中..."
ffmpeg -y -i "$input" -ar 16000 -ac 1 -af dynaudnorm -f segment -segment_time 1800 "$tmpdir/chunk_%03d.wav" || { rm -rf "$tmpdir"; return 1; }
# 各チャンクを文字起こし
local chunk
for chunk in "$tmpdir"/chunk_*.wav; do
echo "文字起こし中: ${chunk:t}"
"$bin" -m "$model" -l ja -osrt -mc 0 -sns \
--vad -vm "$vadmodel" \
-of "${chunk%.wav}" "$chunk" || { rm -rf "$tmpdir"; return 1; }
done
# 連結(SRT:チャンクごとに30分オフセットを加算→通し番号を振り直し)
echo "連結中..."
local raw="$tmpdir/merged_raw.srt"
: > "$raw"
local idx=0 srt offset
for srt in "$tmpdir"/chunk_*.srt; do
offset=$(( idx * 1800 ))
awk -v off="$offset" '
function t2s(t, a){ split(t,a,/[:,]/); return a[1]*3600+a[2]*60+a[3]+a[4]/1000 }
function s2t(x, h,m,s,ms){ h=int(x/3600); x-=h*3600; m=int(x/60); x-=m*60; s=int(x); ms=int((x-s)*1000+0.5); return sprintf("%02d:%02d:%02d,%03d",h,m,s,ms) }
/-->/ { split($0,a,/ +--> +/); print s2t(t2s(a[1])+off) " --> " s2t(t2s(a[2])+off); next }
{ print }
' "$srt" >> "$raw"
echo >> "$raw" # チャンク間をブロック境界(空行)で必ず区切る
idx=$(( idx + 1 ))
done
# 時刻付きプレーンtxtに変換([HH:MM:SS] 本文)
awk 'BEGIN{RS="";FS="\n"} {
if($2 !~ /-->/) next
split($2,a,/ +--> +/); split(a[1],t,",")
txt=""
for(i=3;i<=NF;i++) if($i!="") txt=(txt==""?$i:txt" "$i)
if(txt!="") printf "[%s] %s\n", t[1], txt
}' "$raw" > "${out}.txt"
# 後片付け
rm -rf "$tmpdir"
echo "完了: ${out}.txt"
}
コードのポイント
- 連結処理:分割した各SRTに「30分 × チャンク番号」のオフセットを加算し、タイムスタンプが通しでずれないよう補正しています
-
最終出力:SRTを
[HH:MM:SS] 本文のプレーンテキストに変換して保存します - 後片付け:処理完了後、一時ファイルは自動的に削除します
登録後、設定を反映します。
source ~/.zshrc
使い方
事前の音声変換は不要です。m4a・wavのいずれも、そのまま渡すだけで処理できます。
# iPhoneのボイスメモ(m4a)もそのまま指定可能
whisper ~/Downloads/録音.m4a
# → ~/Downloads/文字起こし_YYYYMMDDHHMM.txt に保存される
出力は以下のように、タイムスタンプ付きのテキストとなります。
[00:00:00] 本日の打ち合わせを始めます
[00:00:04] まず先週の進捗からですが
[00:00:09] 予定していたタスクは概ね完了しています
...
「該当の発言が何分ごろか」を後から追えるため、議事録作成の効率が大きく向上します。
実装上の工夫
ここからが本記事の要点です。単純に利用するだけでは対処しづらい課題があり、その解決策を共有します。
1. 無音区間で同じ言葉が反響する「ハルシネーション」対策
Whisperは、無音や雑音の区間において、実際には発話されていない言葉を生成してしまうことがあります。場合によっては同一のフレーズが繰り返し出力される、いわゆるハルシネーション(幻聴)が発生します。
[00:00:00] 本日の打ち合わせを始めます
[00:00:04] まず先週の進捗からですが
[00:00:09] 予定していたタスクは概ね完了しています
[00:00:12] 概ね完了しています
[00:00:14] 概ね完了しています
[00:00:19] 概ね完了しています
...
これを抑制するため、文字起こしコマンドに以下のオプションを付与しています。
"$bin" -m "$model" -l ja -osrt -mc 0 -sns \
--vad -vm "$vadmodel" \
-of "${chunk%.wav}" "$chunk"
| オプション | 意味 | 効果 |
|---|---|---|
-l ja |
言語を日本語に固定 | 言語の誤判定を防止する |
-osrt |
SRT形式で出力 | タイムスタンプ付きで書き出す |
-mc 0 |
直前の文脈トークンを保持しない(max-context = 0) | 一度発生したハルシネーションが次の区間へ伝播するのを防ぐ |
-sns |
非発話トークンを抑制(suppress non-speech tokens) | 咳・雑音などを言葉として出力しにくくする |
--vad -vm <model> |
音声区間検出(VAD)を有効化 | 無音区間を文字起こしの対象から除外する |
特に効果が大きかったのが、--vad(無音検出)と -mc 0(文脈の非保持) の組み合わせです。無音区間をあらかじめ除外したうえで、仮に不適切な出力が生じても後続に引きずらないため、反響ループを大幅に抑制できました。
VADとは
Voice Activity Detection(音声区間検出) の略称です。「人が発話している区間」のみを検出する仕組みで、無音や雑音のみの区間をスキップできます。本記事ではSileroのVADモデルを使用しています。
2. 長時間音声は「30分ごとに分割」して処理
会議音声は1〜2時間に及ぶことも珍しくありません。これを一括で処理すると、以下の問題が生じます。
長時間音声を一括処理した場合の問題
- タイムスタンプが後半になるほどずれていく(ドリフト)
- ハルシネーションが後半まで連鎖しやすい
- メモリ負荷が増大し、処理が不安定になる
そこで ffmpeg を用いて30分(1800秒)ごとに分割し、1チャンクずつ処理する構成としました。
ffmpeg -y -i "$input" -ar 16000 -ac 1 -af dynaudnorm \
-f segment -segment_time 1800 "$tmpdir/chunk_%03d.wav"
分割単位を30分とした理由は次のとおりです。
- 短すぎると分割の境目で文が途切れやすく、チャンク数も増加する
- 長すぎると前述のドリフト・ハルシネーションが発生しやすくなる
以上を踏まえ、バランスの取れた単位として30分を採用しました。分割後は、各チャンクの時刻に「30分 × チャンク番号」のオフセットを加算して連結するため、通しのタイムスタンプはずれません。
dynaudnorm による音量正規化
ffmpeg の -af dynaudnorm により、音量を自動で正規化しています。発話音量の小さい参加者の声も拾いやすくなり、精度の向上に寄与しました。
まとめ
whisper.cppを利用することで、Macのローカル環境のみで、無料かつ高精度な文字起こし環境を構築できました。
- 無料・ローカル完結:音声データを外部に送信しない
- large-v3:日本語でも高精度
-
VAD +
-mc 0:無音区間でのハルシネーションを抑制 - 30分分割:長時間音声も安定して処理
「クラウドに音声をアップロードせずに文字起こしを行いたい」という方に、ぜひご活用いただければ幸いです。
はつかぜ株式会社では、IT学習や業務に役立つ情報を定期的にお届けしていきたいと思っています。
システム開発のお問い合わせ・ご相談はこちら