diff --git a/DiffSingerSpeakerMix.cs b/DiffSingerSpeakerMix.cs
index 997e4a1..da00c1c 100644
--- a/DiffSingerSpeakerMix.cs
+++ b/DiffSingerSpeakerMix.cs
@@ -41,7 +41,12 @@ float[] Weight(string suffix)
for (int f = 0; f < nFrames; f++)
{
double v = f < sampled.Length ? sampled[f] : double.NaN;
- if (!double.IsNaN(v)) w[f] += (float)v;
+ // 用户值钳到 [0,1]:轨声明即 Continuous(color, 0, 0, 1)、宿主 UI 落笔时钳制,但数据层无硬契约
+ // (锚点+默认值合成、跨引擎同 key 复用、手改工程皆可越界)——同 CombineVariance 的兜底理由。
+ // 负权重尤其危险:它会让下方「Σ>1 才归一」的判据失效(负值把 Σ 拉回 ≤1),
+ // 于是既不归一、默认 suffix 又补上 1-Σ>1,混出凸包外的 embedding(外插而非插值)。
+ // 同形态的 phoneme_mix 轨在 DiffSingerSynthesisSession 采样处已 clamp,此处对齐。
+ if (!double.IsNaN(v)) w[f] += (float)Math.Clamp(v, 0, 1);
}
}
diff --git a/tests/DiffSinger.Tests/DiffSinger.Tests.csproj b/tests/DiffSinger.Tests/DiffSinger.Tests.csproj
index 865aa7b..467e7c5 100644
--- a/tests/DiffSinger.Tests/DiffSinger.Tests.csproj
+++ b/tests/DiffSinger.Tests/DiffSinger.Tests.csproj
@@ -14,10 +14,11 @@
-
+
diff --git a/tests/DiffSinger.Tests/DiffSingerSpeakerMixTests.cs b/tests/DiffSinger.Tests/DiffSingerSpeakerMixTests.cs
new file mode 100644
index 0000000..f23b573
--- /dev/null
+++ b/tests/DiffSinger.Tests/DiffSingerSpeakerMixTests.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using DiffSingerForTuneLab;
+using Xunit;
+
+namespace DiffSinger.Tests;
+
+// DiffSingerSpeakerMix 的逐帧权重合成(零 SDK 依赖,直接编译进测试程序集)。
+// 重点:权重恒为凸组合——每权重 ∈ [0,1] 且逐帧和为 1。破坏凸性会把 embedding 混到
+// 训练分布之外(外插而非插值),模型行为不可预期。
+public class DiffSingerSpeakerMixTests
+{
+ // 逐帧权重和恒为 1(凸组合的必要条件)。
+ static void AssertConvex(DiffSingerSpeakerMix mix, params string[] suffixes)
+ {
+ for (int f = 0; f < mix.FrameCount; f++)
+ {
+ float sum = 0;
+ foreach (var s in suffixes)
+ {
+ float w = WeightOf(mix, s, f);
+ Assert.InRange(w, 0f, 1f);
+ sum += w;
+ }
+ Assert.Equal(1f, sum, precision: 4);
+ }
+ }
+
+ // 借 ToEmbedding 反读某 suffix 的逐帧权重:给该 suffix 单位基向量、其余零向量,
+ // 混出的第 0 维即它的权重。
+ static float WeightOf(DiffSingerSpeakerMix mix, string suffix, int frame)
+ {
+ var spk = mix.ToEmbedding(s => s == suffix ? new[] { 1f } : new[] { 0f }, 1);
+ return spk[frame];
+ }
+
+ [Fact]
+ public void NoTracks_DefaultTakesFullWeight()
+ {
+ var mix = DiffSingerSpeakerMix.Create("Miku", [], 3);
+ for (int f = 0; f < 3; f++)
+ Assert.Equal(1f, WeightOf(mix, "Miku", f), precision: 4);
+ }
+
+ [Fact]
+ public void PartialWeight_DefaultFillsRemainder()
+ {
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [0.25])], 1);
+ Assert.Equal(0.25f, WeightOf(mix, "Teto", 0), precision: 4);
+ Assert.Equal(0.75f, WeightOf(mix, "Miku", 0), precision: 4);
+ AssertConvex(mix, "Miku", "Teto");
+ }
+
+ [Fact]
+ public void OverUnity_NormalizesBySum()
+ {
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [1.0]), ("Luka", [1.0])], 1);
+ Assert.Equal(0.5f, WeightOf(mix, "Teto", 0), precision: 4);
+ Assert.Equal(0.5f, WeightOf(mix, "Luka", 0), precision: 4);
+ AssertConvex(mix, "Miku", "Teto", "Luka");
+ }
+
+ [Fact]
+ public void NaN_TreatedAsUnedited()
+ {
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [double.NaN])], 1);
+ Assert.Equal(0f, WeightOf(mix, "Teto", 0), precision: 4);
+ Assert.Equal(1f, WeightOf(mix, "Miku", 0), precision: 4);
+ }
+
+ // —— 回归:越界输入必须钳到 [0,1],否则凸性被破坏 ——
+
+ // 负权重是最危险的一种:它把 Σ 拉回 ≤1,令「Σ>1 才归一」的判据失效,
+ // 于是既不归一、默认 suffix 又补上 1-Σ>1 —— 混出凸包外的 embedding。
+ // 修复前:Teto=-0.5 会得到 Miku=1.5 / Teto=-0.5。
+ [Theory]
+ [InlineData(-0.5)]
+ [InlineData(-2.0)]
+ [InlineData(-100.0)]
+ public void NegativeWeight_ClampedToZero(double drawn)
+ {
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [drawn])], 1);
+ Assert.Equal(0f, WeightOf(mix, "Teto", 0), precision: 4);
+ Assert.Equal(1f, WeightOf(mix, "Miku", 0), precision: 4);
+ AssertConvex(mix, "Miku", "Teto");
+ }
+
+ [Theory]
+ [InlineData(1.5)]
+ [InlineData(50.0)]
+ public void AboveOneWeight_ClampedThenNormalized(double drawn)
+ {
+ // 单条轨钳到 1 → Σ=1 → 不触发归一、默认补 0:目标 suffix 独占。
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [drawn])], 1);
+ Assert.Equal(1f, WeightOf(mix, "Teto", 0), precision: 4);
+ Assert.Equal(0f, WeightOf(mix, "Miku", 0), precision: 4);
+ AssertConvex(mix, "Miku", "Teto");
+ }
+
+ // 正负混合:修复前 Σ = 2 + (-1) = 1 ⇒ 不归一,混出 Teto=2 / Luka=-1 的外插结果。
+ [Fact]
+ public void MixedSignWeights_StayConvex()
+ {
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [2.0]), ("Luka", [-1.0])], 1);
+ Assert.Equal(1f, WeightOf(mix, "Teto", 0), precision: 4);
+ Assert.Equal(0f, WeightOf(mix, "Luka", 0), precision: 4);
+ AssertConvex(mix, "Miku", "Teto", "Luka");
+ }
+
+ // 凸性 ⇒ 混出的 embedding 必落在各说话人 emb 的凸包内(逐维不超出 min/max)。
+ [Fact]
+ public void ResultingEmbedding_StaysWithinConvexHull()
+ {
+ var embs = new Dictionary(StringComparer.Ordinal)
+ {
+ ["Miku"] = [1f, 0f],
+ ["Teto"] = [0f, 1f],
+ };
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [-0.5])], 1);
+ var spk = mix.ToEmbedding(s => embs.TryGetValue(s, out var e) ? e : new float[2], 2);
+ for (int i = 0; i < 2; i++)
+ {
+ float lo = Math.Min(embs["Miku"][i], embs["Teto"][i]);
+ float hi = Math.Max(embs["Miku"][i], embs["Teto"][i]);
+ Assert.InRange(spk[i], lo, hi);
+ }
+ }
+
+ [Fact]
+ public void MultiFrame_ClampsPerFrame()
+ {
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [-1.0, 0.5, 2.0])], 3);
+ Assert.Equal(0f, WeightOf(mix, "Teto", 0), precision: 4);
+ Assert.Equal(0.5f, WeightOf(mix, "Teto", 1), precision: 4);
+ Assert.Equal(1f, WeightOf(mix, "Teto", 2), precision: 4);
+ AssertConvex(mix, "Miku", "Teto");
+ }
+
+ // 轨比帧数短:缺的帧按未编辑(NaN)处理,不越界。
+ [Fact]
+ public void ShorterTrack_RemainingFramesUseDefault()
+ {
+ var mix = DiffSingerSpeakerMix.Create("Miku", [("Teto", [0.5])], 3);
+ Assert.Equal(0.5f, WeightOf(mix, "Teto", 0), precision: 4);
+ Assert.Equal(0f, WeightOf(mix, "Teto", 1), precision: 4);
+ Assert.Equal(0f, WeightOf(mix, "Teto", 2), precision: 4);
+ AssertConvex(mix, "Miku", "Teto");
+ }
+}