会議の録音を文字起こしするサービスを個人で作っています。ボットが会議に入って録音し、Whisper に投げて
議事録にする、という素直なパイプラインです。そこで一番手を焼いたのが精度そのものではなく、
「無音のときに Whisper が喋りだす」 ことでした。
同じ日に3つの会議ボット(Zoom / Meet / Teams)で同一の日本語テスト通話を録ったとき、
無音区間の文字起こしはこうなりました。
teams (日本語・実内容あり) 「質問に答えてもらえると嬉しいです。」× 8 連続
zoom (無音区間) 「you」× 15
meet (無音区間) 「Speaking Japanese.」× 12、「Bye-bye.」× 3
いずれも音声には何も入っていません。にもかかわらずタイムスタンプ付きのきれいな文が並ぶので、
議事録としては「誰も言っていない発言」が残ります。要約に食わせれば、当然そこも要約されます。
厄介なのは、これが単一の対策では消えないことです。結局、発生する場所が3つ違うので、
3層に分けて潰しました。この記事はその3層の設計と、実際に効いた閾値の話です。
層1: 言語判定を「無音で」やらない
Whisper に言語自動判定させると、冒頭の無音に対して英語を返します。
「無音 → 英語だと判定 → 以降を英語として復号 → 日本語音声を英語に“翻訳”しはじめる」という
連鎖が起きるので、これは幻覚の中でも一番被害が大きい。
会議ボットは人が喋る前に入室するので、録音の頭に1分近い無音が入るのは日常です。
そこで、言語判定用のサンプルを「最初の発話が始まった位置」から取るようにしました。
発話開始位置は ffmpeg の silencedetect に出させています。
_SILENCE_NOISE_DB = "-30dB"
_SILENCE_MIN_SECONDS = 2.0
async def _first_speech_offset(input_path: str) -> float:
"""先頭無音の秒数(不明なら 0.0)。例外でも None でも返さない。"""
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-hide_banner", "-nostats", "-i", input_path, "-vn",
"-af", f"silencedetect=noise={_SILENCE_NOISE_DB}:d={_SILENCE_MIN_SECONDS}",
"-f", "null", "-",
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
onset = _first_speech_from_silencedetect(stderr.decode(errors="replace"))
return 0.0 if onset is None else onset
except Exception as e:
logger.warning("[silence] silencedetect failed (%s) — assuming speech at 0s", e)
return 0.0
閾値の -30dB / 2秒 はわざと緩めです。空調音やキーボードの音は「無音」のままで、
人が喋ったら確実に「無音でない」になる側に倒しています。
設計上ここで大事にしたのは、この関数は絶対に失敗しないことです。検出できなければ 0.0 を返す
=この機能を入れる前と同じ挙動になる。補助的な検出器がジョブ全体を落とすのは割に合いません。
さらに、判定は1窓では決めないようにしました。発話開始位置と、そこから一定時間後の
2窓を取り、両者が一致したときだけ言語をピン留めする。一致しなければ自動判定に戻します。
そして、窓の文字起こしが「無音に対する定型句」だったら、その窓は判定に使いません。
_SILENCE_HALLUCINATIONS = (
"you", "thankyou", "thanksforwatching", "bye", "byebye", "speakingjapanese",
"ご視聴ありがとうございました", "チャンネル登録", "お疲れ様でした", "ありがとうございました",
)
見てのとおり、YouTube の締めの定型句が並びます。学習データの出どころが透けて見える部分で、
「無音に何を喋るか」はモデルごとにかなり決まったレパートリーがあります。
判定は3つの安いテルのどれか1つで十分としました: 60秒窓なのに文字がほとんどない/
繰り返しを畳んだら既知の定型句になる/トークンの大半が同じ単語(distinct / tokens < 0.35)。
層2: そもそも無音を送らない
判定を直しても、無音に文字を生やす挙動自体は消えません。なので、先頭無音が一定以上長ければ
発話開始位置から再エンコードして送るようにしました。
LEADING_SILENCE_TRIM_MIN_SECONDS = 5.0
短い無音(喋り出す前のひと呼吸)はトリムしません。再エンコードのコストを払う価値がないからです。
トリムしたぶんの秒数は、あとで全セグメントのタイムスタンプに足し戻します。ここを忘れると、
録画のシークバーと議事録の時刻がずれて、ユーザー体験としては幻覚より嫌われます。
層3: 返ってきたセグメントを落とす
それでも残るものがあります。ここは保守的に、3つのルールだけで落としました。
判断材料は Whisper が verbose_json で返すセグメント単位の指標(no_speech_prob, avg_logprob)と、
テキストそのものです。
_NO_SPEECH_PROB_MAX = 0.6
_NO_SPEECH_LOGPROB_MAX = -1.0
_REPEAT_RUN_MIN = 3
_REPEAT_RUN_MIN_SHORT = 6 # 「はい」「うん」など3文字以下
_SHORT_TEXT_CHARS = 3
_LATIN_FILLER = {"you", "thankyou", "thanks", "bye", "byebye", "okay", "ok",
"yeah", "hmm", "mm", "um", "uh"}
-
無音:
no_speech_prob > 0.6かつavg_logprob < -1.0のとき落とす。
no_speech_prob単独では落としません — 小声の本物の発話が普通に引っかかるからです。 -
ループ: 正規化後のテキストが3回以上連続で同一なら、最初の1つだけ残して落とす。
ただし3文字以下の短い相槌は6回連続まで許します。日本語の会議は本当に「はい。」「はい。」が続くので。 -
スクリプト不一致: 日本語音声の中に、ラテン文字のフィラーだけのセグメント(
you/Thank you.)が
あったら落とす。日本語の録音が正直に生成しうるテキストではありません。
# ルール2の中核: 連続ランを数え、閾値を超えたぶんだけ落とす
run_end = i + 1
while run_end < n and _norm_seg_text(segments[run_end].get("text") or "") == norm and norm:
run_end += 1
run = run_end - i
threshold = _REPEAT_RUN_MIN_SHORT if len(norm) <= _SHORT_TEXT_CHARS else _REPEAT_RUN_MIN
if run >= threshold:
kept.append(seg) # 最初の1つは残す
dropped += run - 1
i = run_end
continue
実装上のはまりどころが2つありました。
(a) API が返す text は捨てて作り直す。 セグメントを落としても、レスポンスの
response.text には落としたはずの文がそのまま入っています。生き残ったセグメントから
組み直さないと、本文にだけ幻覚が残ります。
segments, dropped = _drop_hallucinated_segments(segments, detected)
text = response.text
if dropped:
text = " ".join(s["text"] for s in segments)
(b) 判定用の指標は保存しない。 no_speech_prob / avg_logprob はフィルタまで運んで、
そこで剥がします。保存する JSON の形を変えないためです(フィルタを入れる変更が、
過去データの形式変更を巻き込むと一気に面倒になる)。
副産物: グロッサリーが言語を引っ張る
同じ根から出たもう1つのバグも書いておきます。ワークスペースの用語集を Whisper の
initial_prompt に渡していたのですが、日本語の用語集が英語会議の言語判定を引っ張り、
英語の会議が「420文字の日本語の翻訳+3回のループ」になったことがありました。
用語集はワークスペース共有なので、誰か1人の日本語登録が、他の全員の英語会議を壊します。
対策はシンプルで、プロンプトの文字種が音声の言語と違うなら、そのプロンプトを捨てる。
日本語の用語は英語の音声認識に何も貢献しないので、捨てても損がありません。
ただし言語が不明なときは捨てない — 用語集が黙って無効化されるほうが、ユーザーには悪い挙動です。
まとめ
- 無音に対する幻覚は「精度」ではなくパイプラインの問題として扱う。層を分けると閾値が決めやすい。
- 検出器は絶対に落とさない設計にする(失敗=導入前と同じ挙動)。
- フィルタは保守的に。本物の1文を消すほうが、フィラーを1つ残すよりずっと悪い。
なので「3回連続」「短文は6回連続」「AND条件」といった、渋い閾値に落ち着きました。 - 落とした結果は必ずテストで固定する。とくに「落としてはいけないもの」(短い実発話、
日本語の連続する相槌)のテストを、落とすほうのテストと同数書いておくと安心して閾値を触れます。
同種の問題を踏んでいる方の参考になれば。閾値はドメイン(会議 / インタビュー / 講演)でかなり変わるはずなので、
そこは各自の録音で測ってください。