文章目录
- ✨前言
- 🎯基频检测
- [🔍 基频检测------自相关 ACF](#🔍 基频检测——自相关 ACF)
- [🛠️NACF + 峰值检测 + 去DC](#🛠️NACF + 峰值检测 + 去DC)
- 🧠YIN算法
- [📚ACF / NACF / YIN 对比总结](#📚ACF / NACF / YIN 对比总结)
- ✨总结
✨前言
在语音处理中,"基频(Fundamental Frequency,F0)"是一个非常核心但又容易被误解的概念。
无论是:
- 变声器
- PSOLA
- AutoTune
- TTS语音合成
- 声码器
- 歌声分析
几乎都离不开基频检测(Pitch Detection)。
很多初学者第一次接触基频检测时,通常会遇到几个问题:
- 为什么自相关(ACF)能检测周期?
- 为什么算法总会出现倍频错误?
- 为什么高频容易误检?
- NACF 和 YIN 又到底改进了什么?
- 为什么 Praat 的结果比自己实现稳定很多?
而大量资料往往只给出公式,却很少真正解释:
"这些算法为什么有效,又为什么会失败"。
因此本文将从"周期"的本质出发,逐步分析:
bash
基频是什么
→ ACF 为什么能检测周期
→ ACF 为什么容易出错
→ NACF 如何改进
→ YIN 为什么更稳定
并结合:
- 波形直觉
- 工程现象
- 代码实现
- 实际结果分析
去真正理解基频检测背后的核心思想。
📌 作者:山河君,未经允许禁止转载
🎯基频检测
📌基频定义
假设一个周期语音信号可以分解成:
x ( t ) = ∑ n = 1 ∞ A n sin ( 2 π n f 0 t + ϕ n ) x(t)=\sum_{n=1}^{\infty}A_n\sin(2\pi nf_0t+\phi_n) x(t)=n=1∑∞Ansin(2πnf0t+ϕn)
如同下图:

其中
| 概念 | 含义 |
|---|---|
| f 0 f_0 f0 | 基频(Fundamental Frequency) |
| 基波 | 最低频率对应的正弦分量 |
| 谐波 | 2 f 0 , 3 f 0 , . . . 2f_0,3f_0,... 2f0,3f0,... 等高次频率 |
这是在语音基础知识专栏的文章介绍,简单来说:
| 名称 | 本质 |
|---|---|
| 基频 | 一个频率值 |
| 基波 | 这个频率对应的波形 |
🎵基频与音高
在语音中:
- 基频决定音高(Pitch)
- 谐波结构决定音色(Timbre)
| 属性 | 音高(Pitch) | 音色(Timbre) |
|---|---|---|
| 感知 | 声音高低 | 谁的声音 |
| 主要决定因素 | 基频 F0 | 谐波 + 包络 + 共振峰 |
| 改变效果 | 变尖/低沉 | 变成另一个人 |
🚀基频检测(Pitch Detection)
在人声中,声带会周期性振动。基频检测常用于检测人声基频:
| 人群 | 常见基频范围 |
|---|---|
| 男性 | 80 ~ 200 Hz |
| 女性 | 150 ~ 400 Hz |
| 儿童 | 更高 |
而常见的基频检测算法有:
| 算法 | 核心思想 | 特点 |
|---|---|---|
| 自相关 ACF | 延迟一个周期后与自身最相似 | 简单经典 |
| AMDF | 周期位置差值最小 | 计算量低 |
| 倒谱 Cepstrum | 周期在倒谱形成峰值 | 频谱分析常用 |
| YIN | 改进差分函数 | 高精度 |
| 深度学习(CREPE/RMVPE) | 神经网络估计F0 | 效果最好 |
其应用在
| 场景 | 用途 |
|---|---|
| 变声器 | 改变音高 |
| PSOLA | 切分 pitch mark |
| AutoTune | 修音 |
| TTS | 合成语调 |
| 声码器 | 提取激励周期 |
| 音乐分析 | 音符检测 |
🔍 基频检测------自相关 ACF
📘ACF 核心公式
R ( k ) = ∑ n = 0 N − k − 1 x [ n ] x [ n + k ] R(k)=\sum_{n=0}^{N-k-1}x[n]x[n+k] R(k)=n=0∑N−k−1x[n]x[n+k]
其中
| 符号 | 含义 |
|---|---|
| R ( k ) R(k) R(k) | 延迟 k k k 时的相关性 |
| k k k | Lag(延迟) |
| N N N | 帧长 |
🌊直观理解
假设:
- 采样率是16kHz
- 基频:200Hz
- 帧长320
那么周期:
T 0 = 16000 / 200 = 80 T_0=16000/200=80 T0=16000/200=80
也就是说每80个采样点为一周期,而对于输入信号最大存在 320 / 80 = 4 320/80=4 320/80=4个周期,那么就会存在:
clike
x[0] 应该像 x[80]
x[1] 应该像 x[81]
x[2] 应该像 x[82]
...
如果信号真的周期为 80:
- 延迟 80 后会再次相似
- ACF 在 lag=80 处会出现峰值
这就是 ACF 能检测周期的本质。
💻代码实现
cpp
int detectPitchACF(const std::vector<float>& x, int fs, int minFreq=50, int maxFreq=500)
{
int N = x.size();
int minLag = fs / maxFreq;
int maxLag = fs / minFreq;
float bestCorr = 0;
int bestLag = minLag;
for (int lag = minLag; lag <= maxLag; lag++)
{
float corr = 0;
for (int i = 0; i < N - lag; i++)
{
corr += x[i] * x[i + lag];
}
if (corr > bestCorr)
{
bestCorr = corr;
bestLag = lag;
}
}
return fs / bestLag;
}
默认频率范围在 [ 50 , 500 ] [50,500] [50,500]Hz之间
🧠结果分析
使用praat工具查看:不连贯蓝色线条是使用工具看到的基频,范围在 [ 130 , 256 ] H z [130,256]Hz [130,256]Hz之间

使用ACF算法:

我们可以看到主体的频率是180~300Hz,这是真实女声基频,但结果里还存在部分异常,现在进行分析:
1)Lag假峰
我们看到异常中有大部分是
500 484 470 444 410
这其实是小 Lag 假峰,对应
| F0 | Lag |
|---|---|
| 500 | 32 |
| 484 | 33 |
| 470 | 34 |
| 444 | 36 |
| 410 | 39 |
对应越高频的Lag,Lag的值越小,而对于越小的Lag
小 lag → 累加更多 → 相关值更大
因此, R ( 1 ) , R ( 2 ) , R ( 3 ) . . . R(1),R(2),R(3)... R(1),R(2),R(3)...本来就大,所以ACF 会天然偏向高频,这是ACF最经典的问题。
2)倍频错误
错误倍频产生的原因是由相关峰相似导致的,例如女声的真实周期本来就短:
F 0 = 250 H z , T 0 = 16000 / 250 = 64 F_0=250Hz,\quad T_0=16000/250=64 F0=250Hz,T0=16000/250=64
而当
F 0 = 500 H z , T 0 = 32 F_0=500Hz, \quad T_0=32 F0=500Hz,T0=32
因为:
bash
32 = 64 / 2
于是:
- T 0 T_0 T0
- T 0 / 2 T_0/2 T0/2
- T 0 / 4 T_0/4 T0/4
都可能形成峰值。
而小 lag 又天然占优:
- 倍频峰甚至可能比真实峰更高
这就是经典 Pitch Doubling。
回到结果中,我们还可以看到
313 326 347 355 372
这里可能是真实的女声高音,尤其:
- 提高声调
- 语气词
- 激动语音
但更像是倍频错误,例如真实为
170Hz -> 347Hz(高频周期更短)
3)极低异常
再分析之前我们首先要知道清音和浊音的区别
| 维度 | 清音(Unvoiced) | 浊音(Voiced) |
|---|---|---|
| 声带状态 | 不振动 | 周期性振动 |
| 声源类型 | 气流摩擦噪声 | 声带周期脉冲 |
| 是否有基频F0 | ❌ 没有 | ✔ 有 |
| 波形特征 | 类随机噪声 | 准周期波形 |
| ACF表现 | 平坦/无明显峰 | 明显峰值 |
| NACF/YIN | 很低或不稳定 | 有清晰最小值/峰 |
| 频谱结构 | 宽带噪声 | 谐波结构明显 |
| 能量分布 | 分散 | 集中 |
| 典型音素 | s, sh, f, h | a, o, e, i, u |
| 感知效果 | 嘶、擦、气流声 | 元音、可唱音 |
回到结果中,例如:
acf:52Hz
这通常意味着ACF 找到了非常大的 Lag:
bash
F0 = 52Hz
→ lag ≈ 307
τ = 16000 / 307 ≈ 52 \tau=16000/307 \approx 52 τ=16000/307≈52
这可能说明:
- 无声段:没有真正周期。
- 低频噪声
- 清音
那么为了解决这种问题,我们可以对于ACF算法进行升级改造。
🛠️NACF + 峰值检测 + 去DC
⚡NACF 原理
ACF 的问题:不同 lag 的能量不公平,因此需要归一化,这就是NACF做的事情:
R ( k ) = ∑ n = 0 N − k − 1 x [ n ] x [ n + k ] ∑ ( x [ n ] 2 x [ n + k ] 2 ) R(k)=\frac{\sum_{n=0}^{N-k-1} x[n]x[n+k]}{\sqrt{\sum (x[n]^2x[n+k]^2)}} R(k)=∑(x[n]2x[n+k]2) ∑n=0N−k−1x[n]x[n+k]
归一化后:
- 消除能量偏置
- 小 lag 不再天然占优
📐峰值检测
峰值检测的核心是不取最大值,而是取找第一个显著峰
c
if (R[k] > R[k-1] &&
R[k] > R[k+1] &&
R[k] > threshold)
{
// 找到峰值
}
这里找第一个显著峰是真实周期 T 0 T_0 T0,一定比 2 T 0 , 3 T 0 2T_0,3T_0 2T0,3T0先出现。所以第一个主峰通常就是真实的基频。
🧹remove DC(去直流)
对于正常震荡的周期信号,如 [ − 1 , 1 , − 1 , 1 ] [-1, 1, -1, 1] [−1,1,−1,1]:
css
1 /\ /\
0---/--\--/--\---
-1 / \/ \
其平均值是0。
但如果整体抬高 [ 2 , 4 , 2 , 4 ] [2, 4, 2, 4] [2,4,2,4]:
css
4 /\ /\
2 / \ / \
0-----------------
这时:
- 波形仍然一样,即真正振荡是 ± 1 \pm 1 ±1
- 但整体往上偏了 2
那么直流分量 D C = 3 DC=3 DC=3,这样会导致:
结果:
- 整个相关函数被抬高
- 所有 lag 都变"大"
- 峰值不明显
所以需要去除直流分量,过程如下:
css
总信号 = DC + AC
去DC后:
只剩 AC
💻核心代码
cpp
void removeDC(std::vector<float> &frame) {
float mean = 0;
for (float v : frame) mean += v;
mean /= frame.size();
for (float &v : frame) v -= mean;
}
float calcNACF(
const std::vector<float>& x,
int lag)
{
float num = 0.0f;
float e1 = 0.0f;
float e2 = 0.0f;
int N = x.size();
for (int i = 0; i < N - lag; i++)
{
float a = x[i];
float b = x[i + lag];
num += a * b;
e1 += a * a;
e2 += b * b;
}
if (e1 == 0 || e2 == 0)
return 0;
return num / sqrt(e1 * e2);
}
int detectPitchACF(
std::vector<float> x,
int fs,
int minFreq=50,
int maxFreq=500)
{
removeDC(x);
int minLag = fs / maxFreq;
int maxLag = fs / minFreq;
std::vector<float> nacf(maxLag + 1);
// 计算 NACF
for (int lag = minLag; lag <= maxLag; lag++)
{
float num = 0;
float e1 = 0;
float e2 = 0;
for (int i = 0; i < x.size() - lag; i++)
{
num += x[i] * x[i + lag];
e1 += x[i] * x[i];
e2 += x[i + lag] * x[i + lag];
}
if (e1 > 0 && e2 > 0)
nacf[lag] = num / sqrt(e1 * e2);
}
// 找第一个峰
for (int lag = minLag + 1; lag < maxLag - 1; lag++)
{
if (nacf[lag] > nacf[lag - 1] &&
nacf[lag] > nacf[lag + 1] &&
nacf[lag] > 0.3f)
{
return fs / lag;
}
}
return 0;
}
📉结果分析

我们可以看到大量输出
css
acf:0
acf:53
acf:57
acf:63
这是由于NACF 去除了能量偏置,于是小 lag 不再天然占优,而大 lag 的随机相关,其受到特别是:
- 清音
- 无声
- 噪声
- 周期弱
的影响,所以其实可以发现,还是可能存在倍频错误 T 0 − > T 0 / 2 T_0->T_0/2 T0−>T0/2,这是ACF算法的局限性,其本质是"峰太宽"。
所以实际工程中通常还会:
- 平滑 NACF
- 设置峰值 prominence
- 使用 VAD
- 限制相邻帧跳变
🧠YIN算法
📖背景
YIN(读作"yin")是一种经典的基频检测(Pitch Detection)算法,由 de Cheveigné 和 Kawahara 在 2002 年提出。
它本质上是对"自相关法(ACF)"的改进,核心目标是:
- 更稳定地检测语音基频(F0)
- 减少"倍频错误"(Pitch Doubling)
- 减少"半频错误"(Pitch Halving)
- 在噪声和真实语音中表现更稳
它广泛用于:
- 语音变声
- PSOLA
- 音高跟踪
- 歌声分析
- Melody Extraction
- 音高修正(AutoTune)
📐核心公式
其核心算法是最小化平方误差:
d ( τ ) = ∑ j = 1 N ( x j − x j + τ ) 2 d(\tau)=\sum_{j=1}^N(x_j-x_{j+\tau})^2 d(τ)=j=1∑N(xj−xj+τ)2
| 符号 | 含义 |
|---|---|
| ( x j ) (x_j) (xj) | 当前采样点 |
| ( x j − τ ) (x_{j-\tau}) (xj−τ) | 延迟τ后的采样点 |
| ( τ ) (\tau) (τ) | lag(延迟) |
| ( N ) (N) (N) | 当前帧长度 |
| ( d ( τ ) ) (d(\tau)) (d(τ)) | τ对应的差分值 |
再进行累计均值归一化(CMNDF)增强对周期的区分能力:
d ' ( τ ) = d ( τ ) 1 τ ∑ j = 1 τ d ( j ) d^`(\tau)=\frac{d(\tau)}{\frac{1}{\tau}\sum_{j=1}^{\tau}d(j)} d'(τ)=τ1∑j=1τd(j)d(τ)
其核心在于比较
cpp
当前波形
vs
延迟τ后的波形
如果非常相似,则差值很小,即 d ( τ ) d(\tau) d(τ)很小,会产生谷值。
⚔️YIN算法特点
对于 ACF 算法,为了解决倍频、小 lag 偏置等问题,引入以下改进:
bash
ACF ------ 通过自相关函数寻找最大相关峰
NACF ------ 能量归一化,消除幅度与能量影响
峰值策略 ------ 选择第一个显著峰或全局最大峰
但自相关函数存在一个根本问题:
相似度函数在周期结构下往往呈现"宽峰",峰值区域不够尖锐,导致周期定位不稳定。
其原因是:
- 相关性是"逐渐变化"的(渐变型函数)
- 周期对齐并不是瞬时发生,而是"连续增强"的
而YIN 将问题转换为:找到两个信号"最不不同"的延迟位置,与ACF的差别为:
- 自相关ACF算法是在找两个周期"有多像"的算法,也就是两个相似周期会产生较大峰值
- YIN算法是在哪里第一次"不再不同"的算法,也就是寻找峰谷
即几何差异可以看为
css
ACF:宽峰(相似性缓慢变化)
______
/ \
___/ \___
YIN:深谷(误差快速塌陷)
\ /
\ /
\___/
🎯抛物线插值
因为真实周期可能不是整数,如同:
bash
80.3
79.7
所以 YIN 会进行抛物线插值,进行二次拟合:
bash
τ-1
τ
τ+1
以提高:
- 音高稳定性
- 频率精度
🔐置信度检测
置信度是指这个周期有多可信。由于我们最终获取到的是归一化后的差分函数 d ' ( τ ) d^`(\tau) d'(τ),它在YIN里:
- 越小 → 越像周期
- 越接近0 → 越可能是真实基频
- 越接近1 → 越不像周期
| d ′ ( τ ) d'(τ) d′(τ) | 含义 |
|---|---|
| 0.05 | 非常强的周期性 |
| 0.2 | 周期性较强 |
| 0.5 | 一般 |
| 0.9 | 基本没周期 |
所以可以在此基础上进行评分,通常使用 1 − d ′ ( τ ) 1-d'(τ) 1−d′(τ)来进行分数评估
💻具体实现
cpp
// 预处理:去直流偏移
void removeDC(std::vector<float> &frame) {
float mean = 0;
for (float v : frame) mean += v;
mean /= frame.size();
for (float &v : frame) v -= mean;
}
// 计算差分函数 D[τ]
void differenceFunction(const std::vector<float> &frame, std::vector<float> &D, int tauMax) {
int N = frame.size();
D.assign(tauMax+1, 0.0);
for (int tau = 1; tau <= tauMax; tau++) {
float sum = 0;
for (int j = 0; j < N - tau; j++) {
float diff = frame[j] - frame[j+tau];
sum += diff * diff;
}
D[tau] = sum;
}
}
// 累积均值归一化(CMNDF),在原数组上进行修改
void cumulativeMeanNormalizedDifference(std::vector<float> &D) {
int tauMax = D.size() - 1;
float runningSum = 0;
D[0] = 1.0;
for (int tau = 1; tau <= tauMax; tau++) {
runningSum += D[tau];
if (runningSum > 0)
D[tau] = D[tau] * tau / runningSum;
else
D[tau] = 1.0;
}
}
// 绝对阈值判断,返回估计的tau值(若失败则返回-1)
int absoluteThreshold(const std::vector<float> &d, float threshold, float &outConfidence) {
int tauMax = d.size() - 1;
int tauEstimate = -1;
for (int tau = 2; tau <= tauMax; tau++) {
if (d[tau] < threshold) {
// 确保是局部最小点
while (tau + 1 <= tauMax && d[tau+1] < d[tau])
tau++;
tauEstimate = tau;
outConfidence = 1.0 - d[tau]; // 置信度定义为1 - d'
break;
}
}
// 若未找到,选择全局最小
if (tauEstimate < 0) {
float minVal = 1.0;
for (int tau = 2; tau <= tauMax; tau++) {
if (d[tau] < minVal) {
minVal = d[tau];
tauEstimate = tau;
}
}
outConfidence = 1.0 - minVal;
}
return tauEstimate;
}
// 抛物线插值计算精确的τ位置
float parabolicInterpolation(const std::vector<float> &D, int tau) {
int tauMax = D.size() - 1;
if (tau <= 1 || tau >= tauMax) return tau;
float x0 = tau - 1, x1 = tau, x2 = tau + 1;
float y0 = D[tau-1], y1 = D[tau], y2 = D[tau+1];
float denom = (y0 - 2*y1 + y2);
if (denom == 0) return tau;
// 极小值精确位置
float p = tau + (y0 - y2) / (2 * denom);
return p;
}
// 对一帧信号执行YIN算法,返回估计基频(Hz)和置信度
float YinPitch(const std::vector<float> &frame, int sampleRate, float threshold, float &outConf) {
int N = frame.size();
int tauMax = N / 2;
std::vector<float> temp = frame;
removeDC(temp);
std::vector<float> D;
differenceFunction(temp, D, tauMax);
cumulativeMeanNormalizedDifference(D);
float confidence;
int tau = absoluteThreshold(D, threshold, confidence);
outConf = confidence;
if (tau < 0) return 0.0;
float tauRefined = parabolicInterpolation(D, tau);
float pitch = sampleRate / tauRefined;
return pitch;
}
⚙️实现结果

我们可以对比评分发现此时和praat软件的结果已经很接近,而praat更稳的原因是在于
| 技术 | 作用 |
|---|---|
| 帧间平滑 | 防止跳变 |
| 动态规划 | 连续 pitch tracking |
| VAD | 去除清音 |
| candidate tracking | 多候选选择 |
| octave cost | 防倍频 |
| transition cost | 防突变 |
📚ACF / NACF / YIN 对比总结
| 特性 | ACF | NACF | YIN |
|---|---|---|---|
| 核心思想 | 最大相关 | 归一化相关 | 最小差分 |
| 小 lag 偏置 | 严重 | 改善 | 很强抑制 |
| 倍频错误 | 多 | 较多 | 少 |
| 清音鲁棒性 | 差 | 一般 | 更强 |
| 峰值形状 | 宽峰 | 宽峰 | 深谷 |
| 精度 | 一般 | 一般 | 高 |
| 工程复杂度 | 低 | 中 | 中高 |
✨总结
本文从最基础的"基频"概念开始,逐步介绍了经典基频检测算法的发展过程。
我们首先理解了:
- 基频(F0)决定音高
- 谐波结构决定音色
- 人声本质上是一种准周期信号
随后分析了经典的自相关法(ACF):
- 延迟一个周期后波形会再次相似
- 因此真实周期位置会产生相关峰
但 ACF 也存在明显问题:
- 小 lag 天然占优
- 倍频/半频错误
- 清音与噪声误检
- 峰值过宽导致定位不稳定
于是进一步引入:
- NACF(归一化自相关)
- 峰值检测
- 去 DC 偏移
来改善相关性计算。
最后介绍了经典的 YIN 算法:
- 将"寻找最大相似" 转换为 "寻找最小差异"
- 使用 CMNDF 增强周期谷值
- 使用阈值与插值提高稳定性与精度
相比传统 ACF,YIN 在:
- 倍频抑制
- 稳定性
- 精度
- 噪声鲁棒性
方面都有明显提升,因此也成为许多现代 Pitch Detection 的经典基础。
当然,真实工程中的基频检测往往还会进一步结合:
- VAD(语音活动检测)
- 帧间平滑
- 候选跟踪
- 动态规划
- 深度学习模型(CREPE / RMVPE)
来获得更稳定、更自然的 F0 曲线。