从波形到Log-Mel Fbank:原理、NumPy实现与特征对齐

📅 发布时间:2026/10/10 12:34:03
从波形到Log-Mel Fbank:原理、NumPy实现与特征对齐
语音算法工程师学习路线 · 音频基础 02问题背景语音模型的输入不仅由张量形状定义还由生成张量的完整前处理决定。两套实现都输出[T, 80]并不意味着它们生成了相同的 Fbank。本文面向需要理解音频前处理、核对训练与部署输入的工程师记录一套可复现的 Log-Mel Fbank 实现并重点讨论工程中容易产生差异的参数。处理流程WAV解码 → float32归一化 → 预加重 → 分帧 → 加窗 → RFFT → 功率谱 → Mel滤波器组 → 对数压缩 → Fbank实验使用 16 kHz、单声道、16-bit PCM WAV帧长 25 ms帧移 10 ms512 点 FFT80 个 Mel 滤波器。短时分析语音在较长时间范围内是非平稳信号但在几十毫秒内可以近似进行短时频谱分析。帧长影响频率分辨率和短时稳定假设帧移影响时间采样密度和计算量。本文不做中心填充对末尾不足一帧的部分补零。帧数计算为1 ceil((max(采样数, 400) - 400) / 160)。固定 1 秒、16000 个采样点得到 99 帧最后一帧补 80 个零如果丢弃不完整末帧则得到 98 帧。不同库采用丢弃、中心填充或其他边界规则时时间帧数可能不同。窗函数与功率谱有限长度截断会产生频谱泄漏。乘窗能降低边界突变但也会改变谱形。训练使用 Hamming部署就不能随意换成 Hann。功率谱计算为spectrum np.fft.rfft(frames, nn_fft) power np.abs(spectrum) ** 2 / n_fft这里的缩放方式也需要写进输入规范。Mel滤波器组与对数Mel 滤波器组在低频区域更密、高频区域更疏。三角滤波器对功率谱做频带汇聚后再使用对数压缩动态范围。mel_energy power filters.T fbank np.log(np.maximum(mel_energy, 1e-10))本实现覆盖 0–8000 Hz用 floor 将 Mel 边界离散到 FFT 频点构造简化三角滤波器不做滤波器面积归一化使用自然对数能量下限为 1e-10。Mel 公式、频率上下限、频点离散、面积归一化、对数底数和能量下限都会影响数值。可复现实验使用 Python 3.10 或以上版本依赖 NumPy。本次验证环境为 Python 3.12.14、NumPy 2.3.5、macOS arm64这是前处理脚本验证没有运行真实语音模型或目标设备。将下方完整代码保存为fbank_demo.py。自备非空的 16 kHz、单声道、16-bit PCM WAV命名为input.wav并放在同一目录脚本不会自动重采样或下混。python -m pip install numpy python -B fbank_demo.py input.wav --save input_fbank.npy以下是完整源码包含命令行入口、分帧和 Mel 滤波器组实现#!/usr/bin/env python3 Read a mono 16-bit PCM WAV and compute Log-Mel Fbank features. from __future__ import annotations import argparse import wave from pathlib import Path import numpy as np def read_wav(path: Path) - tuple[np.ndarray, int]: with wave.open(str(path), rb) as reader: channels reader.getnchannels() sample_width reader.getsampwidth() sample_rate reader.getframerate() frames reader.readframes(reader.getnframes()) if channels ! 1 or sample_width ! 2 or sample_rate ! 16000: raise ValueError(示例只接受 16 kHz / mono / 16-bit PCM WAV请先按模型约定转换) if not frames: raise ValueError(WAV 没有音频采样) samples np.frombuffer(frames, dtypei2).astype(np.float32) / 32768.0 return samples, sample_rate def hz_to_mel(hz: np.ndarray | float) - np.ndarray: return 2595.0 * np.log10(1.0 np.asarray(hz) / 700.0) def mel_to_hz(mel: np.ndarray | float) - np.ndarray: return 700.0 * (10.0 ** (np.asarray(mel) / 2595.0) - 1.0) def frame_signal(samples: np.ndarray, frame_length: int, frame_shift: int) - np.ndarray: if len(samples) frame_length: samples np.pad(samples, (0, frame_length - len(samples))) frame_count 1 int(np.ceil((len(samples) - frame_length) / frame_shift)) total_length (frame_count - 1) * frame_shift frame_length samples np.pad(samples, (0, total_length - len(samples))) starts np.arange(frame_count)[:, None] * frame_shift offsets np.arange(frame_length)[None, :] return samples[starts offsets] def mel_filterbank(sample_rate: int, n_fft: int, n_mels: int) - np.ndarray: mel_points np.linspace(hz_to_mel(0.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), dtypenp.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: np.ndarray, sample_rate: int, frame_ms: float 25.0, shift_ms: float 10.0, n_fft: int 512, n_mels: int 80, pre_emphasis: float 0.97, ) - np.ndarray: if samples.ndim ! 1 or samples.size 0: raise ValueError(samples 必须是非空的一维采样序列) if sample_rate ! 16000 or frame_ms 0 or shift_ms 0 or n_mels 0: raise ValueError(本示例要求 16 kHz 输入帧长、帧移与 Mel 维数均为正) emphasized np.append(samples[0], samples[1:] - pre_emphasis * samples[:-1]) frame_length round(sample_rate * frame_ms / 1000.0) frame_shift round(sample_rate * shift_ms / 1000.0) if frame_length 1 or frame_shift 1 or n_fft frame_length: raise ValueError(帧长和帧移至少为 1 个采样点FFT 点数不能小于帧长) frames frame_signal(emphasized, frame_length, frame_shift) frames * np.hamming(frame_length).astype(np.float32) spectrum np.fft.rfft(frames, nn_fft) power (np.abs(spectrum) ** 2 / n_fft).astype(np.float32) mel_energy power mel_filterbank(sample_rate, n_fft, n_mels).T return np.log(np.maximum(mel_energy, 1e-10)).astype(np.float32) def main() - int: parser argparse.ArgumentParser(description__doc__) parser.add_argument(wav, typePath) parser.add_argument(--n-mels, typeint, default80) parser.add_argument(--save, typePath, help可选将特征保存为 .npy) args parser.parse_args() samples, sample_rate read_wav(args.wav) features compute_fbank(samples, sample_rate, n_melsargs.n_mels) print(fsample_rate{sample_rate}) print(fsamples{samples.shape}, duration{len(samples) / sample_rate:.3f}s) print(ffbank_shape{features.shape}, dtype{features.dtype}) print(fmin{features.min():.6f}, max{features.max():.6f}, mean{features.mean():.6f}) print(first_frame_first_8, np.array2string(features[0, :8], precision5)) if args.save: args.save.parent.mkdir(parentsTrue, exist_okTrue) np.save(args.save, features) print(fsaved{args.save.resolve()}) return 0 if __name__ __main__: raise SystemExit(main())项目内固定 1 秒、440 Hz、16 kHz、单声道、16-bit PCM 合成音频的基准统计为fbank_shape(99, 80), dtypefloat32 min-23.025850, max-3.616596, mean-16.075018自备语音会得到不同的帧数与统计量。上面数字仅描述固定输入不能拿它判断另一段音频是否正确。本文固定 16 kHz 输入以保持 400 点帧长不超过 512 点 FFT改变采样率时需要重新定义整套参数。对齐策略推荐把前处理拆成稳定接口并对固定 WAV 保存以下参考数据归一化后的采样值第一帧加窗结果第一帧功率谱第一帧 Mel 能量第一帧 Log-Mel完整特征的 shape 和统计量。逐层对齐能够判断问题发生在音频解码、帧边界、频谱、Mel 滤波还是后续归一化而不是把所有差异都归因于模型导出。局限本文实现用于解释链路和建立对齐基准没有实现去直流、dither 或 CMVN不声称与 Kaldi、torchaudio、librosa 或任意商用 SDK 默认配置完全一致。项目中应以训练配置和已有参考实现为准本文统计不代表模型效果、速度、内存或功耗。结论Fbank 并不是可替换的通用前处理名称而是一组必须明确版本和参数的计算规则。输入契约没有固定模型部署就没有可靠的数值基线。下一篇一个语音唤醒系统包含哪些模块原作者AI算法学习社。微信搜索公众号「AI算法学习社」回复「语音路线」领取公开免费版。