语音算法工程师学习路线 · 音频基础 02
摘要
本文从 16 kHz 单声道 PCM WAV 出发,使用 NumPy 实现预加重、分帧、Hamming 加窗、RFFT、功率谱、Mel 滤波器组和对数压缩,得到 80 维 Log-Mel Fbank,并说明训练端与部署端的逐层对齐方法。代码用于解释链路,不声称与 Kaldi、WeKWS 或其他前端的默认配置一致,也没有运行真实语音模型或目标设备。
1. 环境与输入
本次验证环境:Python 3.12.14、NumPy 2.3.5、macOS arm64。将第 3 节完整代码保存为 fbank_demo.py;自备非空的 16 kHz、单声道、16-bit PCM WAV,命名为 input.wav 并放在同一目录。代码不自动重采样或下混。
bash
python -m pip install numpy
python -B fbank_demo.py input.wav --save input_fbank.npy
| 参数 | 值 |
|---|---|
| sample rate | 16000 Hz |
| frame length | 25 ms / 400 samples |
| frame shift | 10 ms / 160 samples |
| pre-emphasis | 0.97 |
| window | Hamming |
| n_fft | 512 |
| n_mels | 80 |
| log floor | 1e-10 |
| logarithm | 自然对数 |
2. 从波形到 Fbank 的处理链路
text
PCM float32 → 预加重 → 分帧 → 加窗 → RFFT
→ 功率谱 → Mel 三角滤波器组 → log → [frames, n_mels]
PCM 归一化与预加重
本例按小端 int16 读取采样,除以 32768.0 转为 float32。部署端必须复现训练时的归一化、增益和截幅规则。
预加重使用 y[0]=x[0]、y[t]=x[t]-0.97*x[t-1]。是否预加重以训练配置为准,不应在部署端自行添加。
分帧与补零
16 kHz 下,25 ms 是 400 点,10 ms 是 160 点。短于一帧的输入补到 400 点,其余输入的末帧不足时补零。帧数为:
text
N = 1 + ceil((max(输入采样数, 400) - 400) / 160)
没有中心填充。若参考前端丢弃末帧或采用其他 snip_edges 规则,时间帧数也要重新对齐。
加窗、FFT 与功率谱
乘 Hamming 窗后,用 512 点 RFFT 得到非负频率分量,功率谱使用 abs(spectrum)**2 / 512。幅度谱与功率谱不能混用。FFT 点数小于分析帧长度时,NumPy 会截断输入;本例固定 400 点帧长和 512 点 FFT。
Mel 滤波器组与对数
采用以下 Mel 公式:
text
mel = 2595 × log10(1 + hz / 700)
hz = 700 × (10^(mel / 2595) - 1)
在 0 到 8 kHz 的 Mel 轴上均匀取点,映射到 FFT 频点后构造三角滤波器。本文没有对滤波器面积归一化。能量下限用于避免 log(0);自然对数、log10 和分贝换算的数值不同。
3. 完整可运行脚本
复制下面的全部代码,保存为 fbank_demo.py 即可运行,不依赖未公开的项目附件。
python
import argparse
import wave
from pathlib import Path
import numpy as np
def read_wav(path):
with wave.open(str(path), "rb") as reader:
channels = reader.getnchannels()
width = reader.getsampwidth()
sample_rate = reader.getframerate()
data = reader.readframes(reader.getnframes())
if channels != 1 or width != 2 or sample_rate != 16000:
raise ValueError("只接受 16 kHz / mono / 16-bit PCM WAV;请先按模型约定转换")
if not data:
raise ValueError("WAV 没有音频采样")
samples = np.frombuffer(data, dtype="<i2").astype(np.float32) / 32768.0
return samples, sample_rate
def hz_to_mel(hz):
return 2595.0 * np.log10(1.0 + np.asarray(hz) / 700.0)
def mel_to_hz(mel):
return 700.0 * (10.0 ** (np.asarray(mel) / 2595.0) - 1.0)
def frame_signal(samples, frame_length, frame_shift):
if len(samples) < frame_length:
samples = np.pad(samples, (0, frame_length - len(samples)))
count = 1 + int(np.ceil((len(samples) - frame_length) / frame_shift))
total = (count - 1) * frame_shift + frame_length
samples = np.pad(samples, (0, total - len(samples)))
starts = np.arange(count)[:, None] * frame_shift
return samples[starts + np.arange(frame_length)[None, :]]
def mel_filterbank(sample_rate, n_fft, n_mels):
mel_points = np.linspace(hz_to_mel(0), hz_to_mel(sample_rate / 2), n_mels + 2)
hz_points = mel_to_hz(mel_points)
bins = np.floor((n_fft + 1) * hz_points / sample_rate).astype(int)
bins = np.clip(bins, 0, n_fft // 2)
filters = np.zeros((n_mels, n_fft // 2 + 1), dtype=np.float32)
for index in range(n_mels):
left, center, right = bins[index:index + 3]
if center > left:
filters[index, left:center] = (
np.arange(left, center) - left
) / (center - left)
if right > center:
filters[index, center:right] = (
right - np.arange(center, right)
) / (right - center)
return filters
def compute_fbank(samples, sample_rate, n_mels=80):
emphasized = np.append(samples[0], samples[1:] - 0.97 * samples[:-1])
frame_length = round(sample_rate * 25.0 / 1000.0)
frame_shift = round(sample_rate * 10.0 / 1000.0)
frames = frame_signal(emphasized, frame_length, frame_shift)
frames *= np.hamming(frame_length).astype(np.float32)
spectrum = np.fft.rfft(frames, n=512)
power = (np.abs(spectrum) ** 2 / 512).astype(np.float32)
mel_energy = power @ mel_filterbank(sample_rate, 512, n_mels).T
return np.log(np.maximum(mel_energy, 1e-10)).astype(np.float32)
parser = argparse.ArgumentParser()
parser.add_argument("wav", type=Path)
parser.add_argument("--save", type=Path)
args = parser.parse_args()
samples, sample_rate = read_wav(args.wav)
features = compute_fbank(samples, sample_rate)
print(f"sample_rate={sample_rate}")
print(f"samples={samples.shape}, duration={len(samples) / sample_rate:.3f}s")
print(f"fbank_shape={features.shape}, dtype={features.dtype}")
print(f"min={features.min():.6f}, max={features.max():.6f}, mean={features.mean():.6f}")
if args.save:
np.save(args.save, features)
4. 基准结果与帧数解释
以下是项目内固定 1 秒、440 Hz、16 kHz、单声道、16-bit PCM 合成 WAV 的结果。它用于记录这个固定输入的基准,不代表任意录音都会产生相同统计值。
text
sample_rate=16000
samples=(16000,), duration=1.000s
fbank_shape=(99, 80), dtype=float32
min=-23.025850, max=-3.616596, mean=-16.075018
1 + ceil((16000 - 400) / 160) = 99,最后一帧需要补 80 个零。采用丢弃末帧的规则则会得到 98 帧。形状一致只是对齐检查的一部分;自备音频的长度、内容和幅值不同,帧数或统计量也会不同。
5. 常见错误与排查
- 格式不匹配:本例明确拒绝非 16 kHz、非单声道、非 16-bit PCM 的输入,先核对训练端要求,再转换音频。
- 空文件或损坏文件:检查实际音频采样和 WAV 编码,不要只看后缀。
- 张量形状相同,模型得分不同:核对 32768/32767 的归一化尺度、预加重、dither、去直流、窗函数、FFT 点数、幅度谱/功率谱、Mel 公式、频率范围、滤波器归一化和对数底数。
- 时间帧数不同:检查帧移的单位、中心填充、末帧补零和丢弃规则。
- 接入后整体分布变化:确认 CMVN 的统计范围、参数版本和执行顺序;本例不包含 CMVN。
6. 训练与部署如何逐层对齐
固定同一 WAV,分别导出归一化采样值、加窗帧、功率谱、Mel 能量和 Log-Mel。找到第一处不一致的位置,再检查该层参数。不要只比较最终关键词或识别结果。
逐层核对时,还要保留参数版本与输入文件校验值,避免误拿不同音频或不同前端配置进行比较。
7. 结论与适用范围
本文代码解释 Log-Mel 的核心计算过程,适合建立教学和排查基准。它采用简化的频点离散方式,没有 dither、去直流或 CMVN,不是任意训练前端的直接替代品。项目中以模型训练配置和已验证参考实现为准。
Fbank 是模型输入协议的一部分。两端任意一步不同,即使张量形状一致,也可能改变模型的输入分布。
参考:NumPy RFFT 文档、NumPy Hamming 文档。
下一篇:一个语音唤醒系统到底包含哪些模块?