)
我最早接触语谱图是为了排查一段录音里的噪声位置。当时网上搜到的方案清一色是调用现成库的一行代码出图确实快但图到底代表什么、横轴纵轴怎么对齐、为什么某个地方颜色深完全是一笔糊涂账。后来自己动手用 Python 从读取音频开始经过分帧、加窗、FFT、对数压缩到最终画出色带图整个过程走通之后才真正理解了语谱图的每一个像素是怎么算出来的。这篇文章就把这套“手动画出 spectrogram语谱图”的完整流程写出来。如果你正在学语音信号处理或者需要把一段音频画成语谱图来观察频域特征又或者只是想知道“语谱图到底是什么”这篇文章都适合你。我会把原理讲得尽量通俗代码可以直接复制运行参数调节的部分也会给出我实测过的搭配建议。整篇都是我从零实现一遍之后沉淀下来的经验希望能帮你少踩几个坑。1. 语谱图到底怎么来的理解短时傅里叶变换的核心思路1.1 傅里叶变换的局限与“短时”带来的转机要理解语谱图绕不开傅里叶变换。简单说傅里叶变换能把一段信号从“时间域”换到“频率域”告诉我们这段信号里有哪些频率成分每种频率的强度有多大。但傅里叶变换有一个天然局限它只告诉你整体上有哪些频率却完全不告诉你这些频率出现在什么时间位置。语音信号恰恰是典型的非平稳信号——同一个音节的元音、辅音、声调起伏频率成分都在快速变化。如果对整个录音做一次傅里叶变换得到的结果毫无时间信息没法观察“某一句话发到哪个字时频率怎么样”。短时傅里叶变换解决这个问题的方法很直观把长信号切成许多小段每一段做一个傅里叶变换再按时间顺序把结果拼起来。这个过程就是 STFTShort-Time Fourier Transform。每一段被称为一帧帧的长度通常是 20 毫秒到 50 毫秒左右因为在这个时间尺度内语音信号可以近似看作平稳的。这个近似是语谱图的底层逻辑。我当初想明白这一点之后对后面所有参数的选择就都有数了帧长决定了每帧里能看到多少频率细节帧的移动步长决定了时间轴上的细腻程度。所有参数最终都是在为“时频分辨率”这一个目标服务。1.2 加窗的意义为什么直接切帧不够有人可能会想既然要切帧那直接从信号里截取一段去做 FFT 就行了为什么还要加窗直接截取一段信号相当于把一个无限长的信号乘以一个矩形窗口——这段窗内保留窗外置零。这种硬截断在频域上会造成严重的频谱泄漏。打个比方你拍照时如果镜头边缘是锐利的裁剪光的衍射就会让边缘出现毛刺矩形窗在频域上的表现就是主瓣旁边产生了许多旁瓣本来该是一条线的频率成分变成了一大坨拖尾巴的形状。加窗就是为了缓解这个问题。常见的窗函数比如汉宁窗、汉明窗特点是中间高、两边低让每帧信号在两端平滑地衰减到零。这样切出来的每一帧在频域上的能量泄漏会大幅减少谱线更干净。这里有一个我在初学时的误区以为加窗是为了“强调中间、削弱两边”好像是在牺牲信息。实际不是。加窗是让每一帧信号在边界处满足周期性假设FFT 本身默认信号是循环的如果帧两端突然跳变就会在频域产生虚假的高频成分。窗函数削平了两端的幅度等效于消除了跳变。代价是每帧两端的信息被弱化了所以后面才需要相邻帧重叠让被弱化的部分在相邻帧里重新获得关注。1.3 帧移与重叠让每一帧的信息都不被浪费既然加窗会弱化帧两端的信号那么帧与帧之间就必须有重叠否则每一帧的两端信息就彻底损失了。帧移hop length就是相邻两帧起点之间的距离。比如帧长是 512 点帧移是 256 点那相邻两帧有 50% 的重叠。重叠率越高时间轴上的信息越充裕但计算量也越大。实际中语音处理常用的帧移是帧长的四分之一到一半也就是重叠率 50% 到 75%。这个选择背后的逻辑很朴素既要保证相邻帧之间平滑过渡让语谱图在时间方向看起来连续自然又不能过度重叠导致计算量爆炸。我自己在画图时最常用的是帧长 512、帧移 256也就是 50% 重叠既保证了分辨率出图速度也快。如果需要更精细的时域节奏比如观察辅音的爆发点会临时调整为 75% 重叠。2. 手动画语谱图的完整实现从音频读取到频谱矩阵构建2.1 读取音频数据先把声音变成数字手画语谱图的第一步是拿到音频的时域采样数据。这一步最核心的信息有两个采样率 sample_rate 和每个采样点的数值。采样率决定了频率轴的极限采样点数值决定了你做 FFT 时的输入向量。我习惯用 read 方式读取音频会同时返回采样率和数据数组。读取之后建议立刻检查数据类型。如果是整数型后续 FFT 之前要归一化成浮点数否则数值范围差异会导致频谱幅值出现奇怪的比例问题。这里提供一个最小读取与检查的示例import numpy as np import soundfile as sf data, sample_rate sf.read(test.wav) print(采样率:, sample_rate) print(数据形状:, data.shape, 数据类型:, data.dtype)data 如果是二维数组说明是多声道。手动画语谱图时通常先取单声道直接把多声道平均成单声道是最简单的处理方式if len(data.shape) 1: data np.mean(data, axis1)对于工程师来说这一步的坑在于不同库读出来的数据归一化范围可能不一样。有的库默认归一化到 [-1, 1]有的保持原始 PCM 值。我这里统一以 [-1, 1] 为基准后续所有 FFT 结果都会在这个基础上计算这样对比不同音频时幅值才有意义。2.2 分帧加窗的代码实现双循环不是不行但用滑动窗口更优雅最直观的分帧思路是写一个循环从索引 0 开始每次取 frame_size 个采样点下一帧起点加上 hop_size。这个逻辑完全没问题但 Python 的循环效率偏低音频一长就会明显变慢。我在踩过几次性能坑之后改用 NumPy 的滑动窗口视图来分帧。比如用 stride_tricks 或者直接构建索引矩阵。核心思路是每一帧对应一个起始索引帧内再加一个偏移量。先定义一个通用的分帧函数def framesig(sig, frame_len, hop_len): n_frames 1 (len(sig) - frame_len) // hop_len idx np.arange(frame_len)[None, :] hop_len * np.arange(n_frames)[:, None] return sig[idx]这个函数生成一个二维数组行数是帧数列数是帧长。每一行就是一帧信号。注意边界情况如果音频总长度不是 frame_len hop_len * (n_frames - 1) 的整数关系最后一帧可能取不满。我在这个函数里用的是向后取整的方式即多出来的尾巴直接丢弃。对于画图场景完全够用因为丢掉的通常只是最后一个不完全帧的几十毫秒。如果你要对信号做重构那必须做补零处理画图则不需要那么严格。接下来是加窗。以汉宁窗为例def pre_emphasis(sig, alpha0.97): return np.append(sig[0], sig[1:] - alpha * sig[:-1])加窗本身很简单逐帧乘以窗函数的系数就行window np.hanning(frame_len) frames framesig(pre_emphasis(data), frame_len, hop_len) windowed_frames frames * window这里用预加重则需要解释一下。语音信号的频谱在高频部分衰减更快预加重相当于人为放大高频让高频细节在语谱图画出来时更清楚。对于观察类任务我建议加上这个步骤因为不加预加重的语谱图高频部分很容易颜色过淡看起来像没有信息。但如果你后续要拿这些数据做精确的声学分析预加重可能会改变能量分布就需要谨慎取舍。2.3 从每一帧到频域FFT 与幅度谱的计算分帧加窗得到的二维矩阵每一行是一帧时域信号。对这个矩阵按行做 FFT就得到每一帧的频谱。spec np.fft.rfft(windowed_frames, nfft_size, axis1) magnitude np.abs(spec)这里有两个关键参数需要解释。第一个是 nfft_size。如果不填FFT 的长度就是帧长。如果你想要更高的频率分辨率可以把 fft_size 设置为大于帧长的值比如帧长 512、fft_size 2048这相当于在每帧后面补零再 FFT可以在频域上把谱线插值得更细腻。这个操作叫 zero-padding不会增加有效信息只是让频谱曲线看起来更平滑。在画图场景里这个技巧很实用因为补零后的频谱矩阵尺寸更大、图像更细密视觉上更舒服。第二个是 rfft 而不是 fft。因为实数信号的频谱是共轭对称的rfft 只保留非负频率部分能省一半计算量。FFT 之后得到一个复数矩阵包含幅度和相位。画语谱图时用幅度因为颜色深浅表达的是能量大小相位信息在静态图中无法体现。幅度数值范围非常大。一段普通语音低频部分的频谱幅度可能是高频部分的几百倍。如果直接把这组数值映射到颜色图像会是“黑黑的一大团细节全无”。所以接下来必须做对数压缩。我常用的压缩方式是取对数后再乘以一个缩放系数类似分贝刻度log_spec 20 * np.log10(magnitude 1e-10)加一个极小值是为了防止 log(0) 警告。这里的 1e-10 是经验值如果信号本身幅值极小可以调小到 1e-12。也有人更喜欢直接 np.log(magnitude eps)即自然对数刻度视觉效果差不多只是数值范围不同。到这里我们手上已经有了一张二维矩阵行是时间帧列是频率点值是对数能量。这张矩阵就是语谱图的“数据底色”。3. 绘图的关键细节把数据矩阵变成一张正确可读的语谱图3.1 imshow 与 pcolormesh 的选择和坐标对齐数据矩阵已经就绪最后一步是绘图。Matplotlib 里画二维色块图有两个选择imshow 和 pcolormesh。imshow 的本质是把一个矩阵当图像来显示速度快默认会做插值适合预览。pcolormesh 更底层它根据坐标网格给每个格子填色适合需要精确控制坐标的场景。在画语谱图时默认用 imshow但必须注意一个容易搞错的点——矩阵的行到底是朝上还是朝下。imshow 默认的 origin 参数是 upper也就是矩阵第一行显示在图像顶部。而语谱图的常规惯例是低频在底部、高频在顶部这意味着你需要把矩阵翻转或者设置 originlower。坐标轴标签也容易乱。imshow 默认的坐标是矩阵下标不是真实的时间和频率。必须用 extent 参数来映射import matplotlib.pyplot as plt plt.figure(figsize(12, 5)) plt.imshow( log_spec.T, aspectauto, originlower, extent[0, len(data) / sample_rate, 0, sample_rate / 2] ) plt.xlabel(时间 (s)) plt.ylabel(频率 (Hz)) plt.colorbar(label能量 (dB)) plt.show()这里的 log_spec.T 是因为矩阵本身的维度是“帧数 × 频率点数”imshow 的输入约定是“行数 × 列数”第一维会被映射为 y 轴。如果不转置时间就会落在 y 轴、频率落在 x 轴看起来整张图被旋转了 90 度这是新手最容易碰到的问题。extent 的四个值分别对应 x 轴起点、x 轴终点、y 轴起点、y 轴终点。x 轴终点是音频总时长即 len(data) / sample_rate。y 轴终点是奈奎斯特频率也就是采样率的一半。超过这个值的频率在采样后无法被表示这段信息是香农采样定理的基本结论。3.2 纵横比与拉伸为什么你的语谱图看起来“扁”或者“胖”画出来之后图的长宽比例往往不令人满意。aspectauto 是必须的如果不设置imshow 会默认保持像素的方形纵横比导致语谱图被严重压缩或者拉伸频率细节根本看不清。即使设置了 aspectauto图的最终形状还受到 figure 尺寸影响。我的习惯是把 figsize 设置成宽大于高比如 12:4 到 12:5 之间这样时间轴拉伸开语谱图的纹理细节更容易观察。有一个细节extent 设置之后imshow 会自动把矩阵拉伸到整个坐标范围。如果音频很长时间轴上的信息密度会变低表现为语谱图横向拉得很宽纹理被拉平。反过来如果音频很短时间轴被压缩语谱图看起来又窄又密。解决方法是调整 figure 的宽度或者在保存图片时指定 dpi 与宽高比让输出图片更适配你的展示需求。3.3 颜色映射的选择灰度优先色彩为辅语谱图的颜色映射colormap不只是审美问题。灰度图在打印论文时最常用因为它在黑白印刷下不会丢失层次且人类视觉对灰度亮度变化非常敏感。jet 这类彩虹色映射在视觉上丰富但是有一个明显的缺陷不同颜色之间缺少稳定的亮度递进关系会让读者误以为某些区域的“颜色差”代表“能量差”实际并非如此。我做观察时会先用灰度图确认整体结构没问题之后再切换到色彩图做展示。比较推荐的色彩图是 viridis 或者 inferno它们对色盲友好颜色递进也是有方向的信息呈现更忠实。这里还涉及一个动态范围的裁剪技巧。log_spec 的取值范围可能非常大从零附近到几十分贝以上。如果直接把全部范围映射到颜色数值小的区域会被压到几乎黑色丢失细节。最常见的做法是限定一个显示范围比如只显示 -60dB 到 0dB 之间的部分vmin -60 vmax 0 plt.imshow(..., vminvmin, vmaxvmax)这样低于 -60dB 的部分全部显示为最暗色高于 0dB 的部分显示为最亮色中间的能量变化就能完整展开。vmin 和 vmax 的选择取决于具体音频的动态范围我一般先用默认的范围画一遍看看实际最小值最大值再调整让目标区域对比度最强。4. 参数调节与效果优化帧长、帧移、窗函数和预加重怎么搭配4.1 帧长与频率分辨率的权衡帧长的物理意义是每一帧信号的持续时间。帧长越长单帧内包含的信号周期越多FFT 能分辨的频率更细腻。频率分辨率与帧长成反比分辨率 采样率 / 帧长点数。举个例子采样率 16000Hz帧长 512 点对应 31.25 毫秒频率分辨率约 31.25Hz。也就是说在这个设置下两个频率相差不到 31.25Hz 的成分很难被分开。如果帧长增加到 1024 点频率分辨率变为 15.625Hz格更细。但帧长不能无限增加。语音信号中的许多事件比如辅音的爆发、声门脉冲的起振持续时间只有几个毫秒。帧长太长会把这段时间内的多个不同事件混在一起时间上反而模糊了。这就是时频不确定原理在实践中的体现你无法让时间和频率分辨率同时无限好只能平衡。我在画普通语音的语谱图时常用的搭配是采样率 16000Hz、帧长 512 点、帧移 256 点。这个配置在频率分辨率和时间细节之间比较均衡。如果是分析元音可以加大帧长到 1024 或 2048 点因为元音的周期性强不需要那么高的时间精度。如果是看爆破音或者瞬态噪声则会把帧长降到 256 点宁可损失一点频率清晰度也要把时间节点找准。4.2 帧移与时间分辨率多少重叠最合适帧移决定了时间轴上相邻帧的间距。帧移越小时间轴越细密语谱图看起来更平滑流畅但计算量也更高。帧移通常以帧长的百分比表示常见的是 25% 到 75%。50% 重叠帧移为帧长的一半是最常用的起点这个比例在语音信号处理领域被验证得非常成熟。75% 重叠会让语谱图的纹理连续性更好特别适合关注频率成分随时间缓慢变化的内容比如语调曲线。但也要注意过度重叠不会增加实际信息量只是让图像看起来更“顺滑”本质上是在做插值。如果只是为了观察50% 到 75% 都够用了。4.3 窗函数的对比汉宁、汉明、布莱克曼的取舍窗函数的选择对语谱图的实际效果影响很大。汉宁窗和汉明窗长得非常相似区别在于汉宁窗两端直接归零汉明窗两端还有一个小数值。这导致汉宁窗的旁瓣衰减更快但主瓣稍宽汉明窗的主瓣稍窄但远处旁瓣泄漏相对更大。布莱克曼窗的主瓣更宽旁瓣衰减更极端频率精度会更低但谱线非常干净。实际画语谱图时汉宁窗是默认选择因为它在主瓣宽度和旁瓣衰减之间取得了很好的平衡。如果发现某段频谱上有很多细碎的毛刺干扰观察可以尝试切换到汉明窗通常会有改善。这种“毛刺”和窗函数的旁瓣泄漏有直接关系但换窗不是唯一的解法有时是帧长太短导致频率分辨率不足需要优先调整帧长。4.4 预加重的效果实测预加重系数 alpha 通常取 0.97含义是每个采样点的值减去前一个采样点的 0.97 倍。这个操作会放大高频成分。我对比过同一段音频加与不加预加重的效果。不加时语谱图上 3kHz 以上的区域颜色明显偏暗辅音的摩擦噪声细节几乎看不到。加预加重之后高频部分被抬升语谱图中原本模糊的辅音纹理变得清晰可辨。代价是低频部分的相对强度看起来弱了一些但语音分析中低频信息大多依靠基频曲线即使被削弱也依然可见。需要注意预加重在语音识别特征提取中是标准步骤但如果你是在做音乐分析或者环境声分类预加重未必合适。它相当于一个高通滤波器如果目标声音本身就以高频噪声为主预加重会把噪声特征也放大反而掩盖了有效信息。所以这个参数要按需开合。5. 常见问题与排查技巧实录5.1 图像上下颠倒或者时间轴变成纵轴这个问题的根源在于矩阵维度和 imshow 的 origin 参数。如果你看到频率在横轴、时间在纵轴多半是没有转置矩阵。如果你看到低频在顶部、高频在底部是 origin 参数没有设置为 lower。我自己的习惯是先做一个最小验证用一个已知频率的正弦波生成音频画完图看看颜色条出现在哪个位置。如果某个已知频率出现的竖直条纹在预期位置说明坐标正确。这个方法比肉眼盯着屏幕猜可靠得多。5.2 语谱图上出现明显的横向条纹横向条纹一般有两种原因。第一种是帧移过大相邻帧之间的频谱变化没有被平滑过渡图像呈现出阶梯状。这种情况在减小帧移、提高重叠率之后通常能明显改善。第二种是窗函数使用不当。如果你用的是矩形窗即不加窗频谱泄漏带来的旁瓣会在频率方向形成一个又一个虚假的亮线。换成汉宁窗之后条纹基本会消失。还有一种冷门情况如果音频数据本身有直流偏置也就是信号整体有一个非零的平均值FFT 之后第 0 个频率点直流分量会特别亮而且会像一条水平亮线一样贯穿整张图。解决办法是先把信号减去均值再分帧。5.3 语谱图颜色过深或者过浅细节层次丢失这个问题几乎都出现在动态范围处理上。如果整张图几乎全黑可能是 vmin 设置得过高把大量低能量区域压成了最低色。如果整张图几乎全白是 vmax 设置过低。先不设置 vmin 和 vmax让 Matplotlib 自动生成一次观察颜色条的实际范围再决定裁剪阈值。如果压缩后细节依然不清晰检查一下是不是没有加预加重特别是高频区域。高频如果有信息但幅度太低对数压缩后依然偏暗预加重能直接把下限抬高比单纯调节 vmin 更有效。5.4 音频很长画图很慢分帧后的矩阵大小等于帧数乘以帧长。一分钟的音频16000Hz 采样率帧长 512、帧移 256大约 3745 帧乘以帧长 512矩阵约 192 万个点。直接对这个矩阵做 FFT现代计算机完全能跑动我实测通常在一秒内完成。如果音频是几十分钟的录音比如环境声监测建议先截取感兴趣的一段再画而不是全量处理。或者可以尝试增大帧移比如帧移从 256 增加到 512矩阵行数直接减半计算量也减半。牺牲的是时间轴分辨率画长录音的预览图时很划算。6. 一次完整实操从 WAV 文件到成品语谱图这里给出一个完整可运行的脚本整合前面所有步骤方便你对照复现。import numpy as np import soundfile as sf import matplotlib.pyplot as plt def pre_emphasis(sig, alpha0.97): return np.append(sig[0], sig[1:] - alpha * sig[:-1]) def framesig(sig, frame_len, hop_len): n_frames 1 (len(sig) - frame_len) // hop_len idx np.arange(frame_len)[None, :] hop_len * np.arange(n_frames)[:, None] return sig[idx] def spectrogram(data, sample_rate, frame_len512, hop_len256, fft_size2048): if len(data.shape) 1: data np.mean(data, axis1) data data - np.mean(data) data pre_emphasis(data) frames framesig(data, frame_len, hop_len) windowed frames * np.hanning(frame_len) spec np.abs(np.fft.rfft(windowed, nfft_size, axis1)) log_spec 20 * np.log10(spec 1e-10) return log_spec def plot_spectrogram(log_spec, sample_rate, vminNone, vmaxNone): plt.figure(figsize(12, 4)) plt.imshow( log_spec.T, aspectauto, originlower, extent[0, log_spec.shape[0] * 256 / sample_rate, 0, sample_rate / 2], vminvmin, vmaxvmax, cmapviridis ) plt.xlabel(时间 (s)) plt.ylabel(频率 (Hz)) plt.colorbar(label能量 (dB)) plt.tight_layout() plt.show() data, sr sf.read(sample.wav) spec spectrogram(data, sr) plot_spectrogram(spec, sr, vmin-80, vmax0)这个脚本里extent 的时间终点是用帧数乘以帧移再除以采样率计算的这比用数据长度除以采样率更准确。因为分帧丢掉了尾部的非完整帧时间轴终点应为有效帧覆盖的范围。实测一段 5 秒的录音这个脚本从读取到出图在普通电脑上差不多一秒完成。即使改成帧长 1024、帧移 512也只是毫秒级的差别。这类任务的核心瓶颈通常不在计算而在调试坐标和颜色时反复试错所以脚本里的参数我都尽量做成明确变量方便你快速修改试验。我在实际使用中最大的体会是手动实现一遍语谱图之后再去看那些封装好的函数心里会非常有底。你知道它的默认参数是什么含义出了问题也能猜到大概根源在哪。这个过程就像学做饭看菜谱和从头切菜炒一遍得到的肌肉记忆完全不一样。如果你之前一直用现成函数画图不妨挑一段几秒钟的音频照着上面的思路手写一遍那种“原来如此”的感觉值得体验。