
简介传统的傅里叶变换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。举个例子语音处理常用fs16000nperseg512noverlap256帧长 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实际计算的点数它允许大于等于帧长。比如nperseg512nfft2048意味着每帧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计算长度必须大于等于npersegdetrendconstant每帧去均值或去线性趋势return_onesidedTrue实数信号是否返回单边谱scalingdensitydensity为谱密度spectrum为功率谱modepsd输出模式psd、magnitude、angle等比较容易被忽略的是默认窗。新版scipy里如果你不指定window默认用的是(tukey, 0.25)这种锥形余弦窗而不是很多老教程里写的hann窗。跨项目复现别人代码时这一条经常导致结果对不上我后面在踩坑部分还会提。3.2 fs频率轴的“单位换算器”fs这个参数看起来不起眼但不设置它你得到的频率轴就是归一化频率单位是“周期/采样点”范围在0到0.5之间。这个数值没法直接对应物理频率。只有传了真实的采样率输出f数组才会换算成Hz。比如一段数据采样率是16000Hz不传fs时你会看到频率轴标到0.5传了fs16000后频率轴标到8000Hz。这个0.5对应的就是奈奎斯特频率fs/2。如果你是做语音或音频处理fs几乎总是已知的建议每次都显式传进去别留默认值。3.3 nperseg与noverlap时间分辨率和频率分辨率的跷跷板这是STFT调参里最核心的一组参数。先看公式频率分辨率 Δf fs / nperseg也就是说帧长越长频率分辨率越高。但代价是每一帧覆盖的时间变长了时间方向的定位精度变差。如果你用很长的窗去分析一段变化很快的信号某一帧里可能混杂了好几个不同状态谱图看起来就“糊”了。noverlap的作用则是控制帧与帧之间的时间密度。时间轴上相邻两帧间的间隔是(nperseg - noverlap) / fsnoverlap越大帧间距越小spectrogram的时间像素越密。但它同样不会提高频率分辨率只是让时间方向看起来更平滑。我调参时的经验是先想清楚你要分辨的最小频率间隔是多少用Δf fs/nperseg倒推nperseg然后再根据信号变化的快慢决定noverlap多大。如果信号是快速变化的瞬态信号就把nperseg设短一点如果是稳定的谐波分析就痛痛快快地用长窗。3.4 window参数三种传法结果完全不同在scipy.signal.spectrogram里window参数有三种传法传字符串windowhann底层调用scipy.signal.get_window去生成窗函数传可调用对象windowsignal.windows.hann传函数本身传数组windownp.hanning(nperseg)直接给一个长度等于nperseg的一维数组第三种方式最灵活也最容易出错——一旦数组长度和nperseg对不上函数会直接抛异常。我自己的习惯是直接用字符串简洁而且不容易出问题。如果你要试验自定义窗函数再考虑传数组。还有一个容易忽略的点如果你传了数组或可调用对象noverlap的默认值是根据实际窗长度算的所以调用时最好显式把nperseg和noverlap都写清楚避免行为不可预期。3.5 mode与scaling功率谱密度、振幅谱怎么选mode参数决定Sxx里装的是什么。我最常用的两个值是psd和magnitudemodepsd返回功率谱密度单位是V²/Hz适合分析连续谱和噪声背景语音分析常用。modemagnitude返回振幅谱|X|单位是线性幅度适合看离散谐波分量的相对大小。另一个相关参数是scalingscalingdensity归一化成谱密度体现单位频带宽度内的功率。scalingspectrum不除以频率分辨率直接是整段功率谱。听起来有点绕实际用起来有个简单的选择逻辑如果你要比较不同采样率或不同帧长下的谱能量用density更科学因为它做了频率分辨率归一化如果你只是看相对峰值大小用spectrum或者直接modemagnitude都行。绘图的颜色深浅只关心相对关系时这两种模式差别不大但标注colorbar单位时要区分清楚。3.6 detrend被忽视的直流与漂移问题detrend参数默认是constant意思是每一帧先减去本帧均值再做FFT。这个去均值操作能把直流分量干掉非常实用。很多信号里有一个不为零的直流偏置如果不去掉spectrogram的0Hz附近会永远有一条亮线把低频细节全盖住。如果信号还有线性漂移比如传感器受温度影响产生的缓慢基线变化可以把detrend设为linear每一帧去掉最小二乘拟合的直线趋势。我处理某些加速度计振动信号时遇到过这种情况设了linear之后低频段的谱图干净了很多。detrendFalse一般不建议除非你能确定信号直流偏置为零。4. 读懂输出的三个数组Sxx、f、t如何映射成一张图4.1 返回值的shape与含义scipy.signal.spectrogram的返回值是三个对象f, t, Sxx signal.spectrogram(x, fsfs, nperseg512, noverlap256)f是一维数组长度等于nfft//2 1实数信号单边谱时单位Hz代表每一行对应的频率。t是一维数组代表每一列对应的时间点单位秒。Sxx是二维数组shape为(len(f), len(t))第一维是频率第二维是时间。t的长度不是随便定的它由这个公式决定t帧数 floor((len(x) - nperseg) / (nperseg - noverlap)) 1比如x长度2000nperseg256noverlap128那么t长度 floor((2000-256)/128) 1 floor(13.625) 1 14。Sxx的形状就是(129, 14)129是nfft256时的单边频率点数。我最开始经常搞混Sxx的行列顺序拿到Sxx后直接Sxx[:, 0]当成第一个时间点的频谱结果画出来完全不对。记住一个口诀行是频率列是时间。第一个索引是频率下标第二个索引是时间下标这样怎么切都不会错。4.2 绘图的正确姿势pcolormesh、dB转换与动态范围拿到Sxx之后最直接的绘制方式是pcolormeshimport numpy as np import matplotlib.pyplot as plt # Sxx默认是PSD转成dB显示 plt.pcolormesh(t, f, 10 * np.log10(Sxx 1e-12), shadinggouraud, cmapmagma) plt.colorbar(labelPSD (dB/Hz)) plt.xlabel(Time [s]) plt.ylabel(Frequency [Hz]) plt.show()这里的几个细节值得注意。第一为什么要加1e-12再取log因为Sxx里有很多接近零的值直接log会得到-infpcolormesh遇到-inf会显示成空白。加一个小epsilon是常规操作。也有用np.maximum(Sxx, 1e-12)的效果类似。第二为什么要用10np.log10而不是20np.log10这取决于Sxx的类型。Sxx默认是功率谱密度或功率谱功率量用10倍log如果你设了modemagnitude得到的是幅度谱那就要用20*np.log10。两种混用的结果差一倍单位也会错。我见过不少人在网上问“为什么我的spectrogram颜色偏暗/偏亮”多半是log倍数用错了。第三动态范围。log之后谱值范围可能很大从-120到0 dB直接画会显得对比过强。我一般会设置vmin和vmax比如vmax0, vmin-80意思是只显示最大值往下80dB范围内的信息更弱的细节直接压黑。这个动态范围需要根据信号底噪水平调整没有万能值。4.3 matplotlib.specgram与scipy.signal.spectrogram怎么选如果你只是快速看一眼频谱长什么样matplotlib.pyplot.specgram更省事plt.specgram(x, Fsfs, NFFT512, noverlap256, cmapmagma) 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, fs16000, windowhann, nperseg512, noverlap256, modepsd )这里的核心逻辑是语音信号在20到40ms内近似平稳这是语音处理界的经典结论。16000Hz采样下nperseg512正好是32ms落在最优区间。帧移16ms让时间分辨足够细能捕捉到音节的起止边界。拿到Sxx后可以按每一列求能量和判断这一帧是不是语音段。静音帧的能量和极低语音帧有明显抬升。再加一个自适应阈值就能把连续语音段切出来。这套方案我实测过在安静环境下准确率很稳但信噪比很低时阈值要改成基于噪声估计的动态方案那就不是spectrogram本身的问题了。为什么不把nperseg设成1024因为64ms的窗会把两个相邻的快速音节混在同一帧里端点定位误差会明显变大。语音分析里时间分辨率的优先级通常高于频率分辨率。5.2 音乐音高与和弦识别长窗、高重叠做音乐分析时需求完全反过来了。比如识别吉他扫弦的和弦你需要看清每个音的低频基频。这时候频率分辨率要高窗就要长f, t, Sxx signal.spectrogram( audio, fs44100, windowhann, nperseg4096, noverlap3072, modemagnitude )nperseg4096对应约93ms的窗长Δf 44100/4096 ≈ 10.8Hz。这意味着两个频率如果相差小于约10Hz在谱图上就分不开了。对于中高音区这个分辨率足够比如A4440Hz到A#4466.16Hz间隔约26Hz能清晰分辨。但低音区就麻烦C265.41Hz到C#269.30Hz只差3.89Hznperseg4096根本分不开。如果要真正分辨低音半音nperseg起码要设到16384甚至更高对应370ms以上的窗长。这会让时间分辨率变得很差音符一快谱图就是一片糊。这也是音乐信息检索领域普遍头痛的问题。我的经验是做和弦识别时用中等窗长90ms左右高重叠75%兼顾两边做单音基频提取时再针对性加长窗。5.3 旋转机械的振动故障检测低频分辨率优先处理旋转机械振动信号时我通常关注的频段不高。比如一个以3000rpm运行的电机转频50Hz轴承故障特征频率可能在几十到几百Hz范围。要看清故障频率边带频率分辨率必须足够细f, t, Sxx signal.spectrogram( vibration, fs25600, windowhann, nperseg2560, noverlap2048, modepsd, detrendlinear )nperseg2560对应0.1秒窗长Δf10Hz。这个配置能看清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, axis1)内存问题也别忽视。一小时的44100Hz音频如果用nperseg4096、noverlap3072时间帧数大约有13万个Sxx矩阵要存2066行乘13万列也就是约2.7亿个浮点数算下来超过2GB内存。这种量级就不能一次全算完要分段处理每段算完立刻聚合特征释放内存。单位换算一定要前后一致。modepsd出来的Sxx是密度10倍log是dB/Hzmodemagnitude出来的是幅度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没有万能的参数组合每次拿到新信号我都是先画本文还有配套的精品资源点击获取