Python实现Log-Mel Fbank:分帧、加窗、FFT与Mel滤波器组

语音算法工程师学习路线 · 音频基础 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 文档。

下一篇:一个语音唤醒系统到底包含哪些模块?

相关推荐
甜到心里的蛋糕2 小时前
Playwright 无头浏览器自动发布 CSDN 图文草稿的实践(持久化 profile + 图床直传)
运维·python·playwright·爬虫自动化·博客运营
jason.zeng@15022072 小时前
(六)Prompt 优化
python·ai·langchain·prompt·ai编程·llama
全栈练习生3 小时前
大模型推理全链路
python·ai
泡海椒3 小时前
JQuick-Excel VALIDATION 实战:用作用范围约束 Excel 导入校验
开发语言·python·excel
小静AI工程实验室5 小时前
Python 爬虫解析 JSON-LD:多块 script、@graph 与坏数据的 9 个边界
爬虫·python·json
jason.zeng@15022075 小时前
(八)现有架构上新增一个通用Excel导出工具
python·架构·langchain·excel·llama
袁袁袁袁满5 小时前
AI Agent 如何“看懂“互联网?
爬虫·python·自动化·爬虫实战·多线程爬虫
泡海椒5 小时前
JQuick-Excel 实战:用 VALIDATION 建立可维护的 Excel 导入校验
开发语言·python·excel
Duang007_5 小时前
生产可观测性:从“系统慢“到“根因“的完整链路(Go / TypeScript)
后端·python·golang·typescript·prometheus