从 ACF 到 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 曲线。

相关推荐
冬奇Lab1 小时前
一天一个开源项目(第102篇):NVIDIA Video Search and Summarization - 构建 GPU 加速的视觉智能体
人工智能·计算机视觉·开源
weixin_428005301 小时前
C#调用 AI学习从0开始-第1阶段(基础与工具)-第3天FewShot少样本测试
人工智能·c#
xiaozhazha_1 小时前
技术解析:如何通过AI视频会议系统,解决业务协同与CRM间的“数据管道”问题
人工智能
木子墨5161 小时前
工程算法实战 | 数据库ORDER BY的底层:内存排序 → 外部归并 → 索引优化
数据结构·数据库·python·sql·算法·动态规划
2zcode1 小时前
基于深度学习的车辆品牌与类型智能识别系统设计与实现
人工智能·深度学习·智能交通
小小尚@1 小时前
AI 加持!Adobe Acrobat DC 2026 解锁 PDF 高效办公新体验
人工智能·pdf
NOCSAH1 小时前
统好 AI:AI 赋能生产制造,扎实推进智改数转
人工智能·制造
Soari1 小时前
终结 AI 乱跑(Harness Engineering):深度拆解 ralph-orchestrator,构建确定性的多智能体生命周期编排流
人工智能·生命周期管理·harnesseng·多智能体编排
IT_陈寒1 小时前
被JavaScript的隐式类型转换坑到怀疑人生,记录这次离谱经历
前端·人工智能·后端
victory04311 小时前
从 2025-05 至 2026-05-15按时间顺序整理的“主线模型/技术报告”时间线
人工智能