简介:提供一套基于C/C++的短时傅里叶变换(STFT)与逆变换(ISTFT)实现,面向wav音频与麦克风输入信号处理场景,帮助开发者在时频分析和频域滤波之后重建原始波形,既适合研究学习,也适合嵌入桌面或嵌入式音频项目。代码支持25%和50%的重叠配置,内置Hann窗函数与Ooura FFT加速核心,并提供后处理模块;附带可直接运行的test.cpp示例,演示从wav文件或内存缓冲区完成正逆变换的调用方法,便于在已有音视频处理流程中快速替换或扩展。资源包共13个文件,以.h头文件和.cpp源码为主,同时包含CMakeLists.txt构建脚本、README说明文档、开源许可证以及一个input.wav测试样例,整体压缩后仅1.7MB,轻量便携。目前已有2330人学习下载,适合需要为wav或麦克风输入实现频谱分析、语音降噪、变调等功能的开发者参考。代码还特别指出其FFT输出与MATLAB FFT在个别数值上可能存在误差,方便使用者根据精度要求更换FFT库,这有助于在工程中避免算法不一致带来的调试困难。
1. 傅里叶反变换没能直接变回波形:这个STFT工具箱才是正解
做音频处理的第一年,我天真地以为 STFT 做完、ISTFT 做回去就是一次 IFFT,结果 wav 文件重建出来全是毛刺,麦克风采集的声音更是完全没法听。傅里叶反变换本身没有错,错的是把整段信号当成“一帧”来处理。这个开源的 MATLAB 工具箱整理的是短时傅里叶变换 STFT 与逆短时傅里叶变换 ISTFT 的完整闭环,支持 wav 文件输入、麦克风实时输入,以及通用的信号处理模块接入。它解决的核心问题,就是让你在频谱上做任何处理之后,能忠实地把时间波形恢复出来。适合做语音增强、声音识别前端、振动分析、教学演示的开发者,抄完就能跑,跑完能改。
2. 架构与模块设计:从 wav 和 mic 到 STFT 谱图的数据流
STFT 这个东西看起来就是一句 fft,真正落地的时候涉及的模块比想象中多。拿这份工具箱来说,它拆成了正变换、逆变换、输入适配、参数校验和演示脚本几个部分,每个部分职责单一,改起来不用牵一发动全身。
2.1 目录结构与文件依赖
常见的工程组织方式是这样的:
STFT_Toolbox/ ├── src/ │ ├── stft_forward.m │ └── istft_reconstruct.m ├── demos/ │ ├── demo_wav.m │ ├── demo_mic.m │ └── demo_signal_processing.m ├── utils/ │ ├── check_cola.m │ └── plot_spectrogram.m ├── data/ │ └── sample.wav └── README.mdsrc 目录放的是算法核心,stft_forward.m 负责把时域信号切成帧、加窗、做 FFT,输出一个二维复数矩阵;istft_reconstruct.m 负责把这个矩阵重建回时域。demos 目录把两种输入场景拆开,wav 演示跑一遍离线处理,mic 演示跑实时采集,信号处理演示则展示“先变到频谱域、改完再变回来”的完整链路。utils 里的 check_cola.m 用来校验窗函数和帧移参数是否满足完美重建条件,这个函数非常值得留,后面踩坑部分会专门讲。
依赖关系很简单:demo 脚本调用 src 里的两个核心函数,utils 是辅助工具,data 只放素材。整个工具箱没有跨目录的循环依赖,属于典型的“扁平方便面”结构,新手一眼能看懂,老手也方便把 stft_forward.m 单独抽出去集成到更大的工程里。
2.2 两种输入源的处理差异
wav 输入和麦克风输入在数据流上的差别,主要是“一次性”和“持续不断”的区别。
wav 文件的读取在 MATLAB 里就是一行:
[x, fs] = audioread('sound.wav'); if size(x, 2) > 1 x = mean(x, 2); % 双声道取平均,转成单声道 endaudioread 返回的 x 是列向量,数值范围通常在 [-1, 1]。如果录音是双声道,直接丢给 stft_forward.m 不会报错,但左右声道会互相串扰,重建出来的声音像加了合唱效果,所以要先 mean 一下。
麦克风输入则是另一套逻辑,推荐的做法是用 audioDeviceReader 做流式读取:
deviceReader = audioDeviceReader('SampleRate', fs, 'NumChannels', 1); while true frame = deviceReader(); % 每次读回一小块音频 S = stft_forward(frame, win_len, hop, nfft, fs); % 在这里对 S 做处理 y = istft_reconstruct(S, win_len, hop, nfft); sound(y, fs); endaudioDeviceReader 每次返回一小帧数据,这意味着实时应用必须在“上一次处理的输出还没播完”时就把下一块结果算出来。常见做法是在外面包一层环形缓冲区,攒够 win_len 个采样点才调用一次 stft_forward,否则帧不完整,边界效应会非常明显。
2.3 参数体系:win_len、hop、nfft 怎么配合
STFT 的三个参数决定了一切,也是最容易翻车的地方。窗长 win_len 决定频率分辨率和时间分辨率的平衡;帧移 hop 决定相邻帧重叠多少,直接影响重建质量;nfft 是 FFT 点数,决定频谱的频率刻度。
| 参数 | 取值范围 | 作用与影响 |
|---|---|---|
| win_len | 256/512/1024/2048 | 窗越长,频率分辨率越高,时间分辨率越低 |
| hop | win_len/4 ~ win_len/2 | 越小则帧重叠越多,重建越稳,计算量越大 |
| nfft | 通常等于 win_len,也可取 2 的幂 | 决定频谱的点数,取大于 win_len 会补零插值 |
给新手的建议是:语音用 win_len=512,hop=win_len/4,nfft=win_len;音乐用 win_len=1024 或 2048;振动信号则看你的采样率,目标是把感兴趣的频率范围压到频谱的中部区域。参数要一起换,不能只改一个,否则算出来的时间轴和频率轴会和真实物理意义对不上。
3. 核心代码拆解:STFT 正变换的分帧-加窗-FFT 链路
STFT 的正变换核心就三步:分帧、加窗、FFT。听起来简单,但每一步都有工程细节,尤其是 MATLAB 的索引习惯和帧边界的处理,写不对就会出现“频谱有数据,重建全是噪声”的现象。
3.1 分帧逻辑
分帧的意思是把一长段信号切成若干短的片段,每个片段称为一“帧”。切的时候不能一刀切,相邻帧要重叠,否则帧与帧之间会失去连续性,重建时穿帮。
我一般用循环来实现分帧而不是直接调 buffer,因为循环逻辑更透明,尾帧怎么处理也看得清清楚楚:
function frames = my_frame_split(x, win_len, hop) x = x(:); num_frames = floor((length(x) - win_len) / hop) + 1; frames = zeros(win_len, num_frames); for i = 1:num_frames start_idx = (i - 1) * hop + 1; frames(:, i) = x(start_idx : start_idx + win_len - 1); end end这里有个关键点:帧数不是总长度除以 hop,而是 (总长度 - 窗长) / hop + 1。很多人直接 length(x)/hop,结果最后一帧的起始位置已经超出信号边界。如果信号长度不是 win_len + (num_frames-1)*hop,最后一帧必然越界,所以更稳健的写法是允许尾帧补零。
补零的版本长这样:
num_frames = ceil((length(x) - win_len) / hop) + 1; for i = 1:num_frames start_idx = (i - 1) * hop + 1; end_idx = start_idx + win_len - 1; if end_idx > length(x) frames(:, i) = [x(start_idx:end); zeros(end_idx - length(x), 1)]; else frames(:, i) = x(start_idx:end_idx); end end补零会让最后一帧的能量偏低,这在做纯分析时影响不大,但如果要完美重建,逆变换那边必须对尾帧做同样的补零处理,并且叠加归一化时要把补零部分排除在分母之外,否则尾部会衰减。
3.2 窗函数的选择
分帧之后直接做 FFT 会造成频谱泄漏,因为矩形窗的旁瓣衰减很差。加窗是给每一帧乘一个两端趋近于零的窗函数,让帧边缘的突变变小。STFT 里最常用的是 Hann 窗,有周期性和对称性两种形态。
win = hann(win_len, 'periodic');周期性 Hann 窗适合做频谱分析和 FFT 类处理,因为它的末点为零,首尾衔接更平滑。对称窗更适合做滤波器设计。这里不要选错,否则重建时窗函数叠加的结果不是一个常数值,逆变换的分母归一化会变得很复杂。
加窗在代码里就是一个逐元素乘法:
windowed_frame = frames(:, i) .* win; X = fft(windowed_frame, nfft);注意是 .* 不是 *,MATLAB 里矩阵乘法在这里会直接报维度错误,但更隐蔽的是你容易忘记 frames 是二维矩阵,不小心对整个矩阵乘了一行向量,那样 MATLAB 也会做广播,结果看起来没报错,频谱却是错的。
3.3 完整的 stft_forward 实现
把上面几个步骤拼起来,就是工具箱里 stft_forward.m 的核心内容:
function [S, f, t] = stft_forward(x, win_len, hop, nfft, fs) x = x(:); % 强制列向量 win = hann(win_len, 'periodic'); % 周期Hann窗 num_frames = ceil((length(x) - win_len) / hop) + 1; nfft = max(nfft, win_len); % FFT点数不小于窗长 S = zeros(nfft/2 + 1, num_frames); % 只存单边频谱 for i = 1:num_frames start_idx = (i - 1) * hop + 1; frame = x(start_idx : min(start_idx + win_len - 1, length(x))); if length(frame) < win_len frame = [frame; zeros(win_len - length(frame), 1)]; end X = fft(frame .* win, nfft); S(:, i) = X(1:nfft/2 + 1); % 扔掉负频率部分 end f = (0:nfft/2) * fs / nfft; % 频率轴,单位Hz t = (0:num_frames - 1) * hop / fs; % 时间轴,单位秒 end逻辑说明:输入 x 是整个时域信号,win_len 是每帧长度,hop 是帧移,nfft 是 FFT 点数,fs 是采样率。循环里先按 hop 取出一个窗长的片段,不足则补零,加窗后做 nfft 点 FFT,然后只保留 0 到奈奎斯特频率的 nfft/2+1 个复数点,存到 S 的第 i 列。
S 是一个二维复数矩阵,每一列是某一帧的频谱,每一行是某个频率点的时变过程。这个存储格式决定了后续所有处理的方式:你想做降噪,就对每一列的幅值做处理;你想做语速变换,就对所有列做重排;想画语谱图,直接 imagesc(abs(S)) 就行。
关于单边存储多说一句:实信号的 FFT 结果是共轭对称的,正频率和负频率包含的信息完全重复,所以 S 只需要保留一半。这个优化在内存和计算量上都有意义,但逆变换那边必须记得补回负频率,否则重建出来的信号会带虚部,时间域全是乱码。
4. 核心代码拆解:ISTFT 逆变换的重叠相加与相位恢复
逆变换比正变换容易写错,因为它的目标不只是“把帧变回时域”,还要把相邻帧的重叠部分合理地叠加起来。很多人在这里翻车,核心原因是忘了重叠相加,或者加窗乘了两遍。
4.1 从单边频谱补出完整频谱
stft_forward 存的是单边频谱,逆变换的第一步是把每一列扩展成完整的 FFT 长度。对实信号来说,负频率部分是正频率部分的共轭翻转:
X_full = [S(:, i); conj(S(nfft/2:-1:2, i))];这一行的索引顺序很有讲究。S 的长度是 nfft/2+1,第一行是直流分量,最后一行是奈奎斯特频率。补出来的后半段要跳过直流和奈奎斯特那两项,从第 nfft/2 行开始倒着取,每一行取共轭。如果写反成 conj(S(2:nfft/2)),频率轴的顺序就错了,重建出来时域信号会完全变形。
补全之后调用 ifft 得到时域帧:
frame_time = real(ifft(X_full, nfft)); frame_time = frame_time(1:win_len);如果 nfft 大于 win_len,IFFT 出来的长度是 nfft,但原始帧只有 win_len 个有效点,其余是补零带来的,必须截掉,否则后续重叠相加时帧长度不一致,数组索引直接错位。
4.2 重叠相加与窗函数处理
逆变换的完整循环如下:
function x_rec = istft_reconstruct(S, win_len, hop, nfft) [~, num_frames] = size(S); win = hann(win_len, 'periodic'); total_len = (num_frames - 1) * hop + win_len; x_rec = zeros(total_len, 1); wsum = zeros(total_len, 1); % 窗函数叠加的归一化分母 for i = 1:num_frames X_full = [S(:, i); conj(S(nfft/2:-1:2, i))]; frame_time = real(ifft(X_full, nfft)); frame_time = frame_time(1:win_len); start_idx = (i - 1) * hop + 1; x_rec(start_idx : start_idx + win_len - 1) = ... x_rec(start_idx : start_idx + win_len - 1) + frame_time .* win; wsum(start_idx : start_idx + win_len - 1) = ... wsum(start_idx : start_idx + win_len - 1) + win .* win; end wsum(wsum < eps) = 1; x_rec = x_rec ./ wsum; x_rec = x_rec(1:length(x_rec)); % 实际长度由外部裁剪 end逻辑说明:每一帧先恢复完整频谱并 IFFT 回时域,乘上综合窗 win,然后加到输出信号对应位置上。wsum 累计的是每一帧综合窗的平方,因为正变换时乘了一次分析窗,逆变换再乘一次综合窗,等效每一帧被窗函数平方调制。最后做逐点除法,把窗函数平方叠加带来的幅值调制消掉。
这里分析窗和综合窗都用 Hann 窗,满足“平方 COLA”条件时,wsum 是一个常数,除法不会引入额外失真。如果你的 hop 取得太大,比如 hop 接近 win_len,窗函数平方叠加后 ripple 很大,那么这个除法只能“平均”掉误差,不能彻底消除,重建信号会有明显的起伏。
4.3 重建质量怎么验证
逆变换写完,第一件事不是接麦克风,而是做一个“直接正变换再逆变换”的闭环测试:
[x, fs] = audioread('data/sample.wav'); [S, f, t] = stft_forward(x, 512, 128, 512, fs); x_rec = istft_reconstruct(S, 512, 128, 512); % 计算信噪比或误差 err = x - x_rec(1:length(x)); rms_err = sqrt(mean(err.^2));理想情况下误差应该在 1e-10 量级,也就是浮点数精度范围内的“完美重建”。如果误差在 1e-2 级别,说明参数选得不对,最可能是 hop 没有满足 COLA 条件。我习惯把这个闭环测试放在任何调用 istft_reconstruct 的代码之前,跑一次就知道你的窗和帧移能不能自洽。
5. 避坑记录:短时傅里叶变换最常见的 4 个问题
写这套东西的过程里,我至少踩过下面这几个坑,有些是代码层面的,有些是参数选择层面的,写出来给正准备跑的人当参考。
5.1 现象:重建信号首尾有“鼓包”,幅度明显小于原信号
跑完逆变换之后,信号开头和结尾各有一段幅度衰减,中间正常。原因是边缘帧只有部分有效采样点,补零导致能量偏低,并且在 wsum 归一化时,补零区域也被算了进去,等效把有效信号又除了一次。
解决方法是:在逆变换结束之后截掉首尾各 win_len 个采样点,或者在 stft_forward 之前先给信号做边缘延拓,处理完再裁剪。工具箱里的做法是让调用方自己控制输入长度,逆变换输出的长度与输入严格对不上时,用 x_rec(1:length(x)) 截断。
5.2 现象:相位完全乱了,重建信号听起来像“机器人”
这种情况通常出现在修改过频谱幅值之后。STFT 的相位信息埋在复数矩阵的虚部里,很多人做降噪时只动了 abs(S),把 phase 丢了或者随手置零,结果逆变换出来相位错乱。
处理频谱时记住一个原则:修改幅度之后,一定要把原始相位保留下来,除非你做的是时间拉伸这类必须重估相位的工作。代码里常见的正确姿势是:
S_processed = S .* mask; % mask 是幅度增益,复数乘法同时保留相位如果一定要只改幅度,就先拆开再合回去:
S_processed = abs(S) .* processed_gain .* exp(1i * angle(S));angle(S) 就是提取相位,exp(1i * angle(S)) 把它转回复数形式。少了这一步,后面的 istft_reconstruct 再牛也救不回来。
5.3 现象:audioread 读出来没问题,但一运行就报维度错误
wav 文件如果是双声道,audioread 返回的是 Nx2 矩阵,而 stft_forward 内部用 x(:) 把矩阵强行拉成列向量,两声道的数据会交错在一起,频谱看起来完全不可理喻。
在入口处强制转单声道是最省事的做法:
if size(x, 2) > 1 x = mean(x, 2); end如果是麦克风输入,audioDeviceReader 的 NumChannels 参数要设成 1,或者在读出来之后取第一列。我见过有人用 stereo 录音做处理,结果一直觉得 STFT 有玄学问题,最后发现是两声道相位抵消。
5.4 现象:低频部分模糊,高频部分刺耳,怎么调参都不对
这说明 win_len 和 hop 的比例没配好。win_len 决定频率分辨率,计算公式是 fs / win_len。比如 fs=44100,win_len=512,频率分辨率大约是 86Hz,低频段 100Hz 以下几乎只有一两个频点,当然糊。想看清低频,就得加大 win_len 到 2048 或 4096,代价是时间分辨率变差,瞬态声音会变钝。
Hop 影响的是时间粒度和重建精度。hop 太大,帧之间重叠不足,逆变换的 wsum 有起伏,声音会“发颤”。标准做法是 hop 取 win_len 的 1/4 或 1/2,75% 或 50% 重叠,这样 Hann 窗的平方叠加基本能满足 COLA 条件。调参时要保持“窗长和帧移同步调整”,不要只动一个。
6. 二次开发:把 STFT 工具箱改造成实时频谱显示模块
跑通了 wav 和 mic 两个 demo 之后,最常见的需求是把这套东西接进自己的实时应用,比如做一个频谱显示面板,或者给实时降噪器做前端。
改造思路不是把 stft_forward 整个塞进 while 循环,而是把状态拆出来。实时处理时,音频是一块一块进来的,每次调用只处理最新的一帧,之前的帧状态要保留。一种可行方案是做一个“累积缓冲 + 帧滑动”的封装:
classdef RealtimeSpectrum < handle properties buffer win_len hop nfft fs end methods function obj = RealtimeSpectrum(win_len, hop, nfft, fs) obj.win_len = win_len; obj.hop = hop; obj.nfft = nfft; obj.fs = fs; obj.buffer = zeros(win_len, 1); end function S = update(obj, new_samples) % 新数据拼到 buffer 尾部 obj.buffer = [obj.buffer; new_samples(:)]; % 当前帧从 buffer 尾部往前取 win_len 个点 if length(obj.buffer) >= obj.win_len frame = obj.buffer(end - obj.win_len + 1 : end); win = hann(obj.win_len, 'periodic'); X = fft(frame .* win, obj.nfft); S = X(1:obj.nfft/2 + 1); obj.buffer(1:obj.hop) = []; % 滑动掉 hop 个点 else S = []; end end end end这个类的核心是把“帧定位”从绝对位置改成相对位置,每次丢掉 hop 个旧样本,保留 win_len - hop 个点作为下一帧的前半部分。这样做的好处是无论输入块大小是否等于 hop,都能稳定地每 hop 个新样本输出一帧频谱。真正的实时系统里,audioDeviceReader 返回的块大小不一定正好等于你想要的 hop,有了这个缓冲层就不用操心对齐问题。
验证改造是否成功的方法也很直接:跑一个已知频率的正弦波,比如 440Hz,采样率 44100,用这个类输出频谱,看最大幅值对应的频率是不是落在 440Hz 附近。如果偏出去了,检查 fs 和 nfft 是否一致,再看频率轴 f 的计算。那次我在某个项目里把 fs 写成了 48000,实际音频是 44100,整整偏了将近 9%,不测这个正弦波根本发现不了。
从那以后,我每次写完 STFT 相关模块,都强制先跑一遍“正弦波验证 + 正逆变换误差验证”,再谈接入业务逻辑,这两步花不了两分钟,但能省掉后面排故障的半天时间。希望帮到你。
本文还有配套的精品资源,点击获取