4. 频域分析入门:傅里叶变换(FFT)基础、频谱与功率谱、频域特征提取、基于频域的门限检测

好,咱们进入第四章。说实话,很多做音频的工程师,一听到「傅里叶变换」就头大。我当年也一样,觉得这东西太数学了,跟我写代码有什么关系?

但后来我发现,不懂频域,你根本做不好噪声门限。时域里你看到的是波形乱跳,频域里你一眼就能看出「哦,这是低频轰鸣,那是高频嘶声」。说白了,频域就是给你一副透视眼镜。

4.1 从时域到频域:为什么要做傅里叶变换?

你想想看,时域信号长什么样?就是一条随时间变化的曲线。但这条曲线里,可能混着50Hz的工频干扰、200Hz的人声、3000Hz的齿音。你在时域里根本分不清谁是谁。

傅里叶变换干的事很简单:把信号拆成不同频率的正弦波。每个频率对应一个幅度和相位。这样你就能知道「哪个频率成分强,哪个弱」。

核心思想:任何连续信号,都可以分解为一系列正弦波的叠加。这就是傅里叶级数展开的直观理解。

我在项目中遇到过一件事:有个客户说他们的录音设备有「嗡嗡声」,时域波形看着挺干净,但就是听着难受。我一做FFT,好家伙,50Hz和它的谐波(100Hz、150Hz)全冒出来了。原来是电源滤波没做好。这就是频域分析的价值——让你看到时域里看不到的东西。

4.2 FFT 基础:离散傅里叶变换的快速实现

实际工程中,我们处理的是离散采样信号,所以要用离散傅里叶变换(DFT)。但DFT计算量太大,N点DFT需要N²次复数乘法。N=1024时,就是一百万次运算,实时系统根本扛不住。

FFT(快速傅里叶变换)就是DFT的快速算法。它利用旋转因子的对称性和周期性,把计算量降到N·log₂(N)。N=1024时,只需要约一万次运算,快了100倍。

我的习惯:做音频处理时,FFT点数一般选256、512、1024或2048。点数越大,频率分辨率越高,但时域分辨率越低。这是个权衡。我通常用1024点,兼顾精度和实时性。

下面是一个简单的FFT实现示例,用Python写的,方便你理解流程:

import numpy as np

def my_fft(x):
    """简易FFT实现,仅用于教学"""
    N = len(x)
    if N <= 1:
        return x
    # 分奇偶
    even = my_fft(x[0::2])
    odd = my_fft(x[1::2])
    # 合并
    T = [np.exp(-2j * np.pi * k / N) * odd[k] for k in range(N // 2)]
    return [even[k] + T[k] for k in range(N // 2)] + \
           [even[k] - T[k] for k in range(N // 2)]

# 生成测试信号:50Hz + 120Hz
fs = 1000  # 采样率1kHz
t = np.arange(0, 1, 1/fs)
x = np.sin(2*np.pi*50*t) + 0.5*np.sin(2*np.pi*120*t)

# 做FFT
X = my_fft(x)
freqs = np.fft.fftfreq(len(x), 1/fs)
# 只取正频率部分
idx = np.where(freqs >= 0)
print("频率峰值在:", freqs[idx][np.argmax(np.abs(X[idx]))], "Hz")

嗯,这里要注意:实际工程中别自己手写FFT,直接用numpy.fft或scipy.fftpack。它们经过高度优化,比手写快得多。我当年刚入行时傻乎乎自己写了一个,结果跑起来比库函数慢了20倍,还被同事笑话了。

4.3 频谱与功率谱:你到底在看什么?

做完FFT,你得到的是复数数组。每个复数代表一个频率分量的幅度和相位。但通常我们更关心幅度,也就是频谱。

频谱(Spectrum): 就是|X[k]|,表示每个频率分量的幅度。单位跟原始信号一样。

功率谱(Power Spectrum): 是|X[k]|²,表示每个频率分量的能量分布。单位是信号幅度的平方。

为什么要区分这两个?

  • 频谱适合看「哪个频率成分强」,直观。
  • 功率谱适合做能量分析,比如计算信噪比、门限检测。

我曾经踩过的坑:做功率谱时忘记除以N(FFT点数),结果算出来的能量值大得离谱。后来排查了半天才发现,功率谱的正确计算应该是 |X[k]|² / N。千万别忘了归一化!

另外,实际信号通常用功率谱密度(PSD)更准确。PSD考虑了频率分辨率的影响,单位是V²/Hz。计算时还要乘以2(因为只取了正频率部分)。公式如下:

PSD = 2 * |X[k]|² / (fs * N)

其中fs是采样率,N是FFT点数。这样算出来的PSD,积分后等于信号的总功率。

4.4 频域特征提取:从频谱中挖出有用信息

有了频谱和功率谱,我们能提取哪些特征?我常用的有这几个:

特征名称 计算公式 物理意义 应用场景
频谱质心 Σ(f·P(f)) / ΣP(f) 能量集中的平均频率 区分明亮/暗淡声音
频谱滚降点 能量累计到85%的频率 高频截止位置 判断带宽、噪声类型
子带能量 某频段内P(f)之和 特定频段的能量 噪声门限、语音检测
谱平坦度 几何均值/算术均值 接近1是噪声,接近0是纯音 区分噪声与信号
谱熵 -Σ(P(f)·log(P(f))) 频谱的混乱程度 语音/噪声分类

我个人最常用的是子带能量谱平坦度。为什么?因为做噪声门限时,你不需要知道每个频率的精确值,只需要知道「这个频段有没有信号」。

举个例子:

def subband_energy(psd, freqs, low, high):
    """计算指定频段的能量"""
    mask = (freqs >= low) & (freqs <= high)
    return np.sum(psd[mask])

def spectral_flatness(psd):
    """计算谱平坦度"""
    geometric = np.exp(np.mean(np.log(psd + 1e-12)))
    arithmetic = np.mean(psd)
    return geometric / arithmetic

我在项目中遇到过:用谱平坦度做噪声检测特别灵。当只有背景噪声时,谱平坦度接近1;当有人说话或音乐响起时,谱平坦度会骤降到0.3以下。这个特征比单纯看能量阈值稳定得多。

4.5 基于频域的门限检测:这才是实战

好了,前面铺垫了这么多,终于到核心了。基于频域的门限检测,说白了就是:

  1. 把信号分帧,每帧做FFT
  2. 计算功率谱或子带能量
  3. 跟预设的门限比较
  4. 超过门限判为「有信号」,否则判为「无信号」

但这里有个关键问题:门限怎么设?

方法一:固定门限

最简单,但最不实用。因为环境噪声会变,白天和晚上的背景噪声差好几个dB。固定门限要么漏检,要么误触发。

方法二:自适应门限

实时估计背景噪声,动态调整门限。我常用的方法是:

  • 取前几帧作为噪声估计
  • 每帧更新:如果当前帧能量低于门限,就把它纳入噪声统计
  • 门限 = 噪声均值 + α × 噪声标准差

α一般取2~5,看你想要多灵敏。α=3时,误报率约0.3%(假设噪声服从高斯分布)。

实战代码片段:

class FreqDomainVAD:
    def __init__(self, fs=16000, n_fft=512, alpha=3.0):
        self.fs = fs
        self.n_fft = n_fft
        self.alpha = alpha
        self.noise_mean = None
        self.noise_std = None
        self.learning = True
        self.learn_frames = 0
        
    def process(self, frame):
        # 做FFT
        spec = np.fft.rfft(frame * np.hanning(len(frame)))
        power = np.abs(spec)**2 / self.n_fft
        total_energy = np.sum(power)
        
        if self.learning:
            # 学习阶段:收集噪声统计
            if self.noise_mean is None:
                self.noise_mean = total_energy
                self.noise_std = 0
            else:
                self.noise_mean = 0.95 * self.noise_mean + 0.05 * total_energy
                self.noise_std = 0.95 * self.noise_std + 0.05 * abs(total_energy - self.noise_mean)
            self.learn_frames += 1
            if self.learn_frames > 50:
                self.learning = False
            return False
        
        # 门限检测
        threshold = self.noise_mean + self.alpha * self.noise_std
        is_speech = total_energy > threshold
        
        # 更新噪声估计(如果当前帧是噪声)
        if not is_speech:
            self.noise_mean = 0.95 * self.noise_mean + 0.05 * total_energy
            self.noise_std = 0.95 * self.noise_std + 0.05 * abs(total_energy - self.noise_mean)
        
        return is_speech

这段代码我用了很多年,效果稳定。注意几个细节:

  • 加窗(汉宁窗)减少频谱泄漏
  • 用指数平滑更新噪声统计,比滑动窗口更省内存
  • 学习阶段至少50帧(约1.5秒),确保噪声估计准确

我曾经犯过的错:一开始没做加窗,结果频谱泄漏导致门限检测频繁误触发。后来加了汉宁窗,问题立刻解决。加窗不是可选项,是必选项。

4.6 本章知识体系总览

下面这张图,是我自己画的频域门限检测的完整流程。你照着这个思路走,基本不会出错。

频域门限检测完整流程 ① 信号分帧 帧长20-40ms,重叠50% ② 加窗处理 汉宁窗/汉明窗 ③ FFT变换 N=256/512/1024 ④ 计算功率谱 |X[k]|² / N 或 PSD ⑤a 子带能量提取 低频/中频/高频分段 ⑤b 谱平坦度计算 区分噪声与信号 ⑥ 自适应门限判决

这张图把整个流程串起来了。你从左上角开始,一步步往下走,最后到门限判决。每个环节都有对应的技术细节,我在前面都讲到了。

我的建议:刚开始做频域门限时,别追求花哨的算法。先把基础流程跑通,用固定门限试,再换成自适应门限。一步步来,你会发现频域分析其实没那么可怕。

好了,这一章的内容就到这里。频域分析是噪声门限设计的核心工具,掌握了它,你就能从「看波形猜问题」升级到「看频谱精准定位」。下一章我们会深入更高级的门限策略,但前提是——你得先把FFT和频谱玩熟。


公众号:蓝海资料掘金营,微信deep3321