Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion DiffSingerSpeakerMix.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
3 changes: 2 additions & 1 deletion tests/DiffSinger.Tests/DiffSinger.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
<PackageReference Include="xunit.assert" Version="2.9.2" />
</ItemGroup>

<!-- DiffSingerFrames 是零外部依赖的纯静态工具类,直接编译进测试程序集。
<!-- DiffSingerFrames / DiffSingerSpeakerMix 是零外部依赖的纯静态类,直接编译进测试程序集。
这样即使本地无 TuneLab SDK(CI 以外的环境),也能跑单元测试。 -->
<ItemGroup>
<Compile Include="..\..\DiffSingerFrames.cs" />
<Compile Include="..\..\DiffSingerSpeakerMix.cs" />
</ItemGroup>

</Project>
150 changes: 150 additions & 0 deletions tests/DiffSinger.Tests/DiffSingerSpeakerMixTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, float[]>(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");
}
}
Loading