文章目录
-
-
- [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 还是直接上了深度学习方案?欢迎交流。