From 461535cab2cda750ab5cc75257e2c189198dd233 Mon Sep 17 00:00:00 2001 From: emeraldsingers Date: Sun, 12 Jul 2026 16:48:35 +0300 Subject: [PATCH] Implement XSY cross-synthesis for classic rendering Implement XSY voice-color blending for classic rendering. Render phrases with the primary and XSYC voice colors, then blend their spectra according to the XSY curve using a phase-vocoder DSP pipeline. --- OpenUtau.Core/Classic/ClassicRenderer.cs | 2 + OpenUtau.Core/Classic/WorldlineRenderer.cs | 2 + OpenUtau.Core/Format/USTx.cs | 4 + OpenUtau.Core/Render/RenderEngine.cs | 81 +++++++- OpenUtau.Core/Render/RenderPhrase.cs | 25 ++- OpenUtau.Core/SignalChain/CrossSynthDSP.cs | 189 ++++++++++++++++++ OpenUtau.Core/Ustx/UPhoneme.cs | 11 + OpenUtau.Core/Ustx/UTrack.cs | 14 ++ .../Core/SignalChain/CrossSynthDSPTest.cs | 27 +++ .../Core/USTx/CrossSynthesisExpressionTest.cs | 19 ++ 10 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 OpenUtau.Core/SignalChain/CrossSynthDSP.cs create mode 100644 OpenUtau.Test/Core/SignalChain/CrossSynthDSPTest.cs create mode 100644 OpenUtau.Test/Core/USTx/CrossSynthesisExpressionTest.cs diff --git a/OpenUtau.Core/Classic/ClassicRenderer.cs b/OpenUtau.Core/Classic/ClassicRenderer.cs index b4ee80663..7f90c349a 100644 --- a/OpenUtau.Core/Classic/ClassicRenderer.cs +++ b/OpenUtau.Core/Classic/ClassicRenderer.cs @@ -18,6 +18,8 @@ public class ClassicRenderer : IRenderer { Ustx.DYN, Ustx.PITD, Ustx.CLR, + Ustx.XSYC, + Ustx.XSY, Ustx.ENG, Ustx.VEL, Ustx.VOL, diff --git a/OpenUtau.Core/Classic/WorldlineRenderer.cs b/OpenUtau.Core/Classic/WorldlineRenderer.cs index bd599de10..f28a69ab8 100644 --- a/OpenUtau.Core/Classic/WorldlineRenderer.cs +++ b/OpenUtau.Core/Classic/WorldlineRenderer.cs @@ -34,6 +34,8 @@ public WorldlineRenderer(int version) { Ustx.DYN, Ustx.PITD, Ustx.CLR, + Ustx.XSYC, + Ustx.XSY, Ustx.SHFT, Ustx.VEL, Ustx.VOL, diff --git a/OpenUtau.Core/Format/USTx.cs b/OpenUtau.Core/Format/USTx.cs index 4922d0918..86072408a 100644 --- a/OpenUtau.Core/Format/USTx.cs +++ b/OpenUtau.Core/Format/USTx.cs @@ -34,6 +34,8 @@ public class Ustx { public const string SHFC = "shfc"; public const string TENC = "tenc"; public const string VOIC = "voic"; + public const string XSYC = "xsyc"; + public const string XSY = "xsy"; public static readonly string[] required = { DYN, PITD, CLR, ENG, VEL, VOL, ATK, DEC }; @@ -60,6 +62,8 @@ public static void AddDefaultExpressions(UProject project) { project.RegisterExpression(new UExpressionDescriptor("tone shift (curve)", SHFC, -1200, 1200, 0) { type = UExpressionType.Curve }); project.RegisterExpression(new UExpressionDescriptor("tension (curve)", TENC, -100, 100, 0) { type = UExpressionType.Curve }); project.RegisterExpression(new UExpressionDescriptor("voicing (curve)", VOIC, 0, 100, 100) { type = UExpressionType.Curve }); + project.RegisterExpression(new UExpressionDescriptor("cross synthesis color", XSYC, false, new string[0])); + project.RegisterExpression(new UExpressionDescriptor("cross synthesis (curve)", XSY, 0, 100, 0) { type = UExpressionType.Curve }); string message = string.Empty; if (ValidateExpression(project, "g", GEN)) { diff --git a/OpenUtau.Core/Render/RenderEngine.cs b/OpenUtau.Core/Render/RenderEngine.cs index 75bc3d35b..019d1a82d 100644 --- a/OpenUtau.Core/Render/RenderEngine.cs +++ b/OpenUtau.Core/Render/RenderEngine.cs @@ -48,6 +48,9 @@ class RenderEngine { readonly int endTick; readonly int trackNo; + static readonly System.Collections.Concurrent.ConcurrentDictionary XsyBlendCache = + new System.Collections.Concurrent.ConcurrentDictionary(); + public RenderEngine(UProject project, int startTick = 0, int endTick = -1, int trackNo = -1) { this.project = project; this.startTick = startTick; @@ -253,12 +256,80 @@ private void RenderRequests( var phrase = tuple.Item1; var source = tuple.Item2; var request = tuple.Item3; - var task = phrase.renderer.Render(phrase, progress, request.trackNo, cancellation, true); - task.Wait(); - if (cancellation.IsCancellationRequested) { - break; + bool useXsy = phrase.xsy != null && phrase.xsy.Any(x => x > 0); + if (!useXsy) { + var task = phrase.renderer.Render(phrase, progress, request.trackNo, cancellation, true); + task.Wait(); + if (cancellation.IsCancellationRequested) { + break; + } + source.SetSamples(task.Result.samples); + } else { + string xsyKey = $"{phrase.hash:x16}|" + + string.Join(",", phrase.phones.Select(p => $"{p.oto2?.Set}:{p.oto2?.Alias}")); + if (!XsyBlendCache.TryGetValue(xsyKey, out var blended)) { + var taskA = phrase.renderer.Render(phrase, progress, request.trackNo, cancellation, true); + taskA.Wait(); + if (cancellation.IsCancellationRequested) { + break; + } + float[] samplesA = taskA.Result.samples; + + var otoField = typeof(RenderPhone).GetField("oto"); + var hashField = typeof(RenderPhone).GetField("hash"); + var phraseHashField = typeof(RenderPhrase).GetField("hash"); + var originalOtos = phrase.phones.Select(p => p.oto).ToArray(); + var originalHashes = phrase.phones.Select(p => p.hash).ToArray(); + ulong originalPhraseHash = phrase.hash; + float[] samplesB; + try { + for (int i = 0; i < phrase.phones.Length; i++) { + var phone = phrase.phones[i]; + if (phone.oto2 != null) { + otoField.SetValue(phone, phone.oto2); + hashField.SetValue(phone, phone.hash ^ 0x5858585858585858); + } + } + phraseHashField.SetValue(phrase, phrase.hash ^ 0x5858585858585858); + var taskB = phrase.renderer.Render(phrase, progress, request.trackNo, cancellation, true); + taskB.Wait(); + samplesB = taskB.Result.samples; + } finally { + for (int i = 0; i < phrase.phones.Length; i++) { + otoField.SetValue(phrase.phones[i], originalOtos[i]); + hashField.SetValue(phrase.phones[i], originalHashes[i]); + } + phraseHashField.SetValue(phrase, originalPhraseHash); + } + if (cancellation.IsCancellationRequested) { + break; + } + + const int fftSize = 2048; + const int hopSize = 512; + int totalSamples = Math.Max(samplesA.Length, samplesB.Length); + int frameCount = Math.Max(1, (totalSamples - fftSize) / hopSize + 1); + float[] frameRatios = new float[frameCount]; + int pitchStart = phrase.position - phrase.leading; + for (int f = 0; f < frameCount; f++) { + double timeMs = phrase.positionMs - phrase.leadingMs + + (double)(f * hopSize) / 44100.0 * 1000.0; + double tick = project.timeAxis.MsPosToTickPos(timeMs); + int curveIndex = (int)Math.Max(0, (tick - pitchStart) / 5); + if (phrase.xsy.Length > 0) { + frameRatios[f] = curveIndex < phrase.xsy.Length + ? Math.Clamp(phrase.xsy[curveIndex] / 100f, 0f, 1f) + : Math.Clamp(phrase.xsy.Last() / 100f, 0f, 1f); + } + } + blended = CrossSynthDSP.StftBlend(samplesA, samplesB, frameRatios); + if (XsyBlendCache.Count > 1024) { + XsyBlendCache.Clear(); + } + XsyBlendCache[xsyKey] = blended; + } + source.SetSamples(blended); } - source.SetSamples(task.Result.samples); if (request.sources.All(s => s.HasSamples)) { request.part.SetMix(request.mix); DocManager.Inst.ExecuteCmd(new PartRenderedNotification(request.part)); diff --git a/OpenUtau.Core/Render/RenderPhrase.cs b/OpenUtau.Core/Render/RenderPhrase.cs index 048fd64f6..cfa5d2ebf 100644 --- a/OpenUtau.Core/Render/RenderPhrase.cs +++ b/OpenUtau.Core/Render/RenderPhrase.cs @@ -66,6 +66,7 @@ public class RenderPhone { public readonly double adjustedTempo; public readonly Tuple[] flags;// flag, value, abbr. Abbr is kept here for flag filtering. public readonly string suffix; + public readonly string suffix2; public readonly float volume; public readonly float velocity; public readonly float modulation; @@ -76,6 +77,7 @@ public class RenderPhone { public readonly int toneShift; public readonly UOto oto; + public readonly UOto oto2; public readonly ulong hash; internal RenderPhone(UProject project, UTrack track, UVoicePart part, UNote note, UPhoneme phoneme, int phrasePosition) { @@ -120,6 +122,9 @@ internal RenderPhone(UProject project, UTrack track, UVoicePart part, UNote note string voiceColor = phoneme.GetVoiceColor(project, track); suffix = track.Singer.Subbanks.FirstOrDefault( subbank => subbank.Color == voiceColor)?.Suffix ?? string.Empty; + string targetColor = phoneme.GetVoiceColor2(project, track) ?? string.Empty; + suffix2 = track.Singer.Subbanks.FirstOrDefault( + subbank => subbank.Color == targetColor)?.Suffix ?? string.Empty; volume = phoneme.GetExpression(project, track, Format.Ustx.VOL).Item1 * 0.01f; velocity = phoneme.GetExpression(project, track, Format.Ustx.VEL).Item1 * 0.01f; modulation = phoneme.GetExpression(project, track, Format.Ustx.MOD).Item1 * 0.01f; @@ -129,6 +134,12 @@ internal RenderPhone(UProject project, UTrack track, UVoicePart part, UNote note toneShift = (int)phoneme.GetExpression(project, track, Format.Ustx.SHFT).Item1; oto = phoneme.oto; + if (oto != null && !string.IsNullOrEmpty(targetColor)) { + string basePhoneme = oto.Phonetic ?? phoneme.phoneme; + if (track.Singer.TryGetMappedOto(basePhoneme, note.tone, targetColor, out var secondaryOto)) { + oto2 = secondaryOto; + } + } hash = Hash(); } private ulong Hash() { @@ -147,6 +158,7 @@ private ulong Hash() { } } writer.Write(suffix); + writer.Write(suffix2 ?? string.Empty); writer.Write(volume); writer.Write(velocity); writer.Write(modulation); @@ -187,6 +199,7 @@ public class RenderPhrase { public readonly float[] toneShift; public readonly float[] tension; public readonly float[] voicing; + public readonly float[] xsy; public readonly Tuple[] curves;//custom curves defined by renderer public readonly ulong preEffectHash; public readonly ulong hash; @@ -442,6 +455,16 @@ double Fade(double diff, int pit) { case Format.Ustx.TENC: tension = curveSampled; break; case Format.Ustx.BREC: breathiness = curveSampled; break; case Format.Ustx.VOIC: voicing = curveSampled; break; + case Format.Ustx.XSY: + xsy = curveSampled; + foreach (var phone in phones) { + int startIdx = Math.Max(0, (phone.position - phone.leading - pitchStart) / pitchInterval); + int endIdx = Math.Min(xsy.Length, Math.Max(0, (phone.position - pitchStart) / pitchInterval)); + for (int k = startIdx; k < endIdx; k++) { + xsy[k] = 0f; + } + } + break; default: curves.Add(Tuple.Create(curve.abbr,curveSampled)); break; @@ -498,7 +521,7 @@ private ulong Hash(bool postEffect) { writer.Write(phone.hash); } if (postEffect) { - foreach (var array in new float[][] { pitches, dynamics, gender, breathiness, toneShift, tension, voicing }) { + foreach (var array in new float[][] { pitches, dynamics, gender, breathiness, toneShift, tension, voicing, xsy }) { if (array == null) { writer.Write("null"); } else { diff --git a/OpenUtau.Core/SignalChain/CrossSynthDSP.cs b/OpenUtau.Core/SignalChain/CrossSynthDSP.cs new file mode 100644 index 000000000..4f0becd7d --- /dev/null +++ b/OpenUtau.Core/SignalChain/CrossSynthDSP.cs @@ -0,0 +1,189 @@ +using System; +using System.Linq; +using NWaves.Transforms; + +namespace OpenUtau.Core.SignalChain { + public class CrossSynthDSP { + + const int SAMPLE_RATE = 44100; + const float EPS = 1e-8f; + const bool NWAVES_INVERSE_NORMALIZES = false; + + public static float[] StftBlend( + float[] a, float[] b, + float[] frameRatios, + double[] anchorA = null, + double[] anchorB = null) { + + if (a == null && b != null) return (float[])b.Clone(); + if (b == null && a != null) return (float[])a.Clone(); + if (a == null && b == null) return new float[0]; + int length = Math.Max(a.Length, b.Length); + if (a.Length < length) Array.Resize(ref a, length); + if (b.Length < length) Array.Resize(ref b, length); + if (frameRatios == null || frameRatios.All(r => r < 0.01f)) return (float[])a.Clone(); + if (frameRatios.All(r => r > 0.99f)) return (float[])b.Clone(); + + const int fftSize = 2048; + const int hopSize = 512; + const int half = fftSize / 2 + 1; + float invN = NWAVES_INVERSE_NORMALIZES ? 1f : 1f / fftSize; + var fft = new Fft(fftSize); + var win = MakeHann(fftSize); + float[] omega = new float[half]; + for (int k = 0; k < half; k++) + omega[k] = 2f * MathF.PI * k / fftSize; + + float[] phsAccA = new float[half]; + float[] phsAccB = new float[half]; + float[] prevPhsA = new float[half]; + float[] prevPhsB = new float[half]; + bool firstFrame = true; + float prevEnergyA = 0f; + float prevEnergyB = 0f; + const float TRANSIENT_THRESHOLD = 4.0f; + + float[] output = new float[length]; + float[] wsum = new float[length]; + + float[] reA = new float[fftSize]; + float[] imA = new float[fftSize]; + float[] reB = new float[fftSize]; + float[] imB = new float[fftSize]; + + bool hasAnchors = anchorA != null && anchorB != null && anchorA.Length >= 2; + int totalFrames = (length - fftSize) / hopSize + 1; + + for (int f = 0; f < totalFrames; f++) { + int posA = f * hopSize; + + int posB = posA; + if (hasAnchors) { + double tA_ms = posA * 1000.0 / SAMPLE_RATE; + double tB_ms; + if (tA_ms <= anchorA[0]) tB_ms = anchorB[0]; + else if (tA_ms >= anchorA[anchorA.Length - 1]) tB_ms = anchorB[anchorB.Length - 1]; + else tB_ms = PiecewiseLerp(anchorA, anchorB, tA_ms); + posB = (int)Math.Max(0, Math.Min(length - fftSize, tB_ms * SAMPLE_RATE / 1000.0)); + } + + float ratio = frameRatios != null && f < frameRatios.Length + ? frameRatios[f] + : (frameRatios?.Length > 0 ? frameRatios[frameRatios.Length - 1] : 0f); + + for (int i = 0; i < fftSize; i++) { reA[i] = a[posA + i] * win[i]; imA[i] = 0f; } + fft.Direct(reA, imA); + + for (int i = 0; i < fftSize; i++) { reB[i] = b[posB + i] * win[i]; imB[i] = 0f; } + fft.Direct(reB, imB); + + float energyA = 0f, energyB = 0f; + for (int k = 0; k < half; k++) { + energyA += reA[k]*reA[k] + imA[k]*imA[k]; + energyB += reB[k]*reB[k] + imB[k]*imB[k]; + } + bool silenceToSignalA = !firstFrame && prevEnergyA <= EPS && energyA > EPS; + bool transientA = !firstFrame && prevEnergyA > EPS && energyA / prevEnergyA > TRANSIENT_THRESHOLD; + bool silenceToSignalB = !firstFrame && prevEnergyB <= EPS && energyB > EPS; + bool transientB = !firstFrame && prevEnergyB > EPS && energyB / prevEnergyB > TRANSIENT_THRESHOLD; + bool resetA = firstFrame || silenceToSignalA || transientA; + bool resetB = firstFrame || silenceToSignalB || transientB; + prevEnergyA = energyA; + prevEnergyB = energyB; + const float BYPASS_RATIO = 0.005f; + bool bypass = ratio < BYPASS_RATIO; + + for (int k = 0; k < half; k++) { + float magA = MathF.Sqrt(reA[k]*reA[k] + imA[k]*imA[k]); + float magB = MathF.Sqrt(reB[k]*reB[k] + imB[k]*imB[k]); + + float rawPhsA = MathF.Atan2(imA[k], reA[k]); + float rawPhsB = MathF.Atan2(imB[k], reB[k]); + + if (resetA) { + phsAccA[k] = rawPhsA; + } else { + float dpA = WrapPi(rawPhsA - prevPhsA[k] - omega[k] * hopSize); + phsAccA[k] += omega[k] * hopSize + dpA; + } + if (resetB) { + phsAccB[k] = rawPhsB; + } else { + float dpB = WrapPi(rawPhsB - prevPhsB[k] - omega[k] * hopSize); + phsAccB[k] += omega[k] * hopSize + dpB; + } + + prevPhsA[k] = rawPhsA; + prevPhsB[k] = rawPhsB; + + if (bypass) continue; + const float SILENCE_GATE_FLOOR = 0.001f; + float silenceGateA = MathF.Min(1f, magA / SILENCE_GATE_FLOOR); + float linBlend = magA * (1f - ratio) + magB * ratio; + float geoBlend = MathF.Exp( + MathF.Log(magA + EPS) * (1f - ratio) + + MathF.Log(magB + EPS) * ratio); + float minMag = MathF.Min(magA, magB); + float silenceWeight = MathF.Min(1f, minMag / 0.01f); + float magOut = (linBlend + silenceWeight * (geoBlend - linBlend)) * silenceGateA; + float phsOut = phsAccA[k]; + reA[k] = magOut * MathF.Cos(phsOut); + imA[k] = (k == 0 || k == half - 1) ? 0f : magOut * MathF.Sin(phsOut); + + if (k > 0 && k < fftSize - k) { + reA[fftSize - k] = reA[k]; + imA[fftSize - k] = -imA[k]; + } + } + + firstFrame = false; + if (bypass) { + for (int i = 0; i < fftSize; i++) { + float w = win[i]; + output[posA + i] += a[posA + i] * w * w; + wsum [posA + i] += w * w; + } + } else { + fft.Inverse(reA, imA); + for (int i = 0; i < fftSize; i++) { + float w = win[i]; + output[posA + i] += reA[i] * invN * w * w; + wsum [posA + i] += w * w; + } + } + + } + for (int i = 0; i < length; i++) { + output[i] = wsum[i] > EPS ? output[i] / wsum[i] : 0f; + } + + return output; + } + + static float WrapPi(float x) { + x = (x + MathF.PI) % (2f * MathF.PI); + if (x < 0f) x += 2f * MathF.PI; + return x - MathF.PI; + } + + static float[] MakeHann(int size) { + var w = new float[size]; + for (int i = 0; i < size; i++) { + w[i] = (float)(0.5 * (1.0 - Math.Cos(2.0 * Math.PI * i / size))); + } + return w; + } + + static double PiecewiseLerp(double[] xs, double[] ys, double x) { + for (int i = 0; i < xs.Length - 1; i++) { + double lo = xs[i], hi = xs[i + 1]; + if (x >= lo && x <= hi) { + double span = hi - lo; + if (span < 1e-9) return ys[i]; + return ys[i] + (x - lo) / span * (ys[i + 1] - ys[i]); + } + } + return ys[ys.Length - 1]; + } + } +} diff --git a/OpenUtau.Core/Ustx/UPhoneme.cs b/OpenUtau.Core/Ustx/UPhoneme.cs index 4bb8e08ba..46cc3c52c 100644 --- a/OpenUtau.Core/Ustx/UPhoneme.cs +++ b/OpenUtau.Core/Ustx/UPhoneme.cs @@ -275,6 +275,17 @@ public string GetVoiceColor(UProject project, UTrack track) { } return track.VoiceColorExp.options[index]; } + + public string GetVoiceColor2(UProject project, UTrack track) { + if (track.VoiceColor2Exp == null) { + return null; + } + int index = (int)GetExpression(project, track, Format.Ustx.XSYC).Item1; + if (index < 0 || index >= track.VoiceColor2Exp.options.Length) { + return null; + } + return track.VoiceColor2Exp.options[index]; + } } public class UEnvelope { diff --git a/OpenUtau.Core/Ustx/UTrack.cs b/OpenUtau.Core/Ustx/UTrack.cs index d668f0e9b..1638d3f62 100644 --- a/OpenUtau.Core/Ustx/UTrack.cs +++ b/OpenUtau.Core/Ustx/UTrack.cs @@ -81,6 +81,7 @@ public USinger Singer { if (singer_ != value) { singer_ = value; VoiceColorExp = null; + VoiceColor2Exp = null; } } } @@ -100,6 +101,7 @@ public USinger Singer { public List TrackExpressions { get; set; } = new List(); [YamlIgnore] public UExpressionDescriptor VoiceColorExp { set; get; } + [YamlIgnore] public UExpressionDescriptor VoiceColor2Exp { set; get; } public string[] VoiceColorNames { get; set; } = new string[] { "" }; public UTrack() { @@ -122,6 +124,9 @@ public bool TryGetExpDescriptor(UProject project, string abbr, out UExpressionDe if (abbr == Format.Ustx.CLR && VoiceColorExp != null) { descriptor = VoiceColorExp; return true; + } else if (abbr == Format.Ustx.XSYC && VoiceColor2Exp != null) { + descriptor = VoiceColor2Exp; + return true; } var trackExp = TrackExpressions.FirstOrDefault(e => e.abbr == abbr); if (trackExp != null) { @@ -153,6 +158,7 @@ public void OnSingerRefreshed() { Singer = USinger.CreateMissing(Singer.Name); } VoiceColorExp = null; + VoiceColor2Exp = null; } public void Validate(ValidateOptions options, UProject project) { @@ -171,6 +177,14 @@ public void Validate(ValidateOptions options, UProject project) { VoiceColorExp.max = VoiceColorExp.options.Length - 1; } } + if (project.expressions.TryGetValue(Format.Ustx.XSYC, out var descriptor2)) { + if (VoiceColor2Exp == null && Singer != null && Singer.Found && Singer.Loaded) { + VoiceColor2Exp = descriptor2.Clone(); + var colors = Singer.Subbanks.Select(subbank => subbank.Color).ToHashSet(); + VoiceColor2Exp.options = colors.OrderBy(c => c).ToArray(); + VoiceColor2Exp.max = VoiceColor2Exp.options.Length - 1; + } + } } public bool ValidateVoiceColor(out string[] oldColors, out string[] newColors) { diff --git a/OpenUtau.Test/Core/SignalChain/CrossSynthDSPTest.cs b/OpenUtau.Test/Core/SignalChain/CrossSynthDSPTest.cs new file mode 100644 index 000000000..1964253d1 --- /dev/null +++ b/OpenUtau.Test/Core/SignalChain/CrossSynthDSPTest.cs @@ -0,0 +1,27 @@ +using System; +using System.Linq; +using Xunit; + +namespace OpenUtau.Core.SignalChain { + public class CrossSynthDSPTest { + [Fact] + public void BypassesAtRatioExtremes() { + var a = Enumerable.Range(0, 4096).Select(i => MathF.Sin(i * 0.01f)).ToArray(); + var b = Enumerable.Range(0, 4096).Select(i => MathF.Cos(i * 0.01f)).ToArray(); + + Assert.Equal(a, CrossSynthDSP.StftBlend(a, b, new[] { 0f })); + Assert.Equal(b, CrossSynthDSP.StftBlend(a, b, new[] { 1f })); + } + + [Fact] + public void ProducesFiniteIntermediateBlend() { + var a = Enumerable.Range(0, 4096).Select(i => MathF.Sin(i * 0.01f)).ToArray(); + var b = Enumerable.Range(0, 4096).Select(i => MathF.Cos(i * 0.02f)).ToArray(); + + var result = CrossSynthDSP.StftBlend(a, b, new[] { 0.5f, 0.5f, 0.5f, 0.5f, 0.5f }); + + Assert.Equal(a.Length, result.Length); + Assert.All(result, sample => Assert.True(float.IsFinite(sample))); + } + } +} diff --git a/OpenUtau.Test/Core/USTx/CrossSynthesisExpressionTest.cs b/OpenUtau.Test/Core/USTx/CrossSynthesisExpressionTest.cs new file mode 100644 index 000000000..340cb22fc --- /dev/null +++ b/OpenUtau.Test/Core/USTx/CrossSynthesisExpressionTest.cs @@ -0,0 +1,19 @@ +using OpenUtau.Core.Format; +using OpenUtau.Core.Ustx; +using Xunit; + +namespace OpenUtau.Test.Core.USTx { + public class CrossSynthesisExpressionTest { + [Fact] + public void RegistersCrossSynthesisExpressions() { + var project = new UProject(); + + Ustx.AddDefaultExpressions(project); + + Assert.Equal(UExpressionType.Options, project.expressions[Ustx.XSYC].type); + Assert.Equal(UExpressionType.Curve, project.expressions[Ustx.XSY].type); + Assert.Equal(0, project.expressions[Ustx.XSY].min); + Assert.Equal(100, project.expressions[Ustx.XSY].max); + } + } +}