Read OSS

30秒のウィンドウ:Whisperの文字起こしループと失敗からの復旧

上級

前提知識

  • 第1〜4回の記事
  • デコードシステムへの理解

30秒のウィンドウ:Whisperの文字起こしループと失敗からの復旧

第4回で取り上げたデコーダーは、ちょうど30秒分の mel spectrogram セグメントをひとつ処理するように設計されています。しかし実際の音声はどんな長さにもなりえます — 3秒の短いボイスメモもあれば、2時間のポッドキャストもあるでしょう。transcribe() 関数は、スライディングウィンドウ方式のループによってこのギャップを埋めます。デコードされたセグメントをつなぎ合わせ、デコードの失敗を適切に処理し、ハルシネーションを検出する仕組みを備えています。約475行にわたるこの関数はコードベース中2番目に大きく、実用面では最も重要な関数と言えるでしょう。

関数の初期化

38〜56行目のシグネチャを見ると、ユーザーが利用できる制御パラメータの全容がわかります。

def transcribe(
    model: "Whisper",
    audio: Union[str, np.ndarray, torch.Tensor],
    *,
    verbose: Optional[bool] = None,
    temperature: Union[float, Tuple[float, ...]] = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0),
    compression_ratio_threshold: Optional[float] = 2.4,
    logprob_threshold: Optional[float] = -1.0,
    no_speech_threshold: Optional[float] = 0.6,
    condition_on_previous_text: bool = True,
    initial_prompt: Optional[str] = None,
    carry_initial_prompt: bool = False,
    word_timestamps: bool = False,
    # ...
    **decode_options,
):

127〜178行目の初期化処理では、次のことが行われます。

  1. 音声全体の mel spectrogram を30秒のパディングつきで計算する
  2. 最初の30秒から言語を検出する(未指定の場合)
  3. tokenizer を生成する
  4. clip_timestamps をシークポイントに変換する
flowchart TD
    A["transcribe() called"] --> B["Compute mel for full audio\n+ 30s padding"]
    B --> C{"Language\nspecified?"}
    C -->|No| D["Detect from first 30s\nusing model.detect_language()"]
    C -->|Yes| E["Use specified language"]
    D --> F["Create tokenizer"]
    E --> F
    F --> G["Parse clip_timestamps\ninto seek_clips"]
    G --> H["Enter sliding window loop"]

clip_timestamps を使うと、音声の特定区間だけを処理対象にできます。あらかじめセグメント化されたコンテンツや、非音声区間のスキップが必要な場面で役立ちます。タイムスタンプはフレーム単位のシークポイントに変換され、(start, end) のタプルとして扱われます。

Temperature フォールバック戦略

内部関数 decode_with_fallback() は、Whisper の主要な失敗復旧メカニズムです。デフォルトの temperature タプル (0.0, 0.2, 0.4, 0.6, 0.8, 1.0) の意味はシンプルです — まず greedy デコードを試み、出力が不正常に見えたら、徐々にランダム性を高めながらリトライします。

flowchart TD
    A["Try T=0.0\n(greedy)"] --> B{"Check quality"}
    B -->|"compression_ratio > 2.4\n(repetitive)"| C["Try T=0.2"]
    B -->|"avg_logprob < -1.0\n(low confidence)"| C
    B -->|"no_speech + low logprob\n(silence)"| D["Accept as silence"]
    B -->|"Passes all checks"| E["Accept result"]
    C --> F{"Check quality"}
    F -->|"Still failing"| G["Try T=0.4, 0.6, ..."]
    F -->|"Passes"| E
    G --> H["Accept last result\n(even if poor)"]

フォールバックを駆動する品質指標は2つあります。

  • Compression ratioutils.pycompression_ratio() を使用):len(text_bytes) / len(zlib.compress(text_bytes)) で算出します。2.4を超える場合は、同じフレーズをループで繰り返し生成しているサインです。これは自己回帰モデルで最もよく見られる失敗パターンです。

  • Average log probability:-1.0を下回ると、モデルが自分の出力に自信を持てていない状態を示します。確信度が低く、かつ無音確率が高い場合は、デコードの失敗ではなく無音区間と判断します。

この2つの閾値の関係は少し繊細です。no_speech_prob > no_speech_threshold かつ avg_logprob < logprob_threshold の両条件が揃ったとき、そのセグメントは無音として扱われ、フォールバックは実行されません。本当に無音の区間に対して、無駄な再試行を繰り返さないようにするための工夫です。

ヒント: temperature > 0 のとき、beam search は自動的に無効になります(beam_sizepatience は kwargs から取り除かれます)。同様に、temperature == 0 のときは best_of サンプリングが無効になります。これにより、デコード戦略の整合性が保たれます。

スライディングウィンドウとシーク管理

272〜399行目のメインループが transcribe() の核心です。変数 seek が mel フレーム単位の現在位置を追跡し、デコード結果に応じて異なる量だけ進んでいきます。

コードのコメントにも正直に書かれています。「このループは diff を読みやすくするために、意図的にフラット化されている。」実際には clip_idxseek に対する while ループですが、論理的にはクリップとウィンドウに対するネストされたイテレーションを表しています。

各イテレーションでは次の処理が行われます。

  1. 現在の seek 位置から mel セグメントを切り出す
  2. ちょうど N_FRAMES(3000)フレームになるようパディングする
  3. 直前のコンテキストからプロンプトを設定する
  4. decode_with_fallback() を呼び出す
  5. タイムスタンプトークンを解析してセグメント境界を決定する
  6. seek を進める

シークの進め方は、デコーダーが連続するタイムスタンプトークンを生成したかどうかで2通りに分かれます。

ケース1:連続するタイムスタンプが見つかった場合(通常のケース)。出力は連続するタイムスタンプのペアごとにスライスされ、それぞれが正確な開始・終了時刻を持つセグメントになります。シーケンスが単独のタイムスタンプ(ペアなし)で終わる場合は「この時点以降に音声なし」を意味し、seek は segment_size 分だけ丸ごと進みます。そうでなければ、最後のタイムスタンプの位置まで進みます。

ケース2:連続するタイムスタンプが見つからなかった場合。出力全体がひとつのセグメントになります。タイムスタンプトークンがひとつでもあればその位置が長さを決定し、なければ segment_duration 全体が使われます。seek は常に segment_size 分進みます。

この区別が重要なのは、タイムスタンプトークンがウィンドウ内での精密な位置情報を提供するからです。常に30秒ずつジャンプするのではなく、デコードした音声の正確な長さだけ進むことで、ギャップや重複を防ぐことができます。

無音検出とプロンプトコンディショニング

298〜310行目の無音チェックは、処理をスキップするための高速パスです。

if no_speech_threshold is not None:
    should_skip = result.no_speech_prob > no_speech_threshold
    if logprob_threshold is not None and result.avg_logprob > logprob_threshold:
        should_skip = False  # high confidence overrides no-speech
    if should_skip:
        seek += segment_size
        continue

ロジックはこうです。モデルが無音だと判断し(no_speech_prob > 0.6)、かつ average log probability が低い(不確かさを裏付ける)場合にセグメントをスキップします。ただし、無音確率が高くても logprob が高い場合は文字起こしを信頼します — 小さな声でも明瞭に発話されている可能性があるためです。

ウィンドウをまたいだプロンプトコンディショニングは 503〜505行目で管理されています。

if not condition_on_previous_text or result.temperature > 0.5:
    prompt_reset_since = len(all_tokens)

condition_on_previous_text が有効(デフォルト)の場合、それまでにデコードされたトークンがすべて次のウィンドウへのプロンプトとして渡されます。固有名詞や文体の選択、文脈を引き継ぐことで一貫性が保たれます。ただし、高い temperature が必要だった場合(0.5超)はプロンプトをリセットします。高 temperature の出力は信頼できるコンテキストとは言えないためです。

288〜293行目carry_initial_prompt オプションは、プロンプトリセットを越えて持続するコンテキストを提供します。有効にすると initial_prompt のトークンが常に先頭に追加されるため、専門用語や書式指示が文字起こし全体を通して一貫して維持されます。

ハルシネーションの検出と復旧

word_timestamps を有効にすると、transcribe()419〜472行目のハルシネーション検出を起動します。この挙動は hallucination_silence_threshold パラメータで制御します。

セグメントのスコアリングには、2つのローカル関数が使われます。

def word_anomaly_score(word: dict) -> float:
    probability = word.get("probability", 0.0)
    duration = word["end"] - word["start"]
    score = 0.0
    if probability < 0.15:
        score += 1.0
    if duration < 0.133:
        score += (0.133 - duration) * 15
    if duration > 2.0:
        score += duration - 2.0
    return score

確率が低い(< 0.15)、不自然に短い(< 133ms)、または不自然に長い(> 2秒)単語を「異常」と判断します。is_segment_anomaly() 関数は、句読点を除く最初の8単語のスコアを合計し、合計が3を超えるか単語数に近づいた場合にそのセグメントを問題ありとしてフラグを立てます。

flowchart TD
    A["Segment decoded\nwith word timestamps"] --> B{"is_segment_anomaly?"}
    B -->|No| C["Keep segment"]
    B -->|Yes| D{"Surrounded by\nsilence?"}
    D -->|Yes| E["Skip hallucinated\nsegment, re-seek"]
    D -->|No| C
    
    F["First segment\nanomaly?"] --> G{"Gap before\n> threshold?"}
    G -->|Yes| H["Skip leading silence\nre-seek to gap"]
    G -->|No| C

復旧戦略は位置を考慮した設計になっています。ウィンドウの先頭セグメントが異常で、かつその前に閾値を超えるギャップがある場合、ループはギャップの直後にシークし直します — ハルシネーションを引き起こした無音区間をスキップするためです。ウィンドウ中間でのハルシネーションに対しては、前後の無音を確認し、セグメントリストを切り詰めたうえで異常セグメントの開始位置にシークし直します。

これはヒューリスティックなシステムであり完璧ではありませんが、最もよく見られるパターン — 無音区間やノイズが多い区間でモデルがもっともらしいテキストを生成してしまう問題 — は十分に捕捉できます。

CLI インターフェース

cli() 関数は、transcribe() を引数解析と出力書き込みで包んでいます。528〜567行目の argparse 設定では、transcribe() のすべてのパラメータがコマンドラインフラグに対応しています。

注目すべき CLI の挙動をいくつか紹介します。

  • Temperature のインクリメント:フルのタプルを直接指定する代わりに、--temperature(デフォルト 0)と --temperature_increment_on_fallback(デフォルト 0.2)を設定します。CLI が np.arange(temperature, 1.0 + 1e-6, increment) でタプルを自動生成します。
  • 複数ファイルの処理audio 引数は複数のファイルを受け付けます。各ファイルは独立して文字起こしされ、失敗してもエラー処理によってスキップされ、全体の処理が中断されることはありません。
  • 出力フォーマット--output_format フラグのデフォルトは "all" で、get_writer() のコンポジットパターンを通じてすべてのライター(TXT、VTT、SRT、TSV、JSON)が呼び出されます。

ヒント: 英語専用モデル(.en サフィックス)は、異なる言語が指定されていても警告を出したうえで自動的に language="en" に強制します。言語に合わないモデルを使用した際の混乱を防ぐための仕様です。

次回予告

文字起こしループの全体像が把握できたところで、残る主要なサブシステムはひとつです — 単語レベルのタイムスタンプです。第6回では、Whisper が cross-attention の重みを抽出し、Dynamic Time Warping でトークンに位置合わせし、単語ハイライト付きの字幕フォーマットに整形するまでの仕組みを掘り下げます。実行時に自分自身のソースコードを生成する Triton カーネルについても取り上げます。