AIGC标识 从波形到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 ms,512 点 FFT,80 个 Mel 滤波器。

短时分析

语音在较长时间范围内是非平稳信号,但在几十毫秒内可以近似进行短时频谱分析。帧长影响频率分辨率和短时稳定假设,帧移影响时间采样密度和计算量。

本文不做中心填充,对末尾不足一帧的部分补零。帧数计算为 1 + ceil((max(采样数, 400) - 400) / 160)。固定 1 秒、16000 个采样点得到 99 帧,最后一帧补 80 个零;如果丢弃不完整末帧,则得到 98 帧。不同库采用丢弃、中心填充或其他边界规则时,时间帧数可能不同。

窗函数与功率谱

有限长度截断会产生频谱泄漏。乘窗能降低边界突变,但也会改变谱形。训练使用 Hamming,部署就不能随意换成 Hann。

功率谱计算为:

spectrum = np.fft.rfft(frames, n=n_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, dtype="<i2").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), 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: 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, n=n_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", type=Path)
    parser.add_argument("--n-mels", type=int, default=80)
    parser.add_argument("--save", type=Path, help="可选:将特征保存为 .npy")
    args = parser.parse_args()

    samples, sample_rate = read_wav(args.wav)
    features = compute_fbank(samples, sample_rate, n_mels=args.n_mels)
    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}")
    print("first_frame_first_8=", np.array2string(features[0, :8], precision=5))
    if args.save:
        args.save.parent.mkdir(parents=True, exist_ok=True)
        np.save(args.save, features)
        print(f"saved={args.save.resolve()}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

项目内固定 1 秒、440 Hz、16 kHz、单声道、16-bit PCM 合成音频的基准统计为:

fbank_shape=(99, 80), dtype=float32
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算法学习社」,回复「语音路线」,领取公开免费版。

posted @ 2026-10-08 16:39  野哥李  阅读(2)  评论(0)    收藏  举报