用Python实战解析音频信号:从STFT到梅尔频谱图的完整代码指南(附Librosa/Torchaudio对比)

最近在做一个音频分类的项目,发现很多刚接触音频信号处理的同学,对STFT和梅尔频谱图这两个核心概念的理解,往往停留在“调库”的层面。参数怎么选?不同库的实现有什么区别?为什么我的模型对梅尔频谱图更敏感?这些问题背后,其实是一系列关于信号本质和工程实践的权衡。今天,我们就抛开教科书式的理论推导,直接从代码实战出发,聊聊如何用Python把音频信号“看”得更清楚,以及在不同场景下,Librosa、SciPy和Torchaudio这三个工具库该怎么选、怎么用。

1. 从声音到数字:理解音频信号处理的起点

在开始写代码之前,我们需要对处理的对象有一个直观的认识。我们听到的声音,本质上是空气压力的连续波动。麦克风将这些波动转换成连续的电压信号,而计算机通过采样量化,将这个连续信号变成我们能处理的离散数字序列。这里有两个关键参数:采样率位深度

采样率决定了我们能捕获的最高频率。根据奈奎斯特采样定理,要无失真地还原一个信号,采样率必须至少是信号最高频率的两倍。人耳能听到的频率范围大约是20Hz到20kHz,因此CD音质采用44.1kHz的采样率,足以覆盖人耳听觉上限。位深度则决定了信号的动态范围,即最弱和最响声音之间的差异,通常用16位或24位表示。

当我们拿到一个.wav.mp3文件时,Python库会将其加载为一个一维的NumPy数组或PyTorch张量。这个数组的每个值,代表在某个特定时间点上的振幅。直接绘制这个数组,我们得到的是波形图,它展示了振幅随时间的变化。波形图很直观,但它有一个致命的缺点:它只告诉我们声音“有多响”,却无法告诉我们声音“是什么音高”或“由哪些频率组成”。一个钢琴的中央C和一个鼓声,在波形图上可能看起来都是起伏的波,我们难以区分。这就是我们需要时频分析的原因——我们需要一种方法,既能知道频率成分,又能知道这些成分出现的时间。

提示:在加载音频时,一个常见的参数是sr=None。这意味着保持音频文件原始的采样率。如果你指定一个具体的采样率(如sr=16000),库会自动进行重采样。对于语音处理,16kHz是一个常用值,因为它足以覆盖语音的主要频率成分(通常低于8kHz),同时能减少计算量。

2. 时频分析的基石:深入理解STFT及其工程实现

短时傅里叶变换是连接时域和频域的桥梁。它的核心思想非常直观:既然整个长信号不平稳(频率成分随时间变化),那我们就把信号切成一小段、一小段,假设每一小段在短时间内是平稳的,然后对每一小段分别做傅里叶变换。这样,我们就能得到一个二维矩阵,一个维度是时间(第几小段),另一个维度是频率,矩阵的值代表该时间段内该频率成分的强度。这个矩阵的可视化,就是频谱图

2.1 STFT的关键参数:不只是数字游戏

在代码中,STFT的实现围绕着几个核心参数展开,它们直接决定了频谱图的“清晰度”和计算效率。

  • n_fft (FFT窗口大小):这是最重要的参数之一。它决定了频率分辨率。n_fft越大,频率轴被划分得越细,你越能区分两个相近的频率。公式上,频率分辨率 = 采样率 / n_fft。例如,采样率16kHz,n_fft=512,那么频率分辨率约为31.25Hz;n_fft=2048,分辨率约为7.8Hz。但更大的n_fft意味着更长的分析窗口,会降低时间分辨率。
  • hop_length (帧移):它决定了时间分辨率。hop_length是相邻两帧开始点之间的采样点数。hop_length越小,时间轴上的点越密集,时间分辨率越高,但计算量也越大。通常,hop_length设置为n_fft // 4n_fft // 2,这是一个在时间分辨率和计算效率之间的良好折衷,同时能保证帧之间有足够的重叠,避免信息丢失。
  • window (窗函数):直接截取一段信号(相当于乘以一个矩形窗)会在频谱中引入高频的“频谱泄漏”。为了减少这种效应,我们在截取信号时,会乘以一个两端平滑过渡到零的窗函数,如汉宁窗、汉明窗。这相当于让一帧信号的边缘逐渐衰减,使得帧首尾能平滑连接。

下面是一个用纯NumPy手动实现STFT的简化示例,它能帮你理解这些参数是如何起作用的:

import numpy as np

def manual_stft(signal, sr, n_fft=2048, hop_length=512, window='hann'):
    """
    手动实现简化的STFT计算。
    """
    # 1. 创建窗函数
    if window == 'hann':
        win = np.hanning(n_fft)
    elif window == 'hamming':
        win = np.hamming(n_fft)
    else: # 矩形窗
        win = np.ones(n_fft)

    # 2. 计算总帧数
    num_frames = 1 + (len(signal) - n_fft) // hop_length
    # 3. 初始化STFT矩阵 (频率bins, 时间帧)
    stft_matrix = np.zeros((n_fft // 2 + 1, num_frames), dtype=np.complex128)

    # 4. 分帧、加窗、做FFT
    for i in range(num_frames):
        start = i * hop_length
        end = start + n_fft
        frame = signal[start:end]
        # 如果最后一帧长度不足,用零填充
        if len(frame) < n_fft:
            frame = np.pad(frame, (0, n_fft - len(frame)))
        windowed_frame = frame * win
        # 执行FFT,并取前一半(对称)
        fft_result = np.fft.rfft(windowed_frame)
        stft_matrix[:, i] = fft_result

    # 5. 计算时间轴和频率轴
    times = np.arange(num_frames) * hop_length / sr
    freqs = np.fft.rfftfreq(n_fft, d=1/sr)
    
    return stft_matrix, times, freqs

这个函数清晰地展示了STFT的流程:分帧、加窗、FFT、拼接。在实际项目中,我们当然不会自己写这个轮子,但理解这个过程对于调试参数和结果至关重要。

2.2 三大库的STFT实现对比

理解了原理,我们来看看Librosa、SciPy和Torchaudio这三个库在实现STFT时的异同。选择哪一个,往往取决于你的工作流和目标。

特性 Librosa SciPy (scipy.signal.stft) Torchaudio (torchaudio.transforms.Spectrogram)
主要定位 音频分析与处理专用库 通用科学计算库 PyTorch生态的音频处理库
输出格式 复数NumPy数组 (1+n_fft/2, frames) 复数NumPy数组 (freq_bins, frames) 实数PyTorch张量 (channel, freq_bins, frames)
默认窗函数 汉宁窗 (Hanning) 可自定义,默认是汉宁窗的变体 汉明窗 (Hamming)
易用性 极高,参数命名贴近音频领域 中等,参数名更通用(如nperseg, noverlap 高,与PyTorch生态无缝集成
性能考量 优化过的Cython后端,速度不错 稳健的通用实现 支持GPU加速,适合大批量数据预处理
适用场景 快速原型、研究、音频分析 需要底层控制或与其他SciPy函数集成 深度学习训练pipeline

Librosa的接口最为友好。它的librosa.stft函数直接返回复数STFT矩阵,并且可以方便地用librosa.amplitude_to_db转换为对数刻度进行可视化。对于大多数音频探索性数据分析和快速实验,它是首选。

import librosa
import librosa.display
import matplotlib.pyplot as plt

y, sr = librosa.load(librosa.ex('trumpet'), sr=None)
n_fft, hop_len = 2048, 512

# Librosa 一行代码计算STFT
D = librosa.stft(y, n_fft=n_fft, hop_length=hop_len)
D_db = librosa.amplitude_to_db(np.abs(D), ref=np.max)

plt.figure(figsize=(10, 4))
librosa.display.specshow(D_db, sr=sr, hop_length=hop_len, x_axis='time', y_axis='log')
plt.colorbar(format='%+2.0f dB')
plt.title('Librosa STFT Spectrogram (Log Frequency)')
plt.tight_layout()

SciPysignal.stft函数提供了更底层的控制。它返回频率数组、时间数组和STFT复数矩阵。它的参数命名(如nperseg, noverlap)更接近信号处理教科书。如果你需要精确控制STFT的每一个环节,或者你的项目本身重度依赖SciPy生态,这是一个好选择。

from scipy import signal

# SciPy 计算STFT
f, t, Zxx = signal.stft(y, fs=sr, nperseg=n_fft, noverlap=n_fft-hop_len, window='hann')
magnitude = np.abs(Zxx)
dB = 20 * np.log10(magnitude + 1e-10) # 手动转dB

plt.figure(figsize=(10, 4))
plt.pcolormesh(t, f, dB, shading='gouraud')
plt.colorbar(label='Magnitude (dB)')
plt.ylabel('Frequency [Hz]')
plt.xlabel('Time [sec]')
plt.title('SciPy STFT Spectrogram')
plt.tight_layout()

Torchaudio的核心优势在于与PyTorch的集成。它的Spectrogram变换类返回的是一个张量,可以直接送入GPU进行后续处理,这对于构建端到端的深度学习模型至关重要。在数据加载和增强的pipeline中,使用Torchaudio可以避免在NumPy数组和PyTorch张量之间来回转换。

import torch
import torchaudio
import torchaudio.transforms as T

waveform, sample_rate = torchaudio.load('your_audio.wav') # 直接加载为张量
# 创建变换对象
spectrogram_transform = T.Spectrogram(n_fft=n_fft, hop_length=hop_len, power=2.0)
# 应用变换,得到张量
spec = spectrogram_transform(waveform) # shape: (channel, freq_bins, time)
spec_db = T.AmplitudeToDB()(spec)

# 可视化时需要转为numpy
plt.imshow(spec_db[0].numpy(), origin='lower', aspect='auto', cmap='viridis')

3. 迈向听觉感知:梅尔频谱图的原理与实战

STFT频谱图是基于线性频率刻度的,但人耳对频率的感知并非线性。我们对100Hz到200Hz的差异非常敏感,但对10000Hz到10100Hz的差异几乎听不出来。为了让人工智能模型更好地模拟人类的听觉,或者更高效地处理音频信息,我们需要将线性频率刻度映射到一种更符合人耳感知的刻度上,这就是梅尔刻度

3.1 梅尔滤波器组:从线性到非线性的桥梁

梅尔频谱图的计算,是在STFT功率谱的基础上,乘以一组梅尔滤波器组。你可以把这组滤波器想象成一系列重叠的三角形,覆盖了整个可听频率范围。每个三角形滤波器在低频区域窄而密集,在高频区域宽而稀疏。STFT的每个线性频率bin的能量,会根据其位置,被分配到不同的梅尔滤波器中进行加权求和。最终,我们得到的是每个梅尔频带上的总能量,频率轴的维度也从几百上千个线性bin,压缩到了几十或一百多个梅尔bin。

这个过程的数学表达很简单:梅尔频谱 = 梅尔滤波器组矩阵 × STFT功率谱矩阵。在Librosa中,你可以直接调用librosa.filters.mel来生成这个滤波器组矩阵,并查看它的形状。

import librosa
import matplotlib.pyplot as plt
import numpy as np

sr = 22050
n_fft = 2048
n_mels = 128

# 生成梅尔滤波器组
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels)
# mel_basis.shape 会是 (128, 1025),即128个梅尔滤波器,每个滤波器覆盖1025个频率bin。

# 可视化前20个梅尔滤波器
plt.figure(figsize=(10, 4))
for i in range(20):
    plt.plot(mel_basis[i])
plt.title('First 20 Mel-filter banks')
plt.xlabel('FFT bin index')
plt.ylabel('Weight')
plt.tight_layout()
plt.show()

运行这段代码,你会看到滤波器在低频区(左侧)非常密集,在高频区(右侧)则稀疏而宽阔。这正是梅尔刻度的直观体现。

3.2 三大库的梅尔频谱图生成

同样,三个库都提供了生成梅尔频谱图的功能,但方式和侧重点不同。

Librosa一如既往地简洁。librosa.feature.melspectrogram函数封装了STFT、功率计算、梅尔滤波和对数压缩的完整流程。你只需要关心n_mels(梅尔频带数)这个核心参数。

# Librosa 计算梅尔频谱图
mel_spec = librosa.feature.melspectrogram(y=y, sr=sr, n_fft=n_fft, hop_length=hop_len, n_mels=128)
mel_spec_db = librosa.power_to_db(mel_spec, ref=np.max)

plt.figure(figsize=(10, 4))
librosa.display.specshow(mel_spec_db, sr=sr, hop_length=hop_len, x_axis='time', y_axis='mel')
plt.colorbar(format='%+2.0f dB')
plt.title('Librosa Mel-spectrogram')
plt.tight_layout()

SciPy本身没有内置的梅尔频谱图函数。但我们可以利用其计算STFT,再结合Librosa生成的梅尔滤波器组,手动完成矩阵乘法。这展示了库之间灵活的协作。

from scipy import signal
import librosa

# 用SciPy计算STFT功率谱
f, t, Zxx = signal.stft(y, fs=sr, nperseg=n_fft, noverlap=n_fft-hop_len)
S = np.abs(Zxx) ** 2 # 功率谱

# 用Librosa生成梅尔滤波器组
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=128)

# 应用滤波器组:矩阵乘法
mel_spec = np.dot(mel_basis, S) # (n_mels, time_frames)
mel_spec_db = librosa.power_to_db(mel_spec, ref=np.max)

TorchaudioMelSpectrogram变换类设计得非常高效,尤其适合批量处理。它内部同样集成了STFT、梅尔滤波等步骤,并可以方便地设置mel_scale(如'htk''slaney')等参数。

import torchaudio.transforms as T

# 创建梅尔频谱图变换
mel_spectrogram = T.MelSpectrogram(
    sample_rate=sr,
    n_fft=n_fft,
    hop_length=hop_len,
    n_mels=128,
    mel_scale='htk' # 或 'slaney'
)
# 应用到波形张量
mel_spec_tensor = mel_spectrogram(waveform) # shape: (channel, n_mels, time)
mel_spec_db_tensor = T.AmplitudeToDB()(mel_spec_tensor)

注意:mel_scale参数决定了从Hz到Mel的映射公式。'htk'使用HTK工具包(公式:2595 * log10(1 + f/700)),而'slaney'使用Auditory Toolbox的公式。在大多数现代应用中,两者差异不大,但如果你在复现特定论文的结果,需要注意论文使用的是哪一种。

4. 工程实践:参数调优、可视化与性能陷阱

掌握了基本操作后,我们进入更实际的工程环节。如何为你的任务选择合适的参数?如何有效地可视化?以及有哪些常见的性能坑需要避开?

4.1 参数选择指南:没有最好,只有最合适

  • n_ffthop_length:这是一个经典的权衡。对于语音信号,通常选择20-40ms的窗口长度。在16kHz采样率下,这对应320到640个采样点,因此n_fft常设为512或1024。hop_length通常设为n_fft // 2(50%重叠)或n_fft // 4(75%重叠),后者能提供更平滑的时间轴。
  • n_mels (梅尔频带数):常见的选择是64、80、128或256。更多的频带能保留更细的频率信息,但会增加特征维度。对于语音识别,40或80个梅尔频带是经典设置。对于音乐信息检索或环境声音分类,128或256个频带可能更合适。
  • fminfmax:这两个参数定义了梅尔滤波器组的频率范围。默认是fmin=0fmax=sr/2。但对于某些任务,限制范围可以去除无关噪声。例如,在语音处理中,可以将fmax设为8000,因为大部分语音信息集中在低频。

下面是一个对比不同n_fftn_mels参数效果的代码片段:

fig, axes = plt.subplots(2, 2, figsize=(12, 8))
param_combinations = [(512, 64), (2048, 64), (512, 128), (2048, 128)]

for ax, (n_fft_, n_mels_) in zip(axes.ravel(), param_combinations):
    mel_spec = librosa.feature.melspectrogram(y=y, sr=sr, n_fft=n_fft_, hop_length=hop_len, n_mels=n_mels_)
    mel_spec_db = librosa.power_to_db(mel_spec, ref=np.max)
    img = librosa.display.specshow(mel_spec_db, sr=sr, hop_length=hop_len, x_axis='time', y_axis='mel', ax=ax)
    ax.set_title(f'n_fft={n_fft_}, n_mels={n_mels_}')
    fig.colorbar(img, ax=ax, format='%+2.0f dB')
plt.tight_layout()

运行后,你可以清晰地看到:n_fft增大,频率方向的条纹更细(频率分辨率提高);n_mels增大,纵轴(梅尔频率)的刻度更密集。

4.2 高级可视化技巧

除了基本的specshow,我们还可以通过一些技巧让频谱图传达更多信息。

  • 叠加波形:在频谱图上方叠加原始波形,可以直观地看到时域能量与频域特征的对应关系。
  • 使用对数频率轴:对于音乐信号,使用对数频率轴(y_axis='log')有时比线性轴更能体现音高关系。
  • 染色板选择:Matplotlib的viridisplasmamagmainferno等配色方案在表示能量强度时比默认的jet更具感知均匀性,也更好看。
import librosa
import librosa.display
import matplotlib.pyplot as plt
import numpy as np

y, sr = librosa.load(librosa.ex('brahms'), duration=10)
D = librosa.amplitude_to_db(np.abs(librosa.stft(y)), ref=np.max)

fig, ax = plt.subplots(figsize=(14, 5))
# 绘制频谱图
img = librosa.display.specshow(D, sr=sr, x_axis='time', y_axis='log', ax=ax, cmap='magma')
# 在顶部叠加波形
ax2 = ax.twinx()
times = librosa.times_like(y, sr=sr)
ax2.plot(times, y, alpha=0.5, color='cyan', linewidth=0.5)
ax2.set_ylabel('Amplitude', color='cyan')
ax2.set_ylim([y.min(), y.max()])

ax.set(title='Spectrogram with Overlaid Waveform', ylabel='Frequency [Hz]')
fig.colorbar(img, ax=ax, format='%+2.0f dB', pad=0.01)
plt.tight_layout()
plt.show()

4.3 性能优化与常见陷阱

  • 批量处理与GPU加速:如果你有成千上万的音频文件需要处理,使用循环调用librosa会非常慢。此时,Torchaudio的优势就体现出来了。你可以将数据加载为DataLoader,利用torchaudio.transforms在GPU上进行批量变换,速度能提升几个数量级。
  • 内存占用:高采样率、长音频、大的n_fftn_mels会产生巨大的矩阵。在处理长音频时,考虑流式处理或分块计算。
  • 归一化:在将频谱图输入机器学习模型前,进行归一化是标准操作。常见的有均值方差归一化((x - mean) / std)或最小最大归一化((x - min) / (max - min))。关键是要在训练集上计算统计量,然后应用到验证集和测试集。
  • 静音段处理:音频中可能存在大量静音或背景噪声。直接计算对数能量(log(0))会导致负无穷大。因此,所有库在计算对数谱时,都会加上一个很小的数epsilon(如1e-10)来避免数值问题。你也可以在计算梅尔频谱图之前,先进行一个简单的能量门限过滤。
# 示例:使用能量门限进行静音检测和过滤(简易版)
import numpy as np

frame_length = 2048
hop_length = 512
# 计算短时能量
energy = np.array([
    sum(abs(y[i:i+frame_length]**2))
    for i in range(0, len(y)-frame_length, hop_length)
])
threshold = np.percentile(energy, 10) # 例如,将能量最低的10%视为静音
non_silent_frames = energy > threshold
# 只对非静音帧进行后续的特征提取,可以节省计算并提升特征质量

处理完一批音频特征后,我发现一个容易忽略的点是数据类型的转换。Librosa默认输出float64,而PyTorch模型通常使用float32。在构建pipeline时,提前将数据转换为float32不仅能节省一半内存,在GPU上计算也更快。另一个经验是,对于实时或准实时应用,hop_length的选择比n_fft更能影响延迟,因为hop_length直接决定了输出一帧新特征需要多少新的音频样本。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐