TD-PSOLA 语音变调算法详解与 C++ 实现
文章目录
✨前言
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
- Pitch Detect:对输入语音进行分帧,估计每帧的基频(F0)轮廓,具体文章:从 ACF 到 YIN:基频检测算法原理与实现
- Pitch Marks:基频标记,在YIN算法基础上标记每个声门周期,下文中介绍
- 窗口截取:以每个 pitch mark 为中心,提取长度约为两倍基音周期的信号段,并应用具有完美重构性质的窗函数,具体文章可见:语音信号处理二十三——频谱泄露、加窗以及栅栏效应
- Overlap Add:按设定的变调倍数(pitchScale)调整各帧间距,将帧在时间轴上移动,然后重叠相加输出。具体可见文章:语音信号处理三十二——时频分析之STFT和OLA
💡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;
}
🔬帧提取以及重排列
具体步骤如下:
- 根据基频标记获取到的
center为中心,提取长度N=2*period的帧 - 应用预先生成的窗口向量
window进行窗口截取 - 根据步长进行重新排列
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 保持
如果这篇对你有帮助,欢迎点赞 + 收藏 👍
更多推荐


所有评论(0)