☰
手写STFT:从WAV到语谱图的完整实践与参数详解
2026/10/14 14:28:02 网站建设 项目流程

有一说一,用现成库画语谱图是真方便,librosa.specshow一行、matplotlib.specgram一行,几秒钟就能出一张像模像样的图。但我前阵子在一个项目里被问住了一个基础问题:对方指着图上的横纵坐标问,这个频率分辨率是谁决定的?为什么横轴时间上没有把整段音频铺满?我嘴上说了个大概,心里其实发虚。因为天天调封装好的函数,底层逻辑早就生疏了。

于是那天回去我干了件事:关掉所有音频分析库,只用NumPy和matplotlib,从WAV文件开始,把一张spectrogram(语谱图)一步步手画出来。整个过程比想象中有意思,画完再看那些现成接口,基本就是透明的了。这篇就把手写的过程完整记录下来,包括STFT的参数逻辑、帧与窗的关系、dB缩放的可视化细节、和librosa的数值差异,以及几个不踩不知道的坑。适合两类人:一是对STFT停留在公式阶段、想通过代码建立直觉的同学;二是项目里需要自定义谱图特征、不想被现成接口牵制的工程师。

1. 为什么非要手写一遍:黑盒带来的知识断层

1.1 会调用不等于会设计

绝大多数人第一次接触语谱图,都是从现成函数开始的。输入一个音频路径,输出一张图,中间发生了什么,很少有人逐行追究。我自己也一样,很长一段时间里,n_fft、hop_length、win_length这些参数对我来说就是"照着官方示例填的数字",换一个参数组合,图变密了或者变糊了,我也不清楚深层原因。

直到需要做两件事的时候,这种黑盒用法就撑不住了。一是要在自定义特征里对特定频段做加权,二是要解释为什么某段语音在语谱图上呈现出特定的条纹模式。这时候如果说不清每个像素是怎么来的,后续的调优根本进行不下去。

手写一遍最大的收获不是"我抛弃了库",而是"我知道库在干什么了"。当你能从原始的采样点一路算出复数谱、再映射到屏幕上的颜色值,那些曾经靠背的参数就变成了有物理含义的旋钮。

1.2 动手前先拆解:一张语谱图其实只有两步

语谱图听起来高大上,拆开就是两件事。

第一件事,把一段一维时域信号变成二维矩阵。原始音频是一串采样点,比如44100Hz采样率下,1秒音频就是44100个数字。但是声音不是稳定的,说话、音乐都在随时间变化,直接对整个信号做一次FFT,得到的只是整段音频的平均频谱,时间信息全丢光了。所以要分帧:把长信号切成很多短片段,假设每个片段内信号是近似平稳的,分别做FFT。这样横轴就有了时间,纵轴就有了频率,每个位置上的数值就是那一帧、那一个频率点的幅度(或者相位)。

第二件事,把二维矩阵变成一张彩色图片。二维矩阵里每个格子的数值是幅度,但幅度动态范围极大,一个强频率分量可能是弱分量的几万倍。直接映射成灰度会黑乎乎一片,所以通常取对数转成dB(分贝),再用颜色映射把dB值映射成不同颜色。于是有了横轴是时间、纵轴是频率、颜色是能量的谱图。

1.3 手写之前心里要先有一条完整管线

动手写代码之前,我建议先在心里把整条管线画出来,后面每一段代码都只是这条管线上的一个环节:

读取音频 -> 归一化/转单声道 -> 分帧 -> 加窗 -> FFT -> 取幅度 -> 幅度转dB -> imshow

这条管线里,读取音频是数据预处理,分帧和加窗是STFT的骨架,FFT是数学核心,取幅度和转dB是可视化前处理,imshow是最后一步渲染。任何一步出错,图都会出问题,而且往往是那种"看起来怪怪的但说不上哪里错"的问题。所以后面每一节我都尽量把原理和代码对应上,而不是只给一段能跑的函数。

2. STFT的核心参数:n_fft、hop_length与窗函数的物理含义

2.1 为什么不能对整个信号做一次FFT

这个问题的答案其实刚才提了一嘴:声音是时变的。一个10秒的语音,前3秒可能是清辅音,中间有元音,后面还有背景噪声,如果整体做一次FFT,这些时间信息会全部混在一起,出来的频谱只是一个"平均印象",完全看不出某个音出现在哪个时刻。音乐更是如此,旋律是随时间流动的,只有"时间-频率"同时保留,才能看出旋律线怎么走。

STFT的思路是假设信号在很短的时间内是平稳的。比如取25毫秒一段,在这个尺度上元音的声带振动频率、共振峰结构基本是稳定的,可以认为它是一个周期信号。每个短段做FFT,得到这一段内的频域快照;再把所有短段的频谱拼起来,就形成了"随时间的频谱演化"。这个"短段"就叫一帧,也就是n_fft要管的事情。

2.2 n_fft决定频率分辨率,hop_length决定时间密度

先上两个最核心的关系式,后面所有调参都围绕它们展开。假设采样率是fs,FFT长度是n_fft,帧移是hop_length:

  • 频率分辨率(相邻两个频率刻度之间的间隔):df = fs / n_fft
  • 相邻两帧的时间间隔:dt = hop_length / fs

这两个公式看着简单,但含义很深。n_fft越大,df越小,频率轴越细,能区分两个接近的频率;但每一帧的时间跨度也更长,信号内部如果有变化,会被"抹平"。hop_length则负责谱图在时间方向的密集程度,它不一定等于n_fft,通常取n_fft的四分之一或二分之一,也就是帧与帧之间有大量重叠,这样时间轴才更平滑。

win_length是另一个常见参数,它表示加窗段的实际长度。如果win_length < n_fft,相当于先对较短信号加窗,再在尾部补零到n_fft长度做FFT。补零本身不增加真实频率分辨率,只是让频谱曲线插值更平滑。日常绘图里一般直接让win_length = n_fft,少一个变量,问题更简单。

2.3 窗函数到底在干什么:不是切一刀,而是加权

一开始我有个误解,以为分帧就是把信号按时间"咔嚓咔嚓"切成一段段。后来才发现,直接硬切(矩形窗)会带来严重的频谱泄漏。原因是信号在帧边界处被突然截断,断点会产生不存在的频率成分,在频谱上表现为从真实频率向两边延伸的"拖尾"。

解决办法是加窗:用一个中间高、两端趋近于0的窗函数,和这一帧信号逐点相乘。这样帧中间的样本完整保留,越靠近边缘的样本权重越小,断点的影响被抑制。代价是每一帧的有效信息主要在中间,两边被压小了,所以需要帧与帧之间大量重叠,让那些被压小的部分在相邻帧里以中间位置的身份再出现一次。

常用窗函数各有侧重,我整理了一张表:

窗类型主瓣宽度旁瓣衰减量适用场景
矩形窗最窄约-13 dB不加窗,瞬态分析
Hann窗较宽约-31 dB语音/音频分析默认
Hamming窗较宽约-43 dB语音处理传统方案
Blackman窗最宽约-58 dB需要极低泄漏时

主瓣越窄,真实频率附近的能量越集中;旁瓣衰减越大,远处虚假频率干扰越小。实际语音语谱图大多数时候用Hann窗就够,它兼顾了主瓣宽度和旁瓣抑制,这也是librosa默认用Hann窗的原因。

2.4 一组具体数值:算一次比背十遍有用

假设采样率fs=44100 Hz,取n_fft=1024,hop_length=256,即每次前进256个采样点,帧与帧之间有75%重叠。

频率分辨率df = 44100 / 1024 ≈ 43 Hz。这意味着频率轴上相邻两个刻度相差约43 Hz,1000Hz和1043Hz能区分开,100Hz和130Hz就不行了。对语音来说,这个分辨率能看到共振峰的大致轮廓,但看不到很精细的谐波间隔。

时间分辨率dt = 256 / 44100 ≈ 5.8 ms。也就是说谱图上相邻两列相差约5.8毫秒,处理普通语音、音乐完全够用。

再看帧数。10秒音频总采样点N=441000,在后续会讲到的center=True模式下,帧数大约是1 + N // hop_length = 1723。所以最后得到的复数谱矩阵形状是(1723, 513),其中513来自n_fft/2+1,也就是只有非负频率部分。这引出另一个知识点:实信号的FFT结果关于中心共轭对称,画语谱图只看幅度谱时,只取0 ~ fs/2这一段就够了,另一半是重复信息。

3. 从WAV到复数谱:手写STFT的完整代码

3.1 读取音频:格式转换与单声道化

第一步是读音频。这里用scipy.io.wavfile.read,它返回采样率和NumPy数组。但WAV文件里数据可能是不同整数格式,比如16位、24位、32位,还有8位无符号。如果不做归一化直接进FFT,数值语义会乱七八糟,做后续能量统计时很容易出错。

先写成一个小函数。注意16位整型的取值范围是-32768到32767,所以要除以32768.0转成-1到1之间的浮点数;8位无符号则是128是静音基准;如果读进来是多声道,就沿声道轴取平均,变成单声道。

import numpy as np from scipy.io import wavfile def load_mono_wav(path): fs, data = wavfile.read(path) if data.dtype == np.int16: data = data.astype(np.float32) / 32768.0 elif data.dtype == np.int32: data = data.astype(np.float32) / 2147483648.0 elif data.dtype == np.uint8: data = (data.astype(np.float32) - 128.0) / 128.0 if data.ndim > 1: data = data.mean(axis=1) return fs, data

多声道取平均用的是float32之后做,不要拿原整数直接mean,否则整数截断误差会混在信号里,后面FFT出来高频部分会出现不该有的毛刺。

3.2 分帧:用索引矩阵一次性切出所有帧

分帧最直观的写法是循环,从0开始,每次取n_fft个点,然后跳到下一个起始位置。但Python循环在几百上千帧时还好,音频再长一点就太慢了。这里用索引矩阵的方式,把滑动窗口向量化。

先处理边界。为了让谱图的时间轴自然对齐,STFT通常会在信号两端各补n_fft//2长度的数据。补的方式可以是零填充,也可以用反射填充(把信号开头倒过来接上)。反射填充更平滑,不会像零填充那样在第一帧制造一个从0跳变到信号幅值的冲击。

def stft_manual(x, fs=44100, n_fft=1024, hop_length=256, window='hann', center=True, pad_mode='reflect'): n_frames = 1 + len(x) // hop_length if center else 1 + (len(x) - n_fft) // hop_length if n_frames < 1: n_frames = 1 if center: pad = n_fft // 2 if pad_mode == 'reflect': x_pad = np.pad(x, (pad, pad), mode='reflect') else: x_pad = np.concatenate([np.zeros(pad), x, np.zeros(pad)]) else: x_pad = x idx = np.arange(n_fft)[None, :] + np.arange(n_frames)[:, None] * hop_length frames = x_pad[idx] return frames

idx是核心。它是一张(n_frames, n_fft)的索引表,每一行是某一帧要取的采样点下标。用x_pad[idx]一次性就把所有帧切出来了,结果是一个形状为(n_frames, n_fft)的矩阵。这个写法理解成本低,而且比循环快很多。代价是如果音频特别长、帧数特别多,这个索引矩阵会占内存;实际几千帧完全没问题,几百万帧的极端情况再换有记忆的流式处理。

3.3 加窗与RFFT:复数谱的诞生

拿到帧矩阵后,加窗就是一次广播乘法。Hann窗可以看作0.5 - 0.5*cos(2*pi*n/(n_fft-1)),两端是0,中间是1,乘上去之后帧边缘被平滑衰减。

然后做FFT。这里用np.fft.rfft而不是np.fft.fft,因为实信号频谱共轭对称,只计算非负频率部分,省一半存量和计算量。得到的结果S是一个复数矩阵,形状是(n_frames, n_fft/2+1)。

def stft_manual(x, fs=44100, n_fft=1024, hop_length=256, window='hann', center=True, pad_mode='reflect'): n_frames = 1 + len(x) // hop_length if center else 1 + (len(x) - n_fft) // hop_length if n_frames < 1: n_frames = 1 if center: pad = n_fft // 2 if pad_mode == 'reflect': x_pad = np.pad(x, (pad, pad), mode='reflect') else: x_pad = np.concatenate([np.zeros(pad), x, np.zeros(pad)]) else: x_pad = x if window == 'hann': win = np.hanning(n_fft) elif window == 'hamming': win = np.hamming(n_fft) elif window == 'blackman': win = np.blackman(n_fft) elif window == 'boxcar': win = np.ones(n_fft) idx = np.arange(n_fft)[None, :] + np.arange(n_frames)[:, None] * hop_length frames = x_pad[idx] * win[None, :] S = np.fft.rfft(frames, axis=1) return S

到这里,STFT已经手写完了。S[i, k]的含义是第i帧在第k个频点上的复数谱值,幅度是abs(S[i,k]),相位是angle(S[i,k])。语谱图只需要幅度。

3.4 坐标换算:矩阵行和列怎么对应真实时间和频率

矩阵是有了,但要画图还得知道每个格子代表什么时间、什么频率。这里需要把索引换算成物理量。

帧索引i对应的中心时间,在center=True且做了左右补零/反射的情况下,大约是i * hop_length / fs秒,因为第0帧的中心正好在原信号的0秒附近。频率索引k对应的频率是k * fs / n_fft,范围从0到fs/2。比如fs=44100、n_fft=1024时,k=0是0Hz,k=1约43Hz,k=512是22050Hz。

画图时可以直接用这两个换算关系设置坐标轴,比用格子编号当坐标轴要直观得多。这步看起来不起眼,但坐标弄错会让所有后续分析跟着错,而且很难发现。

4. 从复数谱到彩色图像:dB缩放与坐标映射

4.1 为什么幅度必须取对数

直接拿幅度矩阵画图,结果会非常难看。原因很简单:语音和音乐频谱的动态范围极大。一个强共振峰的能量可能是底噪的十万倍,如果线性映射到灰度,大部分格子都会是黑的,只有少数几个亮点。取对数之后,强信号和弱信号之间的差距被压缩到几十个dB,人眼才能同时看到强弱分布。

dB的计算有个隐藏细节。幅度谱取20 * log10(mag),功率谱取10 * log10(power),两者在数学上等价,因为功率正比于幅度的平方。但很多库会默认用功率谱,比如librosa的power_to_db就是10*log10。你手写时如果用20*log10(mag),出来的图和库函数在数值上会差一个基准选择的问题,视觉形态则完全一致。

4.2 设置基准和下限:不要让动态范围压扁画面

dB值在理论上是负无穷到0(如果以最大值为基准)。但实际画图时,直接画全部范围会让底噪落在很深的颜色上,画面依然显得脏。常见做法是定义一个相对基准,比如把当前帧/整个谱图的最大值作为0dB,然后把低于某个阈值的部分全部截断。

阈值取多少很主观。我常用-80dB,也就是把能量低于最大值万分之一的部分全部压成同一颜色。对一般语音和音乐,这个范围既能展示细节,又不会让底噪抢眼球。如果你想突出微弱的高频成分,可以改成-60dB;如果底噪很干净、想看到更多弱细节,-100dB也可以。

4.3 imshow的四个关键设置

matplotlib的imshow是渲染矩阵的常用函数,但直接拿STFT矩阵进去画,方向通常是乱。原因在于imshow把矩阵的行当成图像高度、列当成图像宽度,而我们的STFT矩阵是(帧数, 频点数),帧数要画在横轴时间上,频点数要画在纵轴频率上。所以需要转置,并且设置以下四个参数:

  • origin='lower':让第0行(频点0)显示在底部,也就是0Hz在底部,频率向上增长。默认的origin='upper'会把低频画在上面,不符合声学图习惯。
  • aspect='auto':自动调整纵横比,不然imshow会把图像强行显示成正方形,导致频率轴被拉长、时间轴被压扁。
  • extent=[0, 时长, 0, fs/2]:把矩阵坐标映射成真实时间和频率坐标。
  • cmap='magma':颜色映射。选择感知均匀的色带,深色背景不容易干扰视觉,高频弱能量也不会太刺眼。

完整渲染函数如下:

import matplotlib.pyplot as plt def draw_spectrogram(S, fs, hop_length, db_range=80): mag = np.abs(S) ref = mag.max() + 1e-10 db = 20 * np.log10(mag / ref + 1e-10) db = np.clip(db, -db_range, 0) total_time = db.shape[0] * hop_length / fs plt.figure(figsize=(12, 5)) plt.imshow(db.T, aspect='auto', origin='lower', extent=[0, total_time, 0, fs/2], cmap='magma') plt.colorbar(label='Relative dB') plt.xlabel('Time (s)') plt.ylabel('Frequency (Hz)') plt.title('Handwritten Spectrogram') plt.tight_layout()

注意db.T,这一步把(帧数, 频点数)转成(频点数, 帧数),才能让imshow正确地把频率当高度、时间当宽度。

5. 和librosa逐帧对账:结果差在哪,为什么差

5.1 先写一段对账代码,别靠感觉

手写实现完之后,第一件事就是和标准库对账。我习惯用一条数据合成一个已知频率的信号来做数值验证,比如1kHz正弦波。这样横坐标频率、纵坐标能量分布都是可预期的,任何偏差都很明显。

对账代码的思路很简单:把我的手写函数和librosa.stft用相同的参数各跑一遍,然后比较两个复数矩阵的差异。

import librosa fs = 44100 t = np.arange(int(2 * fs)) / fs x = np.sin(2 * np.pi * 1000 * t).astype(np.float32) n_fft = 1024 hop_length = 256 S_mine = stft_manual(x, fs=fs, n_fft=n_fft, hop_length=hop_length, window='hann', center=True, pad_mode='reflect') S_lib = librosa.stft(x, n_fft=n_fft, hop_length=hop_length, window='hann', center=True, pad_mode='reflect') diff = np.abs(S_mine - S_lib) print('max abs diff:', diff.max()) print('mean abs diff:', diff.mean())

跑完之后你会发现,最大绝对差异通常能压到1e-6甚至更小,完全在浮点误差范围内。也就是说,手写实现和库的算法本质是同一件事。

5.2 三个必须手动对齐的默认行为

第一次对账大概率对不齐,因为库函数有默认设置,而我手写时很容易忽略这些隐含条件。最容易踩的是下面三个:

第一,center。librosa.stft默认center=True,它会在信号两端各补n_fft//2,然后用反射模式填充。如果我手写时没有做填充,帧数和首尾的频谱都会有差异。这就是为什么我在前面的代码里把center和pad_mode都暴露成参数。

第二,窗函数的周期性。librosa窗口默认是"周期型"Hann窗(fftbins=True),而numpy的np.hanning(n_fft)是对称型Hann窗。两者在点数足够大时差异很小,但严格对齐应该使用周期型定义,比如scipy.signal.get_window('hann', n_fft, fftbins=True)。这个坑非常隐蔽,画图看不出问题,做精确能量对比时会差一口气。

第三,帧数计算公式。center=True时帧数主要取决于len(x) // hop_length,center=False时还要扣掉n_fft再整除。两者差出的帧数集中在首尾边界,视觉上往往表现为第一帧和最后一帧的微小差异。

5.3 关于power_to_db的基准差异

librosa.specshow通常配合librosa.power_to_db使用,它内部默认ref=1.0,也就是绝对幅度基准。而我手写时用了相对最大值做基准,所以数值上会有整体平移,但图像形态完全相同。这提醒我:看语谱图时,colorbar上的绝对值到底意味着什么,取决于你选了哪个基准。库给你的数字不神秘,但也没那么神圣。

在实际项目里,如果要做跨音频的谱图特征对比,最好统一使用相对基准,例如把每段音频的峰值都标成0dB,否则不同文件的音量差异会主导特征分布。

6. 实战调参:时间分辨率、频率分辨率和显示效果怎么取舍

6.1 n_fft不同时到底发生了什么

很多人把n_fft当成一个"越大越精细"的旋钮,实际没那么简单。n_fft变大,频率分辨率变细,但每帧时间变长,时间上的定位精度会变差。下面是fs=44100时的几组值:

n_fft频率分辨率每帧时长语音场景适配
256约172 Hz约5.8 ms清辅音、瞬态
1024约43 Hz约23.2 ms一般语音/音乐
4096约10.8 Hz约92.9 ms低频基频、长乐音

语音里男声基频约80-200Hz,女声约200-350Hz。用n_fft=256时,频率分辨率172Hz,根本区分不开两个相邻的谐波;用n_fft=4096时,92.9ms的帧长又会让清辅音这种快速变化的音变得模糊。所以语音分析默认1024或2048是有道理的,它刚好落在"看到共振峰、又保留时间细节"的区间。

6.2 重叠率多少合适:75%是一个经验值

hop_length决定相邻两帧相隔多远,也就决定了重叠率1 - hop_length/n_fft。最常见的取法是hop_length = n_fft // 4,重叠率75%。为什么是75而不是50?因为加窗后每一帧的有效信息集中在中间,如果每次只前进一半,相邻帧之间的不连续性还是会看出来;前进四分之一时,一个采样点会深度参与多个相邻帧,时间轴自然显得平滑。

重叠率太低会出现"窗格感",谱图上的横向条纹一眼就能看出来;重叠率太高则计算量和内存直线上升,但视觉提升有限。75%是效率和质量之间相当均衡的点。

顺带算一笔内存账。一分钟音频,16kHz采样率,n_fft=1024、hop=256时帧数约1 + 960000//256 ≈ 3751,复数谱形状是(3751, 513),float32下约7.7MB。存复数谱做离线分析完全没压力,但如果是实时流处理,就要考虑分块和缓冲了。

6.3 窗函数对图的影响:从噪底到旁瓣

窗函数的选择直接决定谱图上每个强频率分量是"一条细线"还是"一条带尾巴的柱子"。矩形窗看起来时间分辨率最好,但强频率的旁瓣会泄漏到周围频点,在语谱图上形成一条横向虚线;Hann窗把旁瓣压掉不少,但主瓣变宽,细线也会变粗一点。

如果目标是看清精细的谐波结构,可以在Hann和Hamming之间选;如果目标是检测一个微弱信号旁边是否有一个强信号,Blackman的旁瓣抑制更彻底。语谱图的可视化目的通常不需要极致旁瓣抑制,Hann是稳的选择。

6.4 显示层面的微调:剪裁比调色更有效

很多新手在调cmap上花了很多时间,其实颜色映射是次要的,最重要的是把dB动态范围压到合理区间。底噪被压进-80dB以下后,画面会干净很多;再把最高点定在0dB,中间细节自然就拉开了。

另一个实用技巧是在需要保存图像给深度学习模型用时,先确定好vmin和vmax,用固定的值渲染成图像,不要让imshow每次根据数据动态归一化,否则不同音频的亮度风格不统一,模型学到的特征会夹杂音量差异。

7. 画完不是结束:六个容易翻车的细节与我的习惯

7.1 不归一化会出问题吗

严格说,只画图不归一化,视觉上基本无感,因为dB计算会把整体缩放抵消掉。但如果有其他需求,比如算某频段的绝对能量、或者和不同录音做对比,整型信号直接进FFT会让能量值失去物理含义。我的习惯是从读文件这一步就归一化成float32,后面所有处理都用标准化后的数值,省得中途再补救。

多声道合并那里也要留意。如果声道间有相位差,直接平均会抵消部分信号;语音场景通常影响不大,麦克风阵列或双耳录音做分析时就要谨慎了。

7.2 0Hz处那条亮线是直流偏移

有时候你会发现语谱图0Hz处有一条几乎贯穿全图的亮线。这是因为信号里有一个很小的直流分量,也就是整体偏置。很多采集设备会引入这个偏置,量化过程也可能产生。处理办法很简单:在分帧之前对整段信号减去均值,或者加一个高通滤波器把0Hz附近能量滤掉。

7.3 center=True对首尾帧做了什么

center=True补的反射数据会"伪造"出一段不存在的信号。对于只画谱图,这无伤大雅,首尾多出半帧信息完全看不出区别;但如果要做精确的相位分析、或者对特定时间点的事件做测量,就要意识到首尾若干帧包含了反射伪影,不要在这些位置提取关键数值。

7.4 dB下限裁剪不是丢数据

把低于-80dB的部分剪成同一颜色,不等于这些信息消失了,只是视觉上不展示。但如果这批图要用于训练模型,裁剪相当于对特征做了饱和非线性,一般是有益的,因为它压缩了底噪的动态范围。关键是所有音频用同一个裁剪规则,否则模型会学到裁剪阈值带来的偏移差异。

7.5 别忽略窗增益

窗函数会把帧内能量压低。Hann窗所有系数的平均值大约是0.5,也就是说加窗后信号能量大约减半。如果只是画图,这个系数无所谓;如果算出来的幅度谱要做能量统计、或者要重建信号,就得把窗增益除回去,或者用满足COLA条件的窗和hop设计逆变换。绘图阶段不除是没问题的,但心里要清楚这个量级关系。

7.6 我的个人习惯:先拿合成信号验证坐标轴再碰真实音频

手写谱图最大的风险不是STFT本身,而是坐标轴画反了却没人发现。所以我有一个固定习惯:第一次跑通后,一定先合成一个线性调频信号(chirp),频率从0Hz线性扫到fs/2,画出来应该是一条从左下角滑到右上角的斜线。如果不是,说明extent、origin、hop或转置中至少有一个地方错了。

t = np.linspace(0, 3, int(3 * fs), endpoint=False) x = np.sin(2 * np.pi * (0 + 500 * t) * t).astype(np.float32) S = stft_manual(x, fs=fs, n_fft=1024, hop_length=256) draw_spectrogram(S, fs, 256)

用这个信号验证只需要几秒钟,却能省下面对几小时真实音频时反复猜测的时间。我现在每次改完参数都会先用它跑一遍,确认坐标系统没坏,再上真实数据。这个方法强烈建议你也试试,尤其是你准备把这段代码放进特征提取管线的时候。

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

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

立即咨询