Read OSS

音声波形からメルスペクトログラムへ:Whisper のオーディオフロントエンド

中級

前提知識

  • 第1回(アーキテクチャ概要)
  • 周波数領域 / フーリエ変換の基礎知識
  • PyTorch のテンソル操作

音声波形からメルスペクトログラムへ:Whisper のオーディオフロントエンド

第1回で確認したとおり、Whisper のパイプラインは生の音声から始まり、デコーダーが参照できる特徴ベクトルの列へと変換されます。本記事では、あらゆる音声フォーマットを取り込む FFmpeg サブプロセスから STFT とメルフィルターバンクの計算を経て、時間解像度を半分に落とす AudioEncoder の畳み込みステムに至るまでの道のりを詳細に追います。信号処理では次元のバグが最も頻繁に起こり、最も厄介です。そのため、各ステージでテンソルの shape を丁寧に確認しながら進めます。

オーディオフロントエンドの全体は2つのファイルに収まっています。メルスペクトログラムまでの処理を担う whisper/audio.py(157行)と、それ以降を担う whisper/model.pyAudioEncoder クラスです。

FFmpeg による音声の読み込み

Whisper は音声の読み込みに librosa や soundfile などの Python ライブラリを使いません。代わりに、load_audio() は FFmpeg をサブプロセスとして呼び出します。

cmd = [
    "ffmpeg",
    "-nostdin",
    "-threads", "0",
    "-i", file,
    "-f", "s16le",
    "-ac", "1",
    "-acodec", "pcm_s16le",
    "-ar", str(sr),
    "-"
]
out = run(cmd, capture_output=True, check=True).stdout
return np.frombuffer(out, np.int16).flatten().astype(np.float32) / 32768.0

これは実用的な判断であり、いくつかのメリットがあります。FFmpeg は Python バインディングを必要とせず、MP3、FLAC、OGG、AAC、WAV など事実上あらゆる音声フォーマットをデコードできます。出力は16kHz モノラルの16ビット符号付き整数として raw PCM を標準出力に書き出すため、np.frombuffer で簡単にパースできます。最後に 32768.0 で除算することで、[-1.0, 1.0] の float32 範囲に正規化します。

トレードオフとして、FFmpeg がシステムにインストールされている必要があります。ただし、Whisper が対象とする ML 実践者の多くはすでに FFmpeg を導入済みであることを考えると、これは妥当な選択と言えます。

30秒チャンクという設計

audio.py 冒頭の6つの定数が、システム全体の時間的な構造を定義しています。

SAMPLE_RATE = 16000
N_FFT = 400
HOP_LENGTH = 160
CHUNK_LENGTH = 30
N_SAMPLES = CHUNK_LENGTH * SAMPLE_RATE       # 480000
N_FRAMES = exact_div(N_SAMPLES, HOP_LENGTH)  # 3000

さらに、音声フレームとモデルのトークンを対応づける3つの派生定数があります。

N_SAMPLES_PER_TOKEN = HOP_LENGTH * 2          # 320 (stride-2 conv)
FRAMES_PER_SECOND = exact_div(SAMPLE_RATE, HOP_LENGTH)  # 100
TOKENS_PER_SECOND = exact_div(SAMPLE_RATE, N_SAMPLES_PER_TOKEN)  # 50

30秒チャンクが処理の基本単位です。モデルは30秒の音声セグメントで学習されており、エンコーダーの positional embedding は1500ポジションに固定されています(stride-2 の畳み込みにより、3000メルフレームが半分になります)。長さに関わらず、すべての音声は最終的に30秒の窓単位で処理されます。

この制約を強制するのが pad_or_trim() 関数です。numpy array と PyTorch テンソルの両方に対応しており、短いクリップにはゼロパディングを、長いクリップにはトリミングを行い、ちょうど N_SAMPLES(480,000)サンプルに揃えます。任意の軸に沿って動作する点に注目してください。これは、生の波形(1D)ではなくメルスペクトログラム(2D)に対して操作する際に重要になります。

Tip: utils.pyexact_div() ユーティリティは通常の除算の代わりに使われており、インポート時に設定ミスを検出します。割り切れない場合は、音声定数に誤りがあることを意味します。

STFT とメルスペクトログラムの計算

log_mel_spectrogram() 関数はオーディオフロントエンドの核心です。ファイルパス、numpy array、または torch テンソルを受け取り、エンコーダーにそのまま渡せる対数メルスペクトログラムを返します。

計算は4つのステージで進みます。

flowchart LR
    A["Waveform\n[480000]"] -->|"torch.stft\nN_FFT=400\nHOP=160"| B["Complex STFT\n[201 × 3001]"]
    B -->|"abs()² → magnitudes\n(drop last frame)"| C["Power Spectrum\n[201 × 3000]"]
    C -->|"mel_filters @\n(matrix multiply)"| D["Mel Spectrum\n[80 × 3000]"]
    D -->|"clamp → log10\nmax-normalize → scale"| E["Log-Mel\n[80 × 3000]"]

コードの各ステップを見ていきましょう。

window = torch.hann_window(N_FFT).to(audio.device)
stft = torch.stft(audio, N_FFT, HOP_LENGTH, window=window, return_complex=True)
magnitudes = stft[..., :-1].abs() ** 2

STFT では400サンプル(25ms)のハン窓と160サンプル(10ms)のホップを使用し、(N_FFT/2 + 1) = 201 の周波数ビンを生成します。[..., :-1] で最後の時間フレームを除くことで、480,000サンプルからちょうど3000フレームを得られます。

メルフィルターバンクは mel_filters() によって事前計算済みの .npz ファイルから読み込まれます。

filters = mel_filters(audio.device, n_mels)  # [80, 201] or [128, 201]
mel_spec = filters @ magnitudes               # [80, 3000]

mel_filters() に付与された @lru_cache により、フィルターバンクはディスクから一度だけ読み込まれます。実行時に計算するのではなく .npz ファイルとして保存しておくことで、librosa への依存を避けています。ドックストリングには、このファイルを生成するために使用した librosa の呼び出しがそのまま記載されています。

最後に、対数メル正規化を行います。

log_spec = torch.clamp(mel_spec, min=1e-10).log10()
log_spec = torch.maximum(log_spec, log_spec.max() - 8.0)
log_spec = (log_spec + 4.0) / 4.0

この3ステップの正規化は、(1) ゼロの対数演算を防ぎ、(2) ダイナミックレンジをピークの80dB以内に制限し、(3) 値が概ねゼロを中心になるようにシフトとスケーリングを行います。定数 8.0 と 4.0 は学習時に決定され、モデルに組み込まれた値です。

テンソルの shape の変化を追う

次元の流れを把握することは、Whisper のデバッグや拡張において非常に重要です。波形からエンコーダー出力までの完全なパイプラインを示します。

flowchart TD
    A["Raw Waveform\n<b>[480000]</b>"] --> B["STFT Output\n<b>[201 × 3001]</b>"]
    B --> C["Power Magnitudes\n<b>[201 × 3000]</b>"]
    C --> D["Mel Spectrogram\n<b>[80 × 3000]</b>\n(or 128 × 3000)"]
    D --> E["After conv1\n<b>[d_model × 3000]</b>\nkernel=3, pad=1"]
    E --> F["After conv2\n<b>[d_model × 1500]</b>\nkernel=3, stride=2, pad=1"]
    F --> G["Permuted + Positional\n<b>[1500 × d_model]</b>"]
    G --> H["Transformer Output\n<b>[1500 × d_model]</b>"]

各モデルサイズにおける主要な次元は以下のとおりです。

Model d_model (n_audio_state) n_mels Encoder Layers Encoder Heads
tiny 384 80 4 6
base 512 80 6 8
small 768 80 12 12
medium 1024 80 24 16
large-v3 1280 128 32 20
turbo 1280 128 32 20

large-v3turbo は80ではなく128のメルビンを使用する点に注意してください。メルフィルターバンクのアセットには両方のバリアント(mel_80mel_128)が含まれており、ModelDimensionsn_mels フィールドで適切な方が選択されます。

AudioEncoder:畳み込みステムと Transformer

AudioEncoder は驚くほどコンパクトな実装になっています。

class AudioEncoder(nn.Module):
    def __init__(self, n_mels, n_ctx, n_state, n_head, n_layer):
        super().__init__()
        self.conv1 = Conv1d(n_mels, n_state, kernel_size=3, padding=1)
        self.conv2 = Conv1d(n_state, n_state, kernel_size=3, stride=2, padding=1)
        self.register_buffer("positional_embedding", sinusoids(n_ctx, n_state))
        self.blocks = nn.ModuleList(
            [ResidualAttentionBlock(n_state, n_head) for _ in range(n_layer)]
        )
        self.ln_post = LayerNorm(n_state)

畳み込みステムは、カーネルサイズ3と GELU 活性化関数を持つ2層の1D畳み込みで構成されています。1層目(conv1)は時間解像度を変えずに n_mels チャンネルから n_state(モデルの隠れ次元)へ投影します。2層目(conv2)は stride=2 を使用して、シーケンスを3000フレームから1500フレームへ半減させます。エンコーダー全体でここが唯一の時間方向ダウンサンプリングであり、後続の Transformer ブロックは1500フレームの解像度をそのまま維持します。

flowchart LR
    A["Mel\n[80 × 3000]"] -->|"Conv1d k=3 p=1\nGELU"| B["[d × 3000]"]
    B -->|"Conv1d k=3 s=2 p=1\nGELU"| C["[d × 1500]"]
    C -->|"permute(0,2,1)"| D["[1500 × d]"]
    D -->|"+ sinusoidal pos emb"| E["[1500 × d]"]
    E -->|"N × ResidualAttentionBlock\n(self-attention only)"| F["[1500 × d]"]
    F -->|"LayerNorm"| G["[1500 × d]"]

注目すべき設計上の選択として、エンコーダーは sinusoids() で生成される正弦波ポジショナルエンベディングを使い、デコーダーは学習済みポジショナルエンベディングnn.Parameter)を使います。正弦波エンベディングはバッファとして登録され、学習の対象にはなりません。エンコーダーの音声コンテキスト長は常にちょうど1500で固定されているため、これは理にかなっています。一方、デコーダーは最大 n_text_ctx=448 の可変長トークン列を処理するため、学習済みポジションがより細かいパターンを捉える可能性があります。

エンコーダーで使われる ResidualAttentionBlock は、セルフアテンションのみ(クロスアテンションなし)、4倍展開のフィードフォワード MLP、そして pre-norm の LayerNorm で構成されています。エンコーダーブロックにクロスアテンションが不要なのは、クロスアテンションの対象となるものが存在しないためです。エンコーダーは音声特徴量のみを処理します。

Tip: Whisper は nn.LayerNormnn.Linearnn.Conv1d をカスタムサブクラスでラップしており、正規化時には float32 にキャストし、linear/conv 演算時にはウェイトを入力の dtype にキャストします。これにより、明示的な混合精度管理を必要とせず、FP16 推論での数値的安定性が確保されます。

スペクトログラムからエンコーダーへ:全体像

transcribe() では、メルスペクトログラムは(30秒分のパディングを加えた)音声ファイル全体に対してあらかじめ計算され、単一のテンソルとして保持されます。その後、スライディングウィンドウが進むにつれてスライスされます。

mel = log_mel_spectrogram(audio, model.dims.n_mels, padding=N_SAMPLES)
content_frames = mel.shape[-1] - N_FRAMES

つまり、STFT はウィンドウごとではなく一度だけ計算されます。文字起こしループの各イテレーションでは30秒分のスライスを取り出し、3000フレームにパディングしてエンコーダーに渡します。最後のウィンドウが30秒の境界で終わらない場合でも、パディングによって十分なフレーム数が確保されます。

エンコーダーの出力([batch, 1500, d_model] テンソル)は、デコーダーのクロスアテンション層における key-value コンテキストになります。これは30秒ウィンドウごとに一度計算され、そのウィンドウ内のすべての自己回帰デコードステップで再利用されます(KV キャッシュを通じて。詳細は第4回で取り上げます)。

次回予告

音声波形からエンコーダーの特徴ベクトルまでの全工程を追いました。第3回ではテキスト側に焦点を移し、Whisper のトークナイゼーションシステムを詳しく見ていきます。tiktoken によるテキストのエンコード方法、1501個のタイムスタンプトークンが20ms精度でセグメント境界をどのように表現するか、そして特殊トークン列がモデルに言語とタスクをどのように伝えるかを解説します。