From 7224f4cc2754f1752c33e7a27cd9ec6e11d110f3 Mon Sep 17 00:00:00 2001 From: rokujyushi Date: Sat, 20 Jun 2026 05:25:09 +0900 Subject: [PATCH 1/3] Added new curve editing tools to the piano roll. Added the following new tools to the piano roll toolbar: - Vertical Stretch Tool - Horizontal Stretch Tool - Vertical Shift Tool - Horizontal Shift Tool Added tooltips and icons corresponding to these tools, and updated the settings in PianoRoll.axaml, Strings.axaml, and PianoRollStyles.axaml. Implemented the behavior of the new tools in PianoRoll.axaml.cs and extended the ExpCanvasPointerPressed and ExpCanvasPointerMoved methods. Added logic to CurveViewModel.cs to support the new tools. Implemented the TryGetSelection method to retrieve the selection range. Added new state classes to NoteEditStates.cs and implemented editing logic (move, scale) for each tool. --- OpenUtau/Controls/PianoRoll.axaml | 12 +- OpenUtau/Controls/PianoRoll.axaml.cs | 20 +- OpenUtau/Strings/Strings.axaml | 22 ++ OpenUtau/Styles/PianoRollStyles.axaml | 12 + OpenUtau/ViewModels/CurveViewModel.cs | 25 +- OpenUtau/Views/NoteEditStates.cs | 505 ++++++++++++++++++++++++++ 6 files changed, 586 insertions(+), 10 deletions(-) diff --git a/OpenUtau/Controls/PianoRoll.axaml b/OpenUtau/Controls/PianoRoll.axaml index caa98e9f8..097ff7f6f 100644 --- a/OpenUtau/Controls/PianoRoll.axaml +++ b/OpenUtau/Controls/PianoRoll.axaml @@ -599,10 +599,14 @@ - - - - + + + + + + + + View Vibrato (U) View Waveform (W) View Expressions (L) + Cursor Tool + Left click to select + Pen Tool + Left click to draw + Right click to reset + Line Tool + Left click to draw (draw straight line) + Right click to reset + Eraser Tool + Click to reset + Vertical Stretch Tool + Left click to stretch the curve vertically + Right click to reset + Horizontal Stretch Tool + Left click to stretch the curve horizontally + Right click to reset + Vertical Shift Tool + Left click to move the curve vertically + Right click to reset + Horizontal Shift Tool + Left click to move the curve horizontally + Right click to reset Line Draw Pitch Tool (5) Left click to draw (draw straight line) Right click to reset diff --git a/OpenUtau/Styles/PianoRollStyles.axaml b/OpenUtau/Styles/PianoRollStyles.axaml index 87529becc..a865bb1f0 100644 --- a/OpenUtau/Styles/PianoRollStyles.axaml +++ b/OpenUtau/Styles/PianoRollStyles.axaml @@ -122,6 +122,18 @@ + + + + diff --git a/OpenUtau/ViewModels/CurveViewModel.cs b/OpenUtau/ViewModels/CurveViewModel.cs index 9dd13082f..82da5f8c8 100644 --- a/OpenUtau/ViewModels/CurveViewModel.cs +++ b/OpenUtau/ViewModels/CurveViewModel.cs @@ -19,8 +19,12 @@ public CurveSelectionEvent(CurveSelection selection) { public enum CurveTools { CurveSelectTool, CurvePenTool, - //CurveLineTool, - CurveEraserTool + CurveLineTool, + CurveEraserTool, + CurveVerticalStretchTool, + CurveHorizontalStretchTool, + CurveVerticalShiftTool, + CurveHorizontalShiftTool } public class CurveViewModel : ViewModelBase, ICmdSubscriber { @@ -46,13 +50,16 @@ public void Select(UExpressionDescriptor descriptor, int startTick, int endTick, (int x, int y) endPoint = (endTick, curve?.Sample(endTick) ?? (int)descriptor.CustomDefaultValue); var xs = new List(); var ys = new List(); + int minTick = Math.Min(startTick, endTick); + int maxTick = Math.Max(startTick, endTick); + if (curve != null) { for (int i = 0; i < curve.xs.Count; i++) { var x = curve.xs[i]; - if (endTick < x) { + if (maxTick <= x) { break; } - if (startTick <= x) { + if (minTick < x) { xs.Add(x); ys.Add(curve.ys[i]); } @@ -105,6 +112,16 @@ public void Paste(UVoicePart part, UExpressionDescriptor descriptor) { DocManager.Inst.EndUndoGroup(); } + public bool TryGetSelection(string abbr, out CurveSelection selection) { + if (this.selection.HasValue(abbr)) { + selection = this.selection.Clone(); + return true; + } + + selection = new CurveSelection(); + return false; + } + public void OnNext(UCommand cmd, bool isUndo) { if (cmd is UNotification notif) { if (cmd is LoadPartNotification || cmd is LoadProjectNotification || cmd is SelectExpressionNotification) { diff --git a/OpenUtau/Views/NoteEditStates.cs b/OpenUtau/Views/NoteEditStates.cs index 2b0c6c20a..1a5561efa 100644 --- a/OpenUtau/Views/NoteEditStates.cs +++ b/OpenUtau/Views/NoteEditStates.cs @@ -937,6 +937,511 @@ public override void Update(IPointer pointer, Point point) { vm.CurveViewModel.Select(descriptor, minTick, maxTick, curve); } } + class CurveVerticalShiftState : NoteEditState { + protected readonly UExpressionDescriptor descriptor; + protected CurveSelection? initialSelection; + protected string abbr = string.Empty; + + protected int[] oldXs = Array.Empty(); + protected int[] oldYs = Array.Empty(); + + protected override bool ShowValueTip => true; + protected override string? commandNameKey => "command.exp.edit"; + + public CurveVerticalShiftState( + Control control, + PianoRollViewModel vm, + IValueTip valueTip, + UExpressionDescriptor descriptor) : base(control, vm, valueTip) { + this.descriptor = descriptor; + } + + public override void Begin(IPointer pointer, Point point) { + base.Begin(pointer, point); + + var notesVm = vm.NotesViewModel; + var curveVm = vm.CurveViewModel; + + abbr = descriptor.abbr; + + if (!curveVm.TryGetSelection(abbr, out var selection)) { + initialSelection = null; + return; + } + + initialSelection = selection; + + var part = notesVm.Part; + var curve = part?.curves.FirstOrDefault(c => c.abbr == abbr); + + oldXs = curve?.xs.ToArray() ?? Array.Empty(); + oldYs = curve?.ys.ToArray() ?? Array.Empty(); + } + + public override void Update(IPointer pointer, Point point) { + if (CanEdit(out var part, out var project, out var curve)) { + BuildEditedCurve(point, out var finalXs, out var finalYs); + + if (!CurveEquals(oldXs, oldYs, finalXs, finalYs)) { + DocManager.Inst.ExecuteCmd(new MergedSetCurveCommand( + project, + part, + abbr, + oldXs, + oldYs, + finalXs.ToArray(), + finalYs.ToArray())); + } + } + } + + public override void End(IPointer pointer, Point point) { + base.End(pointer, point); + + if (initialSelection != null && initialSelection.HasValue(abbr)) { + var notesVm = vm.NotesViewModel; + var part = notesVm.Part; + + if (part != null) { + var curve = part.curves.FirstOrDefault(c => c.abbr == abbr); + if (curve != null) { + int startTick = initialSelection.StartPoint.x; + int endTick = initialSelection.EndPoint.x; + + vm.CurveViewModel.Select( + descriptor, + startTick, + endTick, + curve); + } + } + } + + initialSelection = null; + } + + protected virtual bool CanEdit(out UVoicePart part, out UProject project, out UCurve curve) { + part = null!; + project = null!; + curve = null!; + + if (initialSelection == null || !initialSelection.HasValue(abbr)) { + return false; + } + + var notesVm = vm.NotesViewModel; + if (notesVm.Part == null || notesVm.Project == null) { + return false; + } + + var targetCurve = notesVm.Part.curves.FirstOrDefault(c => c.abbr == abbr); + if (targetCurve == null) { + return false; + } + + part = notesVm.Part; + project = notesVm.Project; + curve = targetCurve; + return true; + } + + protected void BuildEditedCurve(Point point, out List newXs, out List newYs) { + newXs = oldXs.ToList(); + newYs = oldYs.ToList(); + + if (initialSelection == null || !initialSelection.HasValue(abbr)) { + return; + } + + initialSelection.GetSelectedRange(abbr, out var selectedXs, out var selectedYs); + + for (int i = 0; i < selectedYs.Count; i++) { + selectedYs[i] = TransformY(selectedXs[i], selectedYs[i], point); + } + + int minTick = Math.Min(initialSelection.StartPoint.x, initialSelection.EndPoint.x); + int maxTick = Math.Max(initialSelection.StartPoint.x, initialSelection.EndPoint.x); + + for (int i = newXs.Count - 1; i >= 0; i--) { + int x = newXs[i]; + if (minTick <= x && x <= maxTick) { + newXs.RemoveAt(i); + newYs.RemoveAt(i); + } + } + + for (int i = 0; i < selectedXs.Count; i++) { + InsertCurvePointSorted(selectedXs[i], selectedYs[i], newXs, newYs); + } + } + + protected virtual int TransformY(int x, int y, Point point) { + int deltaY = PointToCurveValue(point) - PointToCurveValue(startPoint); + valueTip.UpdateValueTip($"add:{deltaY:0}"); + return ClampY(y + deltaY); + } + + protected int PointToCurveValue(Point point) { + if (control.Bounds.Height <= 0) { + return ClampY(descriptor.CustomDefaultValue); + } + + return ClampY(Math.Round( + descriptor.min + (descriptor.max - descriptor.min) * (1 - point.Y / control.Bounds.Height))); + } + + protected int ClampY(double y) { + return (int)Math.Round(Math.Clamp(y, descriptor.min, descriptor.max)); + } + + protected static void InsertCurvePointSorted(int x, int y, List xs, List ys) { + for (int i = 0; i < xs.Count; i++) { + if (xs[i] == x) { + ys[i] = y; + return; + } + + if (x < xs[i]) { + xs.Insert(i, x); + ys.Insert(i, y); + return; + } + } + + xs.Add(x); + ys.Add(y); + } + + private static bool CurveEquals( + int[] oldXs, + int[] oldYs, + List newXs, + List newYs) { + if (oldXs.Length != newXs.Count || oldYs.Length != newYs.Count) { + return false; + } + + for (int i = 0; i < oldXs.Length; i++) { + if (oldXs[i] != newXs[i]) { + return false; + } + } + + for (int i = 0; i < oldYs.Length; i++) { + if (oldYs[i] != newYs[i]) { + return false; + } + } + + return true; + } + } + class CurveVerticalStretchState : CurveVerticalShiftState { + public CurveVerticalStretchState( + Control control, + PianoRollViewModel vm, + IValueTip valueTip, + UExpressionDescriptor descriptor) : base(control, vm, valueTip, descriptor) { + } + + protected override int TransformY(int x, int y, Point point) { + if (initialSelection == null || !initialSelection.HasValue(abbr)) { + return y; + } + + int deltaY = PointToCurveValue(point) - PointToCurveValue(startPoint); + double range = descriptor.max - descriptor.min; + if (range <= 0) { + return y; + } + + double scale = 1.0 + deltaY / range; + valueTip.UpdateValueTip($"scale:{scale:0.00}"); + + int centerY = GetSelectionCenterY(); + double stretchedY = Math.Round(centerY + (y - centerY) * scale); + + return ClampY(stretchedY); + } + + private int GetSelectionCenterY() { + if (initialSelection == null || !initialSelection.HasValue(abbr)) { + return ClampY(descriptor.CustomDefaultValue); + } + + initialSelection.GetSelectedRange(abbr, out _, out var ys); + + if (ys.Count == 0) { + return ClampY(descriptor.CustomDefaultValue); + } + + int minY = ys.Min(); + int maxY = ys.Max(); + return (int)(minY + maxY) / 2; + } + } + + class CurveHorizontalShiftState : NoteEditState { + protected readonly UExpressionDescriptor descriptor; + protected CurveSelection? initialSelection; + protected string abbr = string.Empty; + + protected int[] baseXs = Array.Empty(); + protected int[] baseYs = Array.Empty(); + + protected int lastStartTick; + protected int lastEndTick; + + protected override bool ShowValueTip => true; + protected override string? commandNameKey => "command.exp.edit"; + + public CurveHorizontalShiftState( + Control control, + PianoRollViewModel vm, + IValueTip valueTip, + UExpressionDescriptor descriptor) : base(control, vm, valueTip) { + this.descriptor = descriptor; + } + + public override void Begin(IPointer pointer, Point point) { + base.Begin(pointer, point); + + var notesVm = vm.NotesViewModel; + var curveVm = vm.CurveViewModel; + + abbr = descriptor.abbr; + + if (!curveVm.TryGetSelection(abbr, out var selection)) { + initialSelection = null; + return; + } + + initialSelection = selection; + + var part = notesVm.Part; + var curve = part?.curves.FirstOrDefault(c => c.abbr == abbr); + + baseXs = curve?.xs.ToArray() ?? Array.Empty(); + baseYs = curve?.ys.ToArray() ?? Array.Empty(); + + lastStartTick = initialSelection.StartPoint.x; + lastEndTick = initialSelection.EndPoint.x; + } + + public override void Update(IPointer pointer, Point point) { + if (CanEdit(out var project, out var part)) { + GetCurrentCurveArrays(part, out var oldXs, out var oldYs); + + BuildEditedCurve(point, out var newXs, out var newYs); + + if (!CurveEquals(oldXs, oldYs, newXs, newYs)) { + DocManager.Inst.ExecuteCmd(new MergedSetCurveCommand( + project, + part, + abbr, + oldXs, + oldYs, + newXs.ToArray(), + newYs.ToArray())); + } + } + } + + public override void End(IPointer pointer, Point point) { + base.End(pointer, point); + + if (initialSelection != null && initialSelection.HasValue(abbr)) { + var notesVm = vm.NotesViewModel; + var part = notesVm.Part; + + if (part != null) { + var curve = part.curves.FirstOrDefault(c => c.abbr == abbr); + if (curve != null) { + vm.CurveViewModel.Select( + descriptor, + lastStartTick, + lastEndTick, + curve); + } + } + } + + initialSelection = null; + } + + protected virtual bool CanEdit(out UProject project, out UVoicePart part) { + part = null!; + project = null!; + + if (initialSelection == null || !initialSelection.HasValue(abbr)) { + return false; + } + + var notesVm = vm.NotesViewModel; + if (notesVm.Project == null || notesVm.Part == null) { + return false; + } + + part = notesVm.Part; + project = notesVm.Project; + return true; + } + + protected void GetCurrentCurveArrays(UVoicePart part, out int[] xs, out int[] ys) { + var curve = part.curves.FirstOrDefault(c => c.abbr == abbr); + xs = curve?.xs.ToArray() ?? Array.Empty(); + ys = curve?.ys.ToArray() ?? Array.Empty(); + } + + protected void BuildEditedCurve(Point point, out List newXs, out List newYs) { + newXs = baseXs.ToList(); + newYs = baseYs.ToList(); + + if (initialSelection == null || !initialSelection.HasValue(abbr)) { + return; + } + + initialSelection.GetSelectedRange(abbr, out var selectedXs, out var selectedYs); + + int originalMinTick = Math.Min(initialSelection.StartPoint.x, initialSelection.EndPoint.x); + int originalMaxTick = Math.Max(initialSelection.StartPoint.x, initialSelection.EndPoint.x); + + int movedStartTick = TransformX(initialSelection.StartPoint.x, initialSelection.StartPoint.y, point); + int movedEndTick = TransformX(initialSelection.EndPoint.x, initialSelection.EndPoint.y, point); + + int movedMinTick = Math.Min(movedStartTick, movedEndTick); + int movedMaxTick = Math.Max(movedStartTick, movedEndTick); + + int removeMinTick = Math.Min(originalMinTick, movedMinTick); + int removeMaxTick = Math.Max(originalMaxTick, movedMaxTick); + + for (int i = newXs.Count - 1; i >= 0; i--) { + int x = newXs[i]; + if (removeMinTick <= x && x <= removeMaxTick) { + newXs.RemoveAt(i); + newYs.RemoveAt(i); + } + } + + for (int i = 0; i < selectedXs.Count; i++) { + InsertCurvePointSorted( + TransformX(selectedXs[i], selectedYs[i], point), + TransformY(selectedXs[i], selectedYs[i], point), + newXs, + newYs); + } + + lastStartTick = movedStartTick; + lastEndTick = movedEndTick; + } + + protected virtual int TransformX(int x, int y, Point point) { + int deltaTick = PointToTick(point) - PointToTick(startPoint); + return ClampTick(x + deltaTick); + } + + protected virtual int TransformY(int x, int y, Point point) { + return ClampY(y); + } + + protected int PointToTick(Point point) { + var notesVm = vm.NotesViewModel; + + int tick = notesVm.PointToTick(point); + if (notesVm.IsSnapOn) { + int snapUnit = notesVm.Project.resolution * 4 / notesVm.SnapDiv; + tick = (int)Math.Floor((double)tick / snapUnit) * snapUnit; + } + + return tick; + } + + protected int ClampTick(int tick) { + return Math.Max(0, tick); + } + + protected int ClampY(double y) { + return (int)Math.Round(Math.Clamp(y, descriptor.min, descriptor.max)); + } + + protected static void InsertCurvePointSorted(int x, int y, List xs, List ys) { + for (int i = 0; i < xs.Count; i++) { + if (xs[i] == x) { + ys[i] = y; + return; + } + + if (x < xs[i]) { + xs.Insert(i, x); + ys.Insert(i, y); + return; + } + } + + xs.Add(x); + ys.Add(y); + } + + protected static bool CurveEquals( + int[] oldXs, + int[] oldYs, + List newXs, + List newYs) { + if (oldXs.Length != newXs.Count || oldYs.Length != newYs.Count) { + return false; + } + + for (int i = 0; i < oldXs.Length; i++) { + if (oldXs[i] != newXs[i]) { + return false; + } + } + + for (int i = 0; i < oldYs.Length; i++) { + if (oldYs[i] != newYs[i]) { + return false; + } + } + + return true; + } + } + class CurveHorizontalStretchState : CurveHorizontalShiftState { + public CurveHorizontalStretchState( + Control control, + PianoRollViewModel vm, + IValueTip valueTip, + UExpressionDescriptor descriptor) : base(control, vm, valueTip, descriptor) { + } + + protected override int TransformX(int x, int y, Point point) { + if (initialSelection == null || !initialSelection.HasValue(abbr)) { + return x; + } + + int deltaTick = PointToTick(point) - PointToTick(startPoint); + + int startTick = initialSelection.StartPoint.x; + int endTick = initialSelection.EndPoint.x; + + int minTick = Math.Min(startTick, endTick); + int maxTick = Math.Max(startTick, endTick); + + int width = maxTick - minTick; + if (width <= 0) { + return x; + } + + double centerTick = (minTick + maxTick) / 2.0; + + double scale = 1.0 + (double)deltaTick / width; + scale = Math.Max(0.01, scale); + + int stretchedX = (int)Math.Round(centerTick + (x - centerTick) * scale); + + return ClampTick(stretchedX); + } + } class VibratoChangeStartState : NoteEditState { public readonly UNote note; From 34b0a972958131c0823d966d3e178f47faf33c54 Mon Sep 17 00:00:00 2001 From: nago <35883871+nagotown@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:50:04 -0700 Subject: [PATCH 2/3] change drawLinePitchTool to pitchLineTool (#15) fixes icon display --- OpenUtau/Controls/PianoRoll.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenUtau/Controls/PianoRoll.axaml b/OpenUtau/Controls/PianoRoll.axaml index d8d72f942..15d98c27a 100644 --- a/OpenUtau/Controls/PianoRoll.axaml +++ b/OpenUtau/Controls/PianoRoll.axaml @@ -611,7 +611,7 @@ - + From 6f4e9d01a64ad2f2ff4a135897254d5c73b70841 Mon Sep 17 00:00:00 2001 From: rokujyushi Date: Sat, 4 Jul 2026 21:22:49 +0900 Subject: [PATCH 3/3] Add setting to enable Snap Curve by default Introduced the DefaultSnapCurve property to allow the Snap Curve to be enabled by default. This setting has been implemented in Preferences.cs, PreferencesViewModel.cs, and NoteEditStates.cs, with a new localization key added to Strings.axaml for the UI. --- OpenUtau.Core/Util/Preferences.cs | 1 + OpenUtau/Strings/Strings.axaml | 1 + OpenUtau/ViewModels/PreferencesViewModel.cs | 7 +++++++ OpenUtau/Views/NoteEditStates.cs | 2 ++ 4 files changed, 11 insertions(+) diff --git a/OpenUtau.Core/Util/Preferences.cs b/OpenUtau.Core/Util/Preferences.cs index f14fe185d..9b53c82e9 100644 --- a/OpenUtau.Core/Util/Preferences.cs +++ b/OpenUtau.Core/Util/Preferences.cs @@ -210,6 +210,7 @@ public class SerializablePreferences { public bool RememberUst = true; public bool RememberVsqx = true; public string WinePath = string.Empty; + public bool DefaultSnapCurve = true; public string PhoneticAssistant = string.Empty; public string RecentOpenSingerDirectory = string.Empty; public string RecentOpenProjectDirectory = string.Empty; diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index 98c15749a..9addd100d 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -582,6 +582,7 @@ Warning: this option removes custom presets. Advanced Beta + Snap curves by default Lyrics Helper Lyrics Helper Adds Brackets Remember these file types in "Open Recent" diff --git a/OpenUtau/ViewModels/PreferencesViewModel.cs b/OpenUtau/ViewModels/PreferencesViewModel.cs index 0683b822e..e706c863a 100644 --- a/OpenUtau/ViewModels/PreferencesViewModel.cs +++ b/OpenUtau/ViewModels/PreferencesViewModel.cs @@ -127,6 +127,7 @@ public int SafeMaxThreadCount { [Reactive] public bool RememberUst { get; set; } [Reactive] public bool RememberVsqx { get; set; } public string WinePath => Preferences.Default.WinePath; + [Reactive] public bool DefaultSnapCurve { get; set; } public PreferencesViewModel() { var audioOutput = PlaybackManager.Inst.AudioOutput; @@ -189,6 +190,7 @@ public PreferencesViewModel() { RememberMid = Preferences.Default.RememberMid; RememberUst = Preferences.Default.RememberUst; RememberVsqx = Preferences.Default.RememberVsqx; + DefaultSnapCurve = Preferences.Default.DefaultSnapCurve; ClearCacheOnQuit = Preferences.Default.ClearCacheOnQuit; MessageBus.Current.Listen() @@ -357,6 +359,11 @@ public PreferencesViewModel() { Preferences.Default.RememberVsqx = index; Preferences.Save(); }); + this.WhenAnyValue(vm => vm.DefaultSnapCurve) + .Subscribe(index => { + Preferences.Default.DefaultSnapCurve = index; + Preferences.Save(); + }); this.WhenAnyValue(vm => vm.ClearCacheOnQuit) .Subscribe(index => { Preferences.Default.ClearCacheOnQuit = index; diff --git a/OpenUtau/Views/NoteEditStates.cs b/OpenUtau/Views/NoteEditStates.cs index b048bc95d..0c11284b1 100644 --- a/OpenUtau/Views/NoteEditStates.cs +++ b/OpenUtau/Views/NoteEditStates.cs @@ -907,9 +907,11 @@ public override void Begin(IPointer pointer, Point point) { var notesVm = vm.NotesViewModel; int snapUnit = notesVm.Project.resolution * 4 / notesVm.SnapDiv; int tick = notesVm.PointToTick(point); + if (Preferences.Default.DefaultSnapCurve) { if (notesVm.IsSnapOn) { tick = (int)Math.Floor((double)tick / snapUnit) * snapUnit; } + } startTick = tick; } public override void End(IPointer pointer, Point point) {