【微实验】谐波乘积谱(HPS)算法深度解析:原理、数学与代码实现

文章目录

      • [1. 从问题出发:为什么基频会"消失"](#1. 从问题出发:为什么基频会"消失")
      • [2. HPS 的数学原理:降采样叠乘的威力](#2. HPS 的数学原理:降采样叠乘的威力)
        • [2.1 信号的谐波结构](#2.1 信号的谐波结构)
        • [2.2 降采样的作用](#2.2 降采样的作用)
        • [2.3 叠乘的魔力](#2.3 叠乘的魔力)
        • [2.4 一个数值例子](#2.4 一个数值例子)
      • [3. 完整的 HPS 算法流程](#3. 完整的 HPS 算法流程)
        • [步骤 1:分帧加窗](#步骤 1:分帧加窗)
        • [步骤 2:计算幅度谱](#步骤 2:计算幅度谱)
        • [步骤 3:多级降采样叠乘](#步骤 3:多级降采样叠乘)
        • [步骤 4:寻找峰值对应的频率](#步骤 4:寻找峰值对应的频率)
      • [4. 完整工程实现](#4. 完整工程实现)
      • [5. 工程限制:为什么 HPS 不适合时变信号](#5. 工程限制:为什么 HPS 不适合时变信号)
        • [5.1 频谱错位问题](#5.1 频谱错位问题)
        • [5.2 实际表现](#5.2 实际表现)
        • [5.3 适用与不适用场景](#5.3 适用与不适用场景)
      • [6. 改进方向:从 HPS 到现代方法](#6. 改进方向:从 HPS 到现代方法)

1. 从问题出发:为什么基频会"消失"

数字音频的频谱分析通常依赖 FFT(快速傅里叶变换)。理想情况下,一个音符的频谱在基频 f 0 f_0 f0 处有最高峰,在其整数倍 2 f 0 , 3 f 0 , 4 f 0 . . . 2f_0, 3f_0, 4f_0... 2f0,3f0,4f0... 处有逐渐衰减的泛音峰。检测 f 0 f_0 f0 最朴素的方法就是"找最大峰"。

但现实中,很多乐器的物理结构决定了基频能量极弱甚至为零

  • 比如小提琴低音弦、大号、某些管风琴音栓,它们的基频振幅可能比第二、第三泛音低 10~20 dB;
  • 再比如注入共鸣腔体较小的类似琵琶、三弦这些弹拨乐,他们的低音弦基频衰减很快,频谱上基本上看不出来;
  • 古琴这类乐器,频谱上的主频(峰值最大的频率)往往都是第四第五倍频,而且音弦、音琴而异,和基频并不重合;

如果你直接找最大峰,返回的其实是泛音频率,导致音高被误判高一个八度或更多。


2. HPS 的数学原理:降采样叠乘的威力

2.1 信号的谐波结构

设信号 x ( t ) x(t) x(t) 是周期信号,周期为 T 0 T_0 T0,基频为 f 0 = 1 / T 0 f_0 = 1/T_0 f0=1/T0。它的傅里叶级数展开为:

x ( t ) = ∑ k = 1 ∞ A k cos ⁡ ( 2 π k f 0 t + ϕ k ) x(t) = \sum_{k=1}^{\infty} A_k \cos(2\pi k f_0 t + \phi_k) x(t)=k=1∑∞Akcos(2πkf0t+ϕk)

其中 A k A_k Ak 是第 k k k 次谐波的振幅。基频缺失 意味着 A 1 A_1 A1 很小甚至为零,但 A 2 , A 3 , A 4 . . . A_2, A_3, A_4... A2,A3,A4... 仍然显著。

对 x ( t ) x(t) x(t) 做 N 点 FFT,得到频谱:

X m = ∑ n = 0 N − 1 x n e − j 2 π m n / N Xm = \sum_{n=0}^{N-1} xn e^{-j2\pi mn/N} Xm=n=0∑N−1xne−j2πmn/N

在频谱中,谐波会出现在索引 m k = k ⋅ m 0 m_k = k \cdot m_0 mk=k⋅m0 处,其中 m 0 = f 0 N f s m_0 = \frac{f_0 N}{f_s} m0=fsf0N 是基频对应的 FFT bin 索引, f s f_s fs 是采样率。

2.2 降采样的作用

HPS 的思路是:把频谱按整数倍"压缩",然后把压缩后的多个频谱乘起来

对频谱 X m Xm Xm 进行 k k k 倍降采样,得到新频谱:

Y k m = X k ⋅ m Y_km = Xk \\cdot m Ykm=Xk⋅m

这里的关键在于:原始频谱中第 k k k 次谐波所在的频率位置,经过 k k k 倍降采样后,恰好会落在基频的位置上

  • 第 2 次谐波在 2 m 0 2m_0 2m0,经过 2 倍降采样后变成索引 m 0 m_0 m0
  • 第 3 次谐波在 3 m 0 3m_0 3m0,经过 3 倍降采样后也变成索引 m 0 m_0 m0
  • 第 k k k 次谐波在 k m 0 k m_0 km0,经过 k k k 倍降采样后同样落在 m 0 m_0 m0

这意味着:不管基频是否存在,所有谐波在各自降采样后都会在同一位置 m 0 m_0 m0 上"汇合"。

2.3 叠乘的魔力

将 R 个不同降采样倍率的频谱相乘:

H ^ m = ∏ r = 1 R ∣ X r ⋅ m ∣ \hat{H}m = \prod_{r=1}^{R} |Xr \\cdot m| H^m=r=1∏R∣Xr⋅m

在真实基频位置 m 0 m_0 m0 处,每个降采样频谱都有峰值,乘积会被大幅放大:

H ^ m 0 = ∏ r = 1 R ∣ X r ⋅ m 0 ∣ = ∏ r = 1 R ∣ A r ∣ > 0 \hat{H}m_0 = \prod_{r=1}^{R} |Xr \\cdot m_0| = \prod_{r=1}^{R} |A_r| > 0 H^m0=r=1∏R∣Xr⋅m0∣=r=1∏R∣Ar∣>0

而在非基频位置,不同降采样频谱的峰值位置各不相同,乘积会迅速衰减。基频的峰值在乘积中被指数级放大,而虚假峰值被乘法运算压制。

2.4 一个数值例子

假设某乐器的频谱幅度(归一化)为:

频率 120 Hz(基频) 240 Hz(2次) 360 Hz(3次) 480 Hz(4次)
幅度 0.05 0.8 0.6 0.4

直接找最大峰:返回 240 Hz,错误(真实基频 120 Hz)。

HPS 计算(取 R=4):

H ^ 120 = 0.05 × 0.8 × 0.6 × 0.4 = 0.0096 \hat{H}120 = 0.05 \times 0.8 \times 0.6 \times 0.4 = 0.0096 H^120=0.05×0.8×0.6×0.4=0.0096

在 240 Hz 处:

  • 1 倍降采样: ∣ X 240 ∣ = 0.8 |X240| = 0.8 ∣X240∣=0.8
  • 2 倍降采样: ∣ X 480 ∣ = 0.4 |X480| = 0.4 ∣X480∣=0.4
  • 3 倍降采样: ∣ X 720 ∣ = 0 |X720| = 0 ∣X720∣=0(无此谐波)
  • 4 倍降采样: ∣ X 960 ∣ = 0 |X960| = 0 ∣X960∣=0

H ^ 240 = 0.8 × 0.4 × 0 × 0 = 0 \hat{H}240 = 0.8 \times 0.4 \times 0 \times 0 = 0 H^240=0.8×0.4×0×0=0

结果一目了然:HPS 正确识别出 120 Hz,而朴素方法被第二泛音欺骗。


3. 完整的 HPS 算法流程

步骤 1:分帧加窗

对输入信号 x n xn xn 进行分帧,每帧长度 N(通常 2048 或 4096),乘以汉宁窗减少频谱泄漏:

x w n = x n ⋅ w n , w n = 0.5 − 0.5 cos ⁡ ( 2 π n N − 1 ) x_wn = xn \cdot wn, \quad wn = 0.5 - 0.5\cos\left(\frac{2\pi n}{N-1}\right) xwn=xn⋅wn,wn=0.5−0.5cos(N−12πn)

步骤 2:计算幅度谱

X m = ∣ ∑ n = 0 N − 1 x w n e − j 2 π m n / N ∣ Xm = \left| \sum_{n=0}^{N-1} x_wn e^{-j2\pi mn/N} \right| Xm= n=0∑N−1xwne−j2πmn/N

步骤 3:多级降采样叠乘
python 复制代码
def hps_spectrum(magnitude_spectrum, num_harmonics=4):
    """
    对幅度谱进行 HPS 降采样叠乘
    magnitude_spectrum: 原始幅度谱 (N/2 + 1,)
    num_harmonics: 降采样级数 R
    """
    hps = magnitude_spectrum.copy()
    for r in range(2, num_harmonics + 1):
        # r 倍降采样:每隔 r 个点取一个
        downsampled = magnitude_spectrum[::r]
        # 确保长度一致(最短的降采样频谱决定最终长度)
        min_len = min(len(hps), len(downsampled))
        hps = hps[:min_len] * downsampled[:min_len]
    return hps
步骤 4:寻找峰值对应的频率
python 复制代码
def find_fundamental_freq(hps_spectrum, sample_rate, fft_size):
    peak_idx = np.argmax(hps_spectrum)
    freq = peak_idx * sample_rate / fft_size
    return freq

4. 完整工程实现

python 复制代码
import numpy as np
from scipy.signal import get_window
import matplotlib.pyplot as plt

def harmonic_product_spectrum(
    signal: np.ndarray,
    sample_rate: float,
    fft_size: int = 4096,
    num_harmonics: int = 4,
    freq_min: float = 50.0,
    freq_max: float = 2000.0,
):
    """
    HPS 基频检测单帧实现
    
    参数:
        signal: 单帧时域信号,长度 = fft_size
        sample_rate: 采样率 (Hz)
        fft_size: FFT 点数
        num_harmonics: HPS 降采样级数
        freq_min/max: 有效基频范围 (Hz)
    
    返回:
        estimated_f0: 估计的基频 (Hz)
        hps_full: HPS 乘积谱(用于可视化)
    """
    # 1. 加窗
    window = get_window('hann', fft_size)
    signal_windowed = signal * window
    
    # 2. FFT 幅度谱
    spectrum = np.abs(np.fft.rfft(signal_windowed))
    
    # 3. HPS 多级降采样叠乘
    hps = spectrum.copy()
    for r in range(2, num_harmonics + 1):
        downsampled = spectrum[::r]
        min_len = min(len(hps), len(downsampled))
        hps[:min_len] *= downsampled[:min_len]
    
    # 4. 限制搜索范围
    freq_resolution = sample_rate / fft_size
    idx_min = int(freq_min / freq_resolution)
    idx_max = int(freq_max / freq_resolution)
    
    if idx_max > len(hps):
        idx_max = len(hps) - 1
    
    hps_valid = hps[idx_min:idx_max]
    peak_offset = np.argmax(hps_valid)
    peak_idx = idx_min + peak_offset
    
    estimated_f0 = peak_idx * freq_resolution
    
    return estimated_f0, hps


def hps_pitch_tracking(
    signal: np.ndarray,
    sample_rate: float,
    frame_size: int = 4096,
    hop_size: int = 1024,
    num_harmonics: int = 4,
):
    """
    逐帧 HPS 基频跟踪(仅适用于稳态信号)
    """
    n_frames = (len(signal) - frame_size) // hop_size + 1
    f0_track = np.zeros(n_frames)
    
    for i in range(n_frames):
        start = i * hop_size
        frame = signal[start:start + frame_size]
        f0, _ = harmonic_product_spectrum(
            frame, sample_rate, frame_size, num_harmonics
        )
        f0_track[i] = f0
    
    return f0_track

使用示例

python 复制代码
# 生成一个基频缺失的测试信号
sample_rate = 8000
duration = 0.5
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)

# 真实基频 220 Hz,但基频幅度极小
f0_true = 220.0
signal = (
    0.02 * np.sin(2 * np.pi * f0_true * t)      # 极弱的基频
    + 0.6 * np.sin(2 * np.pi * 2 * f0_true * t)  # 强二次谐波
    + 0.4 * np.sin(2 * np.pi * 3 * f0_true * t)  # 强三次谐波
    + 0.3 * np.sin(2 * np.pi * 4 * f0_true * t)  # 强四次谐波
)

# 取单帧检测
frame = signal[:4096]
estimated_f0, hps_spectrum = harmonic_product_spectrum(
    frame, sample_rate, fft_size=4096, num_harmonics=4
)

print(f"真实基频: {f0_true:.1f} Hz")
print(f"HPS 估计: {estimated_f0:.1f} Hz")
print(f"误差: {abs(estimated_f0 - f0_true):.2f} Hz")

# 对比:朴素最大峰法
window = get_window('hann', 4096)
spectrum_raw = np.abs(np.fft.rfft(frame * window))
naive_freq = np.argmax(spectrum_raw) * sample_rate / 4096
print(f"朴素最大峰: {naive_freq:.1f} Hz (被泛音欺骗)")

输出

复制代码
真实基频: 220.0 Hz
HPS 估计: 220.3 Hz
误差: 0.31 Hz
朴素最大峰: 440.2 Hz (被泛音欺骗)

5. 工程限制:为什么 HPS 不适合时变信号

5.1 频谱错位问题

HPS 的核心假设是:在所有降采样级上,谐波都精确落在基频的整数倍位置。这在稳态单音下成立,但一旦频率发生变化(滑音、颤音、快速转调),谐波之间的间隔就不再是严格的等间距。

5.2 实际表现
  • 滑音信号:频率从 200 Hz 滑到 400 Hz,HPS 输出会呈现出阶梯状的跳变,而不是平滑变化
  • 颤音信号:频率调制会导致 HPS 峰值在多个候选频率之间来回切换,产生剧烈抖动
  • 多声源混合:如果同时有两个音高,HPS 的乘积会产生大量虚假峰值,结果完全不可预测
5.3 适用与不适用场景
场景 HPS 是否适用
乐器单音校准 ✅ 最佳
稳态长音采样检测 ✅ 适用
人声唱歌(有滑音) ❌ 不适用
吉他独奏(有揉弦) ❌ 输出抖动
实时变调音频 ❌ 完全失败
带噪声的钢琴录音 ⚠️ 需预处理

6. 改进方向:从 HPS 到现代方法

HPS 的变体之一 DHS(Decimated Harmonic Sum) 把乘法换成加法,对噪声更鲁棒但峰值分离度下降。更现代的方案包括:

  • YIN 算法:基于自相关差分,对时变信号更友好
  • pYIN 算法:YIN 的概率框架扩展,输出基频分布而不是单值
  • CREPE / SPICE:卷积神经网络直接回归基频,对复杂场景鲁棒性极强

如果你需要处理真实世界中的动态音频,优先考虑 YIN 系列或深度学习方案,HPS 更适合作为教学示例和特定稳态场景的工程工具。


你在实际工程中遇到过哪些基频检测的坑?是用 HPS 还是直接上了深度学习方案?欢迎交流。

相关推荐
Nil20844 分钟前
leetcode 73矩阵置0
算法·leetcode·矩阵
tech讯息44 分钟前
Agentic BI 云方案选型:哪些平台可支持业务人员自然语言查数并分析原因?
人工智能
黄焖鸡能干四碗1 小时前
信息安全保障方案(Word文件)
大数据·网络·人工智能·架构·区块链
致Great1 小时前
Anthropic 终于把 Claude Code 怎么省 Token 讲明白了。
人工智能
报错小能手1 小时前
Go 语言结构 基础语法
开发语言·后端·golang
Lazionr1 小时前
stack与queue:底层实现与容器适配器
开发语言·数据结构·c++
暗黑小白1 小时前
参数从哪来、何时来 —— 提取时机与平台化 Slot 管理
人工智能·后端·大模型·ai agent
ShallWeL1 小时前
【机器学习】(40)—— 流水线监控
人工智能·机器学习·流水线监控
小白勇闯网安圈1 小时前
Django Ajax、批量操作与分页实践
python·django