0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Whisper は沈黙に対して喋る — 会議録音の「幻覚」を3層で止めた話

0
Posted at

会議の録音を文字起こしするサービスを個人で作っています。ボットが会議に入って録音し、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"}
  1. 無音: no_speech_prob > 0.6 かつ avg_logprob < -1.0 のとき落とす。
    no_speech_prob 単独では落としません — 小声の本物の発話が普通に引っかかるからです。

  2. ループ: 正規化後のテキストが3回以上連続で同一なら、最初の1つだけ残して落とす。
    ただし3文字以下の短い相槌は6回連続まで許します。日本語の会議は本当に「はい。」「はい。」が続くので。

  3. スクリプト不一致: 日本語音声の中に、ラテン文字のフィラーだけのセグメント(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条件」といった、渋い閾値に落ち着きました。
  • 落とした結果は必ずテストで固定する。とくに「落としてはいけないもの」(短い実発話、
    日本語の連続する相槌)のテストを、落とすほうのテストと同数書いておくと安心して閾値を触れます。

同種の問題を踏んでいる方の参考になれば。閾値はドメイン(会議 / インタビュー / 講演)でかなり変わるはずなので、
そこは各自の録音で測ってください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?