会議の議事録を自動生成するパイプラインを回していたある日、出力に見覚えのない一文が混ざっていました。「ご視聴ありがとうございました」。社内の定例会議です。誰もそんなことは言っていません。
該当箇所の音声を聞き直すと、そこは登壇者が資料を切り替えている数秒の無音区間でした。Whisper は、何も話されていない時間に、存在しない言葉を書き足していたのです。
これは Whisper の日本語文字起こしでよく知られた挙動です。無音や雑音、BGM だけの区間で、学習データに頻出する定型句——「ご視聴ありがとうございました」「チャンネル登録お願いします」——を幻のように出力する。英語圏では "hallucination" と呼ばれる現象です。
ここで整理するのは、Rork や Cowork で作った音声処理パイプラインに Whisper を組み込んだとき、この幻聴を計測して 、閾値と前処理と Claude の検証で潰していく手順です。感覚で「消えた気がする」で終わらせず、混入率を数字で追いながら立て直した記録として書きます。
なぜ Whisper は無音で言葉を書き足すのか
Whisper は音声を30秒のチャンクに区切り、それぞれを「何かしらのテキスト」に変換しようとします。問題は、入力が無音でも「出力なし」を選びにくいモデルの性質です。
学習データには YouTube 由来の音声が大量に含まれ、動画の末尾や無音部分に定型句が繰り返し登場します。その結果、モデルは「音声情報が乏しい区間 = 定型句」という偏った対応関係を覚えてしまっています。
区間の状態 Whisper の典型的な挙動 混入しやすい定型句
完全な無音 直前の文の繰り返し、または定型句 ご視聴ありがとうございました
BGM・雑音のみ 意味の通らない断片や定型句 チャンネル登録お願いします
小さな相槌のみ 過剰に長い文へ膨張 (直前発話の反復)
つまりこれは「聞き取り間違い」ではなく、「情報が足りないときの穴埋め」です。だから対策も、音を正確に聞かせることではなく、穴埋めが起きた区間を検出して落とす 方向で考えるのが筋になります。
まず幻聴を計測する — セグメントの確信度を出す
対処の前に計測です。Whisper の verbose_json レスポンスには、セグメントごとに確信度の手がかりが含まれています。これをログに出さないと、どこで幻聴が起きているか分かりません。
見るべき指標は2つです。
指標 意味 幻聴のサイン
no_speech_probその区間が「無音」である確率 高い(0.6超) のにテキストが出ている
avg_logprob出力トークンの平均対数確率(確信度) 低い(-1.0未満) = あてずっぽうに近い
no_speech_prob が高いのにテキストがある、しかも avg_logprob が低い——この2つが重なるセグメントが、幻聴のもっとも典型的な形です。
from openai import OpenAI
client = OpenAI( api_key = "YOUR_API_KEY" )
def transcribe_with_confidence (audio_path):
with open (audio_path, "rb" ) as f:
result = client.audio.transcriptions.create(
model = "whisper-1" ,
file = f,
language = "ja" ,
response_format = "verbose_json" ,
# セグメント単位の確信度を取得する
timestamp_granularities = [ "segment" ],
)
flagged = []
for seg in result.segments:
# 幻聴の疑いが濃いセグメントを記録する
suspicious = seg.no_speech_prob > 0.6 and seg.avg_logprob < - 1.0
if suspicious:
flagged.append({
"start" : round (seg.start, 1 ),
"text" : seg.text.strip(),
"no_speech_prob" : round (seg.no_speech_prob, 3 ),
"avg_logprob" : round (seg.avg_logprob, 3 ),
})
return result, flagged
まずはこの flagged を数えるところから始めます。私の議事録パイプラインでは、1時間の会議音声あたり平均で約12箇所が引っかかりました。そのうち大半が例の定型句か、直前の文の繰り返しでした。数を先に知ると、後の対策が効いているかどうかを比較できます。
閾値でフィルタする — ただし本文は守る
計測できたら、閾値でセグメントを落とします。ポイントは、疑わしきは残す の逆を丁寧にやることです。無音で確信度も低いものだけを落とし、少しでも本物の発話が混じる可能性があるものは残します。
def filter_hallucinations (result):
kept, removed = [], []
for seg in result.segments:
is_silence = seg.no_speech_prob > 0.6
is_low_conf = seg.avg_logprob < - 1.0
# 無音確率が高く、かつ確信度も低いものだけ落とす
if is_silence and is_low_conf:
removed.append(seg.text.strip())
continue
kept.append(seg.text.strip())
cleaned = " " .join(t for t in kept if t)
return cleaned, removed
閾値は音源によって調整が要ります。静かな会議室なら no_speech_prob > 0.6 で十分ですが、屋外録音や電話音声だと背景雑音で no_speech_prob が下がり、幻聴を取りこぼします。私は最初 0.5 まで下げて本物の相槌まで削ってしまい、0.6 に戻しました。閾値は一度決めて放置せず、音源が変わったら測り直す前提で持つのが安全です。
⚠️ 確信度だけで機械的に削ると、早口や訛りのある本物の発話も「低確信度」として落ちることがあります。フィルタは「無音確率」と「低確信度」の両方 が揃ったときだけ作動させ、どちらか一方では落とさないでください。
VAD で無音を Whisper に渡す前に落とす
閾値フィルタは事後対処です。もう一段効くのは、そもそも無音区間を Whisper に渡さない前処理——VAD(Voice Activity Detection、音声区間検出)です。
無音を先に切り落としてから文字起こしすれば、Whisper が穴埋めする機会そのものが減ります。
import webrtcvad
import wave
import contextlib
def has_speech (frame_bytes, vad, sample_rate = 16000 ):
return vad.is_speech(frame_bytes, sample_rate)
def strip_silence (pcm_path, aggressiveness = 2 ):
# aggressiveness は 0(緩い)〜3(厳しい)。会議音声は 2 が扱いやすい
vad = webrtcvad.Vad(aggressiveness)
speech_frames = []
with contextlib.closing(wave.open(pcm_path, "rb" )) as wf:
sample_rate = wf.getframerate()
frame_ms = 30
frame_bytes = int (sample_rate * frame_ms / 1000 ) * 2 # 16bit=2byte
while True :
frame = wf.readframes(frame_bytes // 2 )
if len (frame) < frame_bytes:
break
if has_speech(frame, vad, sample_rate):
speech_frames.append(frame)
return b "" .join(speech_frames)
VAD は万能ではありません。息継ぎや短い間まで削ると発話が不自然に繋がるので、aggressiveness は中庸の 2 から始めるのがおすすめです。私は VAD と閾値フィルタを両方入れる構成にしています。前処理で無音の大半を落とし、それでも残る幻聴を事後の確信度フィルタで拾う。二段構えにすると、片方の取りこぼしをもう片方が補ってくれます。音声パイプライン全体の設計は音声エージェントの本番アーキテクチャ の観点とも重なります。
残った幻聴は Claude に検証させる
閾値と VAD を通しても、文脈的におかしい一文がまれに残ります。ここで Claude の出番です。Whisper は「音」を「文字」にする役割、Claude は「文字」が「文脈に合うか」を判断する役割、と分けて考えます。
セグメントの前後を渡し、明らかに文脈から浮いた挿入を指摘させます。ツール(構造化出力)で結果を受け取ると、後段で機械的に処理できます。
import anthropic, json
client = anthropic.Anthropic( api_key = "YOUR_API_KEY" )
def validate_transcript (segments):
# segments: [{"start": 12.4, "text": "..."}] のリスト
numbered = " \n " .join( f "[ { i } ] { s[ 'text' ] } " for i, s in enumerate (segments))
resp = client.messages.create(
model = "claude-sonnet-5" ,
max_tokens = 1024 ,
tools = [{
"name" : "report_hallucinations" ,
"description" : "文脈から浮いた挿入セグメントの番号を返す" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"suspect_indices" : {
"type" : "array" ,
"items" : { "type" : "integer" },
"description" : "前後と繋がらない、または定型句のみのセグメント番号" ,
}
},
"required" : [ "suspect_indices" ],
},
}],
tool_choice = { "type" : "tool" , "name" : "report_hallucinations" },
messages = [{
"role" : "user" ,
"content" : (
"以下は会議音声の文字起こしです。前後の文脈と繋がらず、"
"「ご視聴ありがとうございました」等の配信定型句だけのセグメントを"
"幻聴として検出してください。本物の発話は残してください。 \n\n " + numbered
),
}],
)
for block in resp.content:
if block.type == "tool_use" :
return block.input[ "suspect_indices" ]
return []
この段階を入れると、確信度指標だけでは拾えない「確信度は普通なのに文脈的に明らかにおかしい」挿入も捕まえられます。ただし Claude に任せきりにせず、最終判断はあくまで確信度指標との併用にしています。構造化出力の設計は構造化レスポンスとスキーマ検証 の考え方が役立ちます。
定型句ブラックリストは最後の砦にとどめる
「いっそ『ご視聴ありがとうございました』を文字列一致で全部消せばいい」と考えたくなります。私も最初はそうしました。しかしこれは最後の砦にとどめるべきです。
理由は単純で、本当にその言葉が話される会議もあるからです。動画制作の打ち合わせや配信の振り返りでは、当人が本気で「ご視聴ありがとうございました」と言います。文字列一致で無条件に消すと、本物の発言まで削ってしまいます。
BLOCKLIST = [ "ご視聴ありがとうございました" , "チャンネル登録" ]
def blocklist_filter (segments):
result = []
for s in segments:
text = s[ "text" ].strip()
# 定型句「のみ」で構成され、かつ無音確率が高い場合だけ落とす
only_boilerplate = any (text == b for b in BLOCKLIST )
if only_boilerplate and s.get( "no_speech_prob" , 0 ) > 0.6 :
continue
result.append(s)
return result
ブラックリストを使うなら、必ず確信度指標と組み合わせ、「定型句のみ・かつ無音確率が高い」の二条件が揃ったときだけ落とす。単独の文字列一致で消す運用は、いつか本物を消して信頼を損ないます。
導入してからの数字(実体験メモ)
これらを一つの議事録パイプラインに入れ、個人開発で2週間ほど運用しました。変更は私自身が入れて、私自身の耳で答え合わせをします。
対策前は、1時間の会議音声あたり平均で約12箇所の幻聴が混入していました。内訳は配信定型句が最多で、次に直前発話の反復です。
VAD で無音を前処理し、no_speech_prob > 0.6 かつ avg_logprob < -1.0 の確信度フィルタを通した段階で、混入は平均で約4箇所まで下がりました。そこへ Claude の文脈検証を足すと、最終的に平均で約2箇所まで減りました。対策前の約12箇所からは、およそ83%の削減です。ゼロにはなりません。屋外録音や電話越しの音声では、どうしても背景雑音が確信度を押し上げて取りこぼしが残ります。
大事なのは、削りすぎていないかも同時に測ることです。私は対策前後で本文の文字数を比べ、本物の発話が過剰に落ちていないかを確認しました。幻聴が減っても本文まで痩せていたら意味がありません。数字が良くなっただけで安心しないのは、この種の品質作業に共通する落とし穴だと感じています。
ℹ️ 音源の種類(会議室・屋外・電話・配信)ごとに幻聴の出方は変わります。私は音源タイプごとに閾値を分け、新しい種類の音声を扱い始めたら、まず10分ぶんを手で聞いて混入箇所を数え、閾値を測り直すようにしています。
週次チェックに落とし込む
最後に、これを単発で終わらせないための最小の運用をまとめます。作り込みすぎず、短時間で回せる粒度にしておくのがコツです。
頻度 やること 判断基準
毎回 flagged セグメント数をログに残す 音声1時間あたりの混入数を記録し続ける
週1回 flagged の中身を目視で確認 本物の発話を誤って落としていないか
音源が変わったら 10分を手で聞いて閾値を測り直す no_speech_prob の分布が音源で変わる
月1回 対策前後の本文文字数を比較 削りすぎ(本文の痩せ)が起きていないか
文字起こしの品質は、消した幻聴の数ではなく、残った本文が正しいかで決まります。幻聴を計測しながら潰す習慣は、その状態を保つための地味だけれど効く一手です。私はこの「数えてから削る」順序を崩さないやり方を好みます。理由を数字で説明できる分だけ、後から安心して調整できるからです。
まずは自分のパイプラインで verbose_json を有効にし、no_speech_prob の高いセグメントを1時間ぶん数えてみてください。混入の実数が見えた瞬間から、対策は具体的になります。実装の一助になれば幸いです。お読みいただきありがとうございました。