From 7224f4cc2754f1752c33e7a27cd9ec6e11d110f3 Mon Sep 17 00:00:00 2001 From: rokujyushi Date: Sat, 20 Jun 2026 05:25:09 +0900 Subject: [PATCH] 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;