做信号处理这些年,我养成一个不算讲究但很管用的习惯:不管接到什么算法模块,第一步不是拿真实数据去喂,而是先在程序里生成一段示例信号。原因很直接——真实信号里藏着太多说不清的东西,工频干扰、器件漂移、偶尔的毛刺,你根本分不清算法是起了作用还是单纯被噪声淹没了。示例信号就不一样,频率、幅值、相位每一项都写得明明白白,跑完算法跟预期一比,好坏立刻现形。
这篇内容适合三类人:刚接触数字信号处理、想用几段代码快速搭起测试环境的新手;被某类算法调试折磨、想造出可控输入来定位问题的开发者;还有需要给文档、汇报、课程配演示波形的同学。我会从参数设计讲到代码实现,再讲可视化验证和排查经验,尽量把“示例信号”这件事讲透。按照我个人的习惯,技术文章里能用例子说明白的,绝不多说一句空话。
1. 为什么每个信号处理项目都要从“造信号”开始
1.1 示例信号是算法调试的“标准砝码”
做过实验的人都知道,天平称重前要先校准砝码。示例信号在信号处理里的作用就是那枚标准砝码。你写了一个滤波器,声称它能把工频干扰去掉,怎么证明?最干净的做法是造一段已知含50Hz干扰的混合信号,滤波后再看50Hz还剩多少。如果滤波前你能在频谱图里清楚看到干扰峰,滤波后这个峰塌了下去,算法就是有效的。整个过程里没有任何“可能”,因为输入信号的每个分量都是你亲手放进去的。
真实采集数据做不到这一点。你永远不知道传感器信号里除了目标量之外还有什么,温度漂移、电源噪声、接触不良造成的瞬间尖峰,都会影响结论。有人会觉得“用真实数据验证才有说服力”,这句话没错,但真实数据应该放在算法调通之后作为最终验收。开发阶段一律用示例信号,这是效率最高的路径。
1.2 先定需求再选波形:一张决策清单
很多初学者打开编辑器就写np.sin(2 * np.pi * f * t),代码能跑,但造出来的信号对测试目标毫无帮助。正确顺序应该是先问自己:这次我要验证什么?
我整理了一张决策表,基本覆盖常见场景:
| 测试目标 | 推荐示例信号 | 核心原因 |
|---|---|---|
| 滤波器频率响应 | 线性调频信号 | 一次扫过全频段,一次就能看出通带阻带行为 |
| 去噪算法效果 | 正弦波叠加带限噪声 | 信噪比可控,好坏一眼判断 |
| 瞬态响应/边缘检测 | 方波、窄脉冲 | 沿的陡峭程度能暴露算法对突变信号的响应 |
| 频谱分析精度 | 多频正弦叠加 | 各分量频率已知,直接校验FFT峰值位置 |
| 系统辨识/时延估计 | 扫频或伪随机序列 | 相关特性好,方便算群延迟和冲激响应 |
| 硬件采集链路测试 | 低幅值正弦 | 幅值已知,可核对增益和ADC量化误差 |
这张表背后的逻辑是:每个算法都挑信号里某个特征下手,你要造的信号就是把这个特征放到聚光灯下。先用表格清晰定位,再写代码,比闷头生成一段看起来花哨的波形要省时间得多。
2. 示例信号的数学骨架:采样率、频率、幅值那些绕不开的参数
2.1 采样率与奈奎斯特约束:先算账再写代码
生成任何数字信号,第一个要确定的参数是采样率,也就是每秒采样多少个点。它直接决定了信号能表示的最高频率。按奈奎斯特采样定理,离散信号能无失真表示的最高频率是采样率的一半,这个频率也叫奈奎斯特频率。
举个例子:如果采样率是1000Hz,那么你能表示的最高信号频率是500Hz。造一个600Hz的正弦波,采样出来的点会伪装成100Hz的信号,这就是混叠。真实世界里的混叠一旦发生就不可恢复,因为高频已经“折叠”到了低频区域。所以在写np.arange之前,先按这个不等式估算:信号最高频率必须小于采样频率的一半。
实际工程里我通常留出余量,让最高信号频率不超过采样率的四分之一。比如要分析50Hz工频附近的信号,采样率取1000Hz已经非常充裕;要分析2kHz的超声信号,采样率至少给8kHz起步。宁多勿少,因为后面加数字滤波、抽取降采样都需要带宽余量。
2.2 幅值、相位、偏移量:三个容易被糊弄的参数
幅值决定信号大小,它的单位要跟自己应用场景对齐。在传感器数据里幅值可能代表加速度(m/s²),在音频里代表声压,在控制信号里可能代表电压。造示例信号时,最好把单位写在注释里,否则一个月后你自己都忘了这个2.0究竟是多少伏还是多少米。
相位是很多人忽略但极重要的参数。滤波器群延迟测试最依赖这个量:你输入一个相位已知的正弦波,经过滤波器后比较输出相位的变化,就能算出延迟了多少毫秒。如果你的示例信号连初始相位都没设置,这种测试根本没法做。直流偏移则用来模拟真实传感器被一些恒定背景量偏置的情况,比如压力传感器在没有压力时输出1.5V的偏置,你就要在正弦波上叠加一个直流项。
还有一个重要概念是信噪比。统一信号混入噪声后才能模拟真实环境,信噪比用来刻画信号和噪声的功率关系,单位通常是dB。造信号时先算清信号功率,再按目标信噪比反推噪声功率,而不是随手加一串随机数。
2.3 时间轴长度与点数:一个被反复问错的细节
生成数字信号,本质上是按固定的时间间隔取值。最标准的时间轴写法是:
import numpy as np fs = 1000 # 采样率 1000 Hz duration = 2.0 # 信号时长 2 秒 N = int(fs * duration) # 总采样点数 2000 t = np.arange(N) / fs # 时间轴,每个点间隔 1/fs 秒这里的关键细节是:np.arange(N) / fs得到的时间点是从0到(2 - 0.001)秒,总共2000个点。有些资料会用np.linspace(0, 2, 2000),两者看起来差不多,但linspace的时间间隔是2/1999秒,相当于采样率变成了999.5Hz。造一个150Hz的信号还没什么感觉,一旦做高精度的频率测量,这种细微误差就会变成不可忽视的系统偏差。所以我的习惯是统一用np.arange(N) / fs,它和真实采集设备的工作原理是一致的——每隔固定间隔采一个样,而不是把段时间均匀分成若干份。
3. 五类常用示例信号的生产过程
3.1 正弦波:最基础也最常翻车
正弦波是数字信号处理的地基,FFT、滤波、调制全都围着它转。生成一个标准正弦波只需要一行核心代码:
import numpy as np fs = 1000 N = 1000 t = np.arange(N) / fs f0 = 50 amp = 1.0 phase0 = 0.0 x = amp * np.sin(2 * np.pi * f0 * t + phase0)这段代码我几乎每天都要写,但翻车点反而集中在最简单的环节。第一个坑是把2 * np.pi * f0 * t里的t直接写成了np.arange(N),忘了除以采样率。这会导致频率单位变成“每采样周期”,而不是Hz。第二个坑是混用np.sin和np.cos,如果后面要跟滤波器输出做相位对比,初始相位就会对不上,整个群延迟测量直接废掉。
需要造更复杂的正弦信号时,把多个频率分量叠加起来即可:
f1, a1 = 50, 1.0 f2, a2 = 120, 0.5 x_multi = a1 * np.sin(2 * np.pi * f1 * t) + a2 * np.sin(2 * np.pi * f2 * t)这种叠加信号很适合验证FFT能不能把两个频率峰值都分辨出来。两个频率之间靠得多近才能被分辨,取决于观察时长,这是后话,但造信号时要记住一句话:合成信号的每个分量都应该是“预设可知”的。
3.2 方波与脉冲:研究边缘与瞬时响应
方波在理论上可以用无数个奇次谐波叠加出来,正因为如此,它是检验系统带宽的利器。一个陡峭的方波边缘意味着极高频分量,如果你的数字滤波器把高频都削了,方波输出立刻变成圆角波形,直观到不用看数据。
scipy里生成方波很方便:
from scipy.signal import square fs = 1000 t = np.arange(1000) / fs f_sq = 5 x_sq = square(2 * np.pi * f_sq * t, duty=0.5)duty参数控制高电平占空比。默认0.5就是标准方波,改为0.2就变成窄脉冲序列。如果只需要单脉冲,用numpy初始化一个全零数组再置某段为1:
x_impulse = np.zeros(1000) x_impulse[100:110] = 1.0宽度为10个采样点的脉冲,在1000Hz采样率下就是10毫秒宽。脉冲宽度决定了频率覆盖范围,宽度越窄,频谱越宽,越能激励出系统的高频响应。这类信号最常用来看滤波器的冲激响应和阶跃响应,能够直观反映系统的上升时间和过冲。
3.3 白噪声与带限噪声:给算法上强度
纯正弦波太干净了,任何去噪算法都能轻松应对。要模拟真实环境,噪声是不可少的。最常用的是高斯白噪声:
np.random.seed(42) # 固定种子,保证结果可复现 noise = np.random.randn(N)这里的randn是零均值、单位方差的高斯噪声。直接用也行,但更专业的做法是把它归一化到目标信噪比。信噪比的定义是信号功率与噪声功率之比,如果是20dB,那就意味着信号功率是噪声功率的100倍。因为randn的方差就是功率,所以可以这样写:
signal_power = np.mean(x ** 2) snr_db = 20.0 noise_power = signal_power / (10 ** (snr_db / 10)) noise = np.sqrt(noise_power) * np.random.randn(N)严格的白噪声在频谱上是一条平直的线,但真实的振动噪声、电子热噪声往往集中在某个频段内,这就要用带限噪声。最简单的生成方式是对白噪声做带通滤波,保留你关心的频段内的能量,滤掉其它频段。滤波器可以选二阶或四阶Butterworth带通滤波器,配合零相位滤波消除相位失真。
3.4 线性调频信号:一把扫过频带的“梳子”
线性调频信号,也叫chirp信号,它的频率随时间线性变化。它特别适合一次性观察一个系统在整个频带上的响应,因为它把频率“铺开”在时间轴上,不同时间对应不同频率,看输出幅度的包络就知道系统的通带是否平坦。
scipy直接提供了生成函数:
from scipy.signal import chirp fs = 1000 t = np.arange(5000) / fs x_chirp = chirp(t, f0=1, f1=200, t1=t[-1], method='linear')这段代码生成的是从1Hz扫到200Hz、持续5秒的信号。method参数换成'logarithmic'则是对数扫频。对数扫频特别适合分析音频设备或放大器频响,因为人耳对频率的感知是对数尺度的。注意t1参数必须等于信号末端时间,否则扫频范围会偏离你设定区间。
chirp信号还有一个妙用:做频率标定。你把chirp信号的FFT做出来,峰值出现的时刻对应已知频率,反过来就能校准整个采集链路的时间轴。
4. 从参数到可视化:一段完整的信号生成流水线
4.1 参数计算的完整示例:以一条“复合测试信号”为例
前面把单类信号讲完了,现在把它们串成一个实际案例。假设我要写一段测试信号,用来验证一个“去除工频干扰并保留微弱分量”的算法。需求是:信号时长2秒,包含一个80Hz的有用正弦波(幅值0.5),叠加50Hz工频干扰(幅值0.8),再混入一定量的随机噪声,目标信噪比为15dB。
先算参数。最高频率取80Hz和50Hz两者中更高的80Hz,按至少4倍余量的原则选采样率400Hz就够了。但考虑到后面可能要做频谱细分,我统一用1000Hz,这样2秒信号有2000个点。
生成代码:
import numpy as np fs = 1000 duration = 2.0 N = int(fs * duration) t = np.arange(N) / fs # 有用分量 f_useful = 80 amp_useful = 0.5 x_useful = amp_useful * np.sin(2 * np.pi * f_useful * t) # 工频干扰 f_noise = 50 amp_noise = 0.8 x_line = amp_noise * np.sin(2 * np.pi * f_noise * t) # 混合信号 x_mix = x_useful + x_line # 计算当前信号功率,确定噪声大小以达到15dB信噪比 signal_power = np.mean(x_mix ** 2) snr_db = 15 noise_power = signal_power / (10 ** (snr_db / 10)) x_noise = np.sqrt(noise_power) * np.random.randn(N) x_total = x_mix + x_noise这段代码有一个细节值得注意:算信噪比之前,x_mix里已经包含有用信号和工频干扰,也就是“全信号功率”。如果你只想让有用信号相对噪声达到15dB,就只算x_useful的功率;如果想让整体混合信号相对噪声为15dB,就按上面的写法。两种口径不同,命名、注释一定要写清楚。
4.2 可视化验证:时域图和频谱图一起看
信号造完之后必须画图验证,这是我最坚持的一步。很多人直接拿数组去跑算法,结果算法输出异常,回来查了半天才发现示例信号本身就错了。画图只要几秒钟,却能把频率错误、幅值错误、混叠问题全部暴露出来。
import matplotlib.pyplot as plt from scipy.fft import fft # 时域波形 plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(t, x_total) plt.title("Time Domain") plt.xlabel("Time (s)") plt.ylabel("Amplitude") plt.grid(True) # 频谱 spectrum = np.abs(fft(x_total))[:N // 2] freqs = np.fft.rfftfreq(N, 1 / fs) plt.subplot(1, 2, 2) plt.plot(freqs, 20 * np.log10(spectrum + 1e-12)) plt.title("Spectrum (dB)") plt.xlabel("Frequency (Hz)") plt.ylabel("Magnitude (dB)") plt.grid(True) plt.tight_layout() plt.show()判读时先看时域图,波形应该是正弦叠加的形态,杂乱但有规律。再看频谱图,80Hz和50Hz两个位置应该有明显峰值,80Hz的峰值比50Hz低一点,因为幅值0.5小于0.8。如果峰值出现在60Hz,说明代码里频率算错了;如果除了这两个峰还有一堆莫名其妙的旁瓣,说明边界处理或窗函数有问题。
我一般会额外画一张叠加纯信号和带噪信号的图,对比一下噪声级别是否符合预期。信噪比15dB意味着噪声功率远小于信号功率,波形还能看出正弦轮廓。如果画出来一片杂毛,多半是噪声功率算错了数量级。
4.3 保存与导出:让示例信号变成可复用资产
造好的信号不要每次重新生成,凡是反复使用的固定信号,我都会存成文件。存成numpy原生格式最省事,加载速度快且不损失精度:
np.save("test_signal_80hz_with_line_noise.npy", x_total)需要交给其它工具处理时,存成WAV格式更通用。注意WAV通常用16位整型或32位浮点,如果是浮点数据直接写int16会出问题:
from scipy.io import wavfile wavfile.write("test_signal.wav", fs, (x_total * 32767).astype(np.int16))这里的32767是16位整型的最大值。如果x_total幅值大于1,要先做归一化,否则直接乘32767会削波。存CSV也行,但文件大、读取慢,我基本不推荐。养成文件命名带参数的习惯,例如“fs1000_80Hz_15dB.wav”,这样一段时间后翻目录也能一眼看出信号参数,不用重新加载代码去猜。
5. 生成示例信号时的常见问题与排查实录
5.1 波形“糊成一团”:端点舍入与采样间隔误差
有一次我帮人排查一段看似正常的正弦信号,时间轴写的是np.linspace(0, 1, 1000),采样率我默认是1000Hz。结果用FFT做频率测量,发现测出的频率总比理论值偏了约0.1%。问题就出在linspace的间隔是1/999秒,而不是1/1000秒。0.1%的误差对很多场景无伤大雅,但如果你在做一个要求频率分辨率0.01Hz的测量系统,这个误差足以让结果不可信。
解决办法前面已经说过,统一用np.arange(N) / fs。另外还要注意np.arange在浮点边界可能多一个点或少一个点,最稳妥的写法是先算N再生成t,再检查len(t)是否等于N。代码多写一行,排查时少抓半天头发。
5.2 高频信号看不见了:混叠现象
初学FFT时最容易困惑的一个现象是:明明造了一个400Hz正弦波,采样率也是1000Hz,频谱图画出来却显示100Hz处有峰值。这不是代码错了,而是400Hz信号被1000Hz采样率“折叠”成了100Hz。奈奎斯特频率是500Hz,400Hz低于它,按理不该混叠。我举的这个例子其实说明了另一个常见错误——如果你写f0 = 600,采样率1000Hz,那么奈奎斯特频率是500Hz,600Hz会折叠到400Hz,但如果你在程序里把600Hz设成了600,却用了freqs轴最大500Hz,图上看不到这个信号。
排查方法是:先算fmax是否小于fs/2,再看fft频率轴是否正确生成。推荐直接用np.fft.rfftfreq(N, 1/fs),它会自动给出正频率轴,免得手动构造出错。另一个经验是,测试前期先造低频信号如10Hz,确认链路通了再往上加频率,一旦出现异常,至少能判断是信号生成问题还是算法问题。
5.3 首尾不衔接:拼接流式数据时产生的爆音
做音频流式处理时经常需要把一段信号截成若干块,或者循环播放一个示例音频。如果你生成的信号长度不是信号周期的整数倍,拼接处会出现相位突变,听起来就是“咔哒”一响。比如一个5Hz正弦波,周期0.2秒,在采样率1000Hz下一个周期正好200点。如果取256点作为一段,256不是200的整数倍,下一段开头相位就跟上一段结尾接不上。
解决办法是选择点数N等于信号周期的整数倍。更通用的做法是直接在代码里计算周期对应的点数:
freq = 5 period_samples = int(fs / freq) N_total = period_samples * 10 # 10个完整周期 t = np.arange(N_total) / fs这样整段信号首尾相位自然连续。如果你必须使用固定长度,就先拼完再截断,或者用短时淡入淡出消除爆音。
5.4 频谱看起来和理论不符:泄漏与加窗
生成一段正弦信号,时长1秒,频率50Hz,采样率1000Hz,FFT应该在50Hz处有一个“针尖”一样的峰值。但你实际画出来会发现峰值两侧有一些小幅的拖尾,这就是频谱泄漏。原因是FFT默认对无限长信号截断,截断相当于乘了一个矩形窗,矩形窗的频谱有很多旁瓣,于是主峰两侧就长出“裙边”。
如果信号频率恰好落在FFT分辨率网格的整数倍上,比如fs/N = 1Hz,50.0Hz正好坐落在网格点上,泄漏会很小。如果频率是50.5Hz,泄漏就非常明显。解决办法是接受泄漏然后加窗,常用汉宁窗或汉明窗:
window = np.hanning(N) x_windowed = x * window spectrum = np.abs(fft(x_windowed))加窗会略微降低频率分辨率,但旁瓣会大幅减少。造示例信号时如果目标是用FFT精确测量频率,最好让信号持续多个完整周期,并且信号频率尽量接近fs/N的整数倍。另一条经验是不要只盯着频谱图的峰值,还要看峰宽和旁瓣水平,三者结合起来才能判断信号生成得干不干净。
说到底,生成示例信号这件事看起来小,但它决定了后面所有验证工作的可信度。我在实际项目里吃过不少亏,最后养成的习惯是:每造一段信号,先花两秒钟看一眼频谱图再往下走。频峰对了、幅度对了、噪声底对了,才放心把数据喂给算法。这比事后对着异常结果猜原因要省太多时间。把这一步做成流程的一部分,你之后的调试速度会明显提上来。