Skip to content

Commit c1f480c

Browse files
committed
feat: variance 实参轨——与回显同 key 配对,覆盖预测后喂下游
四条 variance 参数(energy / breathiness / voicing / tension)各增一条**实参轨**, 与既有偏差轨分工:"普通用户调偏差、重度用户改实参"。宿主刚落地的「同 Id 配对 + 烘焙」 (TuneLab 02e6cab)正是这条轨的用处所在。 轨形态 - 偏差轨 `<key>`(连续、归一化、显示名"能量")—— **一分未动**:量程/基线/四条 delta 形状函数(含 voicing 的饱和有理+十二次幂三锚定)/配色全部原样,老工程零迁移。 - 实参轨 `<key>_actual`(分段、真实声学单位、显示名"能量:实参")—— 新增。分段形 ⇒ 未绘制处 NaN = 不接管,一条轨即可表达"哪里我说了算"。 - 回显轨 key 跟着实参轨改成 `<key>_actual`(配对判据只认 Id),**显示名仍用裸名**—— 宿主不要求配对两侧同名,故 tabbar 是「能量」/「能量:实参」、chip 是「能量」,三者不重名。 为什么后缀加在实参轨而不是偏差轨(与宿主参照实现 V1.Voice 的 energy/energy_offset 相反) 裸名是**生态标准名**(对齐 ACE Studio / ACEP)**且是存进工程文件的 key**,按 §14.1/§14.2 该留给归一化偏差轨——别的引擎复用同 key 时值在量程内、语义相近,复用是 feature 而非脏数据; 绝对声学量与模型输出单位绑定,本就不该占公共名。回显 key 不进工程文件(每次合成现算), 跟着改零成本。于是 §14.1 纪律 1 无需破例,也没有任何 key 迁移。 合成顺序:预测 → 偏差轨叠 delta → **实参轨覆盖** → clamp 实参轨是最后一道、用户的最终决定。顺序不可颠倒,第一条理由是硬约束: - **烘焙必须中性**。宿主的"把回显烘焙进实参轨"语义是"先原样接过模型这根线、再改中间一段"; 回显本身已含偏差,若烘焙后还要再叠一次偏差,一个字没改的纯烘焙就会改变声音 (预测 −30 + 偏差 +6 → 回显 −24 → 烘焙 → 再叠 +6 → −18)。 - 名副其实:实参轨与回显同 key 同量程、叠在同一坐标系显示,已画段两条必须重合。 - 两笔用户编辑冲突时以更具体的那笔为准。 代价(已知并接受):改偏差轨不带动已被实参轨钉死的帧,重新烘焙一次即可。这与宿主 Pitch/PitchDeviation 的取舍相反——那边 vibrato 是宿主注入的载波,叠到钉死区正是它的用途。 **两条轨都没动 = 纯预测**,与改动前逐比特一致。 其它 - 数值口径抽出 DiffSingerVarianceCurve.cs(零 TuneLab 依赖,照 DiffSingerCurveInput 先例 编进单测程序集)+ 18 条单测:三级顺序、三处 clamp、无预测器降级、voicing 三锚定、 mulaw 三明治,以及逐参数的 `烘焙→原样喂回 == 恒等`。 - 呈现序(tabbar 按声明序,越常用越靠前):基础调教区(四偏差轨 + gender/speed/…)→ 四条实参轨连成一块 → seed 三条。不做"每个参数后跟一条实参"的穿插。 - 配色:偏差轨与回显轨维持本轨历来的 Color,实参轨用同色系提亮版——一眼可辨为一家, 细线又能压在半透明回显面积上看清。 - 显示名用冒号而非括号("能量:实参"):tabbar 横向空间紧,全角括号占两格、冒号一格。 - schema 文档 §14.2 / §14.4 同步(含上述取舍与代价)。
1 parent 9cc8b15 commit c1f480c

7 files changed

Lines changed: 377 additions & 52 deletions

DiffSingerDeclarations.cs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ public static class DiffSingerDeclarations
4949
public static string SlotKey(string baseKey, int slot) => $"{baseKey}:{slot}";
5050
// 槽显示名:仅一槽时省略序号(key 不受影响,槽数变化时同一 key 显示名会跟着变)。
5151
public static string SlotDisplay(string label, int slot, int slots) => slots == 1 ? label : $"{label} {slot}";
52+
// 实参轨 key:<参数名>_actual。实参轨与回显轨**同用此 key**——同 Id 即宿主的「配对」判据
53+
// (点亮实参轨带亮回显、右键可把回显整段烘焙成实参轨锚点,见宿主 SynthesisBake),故两侧必须一致。
54+
// 为什么加后缀的是实参轨而不是偏差轨:裸名 `energy` 等是**生态标准名 + 存进工程的 key**,按 §14.1/§14.2
55+
// 该留给归一化偏差轨(别的引擎复用同 key 时值在量程内、语义相近);绝对声学量与具体模型绑定、
56+
// 本就不该占公共名。回显 key 不进工程文件(每次合成现算),跟着改零成本。
57+
public static string ActualKey(string key) => key + "_actual";
58+
// 实参轨显示名:"能量:实参"(冒号而非括号——tabbar 横向空间紧,全角括号占两格、冒号一格)。
59+
// key 带 `_actual` 后缀但显示名不带——宿主明文不要求配对两侧同名,
60+
// 故三条轨的呈现是:偏差轨「能量」、实参轨「能量:实参」、回显 chip「能量」。
61+
public static string ActualDisplay(string label) => string.Format(L.Tr("{0}: actual"), label);
5262
// part 属性读槽数,clamp [0, MaxMixSlots]。
5363
public static int MixSlots(PropertyObject partProperties)
5464
=> Math.Clamp((int)Math.Round(partProperties.GetDouble(KeyMixSlots, 0)), 0, MaxMixSlots);
@@ -65,32 +75,53 @@ public static int MixSlots(PropertyObject partProperties)
6575
public const double ToneShiftBaseline = 0, ToneShiftMin = -12, ToneShiftMax = 12;
6676

6777
public readonly record struct VarianceSpec(
68-
string Key, string Display, string Color,
78+
string Key, string Display, string Color, string ActualColor,
6979
Func<VoicebankConfig, bool> Use, Func<VoicebankConfig, bool> Predict,
70-
double EditMin, double EditMax, double Neutral,
80+
double OffsetMin, double OffsetMax, double OffsetNeutral,
7181
double AcousticMin, double AcousticMax,
72-
Func<float, float, float> Delta);
82+
Func<float, float, float> Delta)
83+
{
84+
// 三级合成的数值面(零依赖、有单测):本记录的数值字段照原样转过去,不另立真相源。
85+
// internal(VarianceCurveSpec 与 VoicingDomainCodec 皆为插件内部类型,不进公开面)。
86+
internal VarianceCurveSpec Curve => new(OffsetMin, OffsetMax, OffsetNeutral, AcousticMin, AcousticMax, Delta);
87+
}
7388

74-
// 编辑轨(delta 语义)归一化到小数:energy/breath/tension 中性 0、量程 [-1,1];voicing 中性 1、量程 [0,1.25]。
75-
// Delta(x=预测声学值, y=用户归一化值) 系数随之 ×100:y=1 等价旧 y=100(energy/breath ±12dB、tension ±5)。
76-
// AcousticMin/Max 为回显轨的真实声学单位(dB)值域;EditMin/Max 兼作合成期输入 clamp(宿主数据层无量程硬契约)。
89+
// 每条 variance 参数暴露**两条编辑轨 + 一条回显轨**(分工同宿主参照实现 tests/plugins/V1.Voice,
90+
// 但后缀加在哪条上刻意相反,理由见 ActualKey):
91+
// · 偏差轨 `<key>`(连续、归一化、裸 key、显示名即参数名)—— 基础调教入口。相对语义:不管模型给什么,
92+
// 整体"这儿响一点/气一点"。重合成后仍跟随模型新输出,故换 model/version/seed 后手笔不作废。
93+
// · 实参轨 `<key>_actual`(分段、真实声学单位、显示名"能量:实参")—— 细节修改入口,与回显**同 key、同量程**
94+
// ⇒ 宿主识别为配对:点亮即带亮回显、右键可把回显烘焙进来("抓住模型这根线再改中间一段")。
95+
// · 回显轨 `<key>_actual`(只读、同量程、显示名用裸名,见 BuildReadbackConfigs)。
96+
// 合成顺序(见 CombineVariance):预测 → 偏差轨 Delta 叠加 → 实参轨绘制段整帧覆盖 → clamp 喂下游。
97+
// 实参轨在最后 = 用户的最终决定(画的值就是喂下去的值),这也是宿主"把回显烘焙进实参轨"能保持中性的前提。
98+
// 偏差轨(delta 语义)归一化到小数:energy/breath/tension 中性 0、量程 [-1,1];voicing 中性 1、量程 [0,1.25]。
99+
// Delta(x=预测声学值, y=用户归一化值) 系数按小数刻度 ×100:y=1 等价旧 y=100(energy/breath ±12dB、tension ±5)。
100+
// AcousticMin/Max 三处复用:实参轨量程、回显轨量程、合成期输出 clamp;OffsetMin/Max 兼作偏差轨输入 clamp
101+
// (宿主数据层无量程硬契约)。
77102
// voicing 有意偏离 OpenUtau(其满偏 −12dB 够不着静音底、无法做纯气声段),分两支:
78103
// 下行(y≤1)饱和有理项 + 十二次幂跳水项:起步斜率 48(浅笔即可闻)、消声点实测 ≈ y 0.2、y=0 恒精确触底 −96。
79104
// 导数刻意非单调(先陡后缓再跳水):起步斜率 48 > 可听区平均斜率 ~32,中段必须放缓找平,
80105
// 末端 ~70dB 挤在最后两成行程——三项需求联立的必然形状,勿当 bug 修。
81106
// 上行(y>1)线性 +48(y−1):满偏 1.25 = +12dB(与 energy 正向满偏对齐、避开 0dBFS 过载区);
82107
// 中性线两侧斜率相等(48)、过线无顿挫(仅二阶导跳变)。
83108
// 预测 < −72dB 的帧(本就近静音)下行中段轻微下越 −96,由合成期 clamp 兜住。详见 schema 文档 §14.2。
109+
// 配色:偏差轨与回显轨用 Color(与本轨历来配色一致),实参轨用同色系提亮的 ActualColor——
110+
// 同一参数一眼可辨为一家,细线又能压在半透明回显面积上看清。
84111
public static readonly VarianceSpec[] Variances =
85112
{
86-
new("energy", "Energy", "#E573A5", c => c.UseEnergyEmbed, c => c.PredictEnergy, -1, 1, 0, -96, 0, (x, y) => x + y * 12),
87-
new("breathiness", "Breathiness", "#73E5C2", c => c.UseBreathinessEmbed, c => c.PredictBreathiness, -1, 1, 0, -96, 0, (x, y) => x + y * 12),
88-
new("voicing", "Voicing", "#C2E573", c => c.UseVoicingEmbed, c => c.PredictVoicing, 0, 1.25, 1, -96, 0,
113+
new("energy", "Energy", "#E573A5", "#F0A8C6", c => c.UseEnergyEmbed, c => c.PredictEnergy, -1, 1, 0, -96, 0, (x, y) => x + y * 12),
114+
new("breathiness", "Breathiness", "#73E5C2", "#A8F0DB", c => c.UseBreathinessEmbed, c => c.PredictBreathiness, -1, 1, 0, -96, 0, (x, y) => x + y * 12),
115+
new("voicing", "Voicing", "#C2E573", "#DBF0A8", c => c.UseVoicingEmbed, c => c.PredictVoicing, 0, 1.25, 1, -96, 0,
89116
(x, y) => y > 1 ? x + 48 * (y - 1)
90117
: x - 48 * (1 - y) / (2 - y) - (x + 72) * MathF.Pow(1 - y, 12)),
91-
new("tension", "Tension", "#A573E5", c => c.UseTensionEmbed, c => c.PredictTension, -1, 1, 0, -10, 10, (x, y) => x + y * 5),
118+
new("tension", "Tension", "#A573E5", "#C6A8F0", c => c.UseTensionEmbed, c => c.PredictTension, -1, 1, 0, -10, 10, (x, y) => x + y * 5),
92119
};
93120

121+
// 无预测器(!Predict 而声学仍需该输入)时的基线:取 0(声学单位)再叠偏差轨(与历史行为一致:那时 x 也取 0)。
122+
// 绝对量没有"中性值"可言,这只是缺基线时的唯一确定选择;实参轨画满即可完全接管。
123+
public const float VarianceFallback = DiffSingerVarianceCurve.Fallback;
124+
94125
// manifest retake 三位(legacy → 全 false ⇒ 不暴露任何 seed 轨)。
95126
public static (bool Acoustic, bool Pitch, bool Variance) RetakeOf(ResolvedVoice resolved)
96127
=> resolved.Manifest is { } m ? (m.RetakeAcoustic, m.RetakePitch, m.RetakeVariance) : (false, false, false);
@@ -118,9 +149,11 @@ public static OrderedMap<PropertyKey, AutomationConfig> BuildFixedAutomationConf
118149
VoicebankConfig config, (bool Acoustic, bool Pitch, bool Variance) retake)
119150
{
120151
var map = new OrderedMap<PropertyKey, AutomationConfig>();
152+
// 呈现次序(tabbar 按声明序排)= 基础调教区(四条偏差轨 + gender/speed/…)→ 实参轨四条 → seed 三条,
153+
// 越常用越靠前。偏差轨与实参轨**各自成块、不穿插**:穿插会让常用的四条被高级轨隔开、横向找轨要跳着看。
121154
foreach (var v in Variances)
122155
if (v.Use(config))
123-
map.Add((v.Key, L.Tr(v.Display)), Continuous(v.Color, v.Neutral, v.EditMin, v.EditMax));
156+
map.Add((v.Key, L.Tr(v.Display)), Continuous(v.Color, v.OffsetNeutral, v.OffsetMin, v.OffsetMax));
124157

125158
if (config.UseKeyShiftEmbed)
126159
map.Add((KeyGender, L.Tr("Gender")), Continuous("#E5A573", GenderBaseline, GenderMin, GenderMax));
@@ -135,7 +168,14 @@ public static OrderedMap<PropertyKey, AutomationConfig> BuildFixedAutomationConf
135168
if (config.VocoderPitchControllable)
136169
map.Add((KeyToneShift, L.Tr("Tone shift")), Continuous("#73E573", ToneShiftBaseline, ToneShiftMin, ToneShiftMax));
137170

171+
// 实参轨(分段、真实声学单位、`_actual` key、与回显配对):重度用户的细节修改入口,
172+
// 四条**连成一块**、排在基础调教区之后 seed 之前——与上面的偏差轨一一对应、组内顺序同 Variances 表序。
173+
foreach (var v in Variances)
174+
if (v.Use(config))
175+
map.Add((ActualKey(v.Key), ActualDisplay(L.Tr(v.Display))), Piecewise(v.ActualColor, v.AcousticMin, v.AcousticMax));
176+
138177
// seed 轨:仅当 manifest 声明对应 retake 能力时暴露(连续、基线 0、量程 [0,SeedCurveMax])。
178+
// 排在最后——三条里最不常用(换 take 是偶发动作,不是逐句调教)。
139179
if (retake.Pitch)
140180
map.Add((KeySeedPitch, L.Tr("Pitch seed")), Continuous("#9E9E9E", 0, 0, SeedCurveMax, randomizable: true));
141181
if (retake.Variance)
@@ -162,13 +202,15 @@ public static OrderedMap<PropertyKey, AutomationConfig> BuildFixedAutomationConf
162202
}
163203
}
164204

165-
// 只读回显轨:仅当声学接受该量为输入且方差器能产基线时——显示实参(预测 + 用户 delta 合成后)。
205+
// 只读回显轨:仅当声学接受该量为输入且方差器能产基线时——显示实参(预测 → 偏差轨叠加 → 实参轨覆盖后的值)。
206+
// key **必须**与实参轨一致(`_actual`,配对判据只认 Id);量程一致是烘焙不出界的前提;
207+
// 显示名则用裸名(chip 上就叫"能量"——用户认的是物理量,":实参"只用来标可写那条)。
166208
public static OrderedMap<PropertyKey, AutomationConfig> BuildReadbackConfigs(VoicebankConfig config)
167209
{
168210
var map = new OrderedMap<PropertyKey, AutomationConfig>();
169211
foreach (var v in Variances)
170212
if (v.Use(config) && v.Predict(config))
171-
map.Add((v.Key, L.Tr(v.Display)), Piecewise(v.Color, v.AcousticMin, v.AcousticMax));
213+
map.Add((ActualKey(v.Key), L.Tr(v.Display)), Piecewise(v.Color, v.AcousticMin, v.AcousticMax));
172214
return map;
173215
}
174216

DiffSingerSynthesisSession.cs

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -436,28 +436,35 @@ void AddF(string name, float[] data, int[] dims)
436436
AddF("blend", blendFlat, new[] { mixRows, nFrames });
437437
AddF("f0", acousticF0, new[] { 1, nFrames }); // 声学吃移调 f0(无音区偏移时即原始 f0 同引用)
438438

439-
// —— variance:预测 + 用户 delta 合成喂声学,同一份实参产回显 ——
440-
// 用户曲线按帧求值(连续轨:未编辑处=中性基线 → Delta 恒得纯预测;编辑处 → 叠加),clamp 到声学值域。
441-
// 回显(Use && Predict)= 实参(预测 + 用户 delta、clamp 后)——与 pitch 回显同语义:所见即喂给声学的值。
442-
// mulaw 声库(voicing_domain)三明治:预测解码成 dB 进公式、出公式编码回线上域喂声学;
443-
// 公式/回显/clamp 恒 dB 语义(见 VoicingDomainCodec 与 schema §14.2)。db 声库 codec=null、路径不变。
439+
// —— variance:预测 → 偏差轨叠加 → 实参轨覆盖,合成结果喂声学、同一份实参产回显 ——
440+
// 两条用户曲线按帧求值:实参轨(分段,未绘制处 NaN → 用预测)、偏差轨(连续,未编辑处 = 中性基线
441+
// → Delta 恒等)。故"两条都没动"= 纯预测,与历史行为逐比特一致。
442+
// 回显(Use && Predict)= 这份合成实参——与 pitch 回显同语义:所见即喂给声学的值。
443+
// mulaw 声库(voicing_domain)三明治:预测解码成 dB 进合成、出合成编码回线上域喂声学;
444+
// 合成/回显/clamp 恒 dB 语义(见 VoicingDomainCodec 与 schema §14.2)。db 声库 codec=null、路径不变。
444445
var voicingCodec = VoicingDomainCodec.For(config.VoicingDomain, config.VoicingMu);
445446
var varReadback = new Dictionary<string, IReadOnlyList<Point>>();
446447
foreach (var spec in Variances)
447448
{
448449
float[]? predicted = varCurves[spec.Key];
449-
double[]? user = snapshot.Automations.TryGetValue(spec.Key, out var auto)
450-
? auto.Evaluator.Evaluate(frameTimes)
450+
// 实参轨 = `<key>_actual`(与回显配对);偏差轨 = 裸 key(生态标准名,见 DiffSingerDeclarations)。
451+
// spec.Key 同时还是声学的 ONNX 输入名,三者各在自己的命名空间、互不干涉。
452+
double[]? actual = snapshot.Automations.TryGetValue(ActualKey(spec.Key), out var actualTrack)
453+
? actualTrack.Evaluator.Evaluate(frameTimes)
454+
: null;
455+
double[]? offset = snapshot.Automations.TryGetValue(spec.Key, out var offsetTrack)
456+
? offsetTrack.Evaluator.Evaluate(frameTimes)
451457
: null;
452458
var codec = spec.Key == "voicing" ? voicingCodec : null;
453-
var combined = CombineVariance(spec, predicted, user, nFrames, codec);
459+
var combined = CombineVariance(spec, predicted, actual, offset, nFrames, codec);
454460

455461
if (ac.HasInput(spec.Key))
456462
AddF(spec.Key, codec == null ? combined : Array.ConvertAll(combined, codec.DbToWire),
457463
new[] { 1, nFrames });
458464

465+
// 回显按**回显轨 key**(= 实参轨 key)入账:产物 map 的键须与 mReadbackConfigs 声明一致。
459466
if (spec.Use(config) && spec.Predict(config) && predicted != null)
460-
varReadback[spec.Key] = BuildReadbackSegment(spec, combined, frameTimes, nFrames);
467+
varReadback[ActualKey(spec.Key)] = BuildReadbackSegment(spec, combined, frameTimes, nFrames);
461468
}
462469

463470
// —— gender / velocity:纯用户曲线(无方差器基线),钳到声明量程后按帧 convert 喂声学
@@ -652,27 +659,11 @@ static string CleanSymbol(string symbol)
652659
return slash > 0 ? symbol[(slash + 1)..] : symbol;
653660
}
654661

655-
// 预测 x 与用户值 y(UI 单位,NaN 自由区代入中性)按 OpenUtau delta 函数合成,clamp 到声学值域。
656-
// 预测缺失(null,即 !Predict 而声学仍需该输入)→ 以 0 为基线降级,仅叠加用户 delta。
657-
// codec 非空(mulaw voicing):预测是模型线上值、先解码成 dB 再进公式;返回值恒 dB 语义
658-
// (回显直接用,喂声学前由调用方编码回线上域)。
659-
static float[] CombineVariance(VarianceSpec spec, float[]? predicted, double[]? user, int n,
662+
// 实参 = 预测 → 实参轨绘制段**覆盖** → 偏差轨 delta **叠加** → clamp 到声学值域。
663+
// 数值口径与三级顺序在 [DiffSingerVarianceCurve.cs](DiffSingerVarianceCurve.cs)(零依赖、有单测)。
664+
static float[] CombineVariance(VarianceSpec spec, float[]? predicted, double[]? actual, double[]? offset, int n,
660665
VoicingDomainCodec? codec = null)
661-
{
662-
var result = new float[n];
663-
for (int f = 0; f < n; f++)
664-
{
665-
float x = predicted == null ? 0f : (f < predicted.Length ? predicted[f] : predicted[^1]);
666-
if (codec != null) x = codec.WireToDb(x);
667-
// 用户值钳到编辑量程:宿主 UI 落笔时钳制,但数据层无硬契约(锚点+默认值合成、跨引擎同 key 复用、
668-
// 手改工程皆可越界),而 voicing 的幂式对越界输入极敏感(偶次幂/分母变号),必须本地兜底。
669-
double y = user != null && !double.IsNaN(user[f])
670-
? Math.Clamp(user[f], spec.EditMin, spec.EditMax)
671-
: spec.Neutral;
672-
result[f] = (float)Math.Clamp(spec.Delta(x, (float)y), spec.AcousticMin, spec.AcousticMax);
673-
}
674-
return result;
675-
}
666+
=> DiffSingerVarianceCurve.Combine(spec.Curve, predicted, actual, offset, n, codec);
676667

677668
// 纯用户曲线 → 帧级声学输入:按帧求值用户轨,钳到声明量程 [min,max] 后逐帧 convert
678669
// (无轨 / NaN 自由区 → 中性基线)。钳位理由与逐帧实现见 DiffSingerCurveInput。

0 commit comments

Comments
 (0)