从声波到梅尔频谱:Whisper 的音频前端
前置知识
- ›第 1 篇(架构概览)
- ›频域与傅里叶变换的基本概念
- ›PyTorch 张量操作
从声波到梅尔频谱:Whisper 的音频前端
在第 1 篇中我们已经梳理了 Whisper 的整体流程:从原始音频出发,最终生成解码器可以关注的特征向量序列。本篇将逐步拆解这一过程的每个细节——从调用 FFmpeg 子进程读取任意音频格式,到执行 STFT 和梅尔滤波器组计算,再到 AudioEncoder 中将时间分辨率减半的卷积主干。我们会在每个阶段追踪张量的形状变化,因为在信号处理中,维度错误是最常见也最难排查的问题。
整个音频前端只涉及两个文件:whisper/audio.py(157 行)负责处理直到梅尔频谱的所有逻辑,whisper/model.py 中的 AudioEncoder 类则接手后续的工作。
通过 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 等)。输出是写入 stdout 的原始 PCM 数据——16 位有符号整数、16kHz 单声道——用 np.frombuffer 即可轻松解析。最后除以 32768.0,将数值归一化到 [-1.0, 1.0] 的 float32 范围。
这种方式的代价是系统必须安装 FFmpeg。不过对于面向机器学习从业者的工具来说,这个前提条件完全合理。
30 秒分块设计
audio.py 顶部的六个常量定义了整个系统的时间结构:
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
另外三个派生常量将音频帧与模型 token 关联起来:
N_SAMPLES_PER_TOKEN = HOP_LENGTH * 2 # 320(stride-2 卷积)
FRAMES_PER_SECOND = exact_div(SAMPLE_RATE, HOP_LENGTH) # 100
TOKENS_PER_SECOND = exact_div(SAMPLE_RATE, N_SAMPLES_PER_TOKEN) # 50
30 秒是整个系统最基本的处理单元。模型在 30 秒的音频片段上训练,编码器的位置嵌入固定为 1500 个位置(3000 个梅尔帧经过 stride-2 卷积后减半)。无论音频有多长,最终都会以 30 秒为窗口逐段处理。
pad_or_trim() 函数负责强制执行这一约束:对于过短的片段进行零填充,对于过长的片段进行截断,使其恰好达到 N_SAMPLES(480,000)个采样点。值得注意的是,该函数支持沿任意轴操作,这在处理梅尔频谱(二维)而非原始波形(一维)时尤为重要。
提示: 这里使用的是来自
utils.py的exact_div()工具函数,而非普通除法,目的是在导入时就能捕获配置错误。如果这些数字无法整除,说明音频常量的配置存在问题。
STFT 与梅尔频谱计算
log_mel_spectrogram() 是整个音频前端的核心。它接受文件路径、numpy 数组或 torch 张量作为输入,返回可直接送入编码器的对数梅尔频谱。
整个计算分为四个阶段:
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)的 Hann 窗,跳步为 160 个采样点(10ms),生成 (N_FFT/2 + 1) = 201 个频率分箱。[..., :-1] 丢弃最后一个时间帧,从而从 480000 个采样点中精确得到 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
这三步归一化分别用于:(1)防止对零取对数,(2)将动态范围限制在峰值以下 80 dB,(3)平移并缩放数值,使其大致以零为中心。8.0 和 4.0 这两个常量是训练时确定的超参数,已固化在模型中。
张量形状变换
理解维度的流转对于调试和扩展 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>"]
各模型规格对应的关键维度:
| 模型 | d_model (n_audio_state) | n_mels | 编码器层数 | 编码器注意力头数 |
|---|---|---|---|---|
| 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-v3 和 turbo 使用 128 个梅尔分箱,而非 80 个。梅尔滤波器组资产文件中同时包含两种变体(mel_80 和 mel_128),由 ModelDimensions 中的 n_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)
卷积主干由两层一维卷积构成,均使用 kernel size 3 和 GELU 激活函数。第一层(conv1)将通道数从 n_mels 映射到 n_state(模型的隐藏维度),时间分辨率保持不变。第二层(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)。正弦嵌入以 buffer 的形式注册(不参与训练),这对编码器来说合情合理——音频上下文的长度始终恰好是 1500,没有什么需要学习的。而解码器需要处理长度可变的 token 序列(最多 n_text_ctx=448),可学习的位置嵌入或许能捕捉更细腻的模式。
编码器中的 ResidualAttentionBlock 仅包含自注意力(无交叉注意力)、4 倍扩展的前馈 MLP 以及 pre-norm LayerNorm。编码器块不需要交叉注意力,因为没有可供交叉关注的外部信息——它只处理音频特征本身。
提示: Whisper 对
nn.LayerNorm、nn.Linear和nn.Conv1d进行了自定义封装:归一化操作强制使用 float32,线性/卷积操作的权重则转换为输入的数据类型。这一设计在不需要显式管理混合精度的情况下,保证了 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] 的张量——将成为解码器交叉注意力层的键值上下文。它在每个 30 秒窗口内只计算一次,并在该窗口内所有自回归解码步骤中复用(通过 KV 缓存实现,我们将在第 4 篇中详细探讨)。
下一步
至此,我们已经完整追踪了从声波到编码器特征向量的全部过程。第 3 篇将转向文本侧,深入探讨 Whisper 的分词系统——包括 tiktoken 如何编码文本、1501 个时间戳 token 如何以 20ms 的精度标记片段边界,以及特殊 token 序列如何告知模型使用何种语言和执行何种任务。