diff --git a/OpenUtau.Core/Util/Preferences.cs b/OpenUtau.Core/Util/Preferences.cs index f14fe185d..7d6273400 100644 --- a/OpenUtau.Core/Util/Preferences.cs +++ b/OpenUtau.Core/Util/Preferences.cs @@ -184,10 +184,18 @@ public class SerializablePreferences { public double PlayPosMarkerMargin = 0.9; public int LockStartTime = 0; public int PlaybackAutoScroll = 2; + public double PlaybackVerticalFollowMargin = 1.5; + // Lower damping follows more smoothly; max step caps tracks scrolled per frame. + public double PlaybackVerticalFollowDamping = 3; + public double PlaybackVerticalFollowMaxStep = 1.5; public bool ReverseLogOrder = true; public bool ShowPortrait = true; public bool ShowIcon = true; public bool ShowGhostNotes = true; + public bool ShowPlaybackVerticalFollow = false; + public bool ShowPlaybackNoteHighlight = false; + public double PlaybackHighlightFadeInPerSecond = 8.0; + public double PlaybackHighlightFadeOutPerSecond = 6.2; public EditTool EditTool = new EditTool(); public bool PlayTone = true; public bool ShowVibrato = true; diff --git a/OpenUtau/Controls/NotesCanvas.cs b/OpenUtau/Controls/NotesCanvas.cs index a0b1e46e4..979c495be 100644 --- a/OpenUtau/Controls/NotesCanvas.cs +++ b/OpenUtau/Controls/NotesCanvas.cs @@ -4,6 +4,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media; +using Avalonia.Threading; using OpenUtau.App.ViewModels; using OpenUtau.Core; using OpenUtau.Core.Ustx; @@ -52,6 +53,16 @@ class NotesCanvas : Control { nameof(ShowVibrato), o => o.ShowVibrato, (o, v) => o.ShowVibrato = v); + public static readonly DirectProperty ShowPlaybackNoteHighlightProperty = + AvaloniaProperty.RegisterDirect( + nameof(ShowPlaybackNoteHighlight), + o => o.ShowPlaybackNoteHighlight, + (o, v) => o.ShowPlaybackNoteHighlight = v); + public static readonly DirectProperty PlayPosTickProperty = + AvaloniaProperty.RegisterDirect( + nameof(PlayPosTick), + o => o.PlayPosTick, + (o, v) => o.PlayPosTick = v); public static readonly DirectProperty ShowPhonemizerTagsProperty = AvaloniaProperty.RegisterDirect( nameof(ShowPhonemizerTags), @@ -90,6 +101,14 @@ public bool ShowVibrato { get => showVibrato; private set => SetAndRaise(ShowVibratoProperty, ref showVibrato, value); } + public bool ShowPlaybackNoteHighlight { + get => showPlaybackNoteHighlight; + private set => SetAndRaise(ShowPlaybackNoteHighlightProperty, ref showPlaybackNoteHighlight, value); + } + public int PlayPosTick { + get => playPosTick; + private set => SetAndRaise(PlayPosTickProperty, ref playPosTick, value); + } public bool ShowPhonemizerTags { get => showPhonemizerTags; private set => SetAndRaise(ShowPhonemizerTagsProperty, ref showPhonemizerTags, value); @@ -103,6 +122,11 @@ public bool ShowPhonemizerTags { private bool showPitch = true; private bool showFinalPitch = true; private bool showVibrato = true; + private bool showPlaybackNoteHighlight; + private int playPosTick = int.MinValue; + private DateTime playbackHighlightLastFrame = DateTime.UtcNow; + private readonly Dictionary playbackHighlightLevels = new Dictionary(); + private bool playbackAnimationInvalidateQueued; private bool showPhonemizerTags = true; private PolylineGeometry polylineGeometry = new PolylineGeometry(); private Points points = new Points(); @@ -129,7 +153,10 @@ public NotesCanvas() { MessageBus.Current.Listen() .Subscribe(_ => RefreshGhostNotes()); this.WhenAnyValue(x => x.Part) - .Subscribe(_ => RefreshGhostNotes()); + .Subscribe(_ => { + ResetPlaybackHighlightAnimation(); + RefreshGhostNotes(); + }); } void RefreshGhostNotes() { @@ -146,6 +173,9 @@ void RefreshGhostNotes() { protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); + if (change.Property == PlayPosTickProperty && !showPlaybackNoteHighlight) { + return; + } InvalidateVisual(); } @@ -162,6 +192,7 @@ public override void Render(DrawingContext context) { double leftTick = TickOffset - 480; double rightTick = TickOffset + Bounds.Width / TickWidth + 480; bool hidePitch = viewModel.TickWidth <= ViewConstants.PianoRollTickWidthShowDetails * 0.5; + UpdatePlaybackHighlightAnimation(); if (showGhostNotes) { foreach (UPart otherPart in otherPartsInView) { @@ -219,9 +250,17 @@ private void RenderNoteBody(UNote note, NotesViewModel viewModel, DrawingContext Size size = viewModel.TickToneToSize(note.duration, 1); size = size.WithWidth(size.Width - 1).WithHeight(Math.Floor(size.Height - 2)); Point rightBottom = new Point(leftTop.X + size.Width, leftTop.Y + size.Height); - var brush = selectedNotes.Contains(note) + bool isSelected = selectedNotes.Contains(note); + var brush = isSelected ? (note.Error ? ThemeManager.AccentBrush2Semi : ThemeManager.AccentBrush2) : (note.Error ? ThemeManager.AccentBrush1Semi : ThemeManager.AccentBrush1); + if (!isSelected) { + float highlight = GetPlaybackHighlightLevel(note); + if (highlight > 0.001f) { + var highlightedBrush = note.Error ? ThemeManager.AccentBrush2Semi : ThemeManager.AccentBrush2; + brush = BlendBrush(brush, highlightedBrush, highlight); + } + } context.DrawRectangle(brush, null, new Rect(leftTop, rightBottom), 2, 2); if (TrackHeight < 10 || note.lyric.Length == 0) { return; @@ -335,6 +374,86 @@ private void RenderGhostNote(UNote note, NotesViewModel viewModel, DrawingContex context.DrawRectangle(brush, null, new Rect(leftTop, rightBottom), 2, 2); } + private void ResetPlaybackHighlightAnimation() { + playbackHighlightLevels.Clear(); + playbackHighlightLastFrame = DateTime.UtcNow; + } + + private void UpdatePlaybackHighlightAnimation() { + if (Part == null) { + ResetPlaybackHighlightAnimation(); + return; + } + var now = DateTime.UtcNow; + float dt = (float)Math.Clamp((now - playbackHighlightLastFrame).TotalSeconds, 0, 0.1); + playbackHighlightLastFrame = now; + + UNote? activeNote = null; + if (ShowPlaybackNoteHighlight && PlaybackManager.Inst.PlayingMaster) { + activeNote = Part.notes.FirstOrDefault( + note => note.LeftBound <= PlayPosTick && PlayPosTick < note.RightBound); + if (activeNote != null && !playbackHighlightLevels.ContainsKey(activeNote)) { + playbackHighlightLevels[activeNote] = 0f; + } + } + + bool animating = false; + foreach (var note in playbackHighlightLevels.Keys.ToList()) { + float current = playbackHighlightLevels[note]; + float target = note == activeNote ? 1f : 0f; + float fadeIn = (float)Math.Clamp( + Preferences.Default.PlaybackHighlightFadeInPerSecond, 0.1, 30.0); + float fadeOut = (float)Math.Clamp( + Preferences.Default.PlaybackHighlightFadeOutPerSecond, 0.1, 30.0); + float next = MoveTowards(current, target, (target > current ? fadeIn : fadeOut) * dt); + if (target == 0f && next <= 0.001f) { + playbackHighlightLevels.Remove(note); + continue; + } + playbackHighlightLevels[note] = next; + animating |= Math.Abs(next - target) > 0.001f; + } + if (animating) { + QueuePlaybackAnimationFrame(); + } + } + + private void QueuePlaybackAnimationFrame() { + if (playbackAnimationInvalidateQueued) { + return; + } + playbackAnimationInvalidateQueued = true; + Dispatcher.UIThread.Post(() => { + playbackAnimationInvalidateQueued = false; + InvalidateVisual(); + }, DispatcherPriority.Background); + } + + private float GetPlaybackHighlightLevel(UNote note) { + return playbackHighlightLevels.TryGetValue(note, out var value) ? value : 0f; + } + + private static float MoveTowards(float current, float target, float maxDelta) { + if (Math.Abs(target - current) <= maxDelta) { + return target; + } + return current + Math.Sign(target - current) * maxDelta; + } + + private static IBrush BlendBrush(IBrush from, IBrush to, float amount) { + if (from is not ISolidColorBrush fromSolid || to is not ISolidColorBrush toSolid) { + return amount >= 0.5f ? to : from; + } + amount = Math.Clamp(amount, 0f, 1f); + Color a = fromSolid.Color; + Color b = toSolid.Color; + return new SolidColorBrush(Color.FromArgb( + (byte)(a.A + (b.A - a.A) * amount), + (byte)(a.R + (b.R - a.R) * amount), + (byte)(a.G + (b.G - a.G) * amount), + (byte)(a.B + (b.B - a.B) * amount))); + } + private void RenderPitchBend(UNote note, NotesViewModel viewModel, DrawingContext context) { var pitchExp = note.pitch; var pts = pitchExp.data; diff --git a/OpenUtau/Controls/PianoRoll.axaml b/OpenUtau/Controls/PianoRoll.axaml index 647d22145..6f1a366a7 100644 --- a/OpenUtau/Controls/PianoRoll.axaml +++ b/OpenUtau/Controls/PianoRoll.axaml @@ -169,6 +169,8 @@ ShowVibrato="{Binding NotesViewModel.ShowVibrato}" ShowPitch="{Binding NotesViewModel.ShowPitch}" ShowFinalPitch="{Binding NotesViewModel.ShowFinalPitch}" + ShowPlaybackNoteHighlight="{Binding NotesViewModel.ShowPlaybackNoteHighlight}" + PlayPosTick="{Binding NotesViewModel.PlayPosTick}" ShowPhonemizerTags="{Binding ShowPhonemizerTags}" Part="{Binding NotesViewModel.Part}" PointerPressed="NotesCanvasPointerPressed" @@ -296,6 +298,16 @@ + + + + + + + + + + diff --git a/OpenUtau/Controls/PianoRoll.axaml.cs b/OpenUtau/Controls/PianoRoll.axaml.cs index 00cfaaefe..c1d0b1a45 100644 --- a/OpenUtau/Controls/PianoRoll.axaml.cs +++ b/OpenUtau/Controls/PianoRoll.axaml.cs @@ -284,6 +284,14 @@ void OnMenuShowGhostNotes(object sender, RoutedEventArgs args) { MessageBus.Current.SendMessage(new PianorollRefreshEvent("Part")); } + void OnMenuPlaybackVerticalFollow(object sender, RoutedEventArgs args) { + ViewModel.NotesViewModel.ShowPlaybackVerticalFollow = + !ViewModel.NotesViewModel.ShowPlaybackVerticalFollow; + } + void OnMenuPlaybackNoteHighlight(object sender, RoutedEventArgs args) { + ViewModel.NotesViewModel.ShowPlaybackNoteHighlight = + !ViewModel.NotesViewModel.ShowPlaybackNoteHighlight; + } void OnMenuUseTrackColor(object sender, RoutedEventArgs args) { Preferences.Default.UseTrackColor = !Preferences.Default.UseTrackColor; Preferences.Save(); diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index a5f175793..2e8e270a8 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -515,6 +515,10 @@ Warning: this option removes custom presets. Toggle Overwrite Pitch Mode View Vibrato (U) View Waveform (W) + Center Playback Note Vertically + Highlight Notes On Playback + Note highlight fade-in speed + Note highlight fade-out speed View Expressions (L) Draw Pitch Tool (Shift + 1) Left click to draw diff --git a/OpenUtau/ViewModels/NotesViewModel.cs b/OpenUtau/ViewModels/NotesViewModel.cs index c14e9deee..c0799456f 100644 --- a/OpenUtau/ViewModels/NotesViewModel.cs +++ b/OpenUtau/ViewModels/NotesViewModel.cs @@ -52,6 +52,7 @@ public class NotesViewModel : ViewModelBase, ICmdSubscriber { [Reactive] public double PlayPosHighlightX { get; set; } [Reactive] public double PlayPosHighlightWidth { get; set; } [Reactive] public bool PlayPosWaitingRendering { get; set; } + [Reactive] public int PlayPosTick { get; set; } [Reactive] public bool ShowTips { get; set; } [Reactive] public bool PlayTone { get; set; } [Reactive] public bool ShowVibrato { get; set; } @@ -61,6 +62,8 @@ public class NotesViewModel : ViewModelBase, ICmdSubscriber { [Reactive] public bool ShowPhoneme { get; set; } [Reactive] public bool ShowNoteParams { get; set; } [Reactive] public bool ShowExpressions { get; set; } + [Reactive] public bool ShowPlaybackVerticalFollow { get; set; } + [Reactive] public bool ShowPlaybackNoteHighlight { get; set; } [Reactive] public bool IsSnapOn { get; set; } [Reactive] public string SnapDivText { get; set; } [Reactive] public string KeyText { get; set; } @@ -108,6 +111,9 @@ public class NotesViewModel : ViewModelBase, ICmdSubscriber { private readonly object portraitLock = new object(); private int userSnapDiv = -2; private int userKey => Project.key; + private DateTime playbackVerticalFollowLastFrame = DateTime.UtcNow; + private double? playbackVerticalFollowTargetOffset; + private static readonly int PlaybackVerticalFollowOutlierThreshold = 12; public NotesViewModel() { SnapDivs = new List(); @@ -267,6 +273,23 @@ public NotesViewModel() { Preferences.Default.ShowNoteParams = showNoteParams; Preferences.Save(); }); + ShowPlaybackVerticalFollow = Preferences.Default.ShowPlaybackVerticalFollow; + this.WhenAnyValue(x => x.ShowPlaybackVerticalFollow) + .Subscribe(showPlaybackVerticalFollow => { + Preferences.Default.ShowPlaybackVerticalFollow = showPlaybackVerticalFollow; + Preferences.Save(); + ResetPlaybackVerticalFollowAnimation(); + }); + ShowPlaybackNoteHighlight = Preferences.Default.ShowPlaybackNoteHighlight; + this.WhenAnyValue(x => x.ShowPlaybackNoteHighlight) + .Subscribe(showPlaybackNoteHighlight => { + Preferences.Default.ShowPlaybackNoteHighlight = showPlaybackNoteHighlight; + Preferences.Save(); + MessageBus.Current.SendMessage(new NotesRefreshEvent()); + }); + this.WhenAnyValue(x => x.TrackOffset) + .Skip(1) + .Subscribe(_ => playbackVerticalFollowTargetOffset = null); TickWidth = ViewConstants.PianoRollTickWidthDefault; TrackHeight = ViewConstants.NoteHeightDefault; @@ -443,6 +466,7 @@ private void LoadPart(UPart part, UProject project) { } UnloadPart(); Part = part as UVoicePart; + ResetPlaybackVerticalFollowAnimation(); OnPartModified(); LoadPortrait(part, project); LoadWindowTitle(part, project); @@ -557,6 +581,7 @@ private void LoadTrackColor(UPart? part, UProject? project) { private void UnloadPart() { DeselectNotes(); Part = null; + ResetPlaybackVerticalFollowAnimation(); LoadPortrait(null, null); LoadWindowTitle(null, null); } @@ -978,11 +1003,12 @@ public void ClearPhraseCache() { } private void SetPlayPos(int tick, bool waitingRendering) { + PlayPosTick = tick - (Part?.position ?? 0); PlayPosWaitingRendering = waitingRendering; if (waitingRendering) { return; } - tick -= Part?.position ?? 0; + tick = PlayPosTick; PlayPosX = TickToneToPoint(tick, 0).X; UpdateHighlight(); } @@ -1075,6 +1101,9 @@ public void OnNext(UCommand cmd, bool isUndo) { if (!setPlayPosTick.pause || Preferences.Default.LockStartTime == 1) { MaybeAutoScroll(PlayPosX); } + if (!setPlayPosTick.pause && !setPlayPosTick.waitingRendering) { + MaybeAutoScrollVertical(); + } } else if (cmd is SetRangeSelectionNotification) { UpdateHighlight(); } else if (cmd is FocusNoteNotification focusNote) { @@ -1169,6 +1198,98 @@ private void AutoScroll(double positionX) { TickOffset = Math.Clamp(TickOffset + scrollDelta, 0, HScrollBarMax); } + private void MaybeAutoScrollVertical() { + if (!ShowPlaybackVerticalFollow || Part == null || ViewportTracks <= 0) { + return; + } + var targetNote = FindPlaybackVerticalFollowNote(PlayPosTick); + if (targetNote == null) { + return; + } + double noteTrackPos = ViewConstants.MaxTone - 1 - (targetNote.AdjustedTone - 0.5); + double margin = Preferences.Default.PlaybackVerticalFollowMargin; + double centerTrackPos = TrackOffset + ViewportTracks * 0.5; + + if (playbackVerticalFollowTargetOffset.HasValue && + Math.Abs(playbackVerticalFollowTargetOffset.Value - TrackOffset) < 0.1) { + playbackVerticalFollowTargetOffset = null; + } + if (Math.Abs(noteTrackPos - centerTrackPos) > margin) { + playbackVerticalFollowTargetOffset = noteTrackPos - ViewportTracks * 0.5; + } + + double targetTrackOffset = Math.Clamp( + playbackVerticalFollowTargetOffset ?? TrackOffset, 0, VScrollBarMax); + double scrollDelta = GetVerticalFollowScrollValueDelta(targetTrackOffset); + if (Math.Abs(scrollDelta) >= 0.0001) { + TrackOffset = Math.Clamp(TrackOffset + scrollDelta, 0, VScrollBarMax); + } + } + + private UNote? FindPlaybackVerticalFollowNote(int tick) { + if (Part == null || Part.notes.Count == 0) { + return null; + } + UNote? current = Part.notes.FirstOrDefault( + note => note.LeftBound <= tick && tick < note.RightBound); + if (current != null) { + return IsToneOutlier(current) ? null : current; + } + UNote? next = current?.Next ?? Part.notes.FirstOrDefault(note => note.position > tick); + if (next != null && current == null && tick < GetVerticalFollowPreparationTick(next)) { + return null; + } + return next != null && IsToneOutlier(next) ? null : next; + } + + private bool IsToneOutlier(UNote note) { + if (PlaybackVerticalFollowOutlierThreshold <= 0) { + return false; + } + var window = new List(); + var previous = note.Prev; + for (int i = 0; i < 3 && previous != null; i++, previous = previous.Prev) { + window.Add(previous.tone); + } + var next = note.Next; + for (int i = 0; i < 3 && next != null; i++, next = next.Next) { + window.Add(next.tone); + } + if (window.Count == 0) { + return false; + } + window.Sort(); + double median = window[window.Count / 2]; + return Math.Abs(note.tone - median) > PlaybackVerticalFollowOutlierThreshold; + } + + private int GetVerticalFollowPreparationTick(UNote note) { + int minLead = Math.Max(1, Project.resolution / 4); + int maxLead = Math.Max(minLead, Project.resolution * 2); + int prepTick = note.position - Math.Clamp(note.duration, minLead, maxLead); + UNote? previous = note.Prev; + return previous == null ? prepTick : Math.Max(prepTick, previous.RightBound); + } + + private double GetVerticalFollowScrollValueDelta(double targetTrackOffset) { + var now = DateTime.UtcNow; + double dt = Math.Clamp((now - playbackVerticalFollowLastFrame).TotalSeconds, 0, 0.05); + playbackVerticalFollowLastFrame = now; + double delta = targetTrackOffset - TrackOffset; + if (Math.Abs(delta) < 0.01) { + return 0; + } + double alpha = 1 - Math.Exp(-Preferences.Default.PlaybackVerticalFollowDamping * dt); + double step = delta * alpha; + double maxStep = Preferences.Default.PlaybackVerticalFollowMaxStep; + return Math.Clamp(step, -maxStep, maxStep); + } + + private void ResetPlaybackVerticalFollowAnimation() { + playbackVerticalFollowLastFrame = DateTime.UtcNow; + playbackVerticalFollowTargetOffset = null; + } + private double GetScrollValueDelta(double positionX) { var pageScroll = Preferences.Default.PlaybackAutoScroll == 2; if (pageScroll) { diff --git a/OpenUtau/ViewModels/PreferencesViewModel.cs b/OpenUtau/ViewModels/PreferencesViewModel.cs index 0683b822e..5bfd2c797 100644 --- a/OpenUtau/ViewModels/PreferencesViewModel.cs +++ b/OpenUtau/ViewModels/PreferencesViewModel.cs @@ -58,6 +58,10 @@ public AudioOutputDevice? AudioOutputDevice { [Reactive] public int PreferPortAudio { get; set; } [Reactive] public int LockStartTime { get; set; } [Reactive] public int PlaybackAutoScroll { get; set; } + [Reactive] public double PlaybackVerticalFollowMargin { get; set; } + [Reactive] public double PlaybackVerticalFollowDamping { get; set; } + [Reactive] public double PlaybackHighlightFadeInPerSecond { get; set; } + [Reactive] public double PlaybackHighlightFadeOutPerSecond { get; set; } [Reactive] public double PlayPosMarkerMargin { get; set; } // Paths @@ -141,6 +145,10 @@ public PreferencesViewModel() { UseSystemDefaultDevice = Preferences.Default.UseSystemDefaultAudioDevice; PreferPortAudio = Preferences.Default.PreferPortAudio ? 1 : 0; PlaybackAutoScroll = Preferences.Default.PlaybackAutoScroll; + PlaybackVerticalFollowMargin = Preferences.Default.PlaybackVerticalFollowMargin; + PlaybackVerticalFollowDamping = Preferences.Default.PlaybackVerticalFollowDamping; + PlaybackHighlightFadeInPerSecond = Preferences.Default.PlaybackHighlightFadeInPerSecond; + PlaybackHighlightFadeOutPerSecond = Preferences.Default.PlaybackHighlightFadeOutPerSecond; PlayPosMarkerMargin = Preferences.Default.PlayPosMarkerMargin; LockStartTime = Preferences.Default.LockStartTime; InstallToAdditionalSingersPath = Preferences.Default.InstallToAdditionalSingersPath; @@ -224,6 +232,26 @@ public PreferencesViewModel() { Preferences.Default.PlaybackAutoScroll = autoScroll; Preferences.Save(); }); + this.WhenAnyValue(vm => vm.PlaybackVerticalFollowMargin) + .Subscribe(margin => { + Preferences.Default.PlaybackVerticalFollowMargin = Math.Clamp(margin, 0.0, 10.0); + Preferences.Save(); + }); + this.WhenAnyValue(vm => vm.PlaybackVerticalFollowDamping) + .Subscribe(damping => { + Preferences.Default.PlaybackVerticalFollowDamping = Math.Clamp(damping, 1.0, 20.0); + Preferences.Save(); + }); + this.WhenAnyValue(vm => vm.PlaybackHighlightFadeInPerSecond) + .Subscribe(value => { + Preferences.Default.PlaybackHighlightFadeInPerSecond = Math.Clamp(value, 0.1, 30.0); + Preferences.Save(); + }); + this.WhenAnyValue(vm => vm.PlaybackHighlightFadeOutPerSecond) + .Subscribe(value => { + Preferences.Default.PlaybackHighlightFadeOutPerSecond = Math.Clamp(value, 0.1, 30.0); + Preferences.Save(); + }); this.WhenAnyValue(vm => vm.PlayPosMarkerMargin) .Subscribe(playPosMarkerMargin => { Preferences.Default.PlayPosMarkerMargin = playPosMarkerMargin; diff --git a/OpenUtau/Views/PreferencesDialog.axaml b/OpenUtau/Views/PreferencesDialog.axaml index 290323724..f8c07fab1 100644 --- a/OpenUtau/Views/PreferencesDialog.axaml +++ b/OpenUtau/Views/PreferencesDialog.axaml @@ -129,6 +129,30 @@ + + + + + + + + + + + + + + + + + + + +