SVS 编辑器的干预静默失效:为什么改个歌词你的手调参数就丢了
相关仓库:Zongzi(本文所述机制的实现), EquinoxEditor。
由于目前引擎合并还在进行,并且没有稳定,因此本文内容可能产生较大变化。
上下文
首先介绍一下这个问题是怎么来的。
我在高中期间有一系列想法,笼统来说就是编一个歌声合成(恕下文用 SVS 代替)编辑器以及引擎,并且确定了一套基于「基于生理学的中间参数」的适配协议(参见 Qy 项目介绍)。
开发背景
我本以为这个 idea 会束之高阁,但在 24 年的下半年,因为一些个人变故我选择重新开始推进它。 并且编写了一些简单的代码以及脚手架,但因为编码水平的限制没有什么实际进展,后续因为准备毕业加之尝试搭建 DiffSinger 推理管线失败的关系也被搁置了。
在 25 年的下半年,我突然想继续推进,并且基于此完成了 Orchid ,以及及其相关的 OrchidSymbiont 、 OrchidStratum 以及 OrchidIntervention 。也通过了 Livebook 跑通了 DiffSinger 的管线。
至少就我最开始的想法而言,是实现了。但是并没有和目前的 SVS 编辑器的横向对比以及对其他用户而言所见即所得的效果。因此我想要继续开发完成编辑器。
经过了几个原型的编写后,我想依照 DDD (领域驱动设计)的思路开发。我当时的思路是这样的:
- 完成领域建模并编写代码
- 绑定此前编写的 Orchid 调度引擎并设计歌声合成编辑器的引擎协议并且自己写几个
- 实现 UI
但是在编写领域代码的时候,碰到问题了。更具体一点的话,是参数序列在音符编辑(诸如修改歌词、批量断开/连接等复杂场景)时的保留与否。
Equinox(旧QyEditor)的思想
我需要介绍一下我开发的思路,从某种意义上讲,这个脱胎于 Qy 的思想,不过更原始、更不泛用。
大概包含几点:
- SVS 本质上是从及其抽象的符号到一段音频的过程,但是这个过程中存在大量(可量化)的中间参数
- 不能是黑箱——用户对中间参数有着完全的控制权(intervention)
- 复杂的模型的推理管线可以抽象成 DAG1 (有向无环图)
- (不是约束而是想法/特征)需要设计增量生成,特别是基于 ML 的模型/管线
问题
考虑一个日常操作:你在编辑器中画了一条 pitch 曲线、手调了几个辅音的 preutterance(提前量),然后发现歌词打错了——“着”应该读 zhuo 不是 zhao。
改完歌词,G2P 重新运行。原来音素序列 [tʂ, a, ʊ] 变成
[tʂ, u, ɔ]。但你对 /a/ 的 timing
调整,静默地应用到了 /u/ 上。
这就是干预静默失效(Silent Intervention Staleness):上游参数变化导致下游用户编辑失效,且失效过程没有反馈、没有警告、没有回滚。用户听出来不对劲时,已经不知道是哪次编辑出的问题。
更一般地:爬一个音阶(sol la ti → sol la si),改一个唱名,整个音素序列可能被 G2P 重写。或者多音字替换——“长”读 chang 还是 zhang,只改一个字,所有对这个音素的手调都可能悄悄错位。
为什么难
根源一句话:slot-indexed data has no identity when the index is recomputed。
大多数 SVS 编辑器的参数是”绑在第 N 个音符上”的——用索引或 note ID 做
key。G2P 重跑后音素变了,但 index 没变,delta
就作用到了错误的音素上。这不是实现
bug,是数据模型层面的缺陷:你存的不是”对 /a/ 的 timing
偏移”,而是”对位置 3 的偏移”——而位置 3 现在已经不是 /a/
了。
两个经典场景:
preutterance 溢出。 /s/ 的辅音提前量比
/t/ 长。如果前面的音符很短,辅音 onset
早于前一个音符的结束时间——这个音素跨过了音符边界。它属于谁?属于前面的音符(声学上下文的一部分)还是后面的音符(发音的一部分)?slot
模型回答不了。
melisma(一字多音)。 极端的花腔女高音一个音节横跨十几个音符。Vocaloid 的拆音技法也是同理。一个音素跨多个音符时,slot 根本不唯一——无论你用”第几个音符”还是”第几个音素”做 key,都是错的。
同行怎么做
UTAU
没有内置 G2P。歌词到音素的映射是 voicebank 的 oto.ini 做 WAV 文件名匹配——你写 ti,就找 ti.wav。所以不存在”G2P 重跑后音素变了”的问题。但 envelope 和 pitch bend 绑在音符位置上,preutterance 溢出照样静默发生。无检测、无警告。
SynthV
最清晰的参数体系:Parameter 曲线(Pitch Deviation、Tension 等)是 time-based 的——画在第 3.2 秒的曲线钉在时间轴上,跟”第几个音符”无关。这是正确的方向。但音素级手调(phoneme timing 等)仍然在 G2P 重跑时被静默覆盖。解决了一半。
Vocaloid / OpenUTAU
参数几乎全是 per-note 的。歌词变更时参数静默留在原 note 上,没有任何 staleness 反馈。OpenUTAU 的 .ustx 格式下曲线是 timeline 的,但 G2P 重跑后音符级参数依然被覆盖。
共同点:四个编辑器都在不同程度上遇到干预静默失效,没有一个主动检测或警告。
解决思路
我开发了 Zongzi——一个 Elixir 库,为 SVS 编辑器提供干预锚定和失效检测的基础设施。不跑渲染,只定义契约和纯逻辑。
序列真相:Timeline + SeqID
先把”音符的顺序”和”音符的内容”拆开。
Timeline 维护一条 seq_id
的显式有序链表(note_order),独立于 Note 的生命周期。SeqID
是永久位置标识——音符被 split/merge/drag 后 SeqID 不变。Intervention 锚在
SeqID 上,而非 Note.id。
借鉴了 Sequence CRDT(RGA 系),但剃掉分布式共识部分——单用户编辑不需要 clock 同步。SeqID 由 Timeline 自持 counter 生成,跨会话序列化安全。
锚定:NoteTriplet + 2-of-3 exact match
Intervention 锚在三个 SeqID
上:{prev_seq, current_seq, next_seq}。
当 Timeline 变更后(音符被切分、拖拽、合并、删除),rebase 用 2-of-3 exact match 判存活——不是 fuzzy。fuzzy match 会掩盖上游数据缺陷:如果锚定需要模糊匹配才能存活,说明上游变更本身就不稳定。
| try_match | rebase 决策 |
|---|---|
| 3/3 | :preserve |
| 2/3 | {:rebase, updated}(自动跟) |
| 0–1/3 | {:conflict, :adjacency_lost} |
| merge tombstone | {:conflict, :merged_away} |
| delete tombstone | {:push, neighbor}(沿 strategy
方向推到最近活跃邻居) |
方向由 channel strategy 决定——pitch 曲线尾巴往 :prev
找,phoneme timing offset 往 :next 找。各论各的。
墓碑与 GC
Merge 或 delete 音符后,原 SeqID 变成墓碑——保留在 note_order 中维持邻接稳定性,但不再对应活跃音符。如果某次渲染后用户确认无 conflict,手动执行 GC 回收未被任何 intervention 引用的墓碑。
结构层 vs 语义层
锚定只能回答”锚还在不在”(结构),回答不了”载荷还有没有效”(语义)。改了歌词,SeqID 不变、邻接不变、rebase 返回 3/3——但 G2P 重跑后 phoneme timing 已经过期。
语义有效性在渲染时由 engine strategy 的
resolve/2 比对:挂载时从投影中提取
snapshot(如音素边界的秒值表),渲染时比对当前投影。一致 → apply
delta,不一致 → conflict 上浮。
比对是 exact 的,不引入 tolerance。tolerance 就是 fuzzy match 的后门——和 2-of-3 exact 的纪律一致。
引擎契约
Zongzi 不跑渲染。只定义 Engine.render/1 回调——引擎消费
notes + interventions + tempo,产出 artifact。resolve
在引擎内发生,zongzi 不关心内部实现是 DAG 还是 pipeline。
完整循环:
编辑 → rebase_all → conflict 上浮 UI
↓
render → artifact
↓
adopt / 挂新 intervention → …
缺憾
- TODO / 缺乏针对但音符内部变化的结构检测(e.g. 修改时长)
感悟
TODO
Footnotes
原始想法是「可编辑的节点图」 ↩︎