短时傅里叶变换STFT与spectrogram参数配置实战
2026/9/20 11:17:51 网站建设 项目流程

简介:传统的傅里叶变换(FFT)擅长揭示信号中的频率成分,但面对语音、振动等非平稳信号时,无法回答“某个频率何时出现”这一关键问题。短时傅里叶变换(STFT)通过分帧加窗在时间轴上滑动,将一维信号映射为二维时频谱,以时间分辨率和频率分辨率的折中为代价,实现了频率成分的时间定位。基于STFT生成的spectrogram(语谱图)已成为音频分析、语音处理、机械振动故障检测等工程领域不可或缺的可视化工具。在实际使用Python的scipy.signal.spectrogram时,帧长nperseg、重叠noverlap、窗函数的选择与补零nfft等参数,直接决定频谱图的质量与物理含义。本文结合真实项目经验,系统拆解spectrogram各参数的作用与配置策略,帮助开发者规避频谱泄漏、边界效应等常见陷阱。 前阵子我在整理一段野外录音,想找出里面某段鸟叫具体出现在哪个时间点。当时脑子一热,直接对整个文件做了一次FFT,结果只拿到一张“全局频谱”——图上确实有个峰,但我完全看不出这个峰对应录音的第几秒。后来换成spectrogram函数,几行代码就看清了频率随时间的变化。这件事让我意识到,很多人学了不少傅里叶变换,但遇到非平稳信号时不知道为什么要用短时傅里叶变换(STFT),更不清楚spectrogram这个函数背后的参数到底该怎么设置。这篇东西就围绕STFT和spectrogram函数,把我实际项目里验证过的用法和经验完整梳理一遍,适合做音频分析、语音处理、振动信号诊断的开发者参考。

1. 为什么直接做FFT不够用:从一段音频的“时间定位”说起

1.1 傅里叶变换的“全局平均”困境

标准傅里叶变换的公式写出来就一眼能发现问题:

X(f) = ∫ x(t) · e^(-j2πft) dt

积分区间是整段时间轴。这意味着FFT输出的每个频率分量,是整段信号在这个频率上的“平均状态”。如果信号是平稳的——比如一个稳定的50Hz正弦波,那没问题,FFT能完美告诉你“这里面有50Hz”。但现实中的信号大多是非平稳的:语音在几毫秒内就会变化,音乐有音符起落,机械振动有瞬态冲击。

举个我实测过的例子:一段2秒的信号,前半秒是100Hz正弦,后半秒是1000Hz正弦。对整个信号做FFT,你会看到两个明显的频率峰。可是单看这张频谱图,你完全分不清这两个频率是同时存在,还是先后出现的。如果只关心“有没有这个频率”,FFT够用;一旦关心“这个频率在什么时候出现”,FFT就彻底失效了。

我的第一次踩坑就在这里:看频谱图找到了对应鸟叫的频率,但没法定位时间点,等于只知道了“有鸟叫”,不知道“鸟叫在50秒还是80秒”。这个问题并不是FFT算法有缺陷,而是它的数学形式本身就抛弃了时间维度的信息。

1.2 分段加窗:STFT的朴素起点

STFT的解决思路并不复杂,甚至可以说是“土办法”:既然整段做FFT会丢失时间信息,那就把时间轴切成一段一段的短片段,假设每个短片段内信号近似平稳,然后对每个片段分别做FFT,最后把所有片段的频谱按时间顺序拼起来。

这就是短时傅里叶变换的核心思想。每个短片段由一个窗函数截取出来,窗在时间轴上滑动,每滑动一步就产生一帧频谱。把这一帧帧频谱堆叠成二维矩阵,就得到了spectrogram(语谱图/频谱图)。

你可以把它理解成拍电影:FFT是一张全景照片,STFT是一卷电影胶片。全景照片能看到全局,但看不到时序变化;电影胶片每一帧都是局部的快照,连续起来就有了完整的动态信息。spectrogram就是这卷“频谱电影胶片”,横轴是时间,纵轴是频率,颜色深浅代表能量强弱。

2. 把STFT计算拧开看:窗口、帧移与分辨率的三角关系

2.1 分帧三参数:帧长、帧移、采样率

用scipy的spectrogram函数时,决定STFT行为的主要参数是nperseg(帧长)noverlap(重叠点数),再加上fs(采样率),这三者构成了STFT计算的基本骨架。

  • 帧长 nperseg:每一帧取多少个采样点。帧长除以采样率就是这一帧覆盖的物理时间。
  • 帧移:相邻两帧起点之间的距离,等于 nperseg - noverlap。帧移越小,时间轴上铺的帧越密。
  • 采样率 fs:告诉算法每个采样点对应多少秒,也决定频率轴怎么换算成Hz。

举个例子,语音处理常用fs=16000,nperseg=512,noverlap=256:

  • 帧长 = 512 / 16000 = 32ms
  • 帧移 = (512 - 256) / 16000 = 16ms

也就是说,每32ms的信号做一次FFT,窗每次往前滑动16ms,相邻帧有一半数据是重叠的。这种50%重叠是很多项目里的默认做法,既能保证时间轴平滑,又不至于让计算量翻倍。

2.2 为什么必须加窗:频谱泄漏与窗函数选型

直接从长信号里“切”出一段来做FFT,相当于在时域乘了一个矩形窗。矩形窗在频域上是一个很宽的sinc函数,主瓣旁边带有一大串旁瓣,会把本来只属于某一频率的能量“泄漏”到其他频点上,这就是频谱泄漏。严重的情况下,一个小幅度的真实频率分量会被旁边大信号的旁瓣盖住,导致你看漏了东西。

加窗的目的就是压低这些旁瓣。我常用的窗函数有这么几个:

窗函数主瓣宽度(相对)典型旁瓣衰减特点
boxcar(矩形)最窄约 -13 dB理论上频率分辨率最好,但旁瓣太高,不适合谱分析
hann中等约 -31 dB通用首选,语音和振动分析都常用
hamming中等约 -43 dB(近旁瓣)分辨率与旁瓣折中,早期语音处理常见
blackman较宽约 -58 dB旁瓣抑制最强,但主瓣宽,频率分辨率损失大

我大多数场景直接选hann,包括语音分析和振动分析。原因很简单:它的主瓣宽度适中,旁瓣衰减够用,而且频谱形状比较平滑,不容易出现hamming窗那种近旁瓣被压得特别低、远端旁瓣却缓慢衰减的情况。追求极端旁瓣抑制时再用blackman,但你要接受频率分辨率的损失。

需要注意的是,加窗不是免费的。窗函数的主瓣本身有宽度,主瓣越宽,两个相近频率越难被区分开。所以窗函数的选择本质上是在两个误差之间做权衡:旁瓣泄漏误差和主瓣宽度误差。

2.3 补零nfft的真实作用和常见误解

nfft参数控制的是FFT实际计算的点数,它允许大于等于帧长。比如nperseg=512,nfft=2048,意味着每帧512个点后面补了1536个零,再做2048点FFT。

很多人对补零有误解,以为nfft设得越大频率分辨率越高。不是的。补零只是在原有的频谱包络上做了更密的插值,让曲线看起来更平滑,峰值位置可以估得更精细,但真实分辨率仍然由帧长和窗函数决定,也就是Δf = fs / nperseg,而不是fs / nfft。

我早期也踩过这个坑,以为把nfft调到4096就是“高分辨率模式”,结果频率轴上两个靠得很近的峰照样分不开。后来才明白,要提高真实频率分辨率只有一个办法:加长nperseg。补零的价值在于插值,适合让某个峰值显示得更圆滑、位置更准,但它不能把已经混在一起的频率分开。

3. spectrogram函数参数逐个拆解:scipy.signal与matplotlib的差异

3.1 参数速查表

scipy.signal.spectrogram是我用得最多的函数。它的完整参数比较多,先看一张速查表,后面逐项展开:

参数默认值作用
x必填一维输入信号
fs1.0采样率,决定频率轴单位
window('tukey', 0.25)窗函数,字符串/数组/可调用对象均可
nperseg256(未指定时)每帧长度
noverlapnperseg // 2帧之间的重叠采样点数
nfft与nperseg相同FFT计算长度,必须大于等于nperseg
detrend'constant'每帧去均值或去线性趋势
return_onesidedTrue实数信号是否返回单边谱
scaling'density''density'为谱密度,'spectrum'为功率谱
mode'psd'输出模式:psd、magnitude、angle等

比较容易被忽略的是默认窗。新版scipy里,如果你不指定window,默认用的是('tukey', 0.25)这种锥形余弦窗,而不是很多老教程里写的hann窗。跨项目复现别人代码时,这一条经常导致结果对不上,我后面在踩坑部分还会提。

3.2 fs:频率轴的“单位换算器”

fs这个参数看起来不起眼,但不设置它,你得到的频率轴就是归一化频率,单位是“周期/采样点”,范围在0到0.5之间。这个数值没法直接对应物理频率。只有传了真实的采样率,输出f数组才会换算成Hz。

比如一段数据采样率是16000Hz,不传fs时你会看到频率轴标到0.5,传了fs=16000后频率轴标到8000Hz。这个0.5对应的就是奈奎斯特频率fs/2。如果你是做语音或音频处理,fs几乎总是已知的,建议每次都显式传进去,别留默认值。

3.3 nperseg与noverlap:时间分辨率和频率分辨率的跷跷板

这是STFT调参里最核心的一组参数。先看公式:

频率分辨率 Δf = fs / nperseg

也就是说帧长越长,频率分辨率越高。但代价是每一帧覆盖的时间变长了,时间方向的定位精度变差。如果你用很长的窗去分析一段变化很快的信号,某一帧里可能混杂了好几个不同状态,谱图看起来就“糊”了。

noverlap的作用则是控制帧与帧之间的时间密度。时间轴上相邻两帧间的间隔是(nperseg - noverlap) / fs,noverlap越大,帧间距越小,spectrogram的时间像素越密。但它同样不会提高频率分辨率,只是让时间方向看起来更平滑。

我调参时的经验是:先想清楚你要分辨的最小频率间隔是多少,用Δf = fs/nperseg倒推nperseg;然后再根据信号变化的快慢决定noverlap多大。如果信号是快速变化的瞬态信号,就把nperseg设短一点;如果是稳定的谐波分析,就痛痛快快地用长窗。

3.4 window参数:三种传法,结果完全不同

在scipy.signal.spectrogram里,window参数有三种传法:

  1. 传字符串:window='hann',底层调用scipy.signal.get_window去生成窗函数
  2. 传可调用对象:window=signal.windows.hann,传函数本身
  3. 传数组:window=np.hanning(nperseg),直接给一个长度等于nperseg的一维数组

第三种方式最灵活,也最容易出错——一旦数组长度和nperseg对不上,函数会直接抛异常。我自己的习惯是直接用字符串,简洁而且不容易出问题。如果你要试验自定义窗函数,再考虑传数组。

还有一个容易忽略的点:如果你传了数组或可调用对象,noverlap的默认值是根据实际窗长度算的,所以调用时最好显式把nperseg和noverlap都写清楚,避免行为不可预期。

3.5 mode与scaling:功率谱密度、振幅谱,怎么选

mode参数决定Sxx里装的是什么。我最常用的两个值是'psd'和'magnitude':

  • mode='psd':返回功率谱密度,单位是V²/Hz,适合分析连续谱和噪声背景,语音分析常用。
  • mode='magnitude':返回振幅谱|X|,单位是线性幅度,适合看离散谐波分量的相对大小。

另一个相关参数是scaling:

  • scaling='density':归一化成谱密度,体现单位频带宽度内的功率。
  • scaling='spectrum':不除以频率分辨率,直接是整段功率谱。

听起来有点绕,实际用起来有个简单的选择逻辑:如果你要比较不同采样率或不同帧长下的谱能量,用'density'更科学,因为它做了频率分辨率归一化;如果你只是看相对峰值大小,用'spectrum'或者直接mode='magnitude'都行。绘图的颜色深浅只关心相对关系时,这两种模式差别不大,但标注colorbar单位时要区分清楚。

3.6 detrend:被忽视的直流与漂移问题

detrend参数默认是'constant',意思是每一帧先减去本帧均值再做FFT。这个去均值操作能把直流分量干掉,非常实用。很多信号里有一个不为零的直流偏置,如果不去掉,spectrogram的0Hz附近会永远有一条亮线,把低频细节全盖住。

如果信号还有线性漂移(比如传感器受温度影响产生的缓慢基线变化),可以把detrend设为'linear',每一帧去掉最小二乘拟合的直线趋势。我处理某些加速度计振动信号时遇到过这种情况,设了'linear'之后低频段的谱图干净了很多。detrend=False一般不建议,除非你能确定信号直流偏置为零。

4. 读懂输出的三个数组:Sxx、f、t如何映射成一张图

4.1 返回值的shape与含义

scipy.signal.spectrogram的返回值是三个对象:

f, t, Sxx = signal.spectrogram(x, fs=fs, nperseg=512, noverlap=256)
  • f是一维数组,长度等于nfft//2 + 1(实数信号单边谱时),单位Hz,代表每一行对应的频率。
  • t是一维数组,代表每一列对应的时间点,单位秒。
  • Sxx是二维数组,shape为(len(f), len(t)),第一维是频率,第二维是时间。

t的长度不是随便定的,它由这个公式决定:

t帧数 = floor((len(x) - nperseg) / (nperseg - noverlap)) + 1

比如x长度2000,nperseg=256,noverlap=128,那么t长度 = floor((2000-256)/128) + 1 = floor(13.625) + 1 = 14。Sxx的形状就是(129, 14),129是nfft=256时的单边频率点数。

我最开始经常搞混Sxx的行列顺序,拿到Sxx后直接Sxx[:, 0]当成第一个时间点的频谱,结果画出来完全不对。记住一个口诀:行是频率,列是时间。第一个索引是频率下标,第二个索引是时间下标,这样怎么切都不会错。

4.2 绘图的正确姿势:pcolormesh、dB转换与动态范围

拿到Sxx之后,最直接的绘制方式是pcolormesh:

import numpy as np import matplotlib.pyplot as plt # Sxx默认是PSD,转成dB显示 plt.pcolormesh(t, f, 10 * np.log10(Sxx + 1e-12), shading='gouraud', cmap='magma') plt.colorbar(label='PSD (dB/Hz)') plt.xlabel('Time [s]') plt.ylabel('Frequency [Hz]') plt.show()

这里的几个细节值得注意。

第一,为什么要加1e-12再取log?因为Sxx里有很多接近零的值,直接log会得到-inf,pcolormesh遇到-inf会显示成空白。加一个小epsilon是常规操作。也有用np.maximum(Sxx, 1e-12)的,效果类似。

第二,为什么要用10np.log10而不是20np.log10?这取决于Sxx的类型。Sxx默认是功率谱密度或功率谱,功率量用10倍log;如果你设了mode='magnitude',得到的是幅度谱,那就要用20*np.log10。两种混用的结果差一倍,单位也会错。我见过不少人在网上问“为什么我的spectrogram颜色偏暗/偏亮”,多半是log倍数用错了。

第三,动态范围。log之后谱值范围可能很大,从-120到0 dB,直接画会显得对比过强。我一般会设置vmin和vmax,比如vmax=0, vmin=-80,意思是只显示最大值往下80dB范围内的信息,更弱的细节直接压黑。这个动态范围需要根据信号底噪水平调整,没有万能值。

4.3 matplotlib.specgram与scipy.signal.spectrogram怎么选

如果你只是快速看一眼频谱长什么样,matplotlib.pyplot.specgram更省事:

plt.specgram(x, Fs=fs, NFFT=512, noverlap=256, cmap='magma') plt.xlabel('Time [s]') plt.ylabel('Frequency [Hz]') plt.show()

一行就画完了,它还内置了dB转换、颜色映射等一堆默认行为。

但它的缺点是:返回值和scipy那套不太一样,它返回(spectrum, freqs, t, im)四个对象,spectrum的shape是(freq, time),不过排列顺序和scipy略有差异,直接拿来当数据用容易绕晕。而且matplotlib的specgram可控参数比scipy少很多,比如mode、scaling这些都没有。

我的习惯是分场景:快速预览用plt.specgram,正式分析和二次处理用scipy.signal.spectrogram拿数据再自己画。后者虽然代码多几行,但你能完全控制每个细节,包括后续要叠加其他曲线、裁剪频段、统计能量分布都方便得多。

5. 三个真实项目里的spectrogram参数配置

5.1 语音端点检测:短窗、50%重叠、Hann窗

做语音活动检测时,我的配置基本固定:

f, t, Sxx = signal.spectrogram( audio, fs=16000, window='hann', nperseg=512, noverlap=256, mode='psd' )

这里的核心逻辑是:语音信号在20到40ms内近似平稳,这是语音处理界的经典结论。16000Hz采样下,nperseg=512正好是32ms,落在最优区间。帧移16ms让时间分辨足够细,能捕捉到音节的起止边界。

拿到Sxx后,可以按每一列求能量和,判断这一帧是不是语音段。静音帧的能量和极低,语音帧有明显抬升。再加一个自适应阈值,就能把连续语音段切出来。这套方案我实测过,在安静环境下准确率很稳,但信噪比很低时阈值要改成基于噪声估计的动态方案,那就不是spectrogram本身的问题了。

为什么不把nperseg设成1024?因为64ms的窗会把两个相邻的快速音节混在同一帧里,端点定位误差会明显变大。语音分析里,时间分辨率的优先级通常高于频率分辨率。

5.2 音乐音高与和弦识别:长窗、高重叠

做音乐分析时,需求完全反过来了。比如识别吉他扫弦的和弦,你需要看清每个音的低频基频。这时候频率分辨率要高,窗就要长:

f, t, Sxx = signal.spectrogram( audio, fs=44100, window='hann', nperseg=4096, noverlap=3072, mode='magnitude' )

nperseg=4096对应约93ms的窗长,Δf = 44100/4096 ≈ 10.8Hz。这意味着两个频率如果相差小于约10Hz,在谱图上就分不开了。对于中高音区,这个分辨率足够,比如A4=440Hz到A#4=466.16Hz,间隔约26Hz,能清晰分辨。但低音区就麻烦,C2=65.41Hz到C#2=69.30Hz只差3.89Hz,nperseg=4096根本分不开。

如果要真正分辨低音半音,nperseg起码要设到16384甚至更高,对应370ms以上的窗长。这会让时间分辨率变得很差,音符一快谱图就是一片糊。这也是音乐信息检索领域普遍头痛的问题。我的经验是:做和弦识别时用中等窗长(90ms左右)+高重叠(75%)兼顾两边,做单音基频提取时再针对性加长窗。

5.3 旋转机械的振动故障检测:低频分辨率优先

处理旋转机械振动信号时,我通常关注的频段不高。比如一个以3000rpm运行的电机,转频50Hz,轴承故障特征频率可能在几十到几百Hz范围。要看清故障频率边带,频率分辨率必须足够细:

f, t, Sxx = signal.spectrogram( vibration, fs=25600, window='hann', nperseg=2560, noverlap=2048, mode='psd', detrend='linear' )

nperseg=2560对应0.1秒窗长,Δf=10Hz。这个配置能看清50Hz及其倍频的轮廓,但要分辨间隔只有1-2Hz的边带就不够了,得把nperseg拉到25600(整整1秒)才行。问题是很多设备转速并非绝对稳定,1秒窗内转速漂移会让谱峰被“抹宽”,反而得不偿失。

所以这个场景的真实做法往往是:先用spectrogram做全局预览,找出异常频段和时间段;确认目标后,再针对异常时间段截取数据,用更长窗做精细频谱分析,或者配合包络谱解调。spectrogram在这里的角色更像“雷达”,负责把可疑目标找出来,而不是直接把所有细节都呈现给你。

6. 我踩过的坑与调参心得

6.1 边界效应:spectrogram首尾靠不住

spectrogram的每一帧都要从信号里截一段数据,第一帧从第0个点开始,最后一帧到信号末尾结束。首尾附近的帧因为数据不完整,计算出的频谱幅度会明显偏小。如果你观察一个长信号的谱图,经常能看到最左边和最右边各有一条较暗的竖条,这就是边界效应。

处理办法有两个:一是接受它,分析时忽略首尾几十毫秒;二是给信号做一些边缘处理,比如镜像延拓。大多数场景下直接忽略首尾就够用,没必要为了边界区这点数据增加复杂度。

6.2 时间与频率分辨率的测不准取舍

STFT有个绕不开的物理约束:时间分辨率和频率分辨率不能同时无限提高。窄窗能精确定位时间,但频率看不细;宽窗能分清频率,但时间上就变得模糊。这不是某个参数设置问题,而是傅里叶分析本身的特性。

说一个我反复踩的坑:一开始做振动分析时,总想把nperseg调大,让频率谱线更细。结果确实是频率细了,但当设备转速波动时,谱峰在时间方向上被拉成一条斜线,反而没法判断故障发生的确切时刻。后来我养成了一个习惯——动手调参前先问自己:这个项目里,到底是要定位“什么时候变了”,还是要判断“变了多少频率”?前者优先短窗,后者优先长窗,想清楚了再设nperseg,基本就不会白费功夫。

6.3 多声道、内存和单位换算的坑

spectrogram的输入必须是一维数组。拿到立体声文件直接传进去,scipy会报错。处理方式一般是取单通道或两通道平均:

if audio.ndim == 2: audio = np.mean(audio, axis=1)

内存问题也别忽视。一小时的44100Hz音频,如果用nperseg=4096、noverlap=3072,时间帧数大约有13万个,Sxx矩阵要存2066行乘13万列,也就是约2.7亿个浮点数,算下来超过2GB内存。这种量级就不能一次全算完,要分段处理,每段算完立刻聚合特征,释放内存。

单位换算一定要前后一致。mode='psd'出来的Sxx是密度,10倍log是dB/Hz;mode='magnitude'出来的是幅度,20倍log才是dB。混用的话数值全歪,而且横向对比两个失败实验得出的结论也会被误导。

6.4 默认窗和库里不一致的版本问题

我在第3章提过,新版scipy的spectrogram默认窗是('tukey', 0.25),而matplotlib.specgram默认用hann窗。也就是说,同样一组数据,用两个库的默认参数跑出来,谱图会有肉眼可见的差异。tukey窗在高通滤波和某些特定应用里是好东西,但它的频谱形状和hann差别不小。

如果你在复现别人的代码,或者做跨库对比,强烈建议把window、nperseg、noverlap三个参数全部显式写死,不要依赖任何一方的默认值。这个习惯帮我省掉了大量“结果对不上”的排查时间。另外,不同scipy大版本对mode参数的支持也不同,老版本scipy里spectrogram没有mode参数,升级后突然多出来的这个选项也可能影响你的输出,升级依赖时记得回归测试一下关键代码。

我之前做一次对比实验时就因为这个栽了:老脚本在scipy 1.5上跑得好好的,换到1.11后同样的代码出来的谱图数值完全对不上,排查半天发现是默认窗从hann变成了tukey。现在我的所有spectrogram调用都是全参数显式声明,再也没有这种问题。

6.5 调参不要追求“一次到位”

最后说一点心得。spectrogram没有万能的参数组合,每次拿到新信号,我都是先画

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询