Qwen3-ASR 课程 · 第 5 课 · 补充课(傅里叶系列下篇)

梅尔频谱手把手 —— 给频率轴换一把人耳尺子

⏱ 约 25 分钟(含两个手算示例 + 一个实验室)· 前置:傅里叶可视化 · 本课目标:亲手完成"语谱图 → 梅尔频谱"的每一步换算与合并

语谱图你已经会算了:切帧 → 逐帧扫频 → 排开。梅尔频谱只在最后多加一道工序:把频率轴从"物理尺子(Hz)"换成"人耳尺子(mel)"。为什么换、怎么换、换的时候数字怎么算——这节课全部手把手走一遍。

一、为什么要换尺子:人耳的"不公平"

做一个思想实验,两对声音:

频率都翻倍,听感距离相同——但在线性 Hz 轴上,第一对只占 500Hz 的地盘,第二对占了 4000Hz,8 倍的冤枉空间。人耳天生对低频敏感、高频迟钝。线性语谱图把宝贵的表示空间大量浪费在人耳根本分不出来的高频细节上。

mel 尺子的一句话定义 把 Hz 频率轴做一次非线性换算,使得"听感上差一截"的频率,在 mel 轴上也差一截。效果:低频被拉开(看得细),高频被压缩(看得粗)。

二、手算示例①:Hz 换算成 mel

换算只有一条规则(公式放脚注,先看数字)。取 8 个频率点,换算结果如下——重点看最右一列的"mel 轴地盘":

频率mel 值相邻两点 Hz 差相邻两点 mel 差(mel 轴地盘)
500 Hz607——
1000 Hz1000+500+393
1500 Hz1291+500+291
2000 Hz1521+500+231
3000 Hz1876+1000+355
4000 Hz2146+1000+270
6000 Hz2546+2000+400
8000 Hz2840+2000+294

表格里 8 个 mel 值不用信我,现场算——公式在脚注里,改成任何频率都行:

import warnings warnings.filterwarnings("ignore") import numpy as np def hz_to_mel(f): return 2595 * np.log10(1 + np.asarray(f, dtype=float) / 700) freqs = [500, 1000, 1500, 2000, 3000, 4000, 6000, 8000] # 改我! for f, m in zip(freqs, hz_to_mel(freqs)): print(f"{f:>5} Hz -> {m:6.0f} mel") print("锚点复核: mel(1000) 应为 1000")

看出不公平了吗:

同样的"听感一截",物理 Hz 距离差 4 倍,mel 轴却给它们差不多的地盘——这就是"人耳尺子"的含义。顺带记一个锚点:1000Hz 正好 = 1000 mel(这是 mel 刻度的定义校准点,数学家特意选的)。

脚注:换算公式(本课唯一公式) mel = 2595 × log₁₀(1 + f/700)。例:mel(8000) = 2595 × log₁₀(1 + 8000/700) = 2595 × log₁₀(12.43) ≈ 2840。表里每个数都能用计算器复算。

三、手算示例②:把频率能量"合并装桶"

mel 换算之后,合并的做法是:在 mel 轴上等分若干桶,每桶把它盖住的频点能量合并成一个数。用刚才 8 个频点做一遍——设能量已算出,把 mel 轴(0–2840)三等分:

桶(mel 轴等分)盖住的频点合并计算桶值
桶① 0–947 mel(≈0–920Hz,窄)500Hz(mel 607)只有 1 个频点:0.900.90
桶② 947–1894 mel1000(1000)、1500(1291)、2000(1521)、3000(1876)(0.70+0.20+0.50+0.10)÷40.375
桶③ 1894–2840 mel(≈3050–8000Hz,宽)4000、6000、8000Hz(0.30+0.05+0.10)÷30.15

看桶①和桶③的对比:桶① 只管约 900Hz 宽的低频区、独享 1 个频点;桶③ 要管 4300Hz 宽的高频区、把 3 个频点捏成一柱。低频看得细、高频看得粗——mel 的全部含义就是这张表。

脚注:真实实现的差别 实际的梅尔频谱不用"简单平均",而是给每桶一个三角形权重(桶中心权重 1、边缘 0)做加权平均;桶数典型 80(Whisper)或 128。思想与上表完全一致,只是加权更平滑。

三角滤波器组到底在干嘛:三个作用

上面用"简单平均装桶"演示了思想,现在把三角滤波器组的完整作用说透——它一口气干了三件事:

  1. 装桶(压缩):128 个细频点 → 24/80 个频带。序列变短、计算变省,而且桶的位置和宽度由 mel 刻度决定——mel 换算表是图纸,滤波器组才是真正搬东西的工人。
  2. 抹平细节、留下重点(平滑):决定"说的是什么字"的是共振峰(频谱包络),不是每根谐波的确切位置。三角加权平均把细碎的谐波条纹抹平、把包络留下。附带好处:说话人嗓音高低不同只会让谐波条纹整体平移,包络几乎不动——特征对"谁在说、什么调"更稳定,模型专心认字。
  3. 软边界(为什么是三角形,不是切豆腐):桶与桶之间不用"硬切",而是权重从中心 1 渐变到边缘 0,相邻三角互相重叠。

软边界为什么重要?设相邻两桶边界约在 920Hz,同一个频率成分稍稍滑动会怎样:

成分频率硬切(方桶)三角(示意)
918 Hz桶① 100% / 桶② 0%桶① 0.90 / 桶② 0.10
922 Hz桶① 0% / 桶② 100%桶① 0.88 / 桶② 0.12

硬切方案里,说话人音调稍高一点点,能量整块跳进另一个桶,相邻帧的特征数字剧烈翻转,模型看到抖动的输入;三角方案里数字平滑滑动,边界处信息也不丢(每个频点同时喂给两个相邻三角)。一句话:三角形 = 会渐变的桶壁,让"装桶"这个粗暴操作变得平滑可学。

四、梅尔实验室:亲手拖一遍

下面把整个过程装进一台机器:上面是三角滤波器组(就是"桶",画在 Hz 轴上——注意它们左窄右宽、左边挤右边稀),下面把"合并前的线性频谱"和"合并后的 mel 频带"上下对照。拖滑块改变桶数,点任意紫柱看它的覆盖范围:

三个值得亲手做的观察:

  1. 数三角:频带数 24 时,前 6 个三角全挤在 1kHz 以下,而 3–8kHz 的高频只有寥寥几个宽三角——手算示例①的"不公平"直接画在了眼前。
  2. 看合并:上排 128 根细柱(谐波条纹),下排只剩 24 根粗柱;低频条纹一根根保留,高频条纹被宽桶平均抹平——手算示例②的"装桶"。
  3. 拖滑块到两端:6 个带(太粗,细节丢太多)vs 48 个带(更细,但高频仍比线性轴粗)——频带数是你在微调数据时真正会碰到的参数。

滑块实验室是看,Python 是亲手造。下面就是 librosa 等工具箱内部构造 mel 滤波器的真实做法(简化版):先在 mel 轴上均匀取拐点,拉回 Hz 做三角——整个"装桶"最后就是一次矩阵乘法 F @ 线性频谱:

import warnings warnings.filterwarnings("ignore") import numpy as np import matplotlib.pyplot as plt def hz_to_mel(f): return 2595 * np.log10(1 + np.asarray(f, dtype=float) / 700) def mel_to_hz(m): return 700 * (10 ** (np.asarray(m, dtype=float) / 2595) - 1) n_mels, n_bins, fmax = 24, 128, 8000 # 改 n_mels:6 粗糙 / 80 细腻 mel_pts = np.linspace(0, hz_to_mel(fmax), n_mels + 2) centers = mel_to_hz(mel_pts) # n_mels+2 个拐点:三角的底角与顶点 freqs = np.linspace(0, fmax, n_bins) F = np.zeros((n_mels, n_bins)) # 滤波器组矩阵:第 i 行 = 第 i 个三角 for i in range(n_mels): lo, c, hi = centers[i], centers[i+1], centers[i+2] F[i] = np.clip(np.minimum((freqs-lo)/(c-lo), (hi-freqs)/(hi-c)), 0, 1) plt.figure(figsize=(8, 2.8)) plt.plot(freqs/1000, F.T, lw=1) plt.xlabel("频率 (kHz)"); plt.ylabel("权重") plt.title(f"滤波器组矩阵 F({n_mels}行×{n_bins}列) 的每一行 = 一个三角") plt.tight_layout(); plt.show() # 合成"元音"线性频谱(基频 120Hz 谐波 + 共振峰),再装桶 f0 = 120 spec = np.zeros(n_bins) for h in range(1, int(fmax/f0) + 1): fh = h * f0 g = np.exp(-((fh-800)/420)**2) + 0.5*np.exp(-((fh-1150)/500)**2) spec[int(fh/fmax*n_bins)] += g / h**0.6 spec /= spec.max() mel_spec = F @ spec # 装桶 = 矩阵乘法,就这一行 plt.figure(figsize=(8, 3.6)) plt.subplot(2, 1, 1); plt.plot(freqs/1000, spec, lw=0.8) plt.ylabel("能量"); plt.title(f"线性频谱({n_bins} 频点):谐波条纹") plt.subplot(2, 1, 2) plt.bar(centers[1:-1]/1000, mel_spec, width=(centers[2:]-centers[:-2])/1000*0.9) plt.ylabel("能量"); plt.xlabel("频率 (kHz)") plt.title(f"mel 频谱({n_mels} 带) = F @ 线性频谱") plt.tight_layout(); plt.show() print("F 形状 =", F.shape, "| mel 频谱形状 =", mel_spec.shape) print("对照:改 n_mels=6 再运行(条纹被抹平),n_mels=80(低频条纹保留)")
和滑块实验室互相印证 ① 上面 F 矩阵的行画出来,就是滑块实验室里那排"左窄右宽"的三角——现在你知道它们是一个矩阵的每一行。② n_mels=6 时高频谐波条纹被抹平、=80 时低频细节都在——"频带数"这个微调超参数的影响,一次改一次跑就摸清了。③ 第 6 站微调课里你真的会碰 feature_size: 128 这样的配置——那就是这里的 n_bins 换了个名字而已。

五、串回 Qwen3-ASR

今天的概念在管道里的位置
mel 换算表 / 三角滤波器第 3 课管道图的"特征提取"一站;Whisper 用 80 个 mel 带,Qwen3-ASR 的确切配置(带数、帧长)下节从 processor 亲眼看
低频细、高频粗语音辨识信息集中在低中频——mel 把模型容量花在刀刃上,还顺带把序列压短(128→80),算得更快
频带数参数微调/改造前端时会真实触碰的超参数:太少丢细节,太多费算力
傅里叶系列完结 现在整条链你每一步都手算或手拖过:采样 → 切帧 → 逐帧扫频(频谱)→ mel 装桶(梅尔频谱)→ 喂给 AuT 编码器做注意力。语音前端彻底通关,下一课正式拆 Qwen3-ASR 架构。

检索练习

本课主读材料

去读(约 12 分钟)

Getting to Know the Mel Spectrogram — Dalya Gartzman

读法:这篇按"波形→频谱→mel"顺序用真音频讲一遍,正好当你今天手算的"实战复核版"。重点读 mel 换算和滤波器组两节,和本课的表格、实验室对照印证。

本课新术语已入 术语表:mel 滤波器组、频带。

我是你的老师,别客气 换算表里任何一个数都可以拿计算器验我(log 按钮就是 log₁₀)。哪一步算着不对、或实验室里哪个现象出乎意料,贴回来我们一起看。