✨前言

PSOLA(Pitch Synchronous Overlap Add,基音同步重叠相加)是经典的语音变声、变调、语音时长拉伸算法之一。

它最大的特点:

  • 音质自然
  • 实现难度适中
  • 特别适合:
    • 变声器
    • TTS(语音合成)
    • 语音变调
    • 男变女 / 女变男
    • 语速调整

很多早期变声器、电话语音系统都用它。

📌 作者:山河君,未经允许禁止转载


🎯PSOLA介绍

🔥变声器原理

变声器的核心原理其实可以一句话概括:

把“人声的三个关键特征”拆开处理,再重新合成。

这三个关键特征分别是:音高(Pitch)、音色(Timbre)、时长(Time)。不同变声效果,本质就是对这三者做不同的“变形”。


🧠音高变换原理

对于周期信号:

周期短 → 频率高 → 音高高
周期长 → 频率低 → 音高低

声音 基频
男声 80~180Hz
女声 180~350Hz

如果把基频从:100Hz→200Hz,那么听起来就会高八度。而改变音高有两种方式:

  • 时域拉伸/压缩(改变波形周期)
    • 加速播放 → 音高变高
    • 减速播放 → 音高变低
  • PSOLA / WSOLA(保持语速不变,只改音高)
    • 提高音高 → 变“萝莉/女生/卡通”
    • 降低音高 → 变“男低音/怪兽”

🔬PSOLA原理

PSOLA 的核心思想其实非常直观:人声本质上是“周期波”,例如男性:

  • 基频 F0 ≈ 100Hz
  • 周期 T ≈ 160 samples(16kHz)

那么语音波形看起来像:

|----周期1----|
              |----周期2----|
                            |----周期3----|

PSOLA认为:

只要把这些“周期块”的间距改掉, 人耳就会认为音高变了。


💡PSOLA切片过程

假设原始波形如:

 /\      /\      /\
/  \____/  \____/  \

根据周期大小设置窗口,然后进行切片

[ /\__/ ]
    [ /\__/ ]
        [ /\__/ ]

此时再进行重新摆放,如果是升调,那么频率变高,周期变短:

[ /\__/ ]
  [ /\__/ ]
    [ /\__/ ]

如果是降调:

[ /\__/ ]

       [ /\__/ ]

              [ /\__/ ]

最后 overlap-add 得到新的连续语音。

📌PSOLA具体流程

输入语音
   ↓
Pitch Detect
   ↓
Pitch Marks
   ↓
窗口截取
   ↓
重新排列
   ↓
Overlap Add

💡PSOLA的优势

PSOLA它直接操作真实波形,没有破坏声道包络,不显式分离频谱,保留了:

  • 微观波形结构
  • 共振峰
  • 气声
  • 爆破音
  • 细节纹理

所以小范围 pitch shift 时非常自然,也更像真人。


🎯基频标记(Pitch Mark)

🔥定义

基频标记是标记每个声门周期的位置,例如:

波形:
    /\      /\      /\
___/  \____/  \____/  \__

Pitch Marks:
     ^       ^       ^

Pitch Mark 输出内容本质上是一组时间位置:

[105, 263, 421, 582 ...]

表示:

  • 每个周期中心
  • 或声门闭合附近(GCI)

基频标记的获取通常通过以下算法获得:

方法 原理
基频检测 + 局部峰值搜索 最常见
GCI检测 检测声门闭合瞬间
DYPSA 专业GCI算法
ZFR 零频共振方法

🛠️局部峰值搜索

基于已知的基音周期长度 period(样本数),在语音信号中定位每个周期的中心位置通常选择局部最大值,即“基音标记”。

一个常用策略是:从一个周期处开始,每隔 period 个样本搜索附近的峰值。


std::vector<int> PSOLA::detectMarks(const std::vector<float>& x, int period) {
    std::vector<int> marks;
    int N = (int)x.size();
    // 从第一个完整周期开始,每隔一个周期寻找峰值
    for (int pos = period; pos < N - period; pos += period) {
        int start = pos - period/2;
        int end = pos + period/2;
        if (start < 0) start = 0;
        if (end > N) end = N;
        // 局部最大绝对值点
        float maxVal = 0.0f;
        int maxPos = pos;
        for (int i = start; i < end; i++) {
            if (std::fabs(x[i]) > maxVal) {
                maxVal = std::fabs(x[i]);
                maxPos = i;
            }
        }
        marks.push_back(maxPos);
    }
    return marks;
}

🎯窗口截取、重排列、OLA

⚡窗口长度

每个以基音标记为中心的帧通常取长约 2 倍基音周期 的信号段,以保证帧间可以完美重构。

长度 问题
太短(<1T) 不连续、能量丢失
1T overlap 不够稳定
2T 最平衡
太长(>3T) 灵活性下降、相位问题

💡窗选择

Hann 窗在合适 overlap 条件下具有较好的重叠重构特性。

std::vector<float> PSOLA::hannWindow(int N) {
    std::vector<float> w(N);
    for (int i = 0; i < N; i++) {
        w[i] = 0.5f - 0.5f * std::cos(2.0f * M_PI * i / (N - 1));
    }
    return w;
}

🔬帧提取以及重排列

具体步骤如下:

  1. 根据基频标记获取到的center 为中心,提取长度 N=2*period 的帧
  2. 应用预先生成的窗口向量 window进行窗口截取
  3. 根据步长进行重新排列
int k = 0;
    while (true) {
        int analysisIdx = (int)std::floor(analysisPos);

        if (analysisIdx < 0)
            analysisIdx = 0;

        if (analysisIdx >=
            (int)marks.size()) break;
        // 合成帧中心
        float center = firstCenter + k * newStep;
        int outCenter = (int)std::round(center);
        if (outCenter < 0) {
            analysisPos += 1.0f / pitchScale;
            k++;
            continue;
        }
        if (outCenter >= N + period) break; // 超出输出范围

        // 提取分析帧:以标记为中心,长度2*period
        int markPos = marks[analysisIdx];
        int left = markPos - period;
        // 窗口化并重叠添加到输出
        for (int i = 0; i < winLen; i++) {
            int inPos = left + i;
            int outPos = outCenter - period + i;
            if (inPos >= 0 && inPos < N && outPos >= 0 && outPos < N) {
                float w = window[i];

                out[outPos] += in[inPos] * w;
                norm[outPos] += w;
            }
        }
        // 更新索引
        analysisPos += 1.0f / pitchScale;
        k++;
    }

升调时,需要更频繁地重复使用原始周期;
降调时,则需要跳过部分周期。

因此 analysisPos 会按照 1/pitchScale 的速度推进。


🛠️OLA

将窗函数叠加造成的增益除掉,所以额外记录帧排列位置经过了多少窗,最终进行能量归一化

for (int i = 0; i < N; i++) {
        if (norm[i] > 1e-6f)
            out[i] /= norm[i];
    }

🎯整体代码与效果

🛠️整体代码


#include "psola.h"
#include <algorithm>
#include <cmath>
#include <limits>


// 预处理:去直流偏移
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;
}

std::vector<int> PSOLA::detectMarks(const std::vector<float>& x, int period) {
    std::vector<int> marks;
    int N = (int)x.size();
    // 从第一个完整周期开始,每隔一个周期寻找峰值
    for (int pos = period; pos < N - period; pos += period) {
        int start = pos - period/2;
        int end = pos + period/2;
        if (start < 0) start = 0;
        if (end > N) end = N;
        // 局部最大绝对值点
        float maxVal = 0.0f;
        int maxPos = pos;
        for (int i = start; i < end; i++) {
            if (std::fabs(x[i]) > maxVal) {
                maxVal = std::fabs(x[i]);
                maxPos = i;
            }
        }
        marks.push_back(maxPos);
    }
    return marks;
}

std::vector<float> PSOLA::hannWindow(int N) {
    std::vector<float> w(N);
    for (int i = 0; i < N; i++) {
        w[i] = 0.5f - 0.5f * std::cos(2.0f * M_PI * i / (N - 1));
    }
    return w;
}

std::vector<float> PSOLA::process(const std::vector<float>& in, float pitchScale) {
    int N = (int)in.size();
    if (N == 0) return {};

    // 1. 检测周期(样本数),或使用固定值
    int fs = 16000;
    float outConf = 0.0;
    float f0 = YinPitch(in, 16000, 0.1, outConf);
    if(outConf < 0.5f)
    {
        printf("outConf:%f, period:%fHz\n", outConf,f0);
        return in;
    }

    int period = 16000 / f0;
    if (period <= 0)
        period = fs / 200; // 默认 200Hz 约 80 样本
    // 标记检测
    std::vector<int> marks = detectMarks(in, period);
    if (marks.empty()) {
        // 无标记时返回原始(或直接复制)
        return in;
    }

    // 2. 准备输出缓冲(与输入长度相同)
    std::vector<float> out(N, 0.0f), norm(N, 0.0f);
    // 3. 准备窗口(长度 = 2 * period)
    int winLen = 2 * period;
    std::vector<float> window = hannWindow(winLen);

    // 4. PSOLA 叠加:保持输出时长,分析索引重采样
    float analysisPos = 0.0f;
    // 合成帧中心初始设为第一个标记
    float firstCenter = (float)marks[0];
    // 计算合成帧间隔
    float newStep = (float)period / pitchScale;

    int k = 0;
    while (true) {
        int analysisIdx = (int)std::floor(analysisPos);

        if (analysisIdx < 0)
            analysisIdx = 0;

        if (analysisIdx >=
            (int)marks.size()) break;
        // 合成帧中心
        float center = firstCenter + k * newStep;
        int outCenter = (int)std::round(center);
        if (outCenter < 0) {
            analysisPos += 1.0f / pitchScale;
            k++;
            continue;
        }
        if (outCenter >= N + period) break; // 超出输出范围

        // 提取分析帧:以标记为中心,长度2*period
        int markPos = marks[analysisIdx];
        int left = markPos - period;
        // 窗口化并重叠添加到输出
        for (int i = 0; i < winLen; i++) {
            int inPos = left + i;
            int outPos = outCenter - period + i;
            if (inPos >= 0 && inPos < N && outPos >= 0 && outPos < N) {
                // out[outPos] += in[inPos] * window[i];
                float w = window[i];

                out[outPos] += in[inPos] * w;
                norm[outPos] += w;
            }
        }
        // 更新索引
        analysisPos += 1.0f / pitchScale;
        k++;
    }

    for (int i = 0; i < N; i++) {
        if (norm[i] > 1e-6f)
            out[i] /= norm[i];
    }

    // 输出已与输入长度相同,无需再调整大小
    return out;
}


📡效果

在这里插入图片描述

  • 左图为原始语音,为女生
  • 右图经过PSOLA语音,处理后听感为男生

🎯结果分析

🔥机器人感与断裂

原因 本质
Pitch mark 漂移 周期不同步
overlap 不连续 相位断裂
unvoiced 被周期化 辅音失真
周期长度固定 pitch contour 不自然
formant 被拉动 声音变尖

💡对比Praat

直接看波形可以看出有明显的断裂,对比praat和当前实现,这也是最终的优化方向

模块 当前实现 Praat 对声音的影响
Pitch Mark 定位 简单峰值 / 固定周期 Glottal closure 对齐 + 波形相关性优化 你会出现断裂、抖动;Praat 更连续
周期长度 固定 period 每个周期动态变化(local period) 你容易 phase 漂移;Praat 更自然
Grain 长度 固定 2*period 动态左右周期 Praat 的 formant 连续性更好
窗函数 固定 Hann 动态 pitch-synchronous window Praat overlap 更平滑
OLA 只有 overlap-add WOLA(带 normalization) 你会音量波动、发虚
无声段处理 没特殊处理 噪声保持 Praat 的辅音自然很多
F0 曲线 基本固定 step 平滑连续 pitch tier Praat 不会突然跳调
周期同步 粗略同步 真正 pitch synchronous Praat 更像真人
formant 保持 被动保留 有 formant-aware 策略 你升调容易变尖
overlap 区域 固定位置 最大相关匹配 Praat 拼接更丝滑
共振峰连续性 较弱 较强 你会“机器人感”
最终听感 颗粒、断续、发虚 平滑、自然、人声感强 最大区别

🚀总结

PSOLA(Pitch Synchronous Overlap Add)是一种经典且高效的时域变声算法,其核心思想是:

以基音周期为单位切分语音,再通过调整这些周期块之间的间距,实现音高变化。

本文从:

  • 音高与周期关系
  • Pitch Mark 基频标记
  • 窗口切片
  • Overlap-Add
  • YIN 基频检测
  • 完整 C++ 实现

逐步分析了 PSOLA 的核心原理,并实现了一个基础版本的 TD-PSOLA。

同时也可以看到:

“能运行的 PSOLA”和“工业级自然语音 PSOLA”之间仍存在巨大差距。

真正高质量的 PSOLA 系统,还需要解决:

  • Pitch Mark 精确同步
  • 周期动态变化
  • overlap 最大相关匹配
  • voiced/unvoiced 分离
  • 相位连续性
  • formant 保持

如果这篇对你有帮助,欢迎点赞 + 收藏 👍

更多推荐