SVS 编辑器的干预静默失效:为什么改个歌词你的手调参数就丢了

Work in Progress (20%)
Qy

相关仓库:Zongzi(本文所述机制的实现), EquinoxEditor

由于目前引擎合并还在进行,并且没有稳定,因此本文内容可能产生较大变化。

上下文

首先介绍一下这个问题是怎么来的。

我在高中期间有一系列想法,笼统来说就是编一个歌声合成(恕下文用 SVS 代替)编辑器以及引擎,并且确定了一套基于「基于生理学的中间参数」的适配协议(参见 Qy 项目介绍)。

开发背景

我本以为这个 idea 会束之高阁,但在 24 年的下半年,因为一些个人变故我选择重新开始推进它。 并且编写了一些简单的代码以及脚手架,但因为编码水平的限制没有什么实际进展,后续因为准备毕业加之尝试搭建 DiffSinger 推理管线失败的关系也被搁置了。

在 25 年的下半年,我突然想继续推进,并且基于此完成了 Orchid ,以及及其相关的 OrchidSymbiontOrchidStratum 以及 OrchidIntervention 。也通过了 Livebook 跑通了 DiffSinger 的管线。

至少就我最开始的想法而言,是实现了。但是并没有和目前的 SVS 编辑器的横向对比以及对其他用户而言所见即所得的效果。因此我想要继续开发完成编辑器。

经过了几个原型的编写后,我想依照 DDD (领域驱动设计)的思路开发。我当时的思路是这样的:

  1. 完成领域建模并编写代码
  2. 绑定此前编写的 Orchid 调度引擎并设计歌声合成编辑器的引擎协议并且自己写几个
  3. 实现 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

  1. 原始想法是「可编辑的节点图」 ↩︎