物理建模钢琴音色校准:40 条血泪经验

最近在做一件事:纯代码合成一个像样钢琴音色。零音频文件、零采样资源、首屏零下载——靠 WebAudio 的加法合成 + 物理模型推导泛音参数,实时渲染出 88 键钢琴。
写设计文档不难,难的是把合成出来的声音调到"像那么回事"。这篇文章整理的是校准过程中踩的坑和发现的规律——每条都是反复试验后留下的。

为什么纯合成这么难
拿 Salamander Grand Piano V3(一个 CC-BY 3.0 的音源)当参考音。一开始的方案很简单:分析参考音频的泛音参数,套到合成器里,用坐标下降调参数逼近。结果总分从 9.55 降到 7.27 就再也降不下去了——单听还能接受,和参考对比就怎么听都不对。
问题出在方法论本身:逼近指标不等于逼近听感。MR-STFT、包络、分段频带这些指标优化到极致,听感上"刺/糙/嗡"的问题一个都没解决。转折点是把思路从"指标逼近"切换到"残差迭代":
R0 = 参考原音
├─ 分析:泛音跟踪(频率/相位/振幅包络)
├─ 锁相再合成(相位必须锁定参考,否则相减无意义)
└─ 相减
R1 ≈ 锤击瞬态 + 音板共鸣 + 拍频/失谐误差
├─ 找 R1 最大特征 → 建锤击模型 → 减
R2 ≈ 声音板/交感共鸣 + 随机成分
├─ 建空间/共鸣模型 → 减
R3 ≈ 随机噪声底 → 滤波噪声 → 残差≈静音(目标 -40dB)
看残差,别看总分。残差能量(分频段、分时间段)是更硬的目标函数——相位错一点、频率偏一点都直接暴露,不像 MR-STFT 在平均时给你安慰。
C4v8 全流程走完,残差降到 -21.7dB,用户耳测反馈:"除起音外几乎模仿得很像"。补齐锤击波片后起音也到位了。
原来高次泛音是"双胞胎"
对单键做泛音跟踪时,频谱上看 3.5kHz 以上往往看起来是"一根模糊的刺"——以为就是一个高次泛音,结果分析出来的相位轨迹是乱的。直到我们把每个高次频点按双脊线重新检测,才发现:
真实钢琴的高次泛音是双偏振 doublet。主脊旁边恒定点 +1.5% ~ +2% 的地方有一根伴线,而且伴线常常比主脊更强、更长寿。
怎么判断它到底是弦的模态还是音板模态?换一个键重新分析——随 f0 成比例缩放的,是弦的模态;固定不变的,是音板固有共振。
现在合成模型里,n ≥ 9 的高泛音全部分成 m3 组,按双偏振 + 双脊线处理。单成分强行拟合只会让合成音"闷"——两根线的拍频超出低通滤波器的 15Hz 带宽,相位永远糊成一片。
5ms 内满幅的起音是假的
一个血淋淋的教训:起音检测会偏早 ~11ms(回溯一个分析窗口)。参考音在"击弦前"那 10ms 其实是房间底噪,零能量。但合成音从那个点就开始发响了——结果 0-10ms 残差虚高 +17dB。
解决方法是按能量陡升精修击弦点——取峰值 5% 作为阈值,重新定位的起音和参考对齐后,那个 17dB 的虚高直接消失。
类似的:轨道的混叠镜像 artifact 也会制造参考里根本不存在的东西。有一次在 viewer 的频谱上看到 166Hz / 179Hz 两根对称边带,用户肉眼定位发现是 heterodyne 分析时的 2f 镜像穿过 hop 窗函数后在 344.5Hz 帧率下混叠成的——166 = 2×344.5 − 2f₀,179 = 2f₀ − 344.5。二阶低通 + 截止提到 30Hz 后镜抑制比从 1/5 提高到 1/35,边带干净了,总分反而从 -16.1dB 继续降。
教训:任何时候参考里没有对称边带,先查采样率/帧率互调。
你扫出的"高频泛音"可能根本不是泛音
频谱上看,真钢琴 4-8kHz 高频段有一堆看似"泛音"的峰。但做相位线性度分析之后就原形毕露了:
- 相干泛音:相位-时间线性拟合残差 std ≤ 0.75 rad(含拍频晃动)
- 纯噪声:残差 std ≈ π/√3 ≈ 1.81 rad(完全随机)
C4 的相干/噪声分界在 n ≈ 13(~3.5kHz)。上面那些"泛音"的 active 时间只有 0.06-0.5s,相位完全随机——它们是锤击噪声的带通投影,不是弦的谐波。
这是残差方法论的第一个产出:单看频谱永远发现不了(两者在频谱上长得一模一样),但相位线性度直接区分。如果你用相干正弦堆满高频,合成出来一定"刺耳",因为你把本应是随机噪声的东西用纯音复制了。
顺便一提:8kHz 以上什么都别合成。参考音在那个频段的能量已经低于 16bit 的量化底噪,任何跟踪出来的轨道都是伪迹。
轨道泄漏:宽带反相音的真正元凶
主频道的 heterodyne 分析有相邻通道泄漏问题——boxcar hop 窗的主瓣/旁瓣对 ±262Hz 偏移只有 ~0.29 的抑制。最强的泛音(比如 f0 和前几个谐波)泄漏到隔壁的 heterodyne 通道,在 344.5Hz 帧率下混叠出 ~83Hz 的相位摆动,再合成时载波 ±83/±178Hz 位置出现"裙边"——用户频谱上看到的是"宽带增强"。
包络数据本身是干净的,污染在相位轨迹上(瞬时频率调制谱上能看到 83Hz 尖峰)。
解法是匹配追踪 + 高斯-赛德尔精化:
- 按强度降序跟踪每条轨道,接受后立即从工作信号中减去
- 再跑 2 轮"加回自身 → 在 ref−其他 上重测 → 重减"
- 强力轨的裙边从 +33~+43dB 降到基本干净
教训:所有轨道的共同裙边,先怀疑分析通道间泄漏,而不是合成插值问题。
投影相干度:不要再靠相位线性度筛泛音了
相位线性度是好用,但会误杀拍频簇——两个真实泛音因为弦失谐拍在一起,叠加后的相位当然在游走,不能因此不要它。
终极判别指标是投影相干度:把跟踪出来的那条轨道合成出来,和参考音频做内积——
coherence = <ref, synth> / <synth, synth>
这个东西直接回答"这条轨道在 ref 里到底存在多少"。真实泛音 ≥ 0.85,噪声 ≤ 0.27。阈值取 0.35。
但投影相干度单独用也有坑:多条轨道各自 ≥ 0.5,但彼此高度相关时,朴素求和会重复计能(C4 的全局增益直接崩到 0.56)。必须用 Gram 矩阵做联合最小二乘定增益——相关对自动分配后,假支被压到 0.05。
相位必须相干
用随机相位 vs 同相激发做 A/B 对比,结论非常直白:
真实锤击时各泛音是同相激发的——出来的是干净的"鸣"。随机相位出来的是"刺/糙"——所有泛音能量都在,但听感天差地别。
R1 管线从第一天就要求锁相(heterodyne 天然输出绝对相位),因为在相减前必须参考和合成严格同相。但这个结论的意义不止于此——未来全键数据出"纯粹规律"时,所有合成版音色都应该走同相激发。
几组关键的"硬约束"
校准过程中一些参数是在反复试验中定下来的,走偏了就完全不像:
| 参数 | 值 | 走偏后果 |
|---|---|---|
| 弦间失谐 | ~1 cent(中音区) | 大了 = 没调准的松散 |
| B 非谐系数 | ~0.9e-4(中音区) | 钢琴味 vs 风琴味的分水岭 |
| 高泛音衰减 | 60ms 泄到 30% | 砍太少 = 金属玻璃持续音;砍太多 = 丢 1-2kHz 主体 |
| n2Boost | ×2.5 | 第二泛音被共鸣系统额外加强,物理模型覆盖不到 |
| shelf 底台 | 0.004 | 高阶泛音 n>5 幂律滚降后的残余底台,过量则嗡嗡 |
| 合成包络先平滑再锐化 | w5 → k0≈8.7ms | 先定后锐,向下扩展器会误伤真实弱成分 |
| 衰减两段式(快 + 慢尾) | 0.45s→0.2 快段, 2.8s 尾 | 单段快衰减 = 吉他味,不是钢琴 |
这些不是数学推导出来的——是反复二分法分文件定位、峰值轨迹表 + 分频段 dB 差表,把"说不清"变成数字后才摸到的。
几个被否决的方向
不是所有尝试都走到了最后,记录一下以免再踩:
- 噪声层:想用滤波噪声模拟高频随机分量,用户耳测直接否决——能量匹配 ±1dB 也不行
- 向下扩展器压包络:会自动误伤真实的弱泛音成分
- 精化轮数 > 2:过拟合,残差反而变差
- hop=32 细帧:总分变差
- 单层力度 + 音量映射:力度改变的是锤头硬度带来的音色变化,不是简单加减音量
未来方向
当前主路线:22 个采样键 × v4/v9/v14 三层力度 → 汇总插值,去波动 → 离线预渲染 → 引擎预采样播放。
远景更有意思:全键数据出来后,不直接拿着参数插值,而是拟合出"纯粹规律"——理想的击弦点曲线、硬度曲线、衰减曲线——再用这些理想曲线重渲染。那就会得到一个比原录音更纯的钢琴音色。
