From 0e4124c6a64c943bc274436bb0ba3aa8e8bbadd0 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Tue, 2 Jun 2026 20:33:54 +0800 Subject: [PATCH 1/8] Refresh real curves after phrase rendering --- OpenUtau.Core/Commands/Notifications.cs | 26 ++++ OpenUtau.Core/DocManager.cs | 7 +- OpenUtau.Core/Properties/AssemblyInfo.cs | 3 + OpenUtau.Core/Render/RealCurveUpdater.cs | 145 ++++++++++++++++++ OpenUtau.Core/Render/RenderEngine.cs | 19 ++- .../Core/Render/RealCurveUpdaterTest.cs | 98 ++++++++++++ OpenUtau/ViewModels/NotesViewModel.cs | 2 + 7 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 OpenUtau.Core/Properties/AssemblyInfo.cs create mode 100644 OpenUtau.Core/Render/RealCurveUpdater.cs create mode 100644 OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs diff --git a/OpenUtau.Core/Commands/Notifications.cs b/OpenUtau.Core/Commands/Notifications.cs index 95741549e..499f6324a 100644 --- a/OpenUtau.Core/Commands/Notifications.cs +++ b/OpenUtau.Core/Commands/Notifications.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using OpenUtau.Core.Render; using OpenUtau.Core.Ustx; namespace OpenUtau.Core { @@ -247,6 +249,30 @@ public PartRenderedNotification(UVoicePart part) { public override string ToString() => "Part rendered."; } + public class PhraseRenderedNotification : UNotification { + public readonly RenderPhrase phrase; + public readonly RenderResult result; + public readonly int trackNo; + public override bool Silent => true; + public PhraseRenderedNotification(UVoicePart part, RenderPhrase phrase, RenderResult result, int trackNo) { + this.part = part; + this.phrase = phrase; + this.result = result; + this.trackNo = trackNo; + } + public override string ToString() => "Phrase rendered."; + } + + public class RealCurvesUpdatedNotification : UNotification { + public readonly IReadOnlyList updates; + public override bool Silent => true; + public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates) { + this.part = part; + this.updates = updates; + } + public override string ToString() => "Real curves updated."; + } + public class GotoOtoNotification : UNotification { public readonly USinger? singer; public readonly UOto? oto; diff --git a/OpenUtau.Core/DocManager.cs b/OpenUtau.Core/DocManager.cs index 96b50100d..403da287a 100644 --- a/OpenUtau.Core/DocManager.cs +++ b/OpenUtau.Core/DocManager.cs @@ -10,6 +10,7 @@ using OpenUtau.Classic; using OpenUtau.Core.Editing; using OpenUtau.Core.Lib; +using OpenUtau.Core.Render; using OpenUtau.Core.Ustx; using OpenUtau.Core.Util; using Serilog; @@ -222,9 +223,13 @@ public void ExecuteCmd(UCommand cmd) { rangeEndTick = 0; } else if (cmd is SetPlayPosTickNotification setPlayPosTickNotif) { playPosTick = setPlayPosTickNotif.playPosTick; - } else if (cmd is SetRangeSelectionNotification setRange) { +} else if (cmd is SetRangeSelectionNotification setRange) { rangeStartTick = setRange.startTick; rangeEndTick = setRange.endTick; + } else if (cmd is RealCurvesUpdatedNotification realCurvesNotif) { + if (realCurvesNotif.part is UVoicePart voicePart) { + RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); + } } else if (cmd is SingersChangedNotification) { SingerManager.Inst.SearchAllSingers(); } else if (cmd is ValidateProjectNotification) { diff --git a/OpenUtau.Core/Properties/AssemblyInfo.cs b/OpenUtau.Core/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..aa105cb38 --- /dev/null +++ b/OpenUtau.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenUtau.Test")] diff --git a/OpenUtau.Core/Render/RealCurveUpdater.cs b/OpenUtau.Core/Render/RealCurveUpdater.cs new file mode 100644 index 000000000..1296843a2 --- /dev/null +++ b/OpenUtau.Core/Render/RealCurveUpdater.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenUtau.Core.Ustx; + +namespace OpenUtau.Core.Render { + public readonly struct RealCurveUpdate { + public readonly string abbr; + public readonly ulong phraseHash; + public readonly int startTick; + public readonly int endTick; + public readonly int[] xs; + public readonly int[] ys; + + public RealCurveUpdate(string abbr, ulong phraseHash, int startTick, int endTick, int[] xs, int[] ys) { + this.abbr = abbr; + this.phraseHash = phraseHash; + this.startTick = startTick; + this.endTick = endTick; + this.xs = xs; + this.ys = ys; + } + + public bool IsValid => !string.IsNullOrEmpty(abbr) && + endTick >= startTick && xs.Length == ys.Length && xs.Length > 0; + } + + public static class RealCurveUpdater { + public static RealCurveUpdate[] LoadPhraseUpdates(UVoicePart part, RenderPhrase phrase) { + if (!phrase.renderer.SupportsRealCurve) { + return Array.Empty(); + } + return BuildUpdates(part, phrase, phrase.renderer.LoadRenderedRealCurves(phrase)); + } + + internal static RealCurveUpdate[] BuildUpdates( + UVoicePart part, + RenderPhrase phrase, + IEnumerable results) { + return BuildUpdates(part.position, phrase.position, phrase.hash, results); + } + + internal static RealCurveUpdate[] BuildUpdates( + int partPosition, + int phrasePosition, + ulong phraseHash, + IEnumerable results) { + var updates = new List(); + foreach (var result in results) { + if (string.IsNullOrEmpty(result.abbr) || result.ticks == null || result.values == null) { + continue; + } + int count = Math.Min(result.ticks.Length, result.values.Length); + if (count == 0) { + continue; + } + var ticks = result.ticks + .Take(count) + .Select(tick => phrasePosition - partPosition + (int)tick) + .ToArray(); + var values = result.values + .Take(count) + .Select(value => (int)(value * 1000.0)) + .ToArray(); + int startTick = ticks.Min(); + int endTick = ticks.Max(); + var xs = new List(count + 1) { ticks[0] }; + var ys = new List(count + 1) { -1 }; + xs.AddRange(ticks); + ys.AddRange(values); + updates.Add(new RealCurveUpdate( + result.abbr, + phraseHash, + startTick, + endTick, + xs.ToArray(), + ys.ToArray())); + } + return updates.ToArray(); + } + + public static bool Apply(UProject project, UVoicePart part, IReadOnlyList updates) { + if (updates.Count == 0 || !project.parts.Contains(part)) { + return false; + } + var phraseHashes = part.renderPhrases.Select(phrase => phrase.hash).ToHashSet(); + return Apply(project, part, updates, phraseHashes); + } + + internal static bool Apply( + UProject project, + UVoicePart part, + IReadOnlyList updates, + IReadOnlySet phraseHashes) { + bool changed = false; + foreach (var update in updates) { + if (!update.IsValid || !phraseHashes.Contains(update.phraseHash)) { + continue; + } + changed |= ApplyUpdate(project, part, update); + } + return changed; + } + + private static bool ApplyUpdate(UProject project, UVoicePart part, RealCurveUpdate update) { + var curve = part.curves.FirstOrDefault(curve => curve.abbr == update.abbr); + if (curve == null) { + var track = project.tracks[part.trackNo]; + if (!track.TryGetExpDescriptor(project, update.abbr, out var descriptor)) { + return false; + } + curve = new UCurve(descriptor); + part.curves.Add(curve); + } + RemoveRange(curve.realXs, curve.realYs, update.startTick, update.endTick); + InsertRange(curve.realXs, curve.realYs, update.xs, update.ys); + return true; + } + + internal static void RemoveRange(List xs, List ys, int startTick, int endTick) { + for (int i = xs.Count - 1; i >= 0; --i) { + if (xs[i] >= startTick && xs[i] <= endTick) { + xs.RemoveAt(i); + ys.RemoveAt(i); + } + } + } + + internal static void InsertRange(List targetXs, List targetYs, int[] xs, int[] ys) { + if (xs.Length == 0) { + return; + } + int insertIndex = targetXs.BinarySearch(xs[0]); + if (insertIndex < 0) { + insertIndex = ~insertIndex; + } else { + while (insertIndex < targetXs.Count && targetXs[insertIndex] <= xs[0]) { + insertIndex++; + } + } + targetXs.InsertRange(insertIndex, xs); + targetYs.InsertRange(insertIndex, ys); + } + } +} diff --git a/OpenUtau.Core/Render/RenderEngine.cs b/OpenUtau.Core/Render/RenderEngine.cs index 75bc3d35b..ae1339d9f 100644 --- a/OpenUtau.Core/Render/RenderEngine.cs +++ b/OpenUtau.Core/Render/RenderEngine.cs @@ -258,7 +258,10 @@ private void RenderRequests( if (cancellation.IsCancellationRequested) { break; } - source.SetSamples(task.Result.samples); + var result = task.Result; + source.SetSamples(result.samples); + DocManager.Inst.ExecuteCmd(new PhraseRenderedNotification(request.part, phrase, result, request.trackNo)); + PublishRealCurveUpdates(request.part, phrase); if (request.sources.All(s => s.HasSamples)) { request.part.SetMix(request.mix); DocManager.Inst.ExecuteCmd(new PartRenderedNotification(request.part)); @@ -267,6 +270,20 @@ private void RenderRequests( progress.Clear(); } + private void PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { + if (!phrase.renderer.SupportsRealCurve) { + return; + } + try { + var updates = RealCurveUpdater.LoadPhraseUpdates(part, phrase); + if (updates.Length > 0) { + DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); + } + } catch (Exception e) { + Log.Debug(e, "Failed to refresh rendered real curves."); + } + } + public static void ReleaseSourceTemp() { VoicebankFiles.Inst.ReleaseSourceTemp(); } diff --git a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs new file mode 100644 index 000000000..a2ec9bfd5 --- /dev/null +++ b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Linq; +using OpenUtau.Core.Render; +using OpenUtau.Core.Ustx; +using Xunit; + +namespace OpenUtau.Core { + public class RealCurveUpdaterTest { + const string Ene = "ene"; + + [Fact] + public void BuildUpdatesConvertsTicksToPartLocalCoordinates() { + var result = new RenderRealCurveResult { + abbr = Ene, + ticks = new[] { 0f, 5f, 10f }, + values = new[] { 0.1f, 0.2f, 0.3f }, + }; + + var update = Assert.Single(RealCurveUpdater.BuildUpdates( + partPosition: 100, + phrasePosition: 250, + phraseHash: 42, + results: new[] { result })); + + Assert.Equal(Ene, update.abbr); + Assert.Equal((ulong)42, update.phraseHash); + Assert.Equal(150, update.startTick); + Assert.Equal(160, update.endTick); + Assert.Equal(new[] { 150, 150, 155, 160 }, update.xs); + Assert.Equal(new[] { -1, 100, 200, 300 }, update.ys); + } + + [Fact] + public void ApplyReplacesMatchingPhraseRangeOnly() { + var project = CreateProjectWithCurve(out var part, out var curve); + curve.realXs.AddRange(new[] { 0, 0, 10, 100, 100, 110 }); + curve.realYs.AddRange(new[] { -1, 10, 20, -1, 30, 40 }); + var update = new RealCurveUpdate( + Ene, + phraseHash: 42, + startTick: 100, + endTick: 110, + xs: new[] { 100, 100, 105, 110 }, + ys: new[] { -1, 300, 400, 500 }); + + bool changed = RealCurveUpdater.Apply( + project, + part, + new[] { update }, + new HashSet { 42 }); + + Assert.True(changed); + Assert.Equal(new[] { 0, 0, 10, 100, 100, 105, 110 }, curve.realXs); + Assert.Equal(new[] { -1, 10, 20, -1, 300, 400, 500 }, curve.realYs); + } + + [Fact] + public void ApplySkipsStalePhraseHash() { + var project = CreateProjectWithCurve(out var part, out var curve); + curve.realXs.AddRange(new[] { 0, 0, 10 }); + curve.realYs.AddRange(new[] { -1, 10, 20 }); + var update = new RealCurveUpdate( + Ene, + phraseHash: 42, + startTick: 0, + endTick: 10, + xs: new[] { 0, 0, 5 }, + ys: new[] { -1, 300, 400 }); + + bool changed = RealCurveUpdater.Apply( + project, + part, + new[] { update }, + new HashSet { 43 }); + + Assert.False(changed); + Assert.Equal(new[] { 0, 0, 10 }, curve.realXs); + Assert.Equal(new[] { -1, 10, 20 }, curve.realYs); + } + + static UProject CreateProjectWithCurve(out UVoicePart part, out UCurve curve) { + var project = new UProject(); + var descriptor = new UExpressionDescriptor("energy", Ene, 0, 100, 0) { + type = UExpressionType.Curve, + }; + project.RegisterExpression(descriptor); + part = new UVoicePart { + trackNo = 0, + position = 0, + Duration = 480, + }; + project.parts.Add(part); + curve = new UCurve(descriptor); + part.curves.Add(curve); + return project; + } + } +} diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index c14e9deee..348e13369 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -1095,6 +1095,8 @@ public void OnNext(UCommand cmd, bool isUndo) { MessageBus.Current.SendMessage(new NotesRefreshEvent()); } else if (notif is PartRenderedNotification && notif.part == Part) { MessageBus.Current.SendMessage(new WaveformRefreshEvent()); + } else if (notif is RealCurvesUpdatedNotification && notif.part == Part) { + MessageBus.Current.SendMessage(new NotesRefreshEvent()); } } else if (cmd is PartCommand partCommand) { if (cmd is ReplacePartCommand replacePart) { From 23bca98c7f169eef40c3a57327ed7a65e5aaea7d Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Wed, 3 Jun 2026 16:16:54 +0800 Subject: [PATCH 2/8] Add real-curve event system and DiffSinger scheduler Introduce RenderPhraseEvents to report rendered real-curve fragments during phrase rendering and propagate the new optional parameter through IRenderer and concrete renderers (Classic, Worldline, DiffSinger, Enunu, Vogen, Voicevox). Add a DiffSingerRealCurveScheduler that coalesces curve-edit commands (200ms debounce) and schedules RealCurvesUpdated notifications only for variance-offset curves (ene, brec, voic, tenc). Wire scheduling into DocManager (on execute/undo/redo/undo-group) and make RenderEngine consume RenderPhraseEvents to publish incremental updates when available. Also add small ExpCommand.Key assignments to enable detection, refactor DiffSinger real-curve build logic, and include unit tests for the scheduler and RenderPhraseEvents behavior. --- OpenUtau.Core/Classic/ClassicRenderer.cs | 2 +- OpenUtau.Core/Classic/WorldlineRenderer.cs | 2 +- OpenUtau.Core/Commands/ExpCommands.cs | 5 + .../DiffSingerRealCurveScheduler.cs | 103 +++++++++++++++ .../DiffSinger/DiffSingerRenderer.cs | 118 ++++++++++-------- OpenUtau.Core/DocManager.cs | 24 ++++ OpenUtau.Core/Enunu/EnunuRenderer.cs | 2 +- OpenUtau.Core/Render/IRenderer.cs | 18 ++- OpenUtau.Core/Render/RenderEngine.cs | 37 +++++- OpenUtau.Core/Vogen/VogenRenderer.cs | 2 +- OpenUtau.Core/Voicevox/VoicevoxRenderer.cs | 2 +- .../DiffSingerRealCurveSchedulerTest.cs | 18 +++ .../Core/Render/RealCurveUpdaterTest.cs | 17 +++ 13 files changed, 289 insertions(+), 61 deletions(-) create mode 100644 OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs create mode 100644 OpenUtau.Test/Core/DiffSinger/DiffSingerRealCurveSchedulerTest.cs diff --git a/OpenUtau.Core/Classic/ClassicRenderer.cs b/OpenUtau.Core/Classic/ClassicRenderer.cs index b4ee80663..650507cf2 100644 --- a/OpenUtau.Core/Classic/ClassicRenderer.cs +++ b/OpenUtau.Core/Classic/ClassicRenderer.cs @@ -48,7 +48,7 @@ public RenderResult Layout(RenderPhrase phrase) { }; } - public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender, RenderPhraseEvents? renderEvents = null) { if (phrase.wavtool == SharpWavtool.nameConvergence || phrase.wavtool == SharpWavtool.nameSimple) { return RenderInternal(phrase, progress, trackNo, cancellation, isPreRender); } else { diff --git a/OpenUtau.Core/Classic/WorldlineRenderer.cs b/OpenUtau.Core/Classic/WorldlineRenderer.cs index bd599de10..e747562f1 100644 --- a/OpenUtau.Core/Classic/WorldlineRenderer.cs +++ b/OpenUtau.Core/Classic/WorldlineRenderer.cs @@ -63,7 +63,7 @@ public RenderResult Layout(RenderPhrase phrase) { }; } - public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender, RenderPhraseEvents? renderEvents = null) { var resamplerItems = new List(); foreach (var phone in phrase.phones) { resamplerItems.Add(new ResamplerItem(phrase, phone)); diff --git a/OpenUtau.Core/Commands/ExpCommands.cs b/OpenUtau.Core/Commands/ExpCommands.cs index 072e12c71..d28070f2f 100644 --- a/OpenUtau.Core/Commands/ExpCommands.cs +++ b/OpenUtau.Core/Commands/ExpCommands.cs @@ -327,6 +327,7 @@ public override ValidateOptions ValidateOptions public SetCurveCommand(UProject project, UVoicePart part, string abbr, int x, int y, int lastX, int lastY) : base(part) { this.project = project; this.abbr = abbr; + Key = abbr; this.x = x; this.y = y; this.lastX = lastX; @@ -388,6 +389,7 @@ public MergedSetCurveCommand(UProject project, UVoicePart part, string abbr, int[] oldXs, int[] oldYs, int[] newXs, int[] newYs, bool setReal = false) : base(part) { this.project = project; this.abbr = abbr; + Key = setReal ? string.Empty : abbr; this.oldXs = oldXs; this.oldYs = oldYs; this.newXs = newXs; @@ -439,6 +441,7 @@ public class PasteCurveCommand : ExpCommand { public PasteCurveCommand(UProject project, UVoicePart part, string abbr, IEnumerable xs, IEnumerable ys) : base(part) { this.project = project; this.abbr = abbr; + Key = abbr; this.xs = xs.ToArray(); this.ys = ys.ToArray(); var curve = part.curves.FirstOrDefault(c => c.abbr == abbr); @@ -448,6 +451,7 @@ public PasteCurveCommand(UProject project, UVoicePart part, string abbr, IEnumer public PasteCurveCommand(UProject project, UVoicePart part, string abbr, int startX, int startY, int endX, int endY) : base(part) { this.project = project; this.abbr = abbr; + Key = abbr; this.xs = new int[] { startX, endX }; this.ys = new int[] { startY, endY }; var curve = part.curves.FirstOrDefault(c => c.abbr == abbr); @@ -499,6 +503,7 @@ public class ClearCurveCommand : ExpCommand { readonly int[] oldYs; public ClearCurveCommand(UVoicePart part, string abbr) : base(part) { this.abbr = abbr; + Key = abbr; var curve = Part.curves.FirstOrDefault(curve => curve.abbr == abbr); if (curve != null) { oldXs = curve.xs.ToArray(); diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs new file mode 100644 index 000000000..1db02eab4 --- /dev/null +++ b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenUtau.Core.Render; +using OpenUtau.Core.Ustx; +using Serilog; + +namespace OpenUtau.Core.DiffSinger { + internal static class DiffSingerRealCurveScheduler { + // Coalesce drag-generated curve commands without adding pointer-level preview logic. + const int DebounceMs = 200; + static readonly object lockObj = new object(); + static readonly Dictionary pending = new Dictionary(); + + public static void TrySchedule(UProject project, UVoicePart part, UCommand command) { + if (command is not ExpCommand expCommand || + !IsCurveEditCommand(command) || + string.IsNullOrEmpty(expCommand.Key) || + expCommand.Part != part || + !CanRefresh(project, part, expCommand.Key)) { + return; + } + Schedule(project, part); + } + + static bool IsCurveEditCommand(UCommand command) { + return command is SetCurveCommand || + command is MergedSetCurveCommand || + command is PasteCurveCommand || + command is ClearCurveCommand; + } + + static bool CanRefresh(UProject project, UVoicePart part, string abbr) { + if (!DiffSingerRenderer.ShouldRefreshRealCurvesOnCurveEdit(abbr) || + !project.parts.Contains(part) || + part.trackNo < 0 || + part.trackNo >= project.tracks.Count) { + return false; + } + return project.tracks[part.trackNo].RendererSettings.Renderer is DiffSingerRenderer; + } + + static void Schedule(UProject project, UVoicePart part) { + var cancellation = new CancellationTokenSource(); + lock (lockObj) { + if (pending.TryGetValue(part, out var previous)) { + previous.Cancel(); + } + pending[part] = cancellation; + } + _ = Task.Run(() => RefreshAsync(project, part, cancellation)); + } + + static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation) { + try { + await Task.Delay(DebounceMs, cancellation.Token); + var updates = LoadPartUpdates(project, part, cancellation.Token); + if (updates.Count > 0 && !cancellation.IsCancellationRequested) { + DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); + } + } catch (OperationCanceledException) { + } catch (Exception e) { + Log.Debug(e, "Failed to refresh DiffSinger real curves after curve edit."); + } finally { + lock (lockObj) { + if (pending.TryGetValue(part, out var current) && current == cancellation) { + pending.Remove(part); + } + } + cancellation.Dispose(); + } + } + + static IReadOnlyList LoadPartUpdates( + UProject project, + UVoicePart part, + CancellationToken cancellationToken) { + RenderPhrase[] phrases; + lock (project) { + if (!project.parts.Contains(part) || + part.trackNo < 0 || + part.trackNo >= project.tracks.Count || + project.tracks[part.trackNo].RendererSettings.Renderer is not DiffSingerRenderer) { + return Array.Empty(); + } + phrases = part.renderPhrases.ToArray(); + } + if (phrases.Length == 0) { + return Array.Empty(); + } + var updates = new List(); + foreach (var phrase in phrases) { + if (cancellationToken.IsCancellationRequested) { + return Array.Empty(); + } + updates.AddRange(RealCurveUpdater.LoadPhraseUpdates(part, phrase)); + } + return updates; + } + } +} diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs index 69d973789..6468c24be 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs @@ -79,7 +79,7 @@ public RenderResult Layout(RenderPhrase phrase) { }; } - public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender, RenderPhraseEvents? renderEvents = null) { var task = Task.Run(() => { lock (lockObj) { if (cancellation.IsCancellationRequested) { @@ -114,7 +114,7 @@ public Task Render(RenderPhrase phrase, Progress progress, int tra } } if (result.samples == null) { - result.samples = InvokeDiffsinger(phrase, depth, steps, cancellation); + result.samples = InvokeDiffsinger(phrase, depth, steps, cancellation, renderEvents); if (result.samples != null) { var source = new WaveSource(0, 0, 0, 1); source.SetSamples(result.samples); @@ -135,7 +135,7 @@ public Task Render(RenderPhrase phrase, Progress progress, int tra leadingMs、positionMs、estimatedLengthMs: timeaxis layout in Ms, double */ - float[] InvokeDiffsinger(RenderPhrase phrase, double depth, int steps, CancellationTokenSource cancellation) { + float[] InvokeDiffsinger(RenderPhrase phrase, double depth, int steps, CancellationTokenSource cancellation, RenderPhraseEvents? renderEvents) { var singer = phrase.singer as DiffSingerSinger; //Check if dsconfig.yaml is correct if(String.IsNullOrEmpty(singer.dsConfig.vocoder) || @@ -361,6 +361,7 @@ float[] InvokeDiffsinger(RenderPhrase phrase, double depth, int steps, Cancellat } varianceResult = singer.getVariancePredictor().Process(phrase); } + renderEvents?.ReportRealCurves(BuildRenderedRealCurves(phrase, varianceResult)); //TODO: let user edit variance curves if(singer.dsConfig.useEnergyEmbed){ var energyCurve = phrase.curves.FirstOrDefault(curve => curve.Item1 == ENE); @@ -521,6 +522,10 @@ public RenderPitchResult LoadRenderedPitch(RenderPhrase phrase) { } } + public void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand command) { + DiffSingerRealCurveScheduler.TrySchedule(project, part, command); + } + public List LoadRenderedRealCurves(RenderPhrase phrase) { if (!Preferences.Default.DiffSingerTensorCache) { throw new Exception("Please enable DiffSinger tensor cache and re-render the phrase to display correct base curves."); @@ -532,57 +537,68 @@ public List LoadRenderedRealCurves(RenderPhrase phrase) { var variancePredictor = singer.getVariancePredictor()!; lock (variancePredictor) { var result = variancePredictor.Process(phrase); - var frameMs = result.frameMs; - var headFrames = result.headFrames; - var tailFrames = result.tailFrames; - var startMs = phrase.positionMs - headFrames * frameMs; - var realCurves = new (string, float[], float[], Func)[] { - ( - ENE, result.energy ?? Array.Empty(), - phrase.curves.FirstOrDefault(curve => curve.Item1 == ENE)?.Item2 - ?? Array.Empty(), - x => Math.Clamp(x, -96f, 0f) / 96f + 1f - ), - ( - Format.Ustx.BREC, result.breathiness ?? Array.Empty(), phrase.breathiness, - x => Math.Clamp(x, -96f, 0f) / 96f + 1f - ), - ( - Format.Ustx.VOIC, result.voicing ?? Array.Empty(), phrase.voicing, - x => Math.Clamp(x, -96f, 0f) / 96f + 1f - ), - ( - Format.Ustx.TENC, result.tension ?? Array.Empty(), phrase.tension, - x => Math.Clamp(x, -10f, 10f) / 20f + 0.5f - ), - }.Select(t => { - var abbr = t.Item1; - var realCurve = t.Item2; - if (realCurve.Length == 0) { - return new RenderRealCurveResult { - abbr = abbr, - ticks = Array.Empty(), - values = Array.Empty(), - }; - } - var deltaCurve = DiffSingerUtils.SampleCurve( - phrase, t.Item3, 0, frameMs, realCurve.Length, - headFrames, tailFrames, x => x) - .Select(x => (float)x) - .ToArray(); - var normFunc = t.Item4; + return BuildRenderedRealCurves(phrase, result); + } + } + + internal static bool ShouldRefreshRealCurvesOnCurveEdit(string abbr) { + return abbr == ENE || + abbr == Format.Ustx.BREC || + abbr == Format.Ustx.VOIC || + abbr == Format.Ustx.TENC; + } + + private List BuildRenderedRealCurves(RenderPhrase phrase, VarianceResult result) { + var frameMs = result.frameMs; + var headFrames = result.headFrames; + var tailFrames = result.tailFrames; + var startMs = phrase.positionMs - headFrames * frameMs; + var realCurves = new (string, float[], float[], Func)[] { + ( + ENE, result.energy ?? Array.Empty(), + phrase.curves.FirstOrDefault(curve => curve.Item1 == ENE)?.Item2 + ?? Array.Empty(), + x => Math.Clamp(x, -96f, 0f) / 96f + 1f + ), + ( + Format.Ustx.BREC, result.breathiness ?? Array.Empty(), phrase.breathiness, + x => Math.Clamp(x, -96f, 0f) / 96f + 1f + ), + ( + Format.Ustx.VOIC, result.voicing ?? Array.Empty(), phrase.voicing, + x => Math.Clamp(x, -96f, 0f) / 96f + 1f + ), + ( + Format.Ustx.TENC, result.tension ?? Array.Empty(), phrase.tension, + x => Math.Clamp(x, -10f, 10f) / 20f + 0.5f + ), + }.Select(t => { + var abbr = t.Item1; + var realCurve = t.Item2; + if (realCurve.Length == 0) { return new RenderRealCurveResult { abbr = abbr, - ticks = Enumerable.Range(0, realCurve.Length) - .Select(i => (float)phrase.timeAxis.MsPosToTickPos(startMs + i * frameMs) - phrase.position) - .ToArray(), - values = realCurve.Zip(deltaCurve, varianceDeltaFunctions[abbr]) - .Select(normFunc) - .ToArray() + ticks = Array.Empty(), + values = Array.Empty(), }; - }).ToList(); - return realCurves; - } + } + var deltaCurve = DiffSingerUtils.SampleCurve( + phrase, t.Item3, 0, frameMs, realCurve.Length, + headFrames, tailFrames, x => x) + .Select(x => (float)x) + .ToArray(); + var normFunc = t.Item4; + return new RenderRealCurveResult { + abbr = abbr, + ticks = Enumerable.Range(0, realCurve.Length) + .Select(i => (float)phrase.timeAxis.MsPosToTickPos(startMs + i * frameMs) - phrase.position) + .ToArray(), + values = realCurve.Zip(deltaCurve, varianceDeltaFunctions[abbr]) + .Select(normFunc) + .ToArray() + }; + }).ToList(); + return realCurves; } public UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings) { diff --git a/OpenUtau.Core/DocManager.cs b/OpenUtau.Core/DocManager.cs index 403da287a..467ab4f80 100644 --- a/OpenUtau.Core/DocManager.cs +++ b/OpenUtau.Core/DocManager.cs @@ -263,6 +263,27 @@ public void ExecuteCmd(UCommand cmd) { Publish(cmd); if (!undoGroup.DeferValidate) { Project.Validate(cmd.ValidateOptions); + ScheduleRealCurveRefresh(cmd); + } + } + + void ScheduleRealCurveRefresh(UCommand cmd) { + if (cmd is not ExpCommand expCommand) { + return; + } + var part = expCommand.Part; + if (!Project.parts.Contains(part) || + part.trackNo < 0 || + part.trackNo >= Project.tracks.Count) { + return; + } + Project.tracks[part.trackNo].RendererSettings.Renderer + ?.ScheduleRealCurveRefresh(Project, part, cmd); + } + + void ScheduleRealCurveRefresh(IEnumerable commands) { + foreach (var cmd in commands) { + ScheduleRealCurveRefresh(cmd); } } @@ -291,6 +312,7 @@ public void EndUndoGroup() { Project.ValidateFull(); } undoGroup.Merge(); + ScheduleRealCurveRefresh(undoGroup.Commands); undoGroup = null; Log.Information("undoGroup ended"); ExecuteCmd(new PreRenderNotification()); @@ -326,6 +348,7 @@ public void Undo() { Publish(cmd, true); } redoQueue.AddToBack(group); + ScheduleRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } @@ -343,6 +366,7 @@ public void Redo() { Publish(cmd); } undoQueue.AddToBack(group); + ScheduleRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } diff --git a/OpenUtau.Core/Enunu/EnunuRenderer.cs b/OpenUtau.Core/Enunu/EnunuRenderer.cs index fdf634ab7..e19606726 100644 --- a/OpenUtau.Core/Enunu/EnunuRenderer.cs +++ b/OpenUtau.Core/Enunu/EnunuRenderer.cs @@ -73,7 +73,7 @@ public RenderResult Layout(RenderPhrase phrase) { }; } - public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender, RenderPhraseEvents? renderEvents = null) { var task = Task.Run(() => { lock (lockObj) { if (cancellation.IsCancellationRequested) { diff --git a/OpenUtau.Core/Render/IRenderer.cs b/OpenUtau.Core/Render/IRenderer.cs index a1d420cc1..71e6d8a9a 100644 --- a/OpenUtau.Core/Render/IRenderer.cs +++ b/OpenUtau.Core/Render/IRenderer.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using OpenUtau.Core; using OpenUtau.Core.Ustx; namespace OpenUtau.Core.Render { @@ -59,6 +60,20 @@ public class RenderRealCurveResult { public float[] values; } + public class RenderPhraseEvents { + readonly Action>? realCurvesCallback; + + public RenderPhraseEvents(Action>? realCurvesCallback = null) { + this.realCurvesCallback = realCurvesCallback; + } + + public void ReportRealCurves(IReadOnlyList realCurves) { + if (realCurves.Count > 0) { + realCurvesCallback?.Invoke(realCurves); + } + } + } + /// /// Interface of phrase-based renderer. /// @@ -68,9 +83,10 @@ public interface IRenderer { bool SupportsRealCurve { get { return false; } } bool SupportsExpression(UExpressionDescriptor descriptor); RenderResult Layout(RenderPhrase phrase); - Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender = false); + Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender = false, RenderPhraseEvents? renderEvents = null); RenderPitchResult LoadRenderedPitch(RenderPhrase phrase); List LoadRenderedRealCurves(RenderPhrase phrase) { return new List(0);} + void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand command) { } UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings); } } diff --git a/OpenUtau.Core/Render/RenderEngine.cs b/OpenUtau.Core/Render/RenderEngine.cs index ae1339d9f..ed9dc1da1 100644 --- a/OpenUtau.Core/Render/RenderEngine.cs +++ b/OpenUtau.Core/Render/RenderEngine.cs @@ -253,7 +253,13 @@ 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); + bool realCurvesPublished = false; + var renderEvents = phrase.renderer.SupportsRealCurve + ? new RenderPhraseEvents(realCurves => { + realCurvesPublished = PublishRealCurveUpdates(request.part, phrase, realCurves); + }) + : null; + var task = phrase.renderer.Render(phrase, progress, request.trackNo, cancellation, true, renderEvents); task.Wait(); if (cancellation.IsCancellationRequested) { break; @@ -261,7 +267,9 @@ private void RenderRequests( var result = task.Result; source.SetSamples(result.samples); DocManager.Inst.ExecuteCmd(new PhraseRenderedNotification(request.part, phrase, result, request.trackNo)); - PublishRealCurveUpdates(request.part, phrase); + if (!realCurvesPublished) { + PublishRealCurveUpdates(request.part, phrase); + } if (request.sources.All(s => s.HasSamples)) { request.part.SetMix(request.mix); DocManager.Inst.ExecuteCmd(new PartRenderedNotification(request.part)); @@ -270,18 +278,39 @@ private void RenderRequests( progress.Clear(); } - private void PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { + private bool PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { if (!phrase.renderer.SupportsRealCurve) { - return; + return false; } try { var updates = RealCurveUpdater.LoadPhraseUpdates(part, phrase); if (updates.Length > 0) { DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); + return true; } } catch (Exception e) { Log.Debug(e, "Failed to refresh rendered real curves."); } + return false; + } + + private bool PublishRealCurveUpdates( + UVoicePart part, + RenderPhrase phrase, + IReadOnlyList realCurves) { + if (realCurves.Count == 0) { + return false; + } + try { + var updates = RealCurveUpdater.BuildUpdates(part, phrase, realCurves); + if (updates.Length > 0) { + DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); + return true; + } + } catch (Exception e) { + Log.Debug(e, "Failed to publish rendered real curves."); + } + return false; } public static void ReleaseSourceTemp() { diff --git a/OpenUtau.Core/Vogen/VogenRenderer.cs b/OpenUtau.Core/Vogen/VogenRenderer.cs index 1c3fb51f8..bc817d17b 100644 --- a/OpenUtau.Core/Vogen/VogenRenderer.cs +++ b/OpenUtau.Core/Vogen/VogenRenderer.cs @@ -53,7 +53,7 @@ public RenderResult Layout(RenderPhrase phrase) { }; } - public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender = false) { + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender = false, RenderPhraseEvents? renderEvents = null) { var task = Task.Run(() => { lock (lockObj) { if (cancellation.IsCancellationRequested) { diff --git a/OpenUtau.Core/Voicevox/VoicevoxRenderer.cs b/OpenUtau.Core/Voicevox/VoicevoxRenderer.cs index 3f78878f7..c7eb51101 100644 --- a/OpenUtau.Core/Voicevox/VoicevoxRenderer.cs +++ b/OpenUtau.Core/Voicevox/VoicevoxRenderer.cs @@ -57,7 +57,7 @@ public RenderResult Layout(RenderPhrase phrase) { }; } - public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender) { + public Task Render(RenderPhrase phrase, Progress progress, int trackNo, CancellationTokenSource cancellation, bool isPreRender, RenderPhraseEvents? renderEvents = null) { var task = Task.Run(() => { lock (lockObj) { if (cancellation.IsCancellationRequested) { diff --git a/OpenUtau.Test/Core/DiffSinger/DiffSingerRealCurveSchedulerTest.cs b/OpenUtau.Test/Core/DiffSinger/DiffSingerRealCurveSchedulerTest.cs new file mode 100644 index 000000000..ec4bb1ea8 --- /dev/null +++ b/OpenUtau.Test/Core/DiffSinger/DiffSingerRealCurveSchedulerTest.cs @@ -0,0 +1,18 @@ +using OpenUtau.Core.DiffSinger; +using UstxFormat = OpenUtau.Core.Format.Ustx; +using Xunit; + +namespace OpenUtau.Core { + public class DiffSingerRealCurveSchedulerTest { + [Fact] + public void RealCurveRefreshIsLimitedToVarianceOffsetCurves() { + Assert.True(DiffSingerRenderer.ShouldRefreshRealCurvesOnCurveEdit(DiffSingerUtils.ENE)); + Assert.True(DiffSingerRenderer.ShouldRefreshRealCurvesOnCurveEdit(UstxFormat.BREC)); + Assert.True(DiffSingerRenderer.ShouldRefreshRealCurvesOnCurveEdit(UstxFormat.VOIC)); + Assert.True(DiffSingerRenderer.ShouldRefreshRealCurvesOnCurveEdit(UstxFormat.TENC)); + + Assert.False(DiffSingerRenderer.ShouldRefreshRealCurvesOnCurveEdit(UstxFormat.PITD)); + Assert.False(DiffSingerRenderer.ShouldRefreshRealCurvesOnCurveEdit(UstxFormat.DYN)); + } + } +} diff --git a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs index a2ec9bfd5..f0e32d722 100644 --- a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs +++ b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs @@ -8,6 +8,23 @@ namespace OpenUtau.Core { public class RealCurveUpdaterTest { const string Ene = "ene"; + [Fact] + public void RenderPhraseEventsReportsNonEmptyRealCurvesOnly() { + int reports = 0; + var events = new RenderPhraseEvents(_ => reports++); + + events.ReportRealCurves(new List()); + events.ReportRealCurves(new[] { + new RenderRealCurveResult { + abbr = Ene, + ticks = new[] { 0f }, + values = new[] { 0.1f }, + }, + }); + + Assert.Equal(1, reports); + } + [Fact] public void BuildUpdatesConvertsTicksToPartLocalCoordinates() { var result = new RenderRealCurveResult { From 46e5dd6bebcb42079cf5d12fffbad7dc7ed4b823 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Sat, 6 Jun 2026 12:51:31 +0800 Subject: [PATCH 3/8] Force real curve refresh on undo and redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing scheduler hook only fires for curve-edit ExpCommands, so undoing a note move or phoneme edit left the rendered variance baseline stale: PreRenderProject would hit the wav cache, skip InvokeDiffsinger, and renderEvents.ReportRealCurves never ran. Even when the render-engine fallback did run, ApplyUpdate only cleared the new phrase's tick range, leaving stale tail segments visible whenever undo shrank the phrase. Add IRenderer.ScheduleFullRealCurveRefresh (DiffSinger implements it via DiffSingerRealCurveScheduler.ScheduleForUndo, bypassing the command-type and abbr filters) and call it for every voice part the undone/redone group touched. The notification carries an isFullRefresh flag; on the receiving side RealCurveUpdater.ApplyFullRefresh wipes every realXs/realYs entry in the part before applying the freshly loaded baseline so no ghost ranges survive. Depends on DiffSingerTensorCache being enabled — that is the documented prerequisite for variance baselines and we don't try to re-enable the disabled-cache path here. --- OpenUtau.Core/Commands/Notifications.cs | 4 +- .../DiffSingerRealCurveScheduler.cs | 31 ++++++++++++--- .../DiffSinger/DiffSingerRenderer.cs | 4 ++ OpenUtau.Core/DocManager.cs | 38 +++++++++++++++++- OpenUtau.Core/Render/IRenderer.cs | 1 + OpenUtau.Core/Render/RealCurveUpdater.cs | 21 ++++++++++ .../Core/Render/RealCurveUpdaterTest.cs | 39 +++++++++++++++++++ 7 files changed, 130 insertions(+), 8 deletions(-) diff --git a/OpenUtau.Core/Commands/Notifications.cs b/OpenUtau.Core/Commands/Notifications.cs index 499f6324a..fefbc40f9 100644 --- a/OpenUtau.Core/Commands/Notifications.cs +++ b/OpenUtau.Core/Commands/Notifications.cs @@ -265,10 +265,12 @@ public PhraseRenderedNotification(UVoicePart part, RenderPhrase phrase, RenderRe public class RealCurvesUpdatedNotification : UNotification { public readonly IReadOnlyList updates; + public readonly bool isFullRefresh; public override bool Silent => true; - public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates) { + public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates, bool isFullRefresh = false) { this.part = part; this.updates = updates; + this.isFullRefresh = isFullRefresh; } public override string ToString() => "Real curves updated."; } diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs index 1db02eab4..bc067ab83 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs @@ -22,7 +22,21 @@ public static void TrySchedule(UProject project, UVoicePart part, UCommand comma !CanRefresh(project, part, expCommand.Key)) { return; } - Schedule(project, part); + Schedule(project, part, isFullRefresh: false); + } + + // Triggered by undo/redo: bypass command-type / abbr filter so any project mutation + // forces a full reload of variance-curve baselines, including the cache-hit path + // where Render() short-circuits and renderEvents.ReportRealCurves never fires. + public static void ScheduleForUndo(UProject project, UVoicePart part) { + if (part == null || + !project.parts.Contains(part) || + part.trackNo < 0 || + part.trackNo >= project.tracks.Count || + project.tracks[part.trackNo].RendererSettings.Renderer is not DiffSingerRenderer) { + return; + } + Schedule(project, part, isFullRefresh: true); } static bool IsCurveEditCommand(UCommand command) { @@ -42,7 +56,7 @@ static bool CanRefresh(UProject project, UVoicePart part, string abbr) { return project.tracks[part.trackNo].RendererSettings.Renderer is DiffSingerRenderer; } - static void Schedule(UProject project, UVoicePart part) { + static void Schedule(UProject project, UVoicePart part, bool isFullRefresh) { var cancellation = new CancellationTokenSource(); lock (lockObj) { if (pending.TryGetValue(part, out var previous)) { @@ -50,15 +64,20 @@ static void Schedule(UProject project, UVoicePart part) { } pending[part] = cancellation; } - _ = Task.Run(() => RefreshAsync(project, part, cancellation)); + _ = Task.Run(() => RefreshAsync(project, part, cancellation, isFullRefresh)); } - static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation) { + static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation, bool isFullRefresh) { try { await Task.Delay(DebounceMs, cancellation.Token); var updates = LoadPartUpdates(project, part, cancellation.Token); - if (updates.Count > 0 && !cancellation.IsCancellationRequested) { - DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); + if (cancellation.IsCancellationRequested) { + return; + } + // For undo/redo always publish so stale real curves get cleared even when + // LoadPartUpdates returns nothing (e.g. all phrases removed by the undo). + if (isFullRefresh || updates.Count > 0) { + DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates, isFullRefresh)); } } catch (OperationCanceledException) { } catch (Exception e) { diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs index 6468c24be..ce0c00343 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs @@ -526,6 +526,10 @@ public void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand DiffSingerRealCurveScheduler.TrySchedule(project, part, command); } + public void ScheduleFullRealCurveRefresh(UProject project, UVoicePart part) { + DiffSingerRealCurveScheduler.ScheduleForUndo(project, part); + } + public List LoadRenderedRealCurves(RenderPhrase phrase) { if (!Preferences.Default.DiffSingerTensorCache) { throw new Exception("Please enable DiffSinger tensor cache and re-render the phrase to display correct base curves."); diff --git a/OpenUtau.Core/DocManager.cs b/OpenUtau.Core/DocManager.cs index 467ab4f80..75c753859 100644 --- a/OpenUtau.Core/DocManager.cs +++ b/OpenUtau.Core/DocManager.cs @@ -228,7 +228,11 @@ public void ExecuteCmd(UCommand cmd) { rangeEndTick = setRange.endTick; } else if (cmd is RealCurvesUpdatedNotification realCurvesNotif) { if (realCurvesNotif.part is UVoicePart voicePart) { - RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); + if (realCurvesNotif.isFullRefresh) { + RealCurveUpdater.ApplyFullRefresh(Project, voicePart, realCurvesNotif.updates); + } else { + RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); + } } } else if (cmd is SingersChangedNotification) { SingerManager.Inst.SearchAllSingers(); @@ -287,6 +291,36 @@ void ScheduleRealCurveRefresh(IEnumerable commands) { } } + // After undo/redo we cannot rely on per-command ExpCommand routing: note moves, + // phoneme edits and part-level mutations all change the rendered baseline, and a + // cache-hit re-render skips renderEvents.ReportRealCurves entirely. Schedule a full + // refresh for every voice part the undone group touched so the variance baseline is + // reloaded (and stale ranges cleared) by the same debounced path the curve-edit hook + // uses. + void ScheduleFullRealCurveRefresh(IEnumerable commands) { + var parts = new HashSet(); + foreach (var cmd in commands) { + UVoicePart? part = cmd switch { + ExpCommand ec => ec.Part, + NoteCommand nc => nc.Part, + PartCommand pc => pc.part as UVoicePart, + _ => null + }; + if (part != null) { + parts.Add(part); + } + } + foreach (var part in parts) { + if (!Project.parts.Contains(part) || + part.trackNo < 0 || + part.trackNo >= Project.tracks.Count) { + continue; + } + Project.tracks[part.trackNo].RendererSettings.Renderer + ?.ScheduleFullRealCurveRefresh(Project, part); + } + } + public void StartUndoGroup(string? nameKey = null, bool deferValidate = false) { if (undoGroup != null) { Log.Error("undoGroup already started"); @@ -349,6 +383,7 @@ public void Undo() { } redoQueue.AddToBack(group); ScheduleRealCurveRefresh(group.Commands); + ScheduleFullRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } @@ -367,6 +402,7 @@ public void Redo() { } undoQueue.AddToBack(group); ScheduleRealCurveRefresh(group.Commands); + ScheduleFullRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } diff --git a/OpenUtau.Core/Render/IRenderer.cs b/OpenUtau.Core/Render/IRenderer.cs index 71e6d8a9a..b2b317f89 100644 --- a/OpenUtau.Core/Render/IRenderer.cs +++ b/OpenUtau.Core/Render/IRenderer.cs @@ -87,6 +87,7 @@ public interface IRenderer { RenderPitchResult LoadRenderedPitch(RenderPhrase phrase); List LoadRenderedRealCurves(RenderPhrase phrase) { return new List(0);} void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand command) { } + void ScheduleFullRealCurveRefresh(UProject project, UVoicePart part) { } UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings); } } diff --git a/OpenUtau.Core/Render/RealCurveUpdater.cs b/OpenUtau.Core/Render/RealCurveUpdater.cs index 1296843a2..34d626c95 100644 --- a/OpenUtau.Core/Render/RealCurveUpdater.cs +++ b/OpenUtau.Core/Render/RealCurveUpdater.cs @@ -87,6 +87,27 @@ public static bool Apply(UProject project, UVoicePart part, IReadOnlyList updates) { + if (!project.parts.Contains(part)) { + return false; + } + bool changed = false; + foreach (var curve in part.curves) { + if (curve.realXs.Count > 0 || curve.realYs.Count > 0) { + curve.realXs.Clear(); + curve.realYs.Clear(); + changed = true; + } + } + if (updates.Count > 0) { + changed |= Apply(project, part, updates); + } + return changed; + } + internal static bool Apply( UProject project, UVoicePart part, diff --git a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs index f0e32d722..602efd41c 100644 --- a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs +++ b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using OpenUtau.Core.Render; @@ -95,6 +96,44 @@ public void ApplySkipsStalePhraseHash() { Assert.Equal(new[] { -1, 10, 20 }, curve.realYs); } + [Fact] + public void ApplyFullRefreshClearsEveryRealCurveInPart() { + var project = CreateProjectWithCurve(out var part, out var curve); + // Stale data from a previous render that covered a wider tick range than the + // restored phrase will after undo. + curve.realXs.AddRange(new[] { 0, 0, 10, 50, 50, 80 }); + curve.realYs.AddRange(new[] { -1, 10, 20, -1, 90, 95 }); + + bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); + + Assert.True(changed); + Assert.Empty(curve.realXs); + Assert.Empty(curve.realYs); + } + + [Fact] + public void ApplyFullRefreshNoOpWhenAlreadyEmpty() { + var project = CreateProjectWithCurve(out var part, out _); + + bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); + + Assert.False(changed); + } + + [Fact] + public void ApplyFullRefreshNoOpWhenPartNotInProject() { + var project = CreateProjectWithCurve(out var part, out var curve); + curve.realXs.AddRange(new[] { 0 }); + curve.realYs.AddRange(new[] { 1 }); + project.parts.Remove(part); + + bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); + + Assert.False(changed); + Assert.Equal(new[] { 0 }, curve.realXs); + Assert.Equal(new[] { 1 }, curve.realYs); + } + static UProject CreateProjectWithCurve(out UVoicePart part, out UCurve curve) { var project = new UProject(); var descriptor = new UExpressionDescriptor("energy", Ene, 0, 100, 0) { From b6c905ec70651281fc1a7e87d4966db12257e084 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Sat, 6 Jun 2026 13:52:52 +0800 Subject: [PATCH 4/8] Trim stale real curves on part render instead of wiping on undo The previous undo hook (Force real curve refresh on undo and redo) wiped every realXs/realYs in the part and re-applied whatever the renderer returned. When the curve scheduler ran during a phonemizer rebuild it saw an empty renderPhrases list and returned no updates, so the wipe deleted all curves and they only came back after the next render finished. Replace it with a coverage-based trim. RenderEngine accumulates the per- phrase real-curve updates as each phrase finishes, and when every phrase in the part has produced samples it posts a RealCurveCoverageNotification. DocManager handles it by removing realXs entries that fall outside the union of [startTick, endTick] for each abbr that was actually covered, clearing stale tail data from earlier renders with wider phrase ranges while leaving untouched abbrs alone. --- OpenUtau.Core/Commands/Notifications.cs | 18 +++- .../DiffSingerRealCurveScheduler.cs | 30 ++---- .../DiffSinger/DiffSingerRenderer.cs | 4 - OpenUtau.Core/DocManager.cs | 42 +------- OpenUtau.Core/Render/IRenderer.cs | 1 - OpenUtau.Core/Render/RealCurveUpdater.cs | 67 ++++++++++--- OpenUtau.Core/Render/RenderEngine.cs | 33 ++++--- .../Core/Render/RealCurveUpdaterTest.cs | 99 +++++++++++++++---- OpenUtau/ViewModels/NotesViewModel.cs | 2 + 9 files changed, 184 insertions(+), 112 deletions(-) diff --git a/OpenUtau.Core/Commands/Notifications.cs b/OpenUtau.Core/Commands/Notifications.cs index fefbc40f9..281fb8f6c 100644 --- a/OpenUtau.Core/Commands/Notifications.cs +++ b/OpenUtau.Core/Commands/Notifications.cs @@ -265,16 +265,28 @@ public PhraseRenderedNotification(UVoicePart part, RenderPhrase phrase, RenderRe public class RealCurvesUpdatedNotification : UNotification { public readonly IReadOnlyList updates; - public readonly bool isFullRefresh; public override bool Silent => true; - public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates, bool isFullRefresh = false) { + public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates) { this.part = part; this.updates = updates; - this.isFullRefresh = isFullRefresh; } public override string ToString() => "Real curves updated."; } + // Posted once per part when every phrase in a render pass has reported real-curve data. + // The accumulated updates define which tick ranges the renderer actually covered this pass, + // so DocManager can trim stale realXs/realYs entries that fell outside the new coverage + // (e.g., a phrase shrank at its tail after undo). + public class RealCurveCoverageNotification : UNotification { + public readonly IReadOnlyList updates; + public override bool Silent => true; + public RealCurveCoverageNotification(UVoicePart part, IReadOnlyList updates) { + this.part = part; + this.updates = updates; + } + public override string ToString() => "Real curve coverage."; + } + public class GotoOtoNotification : UNotification { public readonly USinger? singer; public readonly UOto? oto; diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs index bc067ab83..6d3b182d1 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs @@ -22,21 +22,7 @@ public static void TrySchedule(UProject project, UVoicePart part, UCommand comma !CanRefresh(project, part, expCommand.Key)) { return; } - Schedule(project, part, isFullRefresh: false); - } - - // Triggered by undo/redo: bypass command-type / abbr filter so any project mutation - // forces a full reload of variance-curve baselines, including the cache-hit path - // where Render() short-circuits and renderEvents.ReportRealCurves never fires. - public static void ScheduleForUndo(UProject project, UVoicePart part) { - if (part == null || - !project.parts.Contains(part) || - part.trackNo < 0 || - part.trackNo >= project.tracks.Count || - project.tracks[part.trackNo].RendererSettings.Renderer is not DiffSingerRenderer) { - return; - } - Schedule(project, part, isFullRefresh: true); + Schedule(project, part); } static bool IsCurveEditCommand(UCommand command) { @@ -56,7 +42,7 @@ static bool CanRefresh(UProject project, UVoicePart part, string abbr) { return project.tracks[part.trackNo].RendererSettings.Renderer is DiffSingerRenderer; } - static void Schedule(UProject project, UVoicePart part, bool isFullRefresh) { + static void Schedule(UProject project, UVoicePart part) { var cancellation = new CancellationTokenSource(); lock (lockObj) { if (pending.TryGetValue(part, out var previous)) { @@ -64,21 +50,17 @@ static void Schedule(UProject project, UVoicePart part, bool isFullRefresh) { } pending[part] = cancellation; } - _ = Task.Run(() => RefreshAsync(project, part, cancellation, isFullRefresh)); + _ = Task.Run(() => RefreshAsync(project, part, cancellation)); } - static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation, bool isFullRefresh) { + static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation) { try { await Task.Delay(DebounceMs, cancellation.Token); var updates = LoadPartUpdates(project, part, cancellation.Token); - if (cancellation.IsCancellationRequested) { + if (cancellation.IsCancellationRequested || updates.Count == 0) { return; } - // For undo/redo always publish so stale real curves get cleared even when - // LoadPartUpdates returns nothing (e.g. all phrases removed by the undo). - if (isFullRefresh || updates.Count > 0) { - DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates, isFullRefresh)); - } + DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); } catch (OperationCanceledException) { } catch (Exception e) { Log.Debug(e, "Failed to refresh DiffSinger real curves after curve edit."); diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs index ce0c00343..6468c24be 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs @@ -526,10 +526,6 @@ public void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand DiffSingerRealCurveScheduler.TrySchedule(project, part, command); } - public void ScheduleFullRealCurveRefresh(UProject project, UVoicePart part) { - DiffSingerRealCurveScheduler.ScheduleForUndo(project, part); - } - public List LoadRenderedRealCurves(RenderPhrase phrase) { if (!Preferences.Default.DiffSingerTensorCache) { throw new Exception("Please enable DiffSinger tensor cache and re-render the phrase to display correct base curves."); diff --git a/OpenUtau.Core/DocManager.cs b/OpenUtau.Core/DocManager.cs index 75c753859..48a76c48c 100644 --- a/OpenUtau.Core/DocManager.cs +++ b/OpenUtau.Core/DocManager.cs @@ -228,11 +228,11 @@ public void ExecuteCmd(UCommand cmd) { rangeEndTick = setRange.endTick; } else if (cmd is RealCurvesUpdatedNotification realCurvesNotif) { if (realCurvesNotif.part is UVoicePart voicePart) { - if (realCurvesNotif.isFullRefresh) { - RealCurveUpdater.ApplyFullRefresh(Project, voicePart, realCurvesNotif.updates); - } else { - RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); - } + RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); + } + } else if (cmd is RealCurveCoverageNotification coverageNotif) { + if (coverageNotif.part is UVoicePart coveragePart) { + RealCurveUpdater.TrimToCoverage(Project, coveragePart, coverageNotif.updates); } } else if (cmd is SingersChangedNotification) { SingerManager.Inst.SearchAllSingers(); @@ -291,36 +291,6 @@ void ScheduleRealCurveRefresh(IEnumerable commands) { } } - // After undo/redo we cannot rely on per-command ExpCommand routing: note moves, - // phoneme edits and part-level mutations all change the rendered baseline, and a - // cache-hit re-render skips renderEvents.ReportRealCurves entirely. Schedule a full - // refresh for every voice part the undone group touched so the variance baseline is - // reloaded (and stale ranges cleared) by the same debounced path the curve-edit hook - // uses. - void ScheduleFullRealCurveRefresh(IEnumerable commands) { - var parts = new HashSet(); - foreach (var cmd in commands) { - UVoicePart? part = cmd switch { - ExpCommand ec => ec.Part, - NoteCommand nc => nc.Part, - PartCommand pc => pc.part as UVoicePart, - _ => null - }; - if (part != null) { - parts.Add(part); - } - } - foreach (var part in parts) { - if (!Project.parts.Contains(part) || - part.trackNo < 0 || - part.trackNo >= Project.tracks.Count) { - continue; - } - Project.tracks[part.trackNo].RendererSettings.Renderer - ?.ScheduleFullRealCurveRefresh(Project, part); - } - } - public void StartUndoGroup(string? nameKey = null, bool deferValidate = false) { if (undoGroup != null) { Log.Error("undoGroup already started"); @@ -383,7 +353,6 @@ public void Undo() { } redoQueue.AddToBack(group); ScheduleRealCurveRefresh(group.Commands); - ScheduleFullRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } @@ -402,7 +371,6 @@ public void Redo() { } undoQueue.AddToBack(group); ScheduleRealCurveRefresh(group.Commands); - ScheduleFullRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } diff --git a/OpenUtau.Core/Render/IRenderer.cs b/OpenUtau.Core/Render/IRenderer.cs index b2b317f89..71e6d8a9a 100644 --- a/OpenUtau.Core/Render/IRenderer.cs +++ b/OpenUtau.Core/Render/IRenderer.cs @@ -87,7 +87,6 @@ public interface IRenderer { RenderPitchResult LoadRenderedPitch(RenderPhrase phrase); List LoadRenderedRealCurves(RenderPhrase phrase) { return new List(0);} void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand command) { } - void ScheduleFullRealCurveRefresh(UProject project, UVoicePart part) { } UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings); } } diff --git a/OpenUtau.Core/Render/RealCurveUpdater.cs b/OpenUtau.Core/Render/RealCurveUpdater.cs index 34d626c95..8e1c95a95 100644 --- a/OpenUtau.Core/Render/RealCurveUpdater.cs +++ b/OpenUtau.Core/Render/RealCurveUpdater.cs @@ -87,27 +87,70 @@ public static bool Apply(UProject project, UVoicePart part, IReadOnlyList updates) { - if (!project.parts.Contains(part)) { + // Posted once per part after every phrase has reported its real-curve data. For each abbr + // that has at least one update, removes realXs entries that fall outside the union of the + // updates' [startTick, endTick] ranges. This clears stale tail data left over from an + // earlier render whose phrase ranges were wider than the current ones (e.g., after undo + // shrinks a phrase). Abbrs without updates are untouched. + public static bool TrimToCoverage(UProject project, UVoicePart part, IReadOnlyList updates) { + if (!project.parts.Contains(part) || updates.Count == 0) { return false; } + var ranges = new Dictionary>(); + foreach (var update in updates) { + if (!update.IsValid) { + continue; + } + if (!ranges.TryGetValue(update.abbr, out var list)) { + list = new List<(int, int)>(); + ranges[update.abbr] = list; + } + list.Add((update.startTick, update.endTick)); + } bool changed = false; foreach (var curve in part.curves) { - if (curve.realXs.Count > 0 || curve.realYs.Count > 0) { - curve.realXs.Clear(); - curve.realYs.Clear(); - changed = true; + if (!ranges.TryGetValue(curve.abbr, out var list) || list.Count == 0) { + continue; + } + var merged = MergeRanges(list); + for (int i = curve.realXs.Count - 1; i >= 0; --i) { + if (!InAnyRange(merged, curve.realXs[i])) { + curve.realXs.RemoveAt(i); + curve.realYs.RemoveAt(i); + changed = true; + } } - } - if (updates.Count > 0) { - changed |= Apply(project, part, updates); } return changed; } + internal static List<(int start, int end)> MergeRanges(List<(int start, int end)> ranges) { + if (ranges.Count <= 1) { + return ranges; + } + ranges.Sort((a, b) => a.start.CompareTo(b.start)); + var merged = new List<(int start, int end)> { ranges[0] }; + for (int i = 1; i < ranges.Count; ++i) { + var (curStart, curEnd) = merged[merged.Count - 1]; + var (nextStart, nextEnd) = ranges[i]; + if (nextStart <= curEnd) { + merged[merged.Count - 1] = (curStart, Math.Max(curEnd, nextEnd)); + } else { + merged.Add((nextStart, nextEnd)); + } + } + return merged; + } + + static bool InAnyRange(List<(int start, int end)> ranges, int x) { + foreach (var (start, end) in ranges) { + if (x >= start && x <= end) { + return true; + } + } + return false; + } + internal static bool Apply( UProject project, UVoicePart part, diff --git a/OpenUtau.Core/Render/RenderEngine.cs b/OpenUtau.Core/Render/RenderEngine.cs index ed9dc1da1..0b438cc42 100644 --- a/OpenUtau.Core/Render/RenderEngine.cs +++ b/OpenUtau.Core/Render/RenderEngine.cs @@ -249,14 +249,15 @@ private void RenderRequests( tuples = orderedTuples; } var progress = new Progress(tuples.Sum(t => t.Item1.phones.Length)); + var partUpdates = new Dictionary>(); foreach (var tuple in tuples) { var phrase = tuple.Item1; var source = tuple.Item2; var request = tuple.Item3; - bool realCurvesPublished = false; + RealCurveUpdate[]? callbackUpdates = null; var renderEvents = phrase.renderer.SupportsRealCurve ? new RenderPhraseEvents(realCurves => { - realCurvesPublished = PublishRealCurveUpdates(request.part, phrase, realCurves); + callbackUpdates = PublishRealCurveUpdates(request.part, phrase, realCurves); }) : null; var task = phrase.renderer.Render(phrase, progress, request.trackNo, cancellation, true, renderEvents); @@ -267,50 +268,58 @@ private void RenderRequests( var result = task.Result; source.SetSamples(result.samples); DocManager.Inst.ExecuteCmd(new PhraseRenderedNotification(request.part, phrase, result, request.trackNo)); - if (!realCurvesPublished) { - PublishRealCurveUpdates(request.part, phrase); + var phraseUpdates = callbackUpdates ?? PublishRealCurveUpdates(request.part, phrase); + if (phraseUpdates != null && phraseUpdates.Length > 0) { + if (!partUpdates.TryGetValue(request.part, out var list)) { + list = new List(); + partUpdates[request.part] = list; + } + list.AddRange(phraseUpdates); } if (request.sources.All(s => s.HasSamples)) { request.part.SetMix(request.mix); + if (partUpdates.TryGetValue(request.part, out var accumulated) && accumulated.Count > 0) { + DocManager.Inst.ExecuteCmd(new RealCurveCoverageNotification(request.part, accumulated)); + } DocManager.Inst.ExecuteCmd(new PartRenderedNotification(request.part)); } } progress.Clear(); } - private bool PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { + private RealCurveUpdate[]? PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { if (!phrase.renderer.SupportsRealCurve) { - return false; + return null; } try { var updates = RealCurveUpdater.LoadPhraseUpdates(part, phrase); if (updates.Length > 0) { DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); - return true; + return updates; } } catch (Exception e) { Log.Debug(e, "Failed to refresh rendered real curves."); } - return false; + return null; } - private bool PublishRealCurveUpdates( + private RealCurveUpdate[]? PublishRealCurveUpdates( UVoicePart part, RenderPhrase phrase, IReadOnlyList realCurves) { if (realCurves.Count == 0) { - return false; + return null; } try { var updates = RealCurveUpdater.BuildUpdates(part, phrase, realCurves); if (updates.Length > 0) { DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); - return true; + return updates; } } catch (Exception e) { Log.Debug(e, "Failed to publish rendered real curves."); } - return false; + return null; } public static void ReleaseSourceTemp() { diff --git a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs index 602efd41c..9f5f61fdc 100644 --- a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs +++ b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs @@ -97,41 +97,102 @@ public void ApplySkipsStalePhraseHash() { } [Fact] - public void ApplyFullRefreshClearsEveryRealCurveInPart() { + public void TrimToCoverageNoOpWhenPartNotInProject() { var project = CreateProjectWithCurve(out var part, out var curve); - // Stale data from a previous render that covered a wider tick range than the - // restored phrase will after undo. - curve.realXs.AddRange(new[] { 0, 0, 10, 50, 50, 80 }); - curve.realYs.AddRange(new[] { -1, 10, 20, -1, 90, 95 }); + curve.realXs.AddRange(new[] { 0 }); + curve.realYs.AddRange(new[] { 1 }); + project.parts.Remove(part); + + bool changed = RealCurveUpdater.TrimToCoverage(project, part, new[] { + new RealCurveUpdate(Ene, 1, 0, 10, new[] { 0 }, new[] { 1 }), + }); - bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); + Assert.False(changed); + Assert.Equal(new[] { 0 }, curve.realXs); + Assert.Equal(new[] { 1 }, curve.realYs); + } + + [Fact] + public void TrimToCoverageRemovesEntriesOutsideUnionForAbbrsWithUpdates() { + var project = CreateProjectWithCurve(out var part, out var curve); + // Stale tail data past 110 that an earlier render with a wider phrase left behind. + curve.realXs.AddRange(new[] { 0, 0, 10, 100, 100, 110, 150, 200 }); + curve.realYs.AddRange(new[] { -1, 10, 20, -1, 30, 40, 50, 60 }); + + // Two updates whose union is [0,10] U [100,110]. Entries at 150 and 200 fall outside. + bool changed = RealCurveUpdater.TrimToCoverage(project, part, new[] { + new RealCurveUpdate(Ene, 1, 0, 10, new[] { 0, 0, 5, 10 }, new[] { -1, 11, 12, 13 }), + new RealCurveUpdate(Ene, 2, 100, 110, new[] { 100, 100, 105, 110 }, new[] { -1, 31, 32, 33 }), + }); Assert.True(changed); - Assert.Empty(curve.realXs); - Assert.Empty(curve.realYs); + Assert.Equal(new[] { 0, 0, 10, 100, 100, 110 }, curve.realXs); + Assert.Equal(new[] { -1, 10, 20, -1, 30, 40 }, curve.realYs); } [Fact] - public void ApplyFullRefreshNoOpWhenAlreadyEmpty() { - var project = CreateProjectWithCurve(out var part, out _); + public void TrimToCoverageMergesOverlappingRanges() { + var project = CreateProjectWithCurve(out var part, out var curve); + curve.realXs.AddRange(new[] { 0, 50, 80, 150 }); + curve.realYs.AddRange(new[] { 1, 2, 3, 4 }); - bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); + // Two overlapping ranges [0,60] and [40,100] merge into [0,100]; 150 falls outside. + bool changed = RealCurveUpdater.TrimToCoverage(project, part, new[] { + new RealCurveUpdate(Ene, 1, 0, 60, new[] { 0, 60 }, new[] { 1, 2 }), + new RealCurveUpdate(Ene, 2, 40, 100, new[] { 40, 100 }, new[] { 3, 4 }), + }); - Assert.False(changed); + Assert.True(changed); + Assert.Equal(new[] { 0, 50, 80 }, curve.realXs); + Assert.Equal(new[] { 1, 2, 3 }, curve.realYs); } [Fact] - public void ApplyFullRefreshNoOpWhenPartNotInProject() { + public void TrimToCoverageLeavesAbbrsWithoutUpdatesUntouched() { + var project = new UProject(); + var eneDescriptor = new UExpressionDescriptor("energy", Ene, 0, 100, 0) { + type = UExpressionType.Curve, + }; + var breDescriptor = new UExpressionDescriptor("breath", "bre", 0, 100, 0) { + type = UExpressionType.Curve, + }; + project.RegisterExpression(eneDescriptor); + project.RegisterExpression(breDescriptor); + var part = new UVoicePart { trackNo = 0, position = 0, Duration = 480 }; + project.parts.Add(part); + var eneCurve = new UCurve(eneDescriptor); + var breCurve = new UCurve(breDescriptor); + part.curves.Add(eneCurve); + part.curves.Add(breCurve); + eneCurve.realXs.AddRange(new[] { 0, 200 }); + eneCurve.realYs.AddRange(new[] { 1, 2 }); + breCurve.realXs.AddRange(new[] { 0, 200 }); + breCurve.realYs.AddRange(new[] { 3, 4 }); + + // Only ene has an update; bre should be untouched even though its data extends past + // ene's covered range. + bool changed = RealCurveUpdater.TrimToCoverage(project, part, new[] { + new RealCurveUpdate(Ene, 1, 0, 100, new[] { 0, 100 }, new[] { 1, 2 }), + }); + + Assert.True(changed); + Assert.Equal(new[] { 0 }, eneCurve.realXs); + Assert.Equal(new[] { 1 }, eneCurve.realYs); + Assert.Equal(new[] { 0, 200 }, breCurve.realXs); + Assert.Equal(new[] { 3, 4 }, breCurve.realYs); + } + + [Fact] + public void TrimToCoverageNoOpWhenUpdatesEmpty() { var project = CreateProjectWithCurve(out var part, out var curve); - curve.realXs.AddRange(new[] { 0 }); - curve.realYs.AddRange(new[] { 1 }); - project.parts.Remove(part); + curve.realXs.AddRange(new[] { 0, 100 }); + curve.realYs.AddRange(new[] { 1, 2 }); - bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); + bool changed = RealCurveUpdater.TrimToCoverage(project, part, Array.Empty()); Assert.False(changed); - Assert.Equal(new[] { 0 }, curve.realXs); - Assert.Equal(new[] { 1 }, curve.realYs); + Assert.Equal(new[] { 0, 100 }, curve.realXs); + Assert.Equal(new[] { 1, 2 }, curve.realYs); } static UProject CreateProjectWithCurve(out UVoicePart part, out UCurve curve) { diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index 348e13369..a69d7f1de 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -1097,6 +1097,8 @@ public void OnNext(UCommand cmd, bool isUndo) { MessageBus.Current.SendMessage(new WaveformRefreshEvent()); } else if (notif is RealCurvesUpdatedNotification && notif.part == Part) { MessageBus.Current.SendMessage(new NotesRefreshEvent()); + } else if (notif is RealCurveCoverageNotification && notif.part == Part) { + MessageBus.Current.SendMessage(new NotesRefreshEvent()); } } else if (cmd is PartCommand partCommand) { if (cmd is ReplacePartCommand replacePart) { From 1bc1c4de0b559e17ff6a904c2897f3aed4c0de77 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Sat, 6 Jun 2026 14:58:29 +0800 Subject: [PATCH 5/8] Revert "Trim stale real curves on part render instead of wiping on undo" This reverts commit 4f3755fd2f5250584f907b9352bcf06f31a852cb. --- OpenUtau.Core/Commands/Notifications.cs | 18 +--- .../DiffSingerRealCurveScheduler.cs | 30 ++++-- .../DiffSinger/DiffSingerRenderer.cs | 4 + OpenUtau.Core/DocManager.cs | 42 +++++++- OpenUtau.Core/Render/IRenderer.cs | 1 + OpenUtau.Core/Render/RealCurveUpdater.cs | 67 +++---------- OpenUtau.Core/Render/RenderEngine.cs | 33 +++---- .../Core/Render/RealCurveUpdaterTest.cs | 99 ++++--------------- OpenUtau/ViewModels/NotesViewModel.cs | 2 - 9 files changed, 112 insertions(+), 184 deletions(-) diff --git a/OpenUtau.Core/Commands/Notifications.cs b/OpenUtau.Core/Commands/Notifications.cs index 281fb8f6c..fefbc40f9 100644 --- a/OpenUtau.Core/Commands/Notifications.cs +++ b/OpenUtau.Core/Commands/Notifications.cs @@ -265,28 +265,16 @@ public PhraseRenderedNotification(UVoicePart part, RenderPhrase phrase, RenderRe public class RealCurvesUpdatedNotification : UNotification { public readonly IReadOnlyList updates; + public readonly bool isFullRefresh; public override bool Silent => true; - public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates) { + public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates, bool isFullRefresh = false) { this.part = part; this.updates = updates; + this.isFullRefresh = isFullRefresh; } public override string ToString() => "Real curves updated."; } - // Posted once per part when every phrase in a render pass has reported real-curve data. - // The accumulated updates define which tick ranges the renderer actually covered this pass, - // so DocManager can trim stale realXs/realYs entries that fell outside the new coverage - // (e.g., a phrase shrank at its tail after undo). - public class RealCurveCoverageNotification : UNotification { - public readonly IReadOnlyList updates; - public override bool Silent => true; - public RealCurveCoverageNotification(UVoicePart part, IReadOnlyList updates) { - this.part = part; - this.updates = updates; - } - public override string ToString() => "Real curve coverage."; - } - public class GotoOtoNotification : UNotification { public readonly USinger? singer; public readonly UOto? oto; diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs index 6d3b182d1..bc067ab83 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs @@ -22,7 +22,21 @@ public static void TrySchedule(UProject project, UVoicePart part, UCommand comma !CanRefresh(project, part, expCommand.Key)) { return; } - Schedule(project, part); + Schedule(project, part, isFullRefresh: false); + } + + // Triggered by undo/redo: bypass command-type / abbr filter so any project mutation + // forces a full reload of variance-curve baselines, including the cache-hit path + // where Render() short-circuits and renderEvents.ReportRealCurves never fires. + public static void ScheduleForUndo(UProject project, UVoicePart part) { + if (part == null || + !project.parts.Contains(part) || + part.trackNo < 0 || + part.trackNo >= project.tracks.Count || + project.tracks[part.trackNo].RendererSettings.Renderer is not DiffSingerRenderer) { + return; + } + Schedule(project, part, isFullRefresh: true); } static bool IsCurveEditCommand(UCommand command) { @@ -42,7 +56,7 @@ static bool CanRefresh(UProject project, UVoicePart part, string abbr) { return project.tracks[part.trackNo].RendererSettings.Renderer is DiffSingerRenderer; } - static void Schedule(UProject project, UVoicePart part) { + static void Schedule(UProject project, UVoicePart part, bool isFullRefresh) { var cancellation = new CancellationTokenSource(); lock (lockObj) { if (pending.TryGetValue(part, out var previous)) { @@ -50,17 +64,21 @@ static void Schedule(UProject project, UVoicePart part) { } pending[part] = cancellation; } - _ = Task.Run(() => RefreshAsync(project, part, cancellation)); + _ = Task.Run(() => RefreshAsync(project, part, cancellation, isFullRefresh)); } - static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation) { + static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation, bool isFullRefresh) { try { await Task.Delay(DebounceMs, cancellation.Token); var updates = LoadPartUpdates(project, part, cancellation.Token); - if (cancellation.IsCancellationRequested || updates.Count == 0) { + if (cancellation.IsCancellationRequested) { return; } - DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); + // For undo/redo always publish so stale real curves get cleared even when + // LoadPartUpdates returns nothing (e.g. all phrases removed by the undo). + if (isFullRefresh || updates.Count > 0) { + DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates, isFullRefresh)); + } } catch (OperationCanceledException) { } catch (Exception e) { Log.Debug(e, "Failed to refresh DiffSinger real curves after curve edit."); diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs index 6468c24be..ce0c00343 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs @@ -526,6 +526,10 @@ public void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand DiffSingerRealCurveScheduler.TrySchedule(project, part, command); } + public void ScheduleFullRealCurveRefresh(UProject project, UVoicePart part) { + DiffSingerRealCurveScheduler.ScheduleForUndo(project, part); + } + public List LoadRenderedRealCurves(RenderPhrase phrase) { if (!Preferences.Default.DiffSingerTensorCache) { throw new Exception("Please enable DiffSinger tensor cache and re-render the phrase to display correct base curves."); diff --git a/OpenUtau.Core/DocManager.cs b/OpenUtau.Core/DocManager.cs index 48a76c48c..75c753859 100644 --- a/OpenUtau.Core/DocManager.cs +++ b/OpenUtau.Core/DocManager.cs @@ -228,11 +228,11 @@ public void ExecuteCmd(UCommand cmd) { rangeEndTick = setRange.endTick; } else if (cmd is RealCurvesUpdatedNotification realCurvesNotif) { if (realCurvesNotif.part is UVoicePart voicePart) { - RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); - } - } else if (cmd is RealCurveCoverageNotification coverageNotif) { - if (coverageNotif.part is UVoicePart coveragePart) { - RealCurveUpdater.TrimToCoverage(Project, coveragePart, coverageNotif.updates); + if (realCurvesNotif.isFullRefresh) { + RealCurveUpdater.ApplyFullRefresh(Project, voicePart, realCurvesNotif.updates); + } else { + RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); + } } } else if (cmd is SingersChangedNotification) { SingerManager.Inst.SearchAllSingers(); @@ -291,6 +291,36 @@ void ScheduleRealCurveRefresh(IEnumerable commands) { } } + // After undo/redo we cannot rely on per-command ExpCommand routing: note moves, + // phoneme edits and part-level mutations all change the rendered baseline, and a + // cache-hit re-render skips renderEvents.ReportRealCurves entirely. Schedule a full + // refresh for every voice part the undone group touched so the variance baseline is + // reloaded (and stale ranges cleared) by the same debounced path the curve-edit hook + // uses. + void ScheduleFullRealCurveRefresh(IEnumerable commands) { + var parts = new HashSet(); + foreach (var cmd in commands) { + UVoicePart? part = cmd switch { + ExpCommand ec => ec.Part, + NoteCommand nc => nc.Part, + PartCommand pc => pc.part as UVoicePart, + _ => null + }; + if (part != null) { + parts.Add(part); + } + } + foreach (var part in parts) { + if (!Project.parts.Contains(part) || + part.trackNo < 0 || + part.trackNo >= Project.tracks.Count) { + continue; + } + Project.tracks[part.trackNo].RendererSettings.Renderer + ?.ScheduleFullRealCurveRefresh(Project, part); + } + } + public void StartUndoGroup(string? nameKey = null, bool deferValidate = false) { if (undoGroup != null) { Log.Error("undoGroup already started"); @@ -353,6 +383,7 @@ public void Undo() { } redoQueue.AddToBack(group); ScheduleRealCurveRefresh(group.Commands); + ScheduleFullRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } @@ -371,6 +402,7 @@ public void Redo() { } undoQueue.AddToBack(group); ScheduleRealCurveRefresh(group.Commands); + ScheduleFullRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } diff --git a/OpenUtau.Core/Render/IRenderer.cs b/OpenUtau.Core/Render/IRenderer.cs index 71e6d8a9a..b2b317f89 100644 --- a/OpenUtau.Core/Render/IRenderer.cs +++ b/OpenUtau.Core/Render/IRenderer.cs @@ -87,6 +87,7 @@ public interface IRenderer { RenderPitchResult LoadRenderedPitch(RenderPhrase phrase); List LoadRenderedRealCurves(RenderPhrase phrase) { return new List(0);} void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand command) { } + void ScheduleFullRealCurveRefresh(UProject project, UVoicePart part) { } UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings); } } diff --git a/OpenUtau.Core/Render/RealCurveUpdater.cs b/OpenUtau.Core/Render/RealCurveUpdater.cs index 8e1c95a95..34d626c95 100644 --- a/OpenUtau.Core/Render/RealCurveUpdater.cs +++ b/OpenUtau.Core/Render/RealCurveUpdater.cs @@ -87,68 +87,25 @@ public static bool Apply(UProject project, UVoicePart part, IReadOnlyList updates) { - if (!project.parts.Contains(part) || updates.Count == 0) { + // Used by undo/redo: wipe every existing real curve in the part, then apply whatever + // baseline came back from the renderer. The wipe guarantees stale ranges (from a render + // whose phrase end was past the restored end) don't survive as ghost segments. + public static bool ApplyFullRefresh(UProject project, UVoicePart part, IReadOnlyList updates) { + if (!project.parts.Contains(part)) { return false; } - var ranges = new Dictionary>(); - foreach (var update in updates) { - if (!update.IsValid) { - continue; - } - if (!ranges.TryGetValue(update.abbr, out var list)) { - list = new List<(int, int)>(); - ranges[update.abbr] = list; - } - list.Add((update.startTick, update.endTick)); - } bool changed = false; foreach (var curve in part.curves) { - if (!ranges.TryGetValue(curve.abbr, out var list) || list.Count == 0) { - continue; - } - var merged = MergeRanges(list); - for (int i = curve.realXs.Count - 1; i >= 0; --i) { - if (!InAnyRange(merged, curve.realXs[i])) { - curve.realXs.RemoveAt(i); - curve.realYs.RemoveAt(i); - changed = true; - } + if (curve.realXs.Count > 0 || curve.realYs.Count > 0) { + curve.realXs.Clear(); + curve.realYs.Clear(); + changed = true; } } - return changed; - } - - internal static List<(int start, int end)> MergeRanges(List<(int start, int end)> ranges) { - if (ranges.Count <= 1) { - return ranges; - } - ranges.Sort((a, b) => a.start.CompareTo(b.start)); - var merged = new List<(int start, int end)> { ranges[0] }; - for (int i = 1; i < ranges.Count; ++i) { - var (curStart, curEnd) = merged[merged.Count - 1]; - var (nextStart, nextEnd) = ranges[i]; - if (nextStart <= curEnd) { - merged[merged.Count - 1] = (curStart, Math.Max(curEnd, nextEnd)); - } else { - merged.Add((nextStart, nextEnd)); - } - } - return merged; - } - - static bool InAnyRange(List<(int start, int end)> ranges, int x) { - foreach (var (start, end) in ranges) { - if (x >= start && x <= end) { - return true; - } + if (updates.Count > 0) { + changed |= Apply(project, part, updates); } - return false; + return changed; } internal static bool Apply( diff --git a/OpenUtau.Core/Render/RenderEngine.cs b/OpenUtau.Core/Render/RenderEngine.cs index 0b438cc42..ed9dc1da1 100644 --- a/OpenUtau.Core/Render/RenderEngine.cs +++ b/OpenUtau.Core/Render/RenderEngine.cs @@ -249,15 +249,14 @@ private void RenderRequests( tuples = orderedTuples; } var progress = new Progress(tuples.Sum(t => t.Item1.phones.Length)); - var partUpdates = new Dictionary>(); foreach (var tuple in tuples) { var phrase = tuple.Item1; var source = tuple.Item2; var request = tuple.Item3; - RealCurveUpdate[]? callbackUpdates = null; + bool realCurvesPublished = false; var renderEvents = phrase.renderer.SupportsRealCurve ? new RenderPhraseEvents(realCurves => { - callbackUpdates = PublishRealCurveUpdates(request.part, phrase, realCurves); + realCurvesPublished = PublishRealCurveUpdates(request.part, phrase, realCurves); }) : null; var task = phrase.renderer.Render(phrase, progress, request.trackNo, cancellation, true, renderEvents); @@ -268,58 +267,50 @@ private void RenderRequests( var result = task.Result; source.SetSamples(result.samples); DocManager.Inst.ExecuteCmd(new PhraseRenderedNotification(request.part, phrase, result, request.trackNo)); - var phraseUpdates = callbackUpdates ?? PublishRealCurveUpdates(request.part, phrase); - if (phraseUpdates != null && phraseUpdates.Length > 0) { - if (!partUpdates.TryGetValue(request.part, out var list)) { - list = new List(); - partUpdates[request.part] = list; - } - list.AddRange(phraseUpdates); + if (!realCurvesPublished) { + PublishRealCurveUpdates(request.part, phrase); } if (request.sources.All(s => s.HasSamples)) { request.part.SetMix(request.mix); - if (partUpdates.TryGetValue(request.part, out var accumulated) && accumulated.Count > 0) { - DocManager.Inst.ExecuteCmd(new RealCurveCoverageNotification(request.part, accumulated)); - } DocManager.Inst.ExecuteCmd(new PartRenderedNotification(request.part)); } } progress.Clear(); } - private RealCurveUpdate[]? PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { + private bool PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { if (!phrase.renderer.SupportsRealCurve) { - return null; + return false; } try { var updates = RealCurveUpdater.LoadPhraseUpdates(part, phrase); if (updates.Length > 0) { DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); - return updates; + return true; } } catch (Exception e) { Log.Debug(e, "Failed to refresh rendered real curves."); } - return null; + return false; } - private RealCurveUpdate[]? PublishRealCurveUpdates( + private bool PublishRealCurveUpdates( UVoicePart part, RenderPhrase phrase, IReadOnlyList realCurves) { if (realCurves.Count == 0) { - return null; + return false; } try { var updates = RealCurveUpdater.BuildUpdates(part, phrase, realCurves); if (updates.Length > 0) { DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); - return updates; + return true; } } catch (Exception e) { Log.Debug(e, "Failed to publish rendered real curves."); } - return null; + return false; } public static void ReleaseSourceTemp() { diff --git a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs index 9f5f61fdc..602efd41c 100644 --- a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs +++ b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs @@ -97,102 +97,41 @@ public void ApplySkipsStalePhraseHash() { } [Fact] - public void TrimToCoverageNoOpWhenPartNotInProject() { + public void ApplyFullRefreshClearsEveryRealCurveInPart() { var project = CreateProjectWithCurve(out var part, out var curve); - curve.realXs.AddRange(new[] { 0 }); - curve.realYs.AddRange(new[] { 1 }); - project.parts.Remove(part); - - bool changed = RealCurveUpdater.TrimToCoverage(project, part, new[] { - new RealCurveUpdate(Ene, 1, 0, 10, new[] { 0 }, new[] { 1 }), - }); + // Stale data from a previous render that covered a wider tick range than the + // restored phrase will after undo. + curve.realXs.AddRange(new[] { 0, 0, 10, 50, 50, 80 }); + curve.realYs.AddRange(new[] { -1, 10, 20, -1, 90, 95 }); - Assert.False(changed); - Assert.Equal(new[] { 0 }, curve.realXs); - Assert.Equal(new[] { 1 }, curve.realYs); - } - - [Fact] - public void TrimToCoverageRemovesEntriesOutsideUnionForAbbrsWithUpdates() { - var project = CreateProjectWithCurve(out var part, out var curve); - // Stale tail data past 110 that an earlier render with a wider phrase left behind. - curve.realXs.AddRange(new[] { 0, 0, 10, 100, 100, 110, 150, 200 }); - curve.realYs.AddRange(new[] { -1, 10, 20, -1, 30, 40, 50, 60 }); - - // Two updates whose union is [0,10] U [100,110]. Entries at 150 and 200 fall outside. - bool changed = RealCurveUpdater.TrimToCoverage(project, part, new[] { - new RealCurveUpdate(Ene, 1, 0, 10, new[] { 0, 0, 5, 10 }, new[] { -1, 11, 12, 13 }), - new RealCurveUpdate(Ene, 2, 100, 110, new[] { 100, 100, 105, 110 }, new[] { -1, 31, 32, 33 }), - }); + bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); Assert.True(changed); - Assert.Equal(new[] { 0, 0, 10, 100, 100, 110 }, curve.realXs); - Assert.Equal(new[] { -1, 10, 20, -1, 30, 40 }, curve.realYs); + Assert.Empty(curve.realXs); + Assert.Empty(curve.realYs); } [Fact] - public void TrimToCoverageMergesOverlappingRanges() { - var project = CreateProjectWithCurve(out var part, out var curve); - curve.realXs.AddRange(new[] { 0, 50, 80, 150 }); - curve.realYs.AddRange(new[] { 1, 2, 3, 4 }); - - // Two overlapping ranges [0,60] and [40,100] merge into [0,100]; 150 falls outside. - bool changed = RealCurveUpdater.TrimToCoverage(project, part, new[] { - new RealCurveUpdate(Ene, 1, 0, 60, new[] { 0, 60 }, new[] { 1, 2 }), - new RealCurveUpdate(Ene, 2, 40, 100, new[] { 40, 100 }, new[] { 3, 4 }), - }); - - Assert.True(changed); - Assert.Equal(new[] { 0, 50, 80 }, curve.realXs); - Assert.Equal(new[] { 1, 2, 3 }, curve.realYs); - } + public void ApplyFullRefreshNoOpWhenAlreadyEmpty() { + var project = CreateProjectWithCurve(out var part, out _); - [Fact] - public void TrimToCoverageLeavesAbbrsWithoutUpdatesUntouched() { - var project = new UProject(); - var eneDescriptor = new UExpressionDescriptor("energy", Ene, 0, 100, 0) { - type = UExpressionType.Curve, - }; - var breDescriptor = new UExpressionDescriptor("breath", "bre", 0, 100, 0) { - type = UExpressionType.Curve, - }; - project.RegisterExpression(eneDescriptor); - project.RegisterExpression(breDescriptor); - var part = new UVoicePart { trackNo = 0, position = 0, Duration = 480 }; - project.parts.Add(part); - var eneCurve = new UCurve(eneDescriptor); - var breCurve = new UCurve(breDescriptor); - part.curves.Add(eneCurve); - part.curves.Add(breCurve); - eneCurve.realXs.AddRange(new[] { 0, 200 }); - eneCurve.realYs.AddRange(new[] { 1, 2 }); - breCurve.realXs.AddRange(new[] { 0, 200 }); - breCurve.realYs.AddRange(new[] { 3, 4 }); - - // Only ene has an update; bre should be untouched even though its data extends past - // ene's covered range. - bool changed = RealCurveUpdater.TrimToCoverage(project, part, new[] { - new RealCurveUpdate(Ene, 1, 0, 100, new[] { 0, 100 }, new[] { 1, 2 }), - }); + bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); - Assert.True(changed); - Assert.Equal(new[] { 0 }, eneCurve.realXs); - Assert.Equal(new[] { 1 }, eneCurve.realYs); - Assert.Equal(new[] { 0, 200 }, breCurve.realXs); - Assert.Equal(new[] { 3, 4 }, breCurve.realYs); + Assert.False(changed); } [Fact] - public void TrimToCoverageNoOpWhenUpdatesEmpty() { + public void ApplyFullRefreshNoOpWhenPartNotInProject() { var project = CreateProjectWithCurve(out var part, out var curve); - curve.realXs.AddRange(new[] { 0, 100 }); - curve.realYs.AddRange(new[] { 1, 2 }); + curve.realXs.AddRange(new[] { 0 }); + curve.realYs.AddRange(new[] { 1 }); + project.parts.Remove(part); - bool changed = RealCurveUpdater.TrimToCoverage(project, part, Array.Empty()); + bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); Assert.False(changed); - Assert.Equal(new[] { 0, 100 }, curve.realXs); - Assert.Equal(new[] { 1, 2 }, curve.realYs); + Assert.Equal(new[] { 0 }, curve.realXs); + Assert.Equal(new[] { 1 }, curve.realYs); } static UProject CreateProjectWithCurve(out UVoicePart part, out UCurve curve) { diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index a69d7f1de..348e13369 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -1097,8 +1097,6 @@ public void OnNext(UCommand cmd, bool isUndo) { MessageBus.Current.SendMessage(new WaveformRefreshEvent()); } else if (notif is RealCurvesUpdatedNotification && notif.part == Part) { MessageBus.Current.SendMessage(new NotesRefreshEvent()); - } else if (notif is RealCurveCoverageNotification && notif.part == Part) { - MessageBus.Current.SendMessage(new NotesRefreshEvent()); } } else if (cmd is PartCommand partCommand) { if (cmd is ReplacePartCommand replacePart) { From 57dc75980e12eafe1790bca1c225d62ce3dfff21 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Sat, 6 Jun 2026 14:58:29 +0800 Subject: [PATCH 6/8] Revert "Force real curve refresh on undo and redo" This reverts commit 6f223bfbba308f648c0471abb29a6250151098a5. --- OpenUtau.Core/Commands/Notifications.cs | 4 +- .../DiffSingerRealCurveScheduler.cs | 31 +++------------ .../DiffSinger/DiffSingerRenderer.cs | 4 -- OpenUtau.Core/DocManager.cs | 38 +----------------- OpenUtau.Core/Render/IRenderer.cs | 1 - OpenUtau.Core/Render/RealCurveUpdater.cs | 21 ---------- .../Core/Render/RealCurveUpdaterTest.cs | 39 ------------------- 7 files changed, 8 insertions(+), 130 deletions(-) diff --git a/OpenUtau.Core/Commands/Notifications.cs b/OpenUtau.Core/Commands/Notifications.cs index fefbc40f9..499f6324a 100644 --- a/OpenUtau.Core/Commands/Notifications.cs +++ b/OpenUtau.Core/Commands/Notifications.cs @@ -265,12 +265,10 @@ public PhraseRenderedNotification(UVoicePart part, RenderPhrase phrase, RenderRe public class RealCurvesUpdatedNotification : UNotification { public readonly IReadOnlyList updates; - public readonly bool isFullRefresh; public override bool Silent => true; - public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates, bool isFullRefresh = false) { + public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList updates) { this.part = part; this.updates = updates; - this.isFullRefresh = isFullRefresh; } public override string ToString() => "Real curves updated."; } diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs index bc067ab83..1db02eab4 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRealCurveScheduler.cs @@ -22,21 +22,7 @@ public static void TrySchedule(UProject project, UVoicePart part, UCommand comma !CanRefresh(project, part, expCommand.Key)) { return; } - Schedule(project, part, isFullRefresh: false); - } - - // Triggered by undo/redo: bypass command-type / abbr filter so any project mutation - // forces a full reload of variance-curve baselines, including the cache-hit path - // where Render() short-circuits and renderEvents.ReportRealCurves never fires. - public static void ScheduleForUndo(UProject project, UVoicePart part) { - if (part == null || - !project.parts.Contains(part) || - part.trackNo < 0 || - part.trackNo >= project.tracks.Count || - project.tracks[part.trackNo].RendererSettings.Renderer is not DiffSingerRenderer) { - return; - } - Schedule(project, part, isFullRefresh: true); + Schedule(project, part); } static bool IsCurveEditCommand(UCommand command) { @@ -56,7 +42,7 @@ static bool CanRefresh(UProject project, UVoicePart part, string abbr) { return project.tracks[part.trackNo].RendererSettings.Renderer is DiffSingerRenderer; } - static void Schedule(UProject project, UVoicePart part, bool isFullRefresh) { + static void Schedule(UProject project, UVoicePart part) { var cancellation = new CancellationTokenSource(); lock (lockObj) { if (pending.TryGetValue(part, out var previous)) { @@ -64,20 +50,15 @@ static void Schedule(UProject project, UVoicePart part, bool isFullRefresh) { } pending[part] = cancellation; } - _ = Task.Run(() => RefreshAsync(project, part, cancellation, isFullRefresh)); + _ = Task.Run(() => RefreshAsync(project, part, cancellation)); } - static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation, bool isFullRefresh) { + static async Task RefreshAsync(UProject project, UVoicePart part, CancellationTokenSource cancellation) { try { await Task.Delay(DebounceMs, cancellation.Token); var updates = LoadPartUpdates(project, part, cancellation.Token); - if (cancellation.IsCancellationRequested) { - return; - } - // For undo/redo always publish so stale real curves get cleared even when - // LoadPartUpdates returns nothing (e.g. all phrases removed by the undo). - if (isFullRefresh || updates.Count > 0) { - DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates, isFullRefresh)); + if (updates.Count > 0 && !cancellation.IsCancellationRequested) { + DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); } } catch (OperationCanceledException) { } catch (Exception e) { diff --git a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs index ce0c00343..6468c24be 100644 --- a/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs +++ b/OpenUtau.Core/DiffSinger/DiffSingerRenderer.cs @@ -526,10 +526,6 @@ public void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand DiffSingerRealCurveScheduler.TrySchedule(project, part, command); } - public void ScheduleFullRealCurveRefresh(UProject project, UVoicePart part) { - DiffSingerRealCurveScheduler.ScheduleForUndo(project, part); - } - public List LoadRenderedRealCurves(RenderPhrase phrase) { if (!Preferences.Default.DiffSingerTensorCache) { throw new Exception("Please enable DiffSinger tensor cache and re-render the phrase to display correct base curves."); diff --git a/OpenUtau.Core/DocManager.cs b/OpenUtau.Core/DocManager.cs index 75c753859..467ab4f80 100644 --- a/OpenUtau.Core/DocManager.cs +++ b/OpenUtau.Core/DocManager.cs @@ -228,11 +228,7 @@ public void ExecuteCmd(UCommand cmd) { rangeEndTick = setRange.endTick; } else if (cmd is RealCurvesUpdatedNotification realCurvesNotif) { if (realCurvesNotif.part is UVoicePart voicePart) { - if (realCurvesNotif.isFullRefresh) { - RealCurveUpdater.ApplyFullRefresh(Project, voicePart, realCurvesNotif.updates); - } else { - RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); - } + RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); } } else if (cmd is SingersChangedNotification) { SingerManager.Inst.SearchAllSingers(); @@ -291,36 +287,6 @@ void ScheduleRealCurveRefresh(IEnumerable commands) { } } - // After undo/redo we cannot rely on per-command ExpCommand routing: note moves, - // phoneme edits and part-level mutations all change the rendered baseline, and a - // cache-hit re-render skips renderEvents.ReportRealCurves entirely. Schedule a full - // refresh for every voice part the undone group touched so the variance baseline is - // reloaded (and stale ranges cleared) by the same debounced path the curve-edit hook - // uses. - void ScheduleFullRealCurveRefresh(IEnumerable commands) { - var parts = new HashSet(); - foreach (var cmd in commands) { - UVoicePart? part = cmd switch { - ExpCommand ec => ec.Part, - NoteCommand nc => nc.Part, - PartCommand pc => pc.part as UVoicePart, - _ => null - }; - if (part != null) { - parts.Add(part); - } - } - foreach (var part in parts) { - if (!Project.parts.Contains(part) || - part.trackNo < 0 || - part.trackNo >= Project.tracks.Count) { - continue; - } - Project.tracks[part.trackNo].RendererSettings.Renderer - ?.ScheduleFullRealCurveRefresh(Project, part); - } - } - public void StartUndoGroup(string? nameKey = null, bool deferValidate = false) { if (undoGroup != null) { Log.Error("undoGroup already started"); @@ -383,7 +349,6 @@ public void Undo() { } redoQueue.AddToBack(group); ScheduleRealCurveRefresh(group.Commands); - ScheduleFullRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } @@ -402,7 +367,6 @@ public void Redo() { } undoQueue.AddToBack(group); ScheduleRealCurveRefresh(group.Commands); - ScheduleFullRealCurveRefresh(group.Commands); ExecuteCmd(new PreRenderNotification()); } diff --git a/OpenUtau.Core/Render/IRenderer.cs b/OpenUtau.Core/Render/IRenderer.cs index b2b317f89..71e6d8a9a 100644 --- a/OpenUtau.Core/Render/IRenderer.cs +++ b/OpenUtau.Core/Render/IRenderer.cs @@ -87,7 +87,6 @@ public interface IRenderer { RenderPitchResult LoadRenderedPitch(RenderPhrase phrase); List LoadRenderedRealCurves(RenderPhrase phrase) { return new List(0);} void ScheduleRealCurveRefresh(UProject project, UVoicePart part, UCommand command) { } - void ScheduleFullRealCurveRefresh(UProject project, UVoicePart part) { } UExpressionDescriptor[] GetSuggestedExpressions(USinger singer, URenderSettings renderSettings); } } diff --git a/OpenUtau.Core/Render/RealCurveUpdater.cs b/OpenUtau.Core/Render/RealCurveUpdater.cs index 34d626c95..1296843a2 100644 --- a/OpenUtau.Core/Render/RealCurveUpdater.cs +++ b/OpenUtau.Core/Render/RealCurveUpdater.cs @@ -87,27 +87,6 @@ public static bool Apply(UProject project, UVoicePart part, IReadOnlyList updates) { - if (!project.parts.Contains(part)) { - return false; - } - bool changed = false; - foreach (var curve in part.curves) { - if (curve.realXs.Count > 0 || curve.realYs.Count > 0) { - curve.realXs.Clear(); - curve.realYs.Clear(); - changed = true; - } - } - if (updates.Count > 0) { - changed |= Apply(project, part, updates); - } - return changed; - } - internal static bool Apply( UProject project, UVoicePart part, diff --git a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs index 602efd41c..f0e32d722 100644 --- a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs +++ b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using OpenUtau.Core.Render; @@ -96,44 +95,6 @@ public void ApplySkipsStalePhraseHash() { Assert.Equal(new[] { -1, 10, 20 }, curve.realYs); } - [Fact] - public void ApplyFullRefreshClearsEveryRealCurveInPart() { - var project = CreateProjectWithCurve(out var part, out var curve); - // Stale data from a previous render that covered a wider tick range than the - // restored phrase will after undo. - curve.realXs.AddRange(new[] { 0, 0, 10, 50, 50, 80 }); - curve.realYs.AddRange(new[] { -1, 10, 20, -1, 90, 95 }); - - bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); - - Assert.True(changed); - Assert.Empty(curve.realXs); - Assert.Empty(curve.realYs); - } - - [Fact] - public void ApplyFullRefreshNoOpWhenAlreadyEmpty() { - var project = CreateProjectWithCurve(out var part, out _); - - bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); - - Assert.False(changed); - } - - [Fact] - public void ApplyFullRefreshNoOpWhenPartNotInProject() { - var project = CreateProjectWithCurve(out var part, out var curve); - curve.realXs.AddRange(new[] { 0 }); - curve.realYs.AddRange(new[] { 1 }); - project.parts.Remove(part); - - bool changed = RealCurveUpdater.ApplyFullRefresh(project, part, Array.Empty()); - - Assert.False(changed); - Assert.Equal(new[] { 0 }, curve.realXs); - Assert.Equal(new[] { 1 }, curve.realYs); - } - static UProject CreateProjectWithCurve(out UVoicePart part, out UCurve curve) { var project = new UProject(); var descriptor = new UExpressionDescriptor("energy", Ene, 0, 100, 0) { From b57377cc27490df924c9faa6ddd695b53b8b6083 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Tue, 23 Jun 2026 13:33:57 +0800 Subject: [PATCH 7/8] Trim stale real curves on render pass; scope real-curve redraws Fixes ghost real curves left when a phrase shrinks, moves, splits, or is deleted (including after undo/redo): the per-phrase incremental path only cleared the new phrase's tick range, so points outside it survived. - RealCurveUpdater.TrimToCoverage removes realXs/realYs points outside the union of the tick ranges the renderer actually produced this pass. Single O(N) two-pointer walk over the already-sorted points; allocates only when a stale point exists (clean passes are free). - RenderEngine accumulates the reported ranges and posts RealCurveCoverage- Notification once per part when the pass completes. Gated to full passes (startTick==0 && endTick==-1) so partial playback never trims out-of-window curves; the empty-ranges guard avoids wiping during phonemizer rebuilds. - Coverage comes from renderer-reported ranges, so the generic layer stays free of DiffSinger frame-padding specifics. - RollBackUndoGroup now calls ScheduleRealCurveRefresh and PreRenderNotification for consistency with Undo/Redo/EndUndoGroup. Perf: - Drop unused PhraseRenderedNotification (no subscribers; it marshalled a sample buffer to the UI thread every phrase). - Route real-curve refreshes through a dedicated RealCurveRefreshEvent that only invalidates ExpressionCanvas, and only when ShowRealCurve is on, instead of repainting every canvas per phrase. Adds TrimToCoverage regression tests. --- OpenUtau.Core/Commands/Notifications.cs | 24 +++---- OpenUtau.Core/DocManager.cs | 6 ++ OpenUtau.Core/Render/RealCurveUpdater.cs | 69 +++++++++++++++++++ OpenUtau.Core/Render/RenderEngine.cs | 55 +++++++++++---- .../Core/Render/RealCurveUpdaterTest.cs | 40 +++++++++++ OpenUtau/Controls/ExpressionCanvas.cs | 6 ++ OpenUtau/ViewModels/NotesViewModel.cs | 5 +- 7 files changed, 177 insertions(+), 28 deletions(-) diff --git a/OpenUtau.Core/Commands/Notifications.cs b/OpenUtau.Core/Commands/Notifications.cs index 499f6324a..8a1c8b4f0 100644 --- a/OpenUtau.Core/Commands/Notifications.cs +++ b/OpenUtau.Core/Commands/Notifications.cs @@ -249,20 +249,6 @@ public PartRenderedNotification(UVoicePart part) { public override string ToString() => "Part rendered."; } - public class PhraseRenderedNotification : UNotification { - public readonly RenderPhrase phrase; - public readonly RenderResult result; - public readonly int trackNo; - public override bool Silent => true; - public PhraseRenderedNotification(UVoicePart part, RenderPhrase phrase, RenderResult result, int trackNo) { - this.part = part; - this.phrase = phrase; - this.result = result; - this.trackNo = trackNo; - } - public override string ToString() => "Phrase rendered."; - } - public class RealCurvesUpdatedNotification : UNotification { public readonly IReadOnlyList updates; public override bool Silent => true; @@ -273,6 +259,16 @@ public RealCurvesUpdatedNotification(UVoicePart part, IReadOnlyList "Real curves updated."; } + public class RealCurveCoverageNotification : UNotification { + public readonly IReadOnlyList<(int start, int end)> ranges; + public override bool Silent => true; + public RealCurveCoverageNotification(UVoicePart part, IReadOnlyList<(int start, int end)> ranges) { + this.part = part; + this.ranges = ranges; + } + public override string ToString() => "Real curve coverage."; + } + public class GotoOtoNotification : UNotification { public readonly USinger? singer; public readonly UOto? oto; diff --git a/OpenUtau.Core/DocManager.cs b/OpenUtau.Core/DocManager.cs index 467ab4f80..b9ea012bb 100644 --- a/OpenUtau.Core/DocManager.cs +++ b/OpenUtau.Core/DocManager.cs @@ -230,6 +230,10 @@ public void ExecuteCmd(UCommand cmd) { if (realCurvesNotif.part is UVoicePart voicePart) { RealCurveUpdater.Apply(Project, voicePart, realCurvesNotif.updates); } + } else if (cmd is RealCurveCoverageNotification coverageNotif) { + if (coverageNotif.part is UVoicePart coveragePart) { + RealCurveUpdater.TrimToCoverage(Project, coveragePart, coverageNotif.ranges); + } } else if (cmd is SingersChangedNotification) { SingerManager.Inst.SearchAllSingers(); } else if (cmd is ValidateProjectNotification) { @@ -331,7 +335,9 @@ public void RollBackUndoGroup() { } Publish(cmd, true); } + ScheduleRealCurveRefresh(undoGroup.Commands); undoGroup.Commands.Clear(); + ExecuteCmd(new PreRenderNotification()); } public void Undo() { diff --git a/OpenUtau.Core/Render/RealCurveUpdater.cs b/OpenUtau.Core/Render/RealCurveUpdater.cs index 1296843a2..6846e45ab 100644 --- a/OpenUtau.Core/Render/RealCurveUpdater.cs +++ b/OpenUtau.Core/Render/RealCurveUpdater.cs @@ -87,6 +87,75 @@ public static bool Apply(UProject project, UVoicePart part, IReadOnlyList ranges) { + if (!project.parts.Contains(part) || ranges.Count == 0) { + return false; + } + var merged = MergeRanges(ranges); + bool changed = false; + foreach (var curve in part.curves) { + if (TrimCurve(curve, merged)) { + changed = true; + } + } + return changed; + } + + static bool TrimCurve(UCurve curve, List<(int start, int end)> merged) { + var xs = curve.realXs; + var ys = curve.realYs; + if (xs.Count == 0) { + return false; + } + List? newXs = null; + List? newYs = null; + int ri = 0; + for (int i = 0; i < xs.Count; ++i) { + int x = xs[i]; + while (ri < merged.Count && merged[ri].end < x) { + ri++; + } + bool keep = ri < merged.Count && x >= merged[ri].start; + if (keep) { + newXs?.Add(x); + newYs?.Add(ys[i]); + } else if (newXs == null) { + newXs = new List(xs.Count); + newYs = new List(ys.Count); + for (int j = 0; j < i; ++j) { + newXs.Add(xs[j]); + newYs.Add(ys[j]); + } + } + } + if (newXs == null) { + return false; + } + curve.realXs = newXs; + curve.realYs = newYs; + return true; + } + + internal static List<(int start, int end)> MergeRanges(IReadOnlyList<(int start, int end)> ranges) { + var sorted = ranges.ToList(); + sorted.Sort((a, b) => a.start.CompareTo(b.start)); + var merged = new List<(int start, int end)> { sorted[0] }; + for (int i = 1; i < sorted.Count; ++i) { + var last = merged[merged.Count - 1]; + if (sorted[i].start <= last.end) { + merged[merged.Count - 1] = (last.start, Math.Max(last.end, sorted[i].end)); + } else { + merged.Add(sorted[i]); + } + } + return merged; + } + internal static bool Apply( UProject project, UVoicePart part, diff --git a/OpenUtau.Core/Render/RenderEngine.cs b/OpenUtau.Core/Render/RenderEngine.cs index ed9dc1da1..f31b2cbce 100644 --- a/OpenUtau.Core/Render/RenderEngine.cs +++ b/OpenUtau.Core/Render/RenderEngine.cs @@ -249,14 +249,20 @@ private void RenderRequests( tuples = orderedTuples; } var progress = new Progress(tuples.Sum(t => t.Item1.phones.Length)); + // Only full-project passes (pre-render / export) maintain the real-curve coverage + // invariant. Partial playback passes must not trim curves outside their tick window. + bool maintainCoverage = startTick == 0 && endTick == -1; + var coverageRanges = maintainCoverage + ? new Dictionary>() + : null; foreach (var tuple in tuples) { var phrase = tuple.Item1; var source = tuple.Item2; var request = tuple.Item3; - bool realCurvesPublished = false; + RealCurveUpdate[]? publishedUpdates = null; var renderEvents = phrase.renderer.SupportsRealCurve ? new RenderPhraseEvents(realCurves => { - realCurvesPublished = PublishRealCurveUpdates(request.part, phrase, realCurves); + publishedUpdates = PublishRealCurveUpdates(request.part, phrase, realCurves); }) : null; var task = phrase.renderer.Render(phrase, progress, request.trackNo, cancellation, true, renderEvents); @@ -266,51 +272,74 @@ private void RenderRequests( } var result = task.Result; source.SetSamples(result.samples); - DocManager.Inst.ExecuteCmd(new PhraseRenderedNotification(request.part, phrase, result, request.trackNo)); - if (!realCurvesPublished) { - PublishRealCurveUpdates(request.part, phrase); + if (publishedUpdates == null) { + publishedUpdates = PublishRealCurveUpdates(request.part, phrase); + } + if (coverageRanges != null && publishedUpdates != null) { + AccumulateCoverage(coverageRanges, request.part, publishedUpdates); } if (request.sources.All(s => s.HasSamples)) { request.part.SetMix(request.mix); + if (coverageRanges != null && + phrase.renderer.SupportsRealCurve && + coverageRanges.TryGetValue(request.part, out var ranges) && + ranges.Count > 0) { + DocManager.Inst.ExecuteCmd(new RealCurveCoverageNotification(request.part, ranges)); + } DocManager.Inst.ExecuteCmd(new PartRenderedNotification(request.part)); } } progress.Clear(); } - private bool PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { + private RealCurveUpdate[]? PublishRealCurveUpdates(UVoicePart part, RenderPhrase phrase) { if (!phrase.renderer.SupportsRealCurve) { - return false; + return null; } try { var updates = RealCurveUpdater.LoadPhraseUpdates(part, phrase); if (updates.Length > 0) { DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); - return true; + return updates; } } catch (Exception e) { Log.Debug(e, "Failed to refresh rendered real curves."); } - return false; + return null; } - private bool PublishRealCurveUpdates( + private RealCurveUpdate[]? PublishRealCurveUpdates( UVoicePart part, RenderPhrase phrase, IReadOnlyList realCurves) { if (realCurves.Count == 0) { - return false; + return null; } try { var updates = RealCurveUpdater.BuildUpdates(part, phrase, realCurves); if (updates.Length > 0) { DocManager.Inst.ExecuteCmd(new RealCurvesUpdatedNotification(part, updates)); - return true; + return updates; } } catch (Exception e) { Log.Debug(e, "Failed to publish rendered real curves."); } - return false; + return null; + } + + private static void AccumulateCoverage( + Dictionary> coverage, + UVoicePart part, + RealCurveUpdate[] updates) { + if (!coverage.TryGetValue(part, out var ranges)) { + ranges = new List<(int start, int end)>(); + coverage[part] = ranges; + } + foreach (var update in updates) { + if (update.IsValid) { + ranges.Add((update.startTick, update.endTick)); + } + } } public static void ReleaseSourceTemp() { diff --git a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs index f0e32d722..e72168dda 100644 --- a/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs +++ b/OpenUtau.Test/Core/Render/RealCurveUpdaterTest.cs @@ -95,6 +95,46 @@ public void ApplySkipsStalePhraseHash() { Assert.Equal(new[] { -1, 10, 20 }, curve.realYs); } + [Fact] + public void TrimToCoverageRemovesGhostPointsOutsideCoverage() { + var project = CreateProjectWithCurve(out var part, out var curve); + curve.realXs.AddRange(new[] { 0, 0, 25, 50, 60, 80, 100 }); + curve.realYs.AddRange(new[] { -1, 10, 20, 30, 40, 50, 60 }); + + bool changed = RealCurveUpdater.TrimToCoverage( + project, part, new List<(int start, int end)> { (0, 50) }); + + Assert.True(changed); + Assert.Equal(new[] { 0, 0, 25, 50 }, curve.realXs); + Assert.Equal(new[] { -1, 10, 20, 30 }, curve.realYs); + } + + [Fact] + public void TrimToCoverageKeepsPointsWhenAllInsideCoverage() { + var project = CreateProjectWithCurve(out var part, out var curve); + curve.realXs.AddRange(new[] { 0, 0, 25, 50 }); + curve.realYs.AddRange(new[] { -1, 10, 20, 30 }); + + bool changed = RealCurveUpdater.TrimToCoverage( + project, part, new List<(int start, int end)> { (0, 50) }); + + Assert.False(changed); + Assert.Equal(new[] { 0, 0, 25, 50 }, curve.realXs); + } + + [Fact] + public void TrimToCoverageSkipsWhenNoRanges() { + var project = CreateProjectWithCurve(out var part, out var curve); + curve.realXs.AddRange(new[] { 0, 50, 100 }); + curve.realYs.AddRange(new[] { -1, 30, 60 }); + + bool changed = RealCurveUpdater.TrimToCoverage( + project, part, new List<(int start, int end)>()); + + Assert.False(changed); + Assert.Equal(3, curve.realXs.Count); + } + static UProject CreateProjectWithCurve(out UVoicePart part, out UCurve curve) { var project = new UProject(); var descriptor = new UExpressionDescriptor("energy", Ene, 0, 100, 0) { diff --git a/OpenUtau/Controls/ExpressionCanvas.cs b/OpenUtau/Controls/ExpressionCanvas.cs index 901ff5def..03817a3f0 100644 --- a/OpenUtau/Controls/ExpressionCanvas.cs +++ b/OpenUtau/Controls/ExpressionCanvas.cs @@ -78,6 +78,12 @@ public ExpressionCanvas() { circleGeometry = new EllipseGeometry(new Rect(-4.5, -4.5, 9, 9)); MessageBus.Current.Listen() .Subscribe(_ => InvalidateVisual()); + MessageBus.Current.Listen() + .Subscribe(_ => { + if (ShowRealCurve) { + InvalidateVisual(); + } + }); MessageBus.Current.Listen() .Subscribe(e => { selectedNotes.Clear(); diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index 348e13369..bd337b5cd 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -24,6 +24,7 @@ namespace OpenUtau.App.ViewModels { public class NotesRefreshEvent { } + public class RealCurveRefreshEvent { } public class NotesSelectionEvent { public readonly UNote[] selectedNotes; public readonly UNote[] tempSelectedNotes; @@ -1096,7 +1097,9 @@ public void OnNext(UCommand cmd, bool isUndo) { } else if (notif is PartRenderedNotification && notif.part == Part) { MessageBus.Current.SendMessage(new WaveformRefreshEvent()); } else if (notif is RealCurvesUpdatedNotification && notif.part == Part) { - MessageBus.Current.SendMessage(new NotesRefreshEvent()); + MessageBus.Current.SendMessage(new RealCurveRefreshEvent()); + } else if (notif is RealCurveCoverageNotification && notif.part == Part) { + MessageBus.Current.SendMessage(new RealCurveRefreshEvent()); } } else if (cmd is PartCommand partCommand) { if (cmd is ReplacePartCommand replacePart) { From d6d67c27a60b077426757d5139e71899d5a8dda1 Mon Sep 17 00:00:00 2001 From: KakaruHayate Date: Tue, 23 Jun 2026 18:42:59 +0800 Subject: [PATCH 8/8] Fix thread safety in RefreshRealCurves background read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RefreshRealCurves.RunAsync runs on a background thread and reads curve.realXs/realYs via .ToArray() to build MergedSetCurveCommand snapshots. When a concurrent RealCurvesUpdatedNotification fires on the UI thread (from auto-refresh), List.ToArray() races with RemoveAt/InsertRange — potentially causing IndexOutOfRangeException or a corrupted snapshot. Move the snapshot reads inside PostOnUIThread so they are serialized with all other real-curve mutations on the UI thread. Reported by external review (issue #1: thread safety). --- OpenUtau.Core/Editing/NoteBatchEdits.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/OpenUtau.Core/Editing/NoteBatchEdits.cs b/OpenUtau.Core/Editing/NoteBatchEdits.cs index d8b4248e1..0feaa66d2 100644 --- a/OpenUtau.Core/Editing/NoteBatchEdits.cs +++ b/OpenUtau.Core/Editing/NoteBatchEdits.cs @@ -826,17 +826,17 @@ public void RunAsync( finished += 1; setProgressCallback(finished, part.renderPhrases.Count); } - var commands = curveDict - .Select(kv => new MergedSetCurveCommand( - project, part, kv.Key, - kv.Value?.realXs.ToArray() ?? Array.Empty(), - kv.Value?.realYs.ToArray() ?? Array.Empty(), - newXsDict[kv.Key].ToArray(), - newYsDict[kv.Key].ToArray(), - true)) - .ToList(); DocManager.Inst.PostOnUIThread(() => { + var commands = curveDict + .Select(kv => new MergedSetCurveCommand( + project, part, kv.Key, + kv.Value?.realXs.ToArray() ?? Array.Empty(), + kv.Value?.realYs.ToArray() ?? Array.Empty(), + newXsDict[kv.Key].ToArray(), + newYsDict[kv.Key].ToArray(), + true)) + .ToList(); docManager.StartUndoGroup("command.batch.note", true); commands.ForEach(docManager.ExecuteCmd); docManager.EndUndoGroup();