资讯详情 手写STFT的实现方法:从DFT原理到Matlab代码,真正搞懂时频分析
📅 2026/10/4 9:48:56
上个月接了一批振动数据的时频分析任务老板让把STFT时频图做出来。我第一反应是打开Matlab敲一行spectrogram调用两秒钟就出图。可汇报时被问住了时频图上每个点到底怎么来的窗长为什么取256频率轴为什么是257个点当场答不上来。回来之后我索性不调用任何现成API用最原始的DFT定义手写了一个短时傅里叶变换写完再看那些时频图理解完全不一样了。这篇文章就把完整思路和代码放出来给想真正弄懂STFT原理、想在Matlab里自己实现时频分析的朋友做参考。要提前说清楚一个概念标题写的非调用Matlab API指的是不直接用信号处理工具箱里封装好的stft、spectrogram这类高级接口而是自己把分帧、加窗、DFT这三个核心步骤写出来。fft这种底层通用运算我建议照常使用它只是快速计算离散傅里叶变换的工具和STFT封装函数不是一个层面的东西。如果你连fft都不想用我也会给一个纯DFT矩阵的实现两方面都覆盖到。1. 为什么要自己写STFT内置函数做不了的几件事1.1 黑盒问题的三个具体痛点spectrogram确实好用但它是个黑盒。我实际使用中遇到过三个具体痛点这是促使我手写实现的直接原因。第一是返回结果的量纲不清晰。用spectrogram默认输出拿到的到底是幅值谱、功率谱还是功率谱密度不同Matlab版本之间还有差异。我在对比两组实验数据时发现同一信号用不同版本算出来的数值差了一个比例系数查了半天help才明白是归一化方式变了。手写实现之后返回量纲完全由自己控制一清二楚。第二是窗函数定制不灵活。内置接口虽然能传窗向量进去但如果你要做特殊处理比如非对称窗、自己采集的实测窗、甚至逐帧变化的窗用spectrogram就很别扭。我后来做过一个滚动轴承故障诊断的小项目需要对比不同窗函数对能量泄漏的影响手写STFT可以随手替换任意窗内置接口反而限制多。第三是坐标轴和边界行为不透明。内置函数返回的时间轴到底是窗起始时刻还是窗中心时刻这直接影响峰值在时频图上的位置。不同版本默认行为还不一样出图的时候稍不注意就画偏了。1.2 手写一次胜过查十次help从学习角度看手写STFT的收益远大于成本。很多朋友问过我一个问题为什么实信号的单边频谱图频率轴通常画nfft/21个点如果你只是调用函数这种问题永远不会逼到面前自己写一遍你自然就明白这是由实信号频谱的共轭对称性决定的——正频率部分已经包含全部信息负频率部分镜像重复画图时分掉一半即可。另一个收获在面试环节。现在不少信号处理岗位面试会直接问STFT的实现细节比如窗长和FFT点数不一样时会发生什么帧移与时间分辨率的关系没手写过的人很容易被问倒。我后来带实习生也明显感觉到手写过一遍STFT的同学讨论问题时能直接说出频率分辨率由窗长决定不是由nfft决定这种关键结论这就是底层理解带来的差距。1.3 什么时候还是老实调用内置函数这里要说句公道话手写STFT主要用于学习和定制不代表生产环境必须抛弃内置函数。工具箱里的实现经过大量优化和测试数值稳定性好边界处理考虑周全性能也更高。我的建议是两手都用初版、定制化、异常排查时用手写版本快速出图、标准流程、需要高可靠性时用内置函数同时用内置函数的结果做交叉验证。这样组合起来你对算法和工具都心里有数。2. 从连续公式到离散矩阵STFT到底在算什么2.1 傅里叶变换的全局视野缺陷先回到最基础的问题为什么要引入STFT因为普通傅里叶变换只适合平稳信号。所谓平稳是指信号的统计特性不随时间变化比如持续的50Hz正弦波。但现实中大量信号是非平稳的语音信号每个音节的频率都在变轴承故障振动信号会出现随时间变化的冲击成分雷达回波的频率受多普勒效应影响。对一个频率随时间扫过的chirp信号做普通FFT结果是一条宽频带你只能看到信号包含一大片频率成分完全看不出频率随时间的变化规律。打个比方普通FFT相当于给一群人拍一张合影你只能知道照片里有人看不出谁站在哪个位置。STFT则是在照片上打上网格分区域统计既要人的信息也要位置的信息。这个思路用一句话概括对非平稳信号分帧假设每一小段内信号近似平稳然后对每一小段做傅里叶变换。帧与帧之间的变化就体现为时频图上的能量迁移。2.2 连续STFT公式和离散化三件套连续短时傅里叶变换的数学定义是STFT_x(τ, f) ∫ x(t) · w(t - τ) · e^{-j2πft} dt其中w(t)是窗函数τ是窗在时间轴上的位置。这个公式的含义很直观把窗函数滑动到τ处用窗w(t-τ)截取信号x(t)在τ附近的一小段然后对这一小段做傅里叶变换。实际处理的是采样后的离散信号要把上面的连续公式离散化需要三个步骤分帧设帧长为N帧移为hop。第m帧对应的时间区间是[(m-1)·hop, (m-1)·hop N - 1]。注意相邻帧之间有重叠因为hop通常小于N这是为了保证窗覆盖的连续性。加窗把每一帧信号与窗函数逐点相乘。窗函数在边缘处衰减用于减小截断造成的频谱泄漏。DFT对加窗后的每一帧做N点离散傅里叶变换。离散公式写出来就是X[m, k] Σ_{n0}^{N-1} x[(m-1)·hop n] · w[n] · e^{-j2πkn/N}其中m是帧索引k是频率索引。整段信号处理完会得到一个二维复数矩阵X横轴是时间帧纵轴是频率频点矩阵元素是复数取模就得到幅度时频图。2.3 时频矩阵的形状与不确定原理假设信号长度是L帧长N帧移hop那么帧数M的计算公式是M ceil((L - N) / hop) 1这里ceil表示向上取整。我上面的公式默认最后一帧不足N点时用零填充补满如果不补零就是floor((L - N) / hop) 1会丢掉尾部数据。频率轴方向如果做N点DFT理论上得到N个频点但对实信号来说负频率部分与正频率部分共轭对称所以通常只保留单边取前N/2 1个点对应频率范围0到fs/2。这里必须说清楚一个很多人混淆的结论频率分辨率由帧长N决定Δf fs / N时间分辨率由窗长决定窗越长时间上的模糊越严重。帧移hop只决定时频图在时间轴上的采样密度它和时间分辨率不是一回事。我见过很多资料把hop小误解为时间分辨率高严格讲真正的时间分辨率受窗长限制帧移只是画图的网格密度。两者要区分看待。更本质的是海森堡不确定性原理在时频分析中的体现Δf · Δt ≥ 常数。也就是说你不可能同时获得任意高的频率分辨率和任意高的时间分辨率。窗取短了时间定位准但频率模糊窗取长了频率定位准但时间模糊。STFT的所有参数选择本质上都是在找这个平衡点。3. 手写STFT的Matlab代码从DFT循环到fft加速3.1 完全按定义写DFT矩阵版本为了让人看清STFT的数学本质我建议先写一个最笨、最直白的版本不用fft直接按离散傅里叶变换的定义构造旋转因子矩阵来做计算。这个版本虽然慢但每一行都能对应到公式调试和理解都容易。function [S, f, t] stft_dft(x, fs, winLen, hop, nfft, winType) % 短时傅里叶变换DFT矩阵版完全按定义实现不调用fft/spectrogram % 输入 % x - 单通道信号行向量或列向量 % fs - 采样率 % winLen - 窗长度帧长 % hop - 帧移 % nfft - DFT点数建议winLen % winType - 窗类型rect,hann,hamming,blackman % 输出 % S - 复数时频矩阵维度为 (nfft/21) x M % f - 频率轴长度为 nfft/21 % t - 时间轴长度为 M对应每帧窗中心 x x(:).; L length(x); % --- 生成窗函数 --- switch lower(winType) case rect w ones(1, winLen); case hann w 0.5 - 0.5 * cos(2*pi*(0:winLen-1)/(winLen-1)); case hamming w 0.54 - 0.46 * cos(2*pi*(0:winLen-1)/(winLen-1)); case blackman n 0:winLen-1; w 0.42 - 0.5*cos(2*pi*n/(winLen-1)) 0.08*cos(4*pi*n/(winLen-1)); otherwise error(未知窗类型: %s, winType); end % --- 帧数计算与零填充 --- nFrames ceil((L - winLen) / hop) 1; totalLen (nFrames - 1) * hop winLen; xpad [x, zeros(1, totalLen - L)]; % --- 构造DFT旋转因子矩阵 --- % k是频率索引n是时间索引 k (0:nfft/2).; % 单边频点 n 0:winLen-1; % 帧内采样点 D exp(-1j * 2 * pi * k * n / nfft); % --- 逐帧加窗并计算DFT --- S zeros(nfft/21, nFrames); % 预分配 for m 1:nFrames idx (m-1)*hop (1:winLen); frame xpad(idx) .* w; S(:, m) D * frame.; % 矩阵乘实现DFT end % --- 频率轴与时间轴 --- f (0:nfft/2) * fs / nfft; t ((0:nFrames-1) * hop (winLen-1)/2) / fs; end这段代码里旋转因子矩阵D的构造是关键。D的第k行、第n列元素是exp(-j2πkn/nfft)这就是DFT定义中的复指数。帧向量与D做矩阵乘法等价于对这个帧求DFT。由于D索引是行向量对应频点、列向量对应时间乘出来的结果就是频域向量。这里我特意只保留单边频点0到nfft/2所以S的行数是nfft/21。3.2 换用fft的高效版本stft_dft能跑但效率太低DFT矩阵乘法时间复杂度是O(N²)实际用起来会很慢。改进的办法就是换用fft。fft是Matlab的底层基础运算不是STFT封装接口用它完全符合非调用API的精神——分帧、加窗、单边处理这些STFT的核心逻辑仍然是我们自己实现的。function [S, f, t] stft_fft(x, fs, winLen, hop, nfft, winType) % 短时傅里叶变换循环fft版 % 参数含义与stft_dft一致 x x(:).; L length(x); switch lower(winType) case rect w ones(1, winLen); case hann w 0.5 - 0.5 * cos(2*pi*(0:winLen-1)/(winLen-1)); case hamming w 0.54 - 0.46 * cos(2*pi*(0:winLen-1)/(winLen-1)); case blackman n 0:winLen-1; w 0.42 - 0.5*cos(2*pi*n/(winLen-1)) 0.08*cos(4*pi*n/(winLen-1)); otherwise error(未知窗类型: %s, winType); end nFrames ceil((L - winLen) / hop) 1; totalLen (nFrames - 1) * hop winLen; xpad [x, zeros(1, totalLen - L)]; oneSideLen nfft/2 1; S zeros(oneSideLen, nFrames); % 预分配提升速度 for m 1:nFrames idx (m-1)*hop (1:winLen); frame xpad(idx) .* w; spec fft(frame, nfft); % 核心用fft算DFT S(:, m) spec(1:oneSideLen); % 取单边 end f (0:nfft/2) * fs / nfft; t ((0:nFrames-1) * hop (winLen-1)/2) / fs; end两段代码唯一的本质区别就是fft(frame, nfft)替代了D * frame.。运行速度差几十倍不止。在正式分析中我都用stft_fft。3.3 边界处理零填充和最后一帧写代码时一定会遇到一个问题信号长度L不一定能恰好分完整个帧最后一帧可能不足winLen个点。我的处理策略是零填充先用totalLen算出补零后的总长度把信号尾部补零到足够覆盖最后一帧。这样做的理由很简单零填充不会引入额外的趋势或频率成分虽然会在边缘造成窗与零相交的瞬态并且能保持时间轴均匀连续不至于因为丢掉尾部数据而漏掉信号末端的事件。另一种策略是直接丢弃不足一帧的尾部。这个做法在实时系统中常用因为实时处理无法预知未来数据。但离线分析时我建议保留零填充尤其是信号尾部存在瞬态冲击时丢掉就损失了关键信息。3.4 一次调用示例用最简单的正弦信号验证一下函数能否正常工作。假设fs1000Hz信号1秒钟频率100Hz帧长256帧移64nfft512使用汉宁窗fs 1000; t 0:1/fs:1-1/fs; x sin(2*pi*100*t); [S, f, tout] stft_fft(x, fs, 256, 64, 512, hann); disp(size(S)); % 期望输出257行 13列我来算一下预期尺寸信号长度L1000帧长N256hop64帧数Mceil((1000-256)/64)1ceil(11.625)113。单边频点数nfft/21257。所以S是257×13的复数矩阵。每一列是某一帧的频谱每一行是某个频率成分随时间的变化。S(52, 5)表示第5帧在频率f(52)处的复数幅度其中f(52)52×1000/512≈101.6Hz正好接近100Hz。这个索引关系就是STFT结果的全部意义所在。4. 窗函数、帧长、帧移与nfft时频图的四种生死抉择4.1 窗函数主瓣与旁瓣的取舍窗函数是STFT里第一个要选的参数。为什么不能直接用矩形窗直接截断因为矩形窗的频谱旁瓣衰减只有约13dB截断产生的频谱泄漏会把强频率成分的能量泄漏到附近频点形成伪峰严重时遮盖真实弱信号。加窗的本质就是让帧边界平滑衰减抑制泄漏代价是主瓣变宽频率分辨率略微下降。常用窗函数的几个关键指标如下表窗函数主瓣宽度旁瓣衰减典型用途矩形窗最窄约13dB瞬时事件检测、算法验证汉宁窗较窄约31dB一般信号分析通用首选海明窗较窄约41dB语音、振动信号布莱克曼窗较宽约58dB需要强旁瓣抑制、弱分量检测主瓣越宽两个频率靠得很近的分量就越难区分旁瓣越低强分量对邻近频点的污染越少。这两个指标互相矛盾没有最优窗只有最合适的窗。我的经验是刚开始做时频分析用汉宁窗基本不会错谱图干净泄漏适中。如果你发现强频率旁边出现奇怪的旁瓣条带再试试布莱克曼窗压一下旁瓣。代码里我手写窗函数公式不做内置函数调用。例如汉宁窗就是w[n]0.5-0.5cos(2πn/(N-1))。注意分母是N-1而不是N这样保证窗在两端恰好为0这是周期对称的写法。4.2 帧长和帧移怎么定帧长选择的第一原则是先定所需的频率分辨率再反推帧长。如果需要区分的最小频率间隔是Δf采样率是fs那么帧长N至少是fs/Δf。比如采样率1000Hz想分辨10Hz间隔的频率成分帧长至少100点实际取128或256。取更大的帧长可以更精细地区分频率但代价是时间上更模糊突发信号在时频图上会被拉成一片。帧移的选择相对灵活一般取帧长的25%到50%对应重叠率50%到75%。重叠率越高时频图时间轴越平滑视觉连续性越好但计算量也越大——因为帧数变多。我常用的默认值hop N/4也就是75%重叠。这个设置画出来的时频图时间方向比较细腻不会出现明显的锯齿感。下面是几个典型应用的参数建议供参考应用场景采样率帧长帧移说明语音分析8k~16kHz20~40ms对应点数帧长的一半匹配语音短时平稳特征振动监测按转速定1~2个旋转周期帧长的25%兼顾冲击成分时间定位生物电信号250~1000Hz0.5~1s帧长一半频率分辨率优先4.3 nfft比帧长大是什么操作nfft参数经常被忽略但其实有一个很常见的问题nfft可以大于帧长。比如窗长256nfft取512这意味着对加窗后的256点补零到512点再做FFT。补零不会增加真实信息量但会让频谱在频域上插值——谱线更密曲线更平滑峰值定位可以更精细。注意这只是看起来更精细真实频率分辨率仍然由帧长决定不会因为补零而提高。另一个更隐蔽的坑如果nfft小于帧长Matlab的fft函数会直接截断前nfft个点等于只用了窗的前半段信号这几乎肯定不是你想要的。所以写代码时务必保证nfft winLen。我一般取nfft max(winLen, 2^nextpow2(winLen))也就是取成不小于帧长的2的幂这样既能利用FFT的快速算法又不会意外截断。5. 把时频矩阵画成图坐标轴校准与幅度归一化5.1 用imagesc画时频图别漏了axis xy算完S矩阵下一步就是可视化。最常用的工具是imagesc它把矩阵画成一张颜色图颜色深浅对应数值大小。但有一个极易踩的坑imagesc默认y轴方向是从上到下递增而频率轴应该是从下到上递增所以绘制时必须加一句axis xy翻转y轴否则频率图是倒的低频在上高频在下看着特别别扭。figure; imagesc(t, f, 20*log10(abs(S) eps)); axis xy; % 关键翻转y轴 xlabel(Time (s)); ylabel(Frequency (Hz)); title(STFT时频图); colorbar; colormap(jet);这五行代码是我每次分析的固定开头。colorbar显示幅度标尺colormap(jet)是彩色映射也可以用parula或turbo看个人习惯。实际出图时建议把图例范围也设置一下比如clim([-60 20])把动态范围压到60dB内否则弱信号特征会被强信号压得看不见。5.2 画幅值还是画dB直接画abs(S)通常效果不好。原因在于STFT得到的幅度谱动态范围很大——强分量可能是弱分量的几百上千倍直接线性画图弱分量在颜色条上几乎无法分辨。解决方法是转成对数刻度画幅度dB图S_dB 20*log10(abs(S) eps);加eps是为了防止abs(S)出现0时log10(0)产生负无穷警告。20log10(abs(S))是幅度dB表示如果要画功率谱dB是10log10(abs(S).^2)两者在数学上完全等价因为10log10(a²) 20log10(a)。所以到底写20还是10取决于你心里想的是幅度还是功率图面结果是相同的。dB画法的动态范围一般习惯压到60到80dB。低于这个范围太多的一般是数值噪声不必展示。5.3 幅度归一化为什么我的S和spectrogram差一个倍数手写STFT新手经常会发现一个问题自己的结果和spectrogram输出的数值对不上甚至差好几倍。这通常是归一化差异造成的。我的代码里S是加窗信号的单边DFT幅值谱没有做任何额外的归一化而spectrogram默认返回的是功率谱密度它会把幅值平方后除以fs * sum(w.^2)再做单边谱加倍处理。所以直接比较数值肯定对不上。如果你需要两者在数值上可比可以按下面公式把幅值谱转成功率谱密度Pxx abs(S).^2 / (fs * sum(w.^2));再做单边谱时除直流和奈奎斯特频点外还要乘以2。这个细节用的时候容易乱我的建议是在自定义实现和内置函数之间做对比验证时统一转成单边幅值谱再画图也就是都取abs(S)看趋势不完全依赖绝对数值。5.4 坐标轴常见错误两则坐标轴的错误比幅度归一化更隐蔽但造成的误导也更严重。第一个错误是频率轴整体偏移一个bin。频率轴正确写法是f (0:nfft/2) * fs / nfft索引从0开始。有人写成f (1:nfft/21) * fs / nfft结果所有频率点都向右偏移了fs/nfft时频图中的脊线位置会比理论值偏高。差一个bin也许看起来不大但在精密测频时就是实打实的误差。第二个错误是时间轴起点取错。我在代码里把时间轴定为每帧窗中心位置t ((0:M-1)*hop (winLen-1)/2) / fs。为什么要用窗中心而不是窗起始因为窗函数在中心处权重最大信号在窗中心附近的贡献最集中瞬时频率的理论值也对应窗中心时刻。如果取窗起始时刻chirp信号的峰值脊线会比理论瞬时频率整体偏移半个窗长看着不明显但做定量对比时误差立刻暴露。这是我自己实际验证时踩过的坑建议大家都用窗中心来做时间轴。6. 用线性调频信号验证实现峰值的频率误差在2Hz以内6.1 自己生成chirp信号不用工具箱也行写完代码最重要的一件事就是验证。不能只看图感觉对了要有一个已知解析性质的信号做定量检验。线性调频信号chirp是最理想的选择因为它的瞬时频率有精确的数学表达式可以逐点对比。自己生成一个从50Hz线性扫到250Hz、时长2秒、采样率1000Hz的chirp信号完全不用工具箱函数fs 1000; T 2; ts 0:1/fs:T-1/fs; f0 50; f1 250; k (f1 - f0) / T; % 调频斜率单位Hz/s phase 2*pi*(f0*ts 0.5*k*ts.^2); x sin(phase);瞬时频率的理论值是f_true(t) f0 k·t也就是从50Hz线性上升到250Hz。这样我们就有了一个可以精确计算的标准答案后面所有验证都围绕它展开。6.2 用我们的STFT跑一遍再和spectrogram对比调用手写的stft_fft跑一次参数取winLen256、hop64、nfft512、hann窗[S, f, tout] stft_fft(x, fs, 256, 64, 512, hann); S_dB 20*log10(abs(S) eps); figure; imagesc(tout, f, S_dB); axis xy; xlabel(Time (s)); ylabel(Frequency (Hz)); colormap(jet); colorbar;画出来后应该能看到一条从时间轴0秒50Hz到2秒250Hz的明亮脊线斜向右上方非常清晰。再用内置spectrogram做交叉验证% 仅用于交叉验证不是本文实现的核心 w 0.5 - 0.5*cos(2*pi*(0:255)/255); % 手写hann窗避免调用 overlap 256 - 64; [Sref, fref, tref] spectrogram(x, w, overlap, 512, fs);你会发现两者的时频图形态基本一致脊线走向完全重合。视觉上看我的实现和内置函数的差异主要出现在弱幅度的背景“噪底”上这是归一化方式和默认窗缩放不同造成的不影响主特征。6.3 提取峰值频率做定量对比肉眼对比还不够做定量验证才能真正说明问题。方法很简单对每一帧找出幅度最大的频点作为该时刻的“峰值频率估计”然后和理论瞬时频率对比。[~, idx] max(abs(S), [], 1); f_est f(idx); % 每帧的峰值频率 f_true f0 k * tout; % 理论瞬时频率 err abs(f_est - f_true); % 去掉首尾各3帧避免边界效应影响 errMid err(4:end-3); disp([中间区域最大频率误差: , num2str(max(errMid)), Hz]);在我这组参数下中间区域的峰值频率误差大约是1.5到2Hz。这个量级怎么理解nfft512、fs1000频率分辨率是fs/nfft≈1.95Hz所以误差在一个频率bin左右属于正常的量化误差。如果误差远大于这个范围说明实现哪里有问题需要回头检查。边界处的误差通常会大一些这是因为首尾帧经过零填充窗覆盖了无效数据区域加上窗的主瓣宽度会让峰值位置产生偏移。这是STFT本身的性质不是代码bug。定量验证的思路对所有手写时频工具都适用先造一个已知答案的信号再检查误差是否落在理论范围内。7. 长信号跑不动的性能优化与边界坑7.1 三重性能问题循环、未预分配、非幂nfftstft_fft在短信号上没问题但信号一长比如振动监测连续采几分钟数据几十万甚至几百万个点逐帧for循环的效率问题就出来了。性能瓶颈主要有三个第一是没有预分配。Matlab的数组在循环中若不断扩展会反复触发内存重分配速度慢到怀疑人生。所以我在代码里提前S zeros(oneSideLen, nFrames)这是最基本的优化。第二是非2的幂的nfft。FFT的快速算法对2的幂最友好如果nfft是质数或者因子复杂内置fft会退化为较慢的算法。所以尽量取2的幂。第三是循环本身的开销。即使预分配了每帧调用一次fft仍然有函数调用开销和索引开销。对于离线分析可以把分帧操作向量化一次构造出所有帧然后一次性做FFT能显著提速。7.2 矢量化版本代码下面这个是向量化版STFT核心思路是用一个帧索引矩阵一次性取出所有帧function [S, f, t] stft_vec(x, fs, winLen, hop, nfft, winType) % 短时傅里叶变换向量化分帧版适合长信号 x x(:).; L length(x); switch lower(winType) case rect w ones(1, winLen); case hann w 0.5 - 0.5 * cos(2*pi*(0:winLen-1)/(winLen-1)); otherwise error(未知窗类型); end nFrames ceil((L - winLen) / hop) 1; totalLen (nFrames - 1) * hop winLen; xpad [x, zeros(1, totalLen - L)]; % 关键帧索引矩阵每列是一帧的索引 idxMat (0:winLen-1). (0:nFrames-1) * hop; frames xpad(idxMat) .* (w.); % winLen x nFrames每列一帧已加窗 S fft(frames, nfft, 1); % 沿第1维做FFT一次算完 S S(1:nfft/21, :); % 取单边 f (0:nfft/2) * fs / nfft; t ((0:nFrames-1) * hop (winLen-1)/2) / fs; endidxMat的构造利用了Matlab的隐式扩展一个winLen×1的列向量加上一个1×nFrames的行向量得到一个winLen×nFrames矩阵。xpad(idxMat)一次取出所有帧再乘上窗的列向量完成加窗。fft(frames, nfft, 1)对所有帧同时做FFT返回维度是nfft×nFrames。这个版本在信号很长时比for循环快不少实测我能感知到的提升在小几十倍级别具体取决于信号长度和机器配置。性能优化上还有一个细节abs(S)和20*log10这些操作尽量向量化避免嵌套循环。Matlab的向量化思维在任何信号处理代码里都值得养成。7.3 边界效应造成的竖条、暗条时频图上经常看到边缘出现异常的竖条或暗条这不是算法错误而是边界效应。首帧和末帧的部分窗区间落在补零区域窗函数覆盖的信号实际长度小于winLen能量自然偏低取对数后这些帧会比中间帧暗很多。如果信号本身在起始位置有很强的瞬态冲击零填充还会让首帧的频谱出现“振铃”现象。处理方式有三种一是分析时直接在时间轴上裁掉首尾各几帧比如我在验证时去掉前3帧和后3帧二是用反射延拓或复制延拓代替零填充把边界处的信号延拓出去让首帧覆盖到真实数据三是用已分析帧数提示自己谨慎解释边缘信息。我的经验是除非边界处的时频信息本身是研究重点否则直接裁剪是最简单可靠的办法。8. 从STFT还能往哪走ISTFT、变窗长与跨语言移植8.1 逆变换重叠相加法OLA的核心理解了正变换逆变换ISTFT也就水到渠成。STFT的逆变换核心思想是重叠相加Overlap-Add步骤概括起来对每一帧频谱做逆FFT得到带窗的时域帧。把相邻帧按hop位移在时间轴上重叠对齐。把重叠区域的值相加。用窗函数的累计权重做归一化恢复原始信号。最关键的一步是第4步。因为相邻帧重叠同一个时间点会被多个窗覆盖简单相加会导致幅度不平坦。解决方法是构造一个权重向量统计每个时间点被窗覆盖的累计值再做逐点除法。这个技术主要用在时频域滤波、语音去噪、时间拉伸等场景。理解了正变换之后逆变换只要一天就能写通。8.2 变窗长与自适应时频STFT最大的局限就是固定窗长。窗长一旦确定整个时频图的频率分辨率和时间分辨率就锁死了。实际信号往往同时包含缓变成分和瞬态突变单一窗长很难兼顾。处理这类信号有几个方向一是多窗长并行分别用短窗和长窗计算两个STFT短窗捕捉瞬态长窗刻画频谱细节再想办法融合。二是改用小波变换小波通过尺度伸缩天然实现了变分辨率高频处时间分辨率好、低频处频率分辨率好适合非平稳信号。三是更现代的同步挤压变换Synchrosqueezing它可以对STFT或小波结果进行“挤压”重排让时频脊线更尖锐。但不管后面这些工具多高级STFT都是入门和理解时频分析的最佳入口。它把加窗截断这个朴素的思路演示得最清楚。8.3 移植到其他语言时的注意点手写STFT的一大优势就是容易移植。很多场合你没法用Matlab比如嵌入式实时系统、Python后端、甚至FPGA上的实时处理。移植时有几个容易出错的点Python版的一个坑是数组索引从0开始而Matlab从1开始分帧索引公式要做相应调整另一个坑是numpy.fft.fft默认归一化和Matlab一致但scipy.signal.stft有自己的一套窗缩放约定不要混用。C语言实现则要注意复数运算库的选择以及FFT输入输出数组的长度对齐。跨语言验证的方法和我前面说的一样用同一个chirp信号分别跑两个实现对比输出矩阵的最大幅度误差。写到这里我自己的体会是手写一次STFT相当于给时频分析这四个字补上了一堂完整的实践课。正式项目里我并不会完全抛弃spectrogram但每次用内置函数时我再也不会对着时频图上的异常现象发懵了。理解底层实现带来的判断力才是这次手写最大的收获。