From 61804f7c185acc88fca175ca15b1ff1c47799f89 Mon Sep 17 00:00:00 2001 From: Jed556 Date: Sun, 4 Oct 2026 03:18:44 +0800 Subject: [PATCH 1/5] Create studio view --- .../Entities/KeyChangeMarker.cs | 42 + AutoMidiPlayer.Data/Entities/Song.cs | 51 + AutoMidiPlayer.Data/Midi/MidiTrack.cs | 14 + AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj | 2 +- AutoMidiPlayer.WPF/Bootstrapper.cs | 3 +- .../PianoRoll/HorizontalPianoRoll.xaml | 44 + .../PianoRoll/HorizontalPianoRoll.xaml.cs | 285 ++++ .../InteractivePianoRollControl.xaml | 58 + .../InteractivePianoRollControl.xaml.cs | 420 ++++++ .../PianoRoll/MidiTimelineScrubber.xaml | 133 ++ .../PianoRoll/MidiTimelineScrubber.xaml.cs | 424 ++++++ .../Controls/PianoRoll/PianoRollCanvas.cs | 1250 +++++++++++++++++ .../Controls/PianoRoll/TimelineCanvas.cs | 513 +++++++ .../Helpers/CachedViewManager.cs | 2 +- .../Helpers/TrackColorPalette.cs | 145 ++ AutoMidiPlayer.WPF/Services/FileService.cs | 44 + .../Services/PlaybackService.cs | 89 +- .../Views/MainWindow/MainWindow.xaml | 4 + .../Views/MainWindow/MainWindowViewModel.cs | 5 + .../Views/Studio/StudioSidebarControl.xaml | 315 +++++ .../Views/Studio/StudioSidebarControl.xaml.cs | 11 + .../Views/Studio/StudioView.xaml | 317 +++++ .../Views/Studio/StudioView.xaml.cs | 203 +++ .../Views/Studio/StudioViewModel.cs | 650 +++++++++ .../Views/Track/TrackViewModel.cs | 1 + 25 files changed, 5009 insertions(+), 16 deletions(-) create mode 100644 AutoMidiPlayer.Data/Entities/KeyChangeMarker.cs create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs create mode 100644 AutoMidiPlayer.WPF/Helpers/TrackColorPalette.cs create mode 100644 AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml create mode 100644 AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml.cs create mode 100644 AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml create mode 100644 AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs create mode 100644 AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs diff --git a/AutoMidiPlayer.Data/Entities/KeyChangeMarker.cs b/AutoMidiPlayer.Data/Entities/KeyChangeMarker.cs new file mode 100644 index 0000000..18664dc --- /dev/null +++ b/AutoMidiPlayer.Data/Entities/KeyChangeMarker.cs @@ -0,0 +1,42 @@ +using System; +using System.Text.Json.Serialization; + +namespace AutoMidiPlayer.Data.Entities; + +/// +/// Represents a timed key offset change (modulation point) within a song. +/// +public class KeyChangeMarker +{ + /// + /// Timestamp in milliseconds from the start of the song. + /// + public long TimeMs { get; set; } + + /// + /// The relative key offset for this section (relative to Song.BaseKey). + /// + public int KeyOffset { get; set; } + + /// + /// Optional section-specific transpose mode override. + /// If null, inherits the song's global Transpose mode. + /// + public Transpose? Transpose { get; set; } + + /// + /// Optional section title or label (e.g., "Verse 1", "Chorus (+1)", "Key Change"). + /// + public string? Label { get; set; } + + [JsonIgnore] + public TimeSpan Time => TimeSpan.FromMilliseconds(Math.Max(0, TimeMs)); + + [JsonIgnore] + public string TimeString => Time.ToString(@"mm\:ss\.fff"); + + [JsonIgnore] + public string NoteDisplay => MusicConstants.GetNoteName(KeyOffset); + + public override string ToString() => $"{TimeString} - {NoteDisplay} ({KeyOffset:+0;-0;0})"; +} diff --git a/AutoMidiPlayer.Data/Entities/Song.cs b/AutoMidiPlayer.Data/Entities/Song.cs index 10ea002..127c109 100644 --- a/AutoMidiPlayer.Data/Entities/Song.cs +++ b/AutoMidiPlayer.Data/Entities/Song.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.IO; +using System.Linq; using System.Security.Cryptography; +using System.Text.Json; namespace AutoMidiPlayer.Data.Entities; @@ -77,6 +80,54 @@ public Song(string path, int key) /// public uint? ChordDetectionMilliseconds { get; set; } + private string? _keyChanges; + private List? _cachedKeyChangeMarkers; + + /// + /// JSON-serialized list of timed key modulation markers. + /// + public string? KeyChanges + { + get => _keyChanges; + set + { + _keyChanges = value; + _cachedKeyChangeMarkers = null; + } + } + + [NotMapped] + public List KeyChangeMarkers + { + get + { + if (_cachedKeyChangeMarkers != null) return _cachedKeyChangeMarkers; + if (string.IsNullOrWhiteSpace(KeyChanges)) return _cachedKeyChangeMarkers = new(); + try { return _cachedKeyChangeMarkers = JsonSerializer.Deserialize>(KeyChanges) ?? new(); } + catch { return _cachedKeyChangeMarkers = new(); } + } + set + { + _cachedKeyChangeMarkers = value; + KeyChanges = value is { Count: > 0 } ? JsonSerializer.Serialize(value) : null; + } + } + + public KeyChangeMarker? GetKeyMarkerAtTime(TimeSpan time, long lookaheadMs = 35) + { + var markers = KeyChangeMarkers; + if (markers.Count == 0) return null; + var ms = (long)time.TotalMilliseconds + lookaheadMs; + return markers.Where(m => m.TimeMs <= ms).MaxBy(m => m.TimeMs); + } + + public int GetEffectiveKeyOffsetAtTime(TimeSpan time) + { + var marker = GetKeyMarkerAtTime(time); + var offset = marker?.KeyOffset ?? Key; + return MusicConstants.GetEffectiveKeyOffset(offset, BaseKey); + } + /// /// Computes SHA-256 hash of a file's content. /// diff --git a/AutoMidiPlayer.Data/Midi/MidiTrack.cs b/AutoMidiPlayer.Data/Midi/MidiTrack.cs index 2d97430..4a9abd1 100644 --- a/AutoMidiPlayer.Data/Midi/MidiTrack.cs +++ b/AutoMidiPlayer.Data/Midi/MidiTrack.cs @@ -62,6 +62,20 @@ public int DisplayTrackNumber } } + private int _totalTracks = 1; + public int TotalTracks + { + get => _totalTracks; + set + { + if (_totalTracks != value) + { + _totalTracks = value; + OnPropertyChanged(); + } + } + } + private HashSet? _noteNumbers; // Cached note numbers for fast lookup private Dictionary>? _noteTimingsUs; private const int GlowDurationMs = 150; // How long the glow stays on diff --git a/AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj b/AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj index 34293c6..393237f 100644 --- a/AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj +++ b/AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj @@ -6,7 +6,7 @@ true AutoMidiPlayer.WPF.App app.manifest - 7.13.7 + 7.14.0 Resources\logo.ico enable https://github.com/Jed556/AutoMidiPlayer diff --git a/AutoMidiPlayer.WPF/Bootstrapper.cs b/AutoMidiPlayer.WPF/Bootstrapper.cs index 0efe6da..6f10c3b 100644 --- a/AutoMidiPlayer.WPF/Bootstrapper.cs +++ b/AutoMidiPlayer.WPF/Bootstrapper.cs @@ -58,7 +58,8 @@ private static readonly (string ColumnName, string SqlType)[] SongColumnMigratio ("Bpm", "REAL NULL"), ("BaseKey", "INTEGER NULL"), ("CachedDurationMs", "INTEGER NULL"), - ("CachedNativeBpm", "REAL NULL") + ("CachedNativeBpm", "REAL NULL"), + ("KeyChanges", "TEXT NULL") ]; public Bootstrapper() diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml new file mode 100644 index 0000000..7d36dcf --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs new file mode 100644 index 0000000..3421d21 --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using AutoMidiPlayer.Data.Entities; +using AutoMidiPlayer.Data.Midi; + +namespace AutoMidiPlayer.WPF.Controls.PianoRoll; + +public partial class HorizontalPianoRoll : UserControl +{ + public event EventHandler? ScrubRequested; + public event EventHandler? MarkerClicked; + public event EventHandler? ZoomAdjustRequested; + public event EventHandler? HoverTimeChanged; + public event EventHandler? ViewTimeChanged; + + public static readonly DependencyProperty IsSyncEnabledProperty = + DependencyProperty.Register( + nameof(IsSyncEnabled), + typeof(bool), + typeof(HorizontalPianoRoll), + new PropertyMetadata(true, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.IsSyncEnabled = (bool)e.NewValue; + } + })); + + public bool IsSyncEnabled + { + get => (bool)GetValue(IsSyncEnabledProperty); + set => SetValue(IsSyncEnabledProperty, value); + } + + public static readonly DependencyProperty ViewTimeProperty = + DependencyProperty.Register( + nameof(ViewTime), + typeof(TimeSpan?), + typeof(HorizontalPianoRoll), + new PropertyMetadata(null, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.ViewTime = (TimeSpan?)e.NewValue; + } + })); + + public TimeSpan? ViewTime + { + get => (TimeSpan?)GetValue(ViewTimeProperty); + set => SetValue(ViewTimeProperty, value); + } + + public static readonly DependencyProperty HoverTimeProperty = + DependencyProperty.Register( + nameof(HoverTime), + typeof(TimeSpan?), + typeof(HorizontalPianoRoll), + new PropertyMetadata(null, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.HoverTime = (TimeSpan?)e.NewValue; + } + })); + + public TimeSpan? HoverTime + { + get => (TimeSpan?)GetValue(HoverTimeProperty); + set => SetValue(HoverTimeProperty, value); + } + public static readonly DependencyProperty MidiFileProperty = + DependencyProperty.Register( + nameof(MidiFile), + typeof(MidiFile), + typeof(HorizontalPianoRoll), + new PropertyMetadata(null, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.MidiFile = (MidiFile?)e.NewValue; + } + })); + + public MidiFile? MidiFile + { + get => (MidiFile?)GetValue(MidiFileProperty); + set => SetValue(MidiFileProperty, value); + } + + public static readonly DependencyProperty CurrentTimeProperty = + DependencyProperty.Register( + nameof(CurrentTime), + typeof(TimeSpan), + typeof(HorizontalPianoRoll), + new PropertyMetadata(TimeSpan.Zero, (d, e) => ((HorizontalPianoRoll)d).Canvas.CurrentTime = (TimeSpan)e.NewValue)); + + public TimeSpan CurrentTime + { + get => (TimeSpan)GetValue(CurrentTimeProperty); + set => SetValue(CurrentTimeProperty, value); + } + + public static readonly DependencyProperty KeyChangeMarkersProperty = + DependencyProperty.Register( + nameof(KeyChangeMarkers), + typeof(IEnumerable), + typeof(HorizontalPianoRoll), + new PropertyMetadata(null, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.KeyChangeMarkers = (IEnumerable?)e.NewValue; + } + })); + + public IEnumerable? KeyChangeMarkers + { + get => (IEnumerable?)GetValue(KeyChangeMarkersProperty); + set => SetValue(KeyChangeMarkersProperty, value); + } + + public static readonly DependencyProperty ZoomLevelProperty = + DependencyProperty.Register( + nameof(ZoomLevel), + typeof(double), + typeof(HorizontalPianoRoll), + new FrameworkPropertyMetadata(1.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, (d, e) => ((HorizontalPianoRoll)d).Canvas.ZoomLevel = (double)e.NewValue)); + + public double ZoomLevel + { + get => (double)GetValue(ZoomLevelProperty); + set => SetValue(ZoomLevelProperty, value); + } + + public static readonly DependencyProperty KeyHeightProperty = + DependencyProperty.Register( + nameof(KeyHeight), + typeof(double), + typeof(HorizontalPianoRoll), + new FrameworkPropertyMetadata(16.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.KeyHeight = (double)e.NewValue; + } + })); + + public double KeyHeight + { + get => (double)GetValue(KeyHeightProperty); + set => SetValue(KeyHeightProperty, value); + } + + public event EventHandler? KeyHeightAdjustRequested; + + public HorizontalPianoRoll() + { + InitializeComponent(); + Canvas.ScrubRequested += (s, time) => ScrubRequested?.Invoke(this, time); + Canvas.MarkerClicked += (s, marker) => MarkerClicked?.Invoke(this, marker); + Canvas.HoverTimeChanged += (s, time) => HoverTimeChanged?.Invoke(this, time); + Canvas.ViewTimeChanged += (s, time) => + { + ViewTime = time; + ViewTimeChanged?.Invoke(this, time); + }; + Canvas.ScrollBoundsChanged += (s, e) => UpdateScrollBounds(); + Canvas.VerticalOffsetChanged += (s, offset) => + { + if (VerticalScrollBar != null && Math.Abs(VerticalScrollBar.Value - offset) > 0.01) + { + VerticalScrollBar.Value = offset; + } + }; + + PreviewMouseWheel += OnPreviewMouseWheel; + SizeChanged += (_, _) => UpdateScrollBounds(); + Loaded += (_, _) => + { + if (Canvas != null) + { + if (Canvas.MidiFile != MidiFile) + Canvas.MidiFile = MidiFile; + if (Canvas.KeyChangeMarkers != KeyChangeMarkers) + Canvas.KeyChangeMarkers = KeyChangeMarkers; + Canvas.IsSyncEnabled = IsSyncEnabled; + Canvas.ViewTime = ViewTime; + Canvas.KeyHeight = KeyHeight; + } + Canvas?.RebuildNoteIndex(); + UpdateScrollBounds(); + }; + } + + public bool IsOverflowing => Canvas?.IsOverflowing ?? false; + public PianoRollCanvas RollCanvas => Canvas; + + public void RefreshNotes() + { + Canvas.RebuildNoteIndex(); + UpdateScrollBounds(); + } + + private void UpdateScrollBounds() + { + if (Canvas == null || VerticalScrollBar == null) return; + var totalHeight = Canvas.TotalContentHeight; + var viewportHeight = Canvas.ActualHeight; + + if (totalHeight > viewportHeight && viewportHeight > 0) + { + VerticalScrollBar.Visibility = Visibility.Visible; + VerticalScrollBar.Maximum = totalHeight - viewportHeight; + VerticalScrollBar.ViewportSize = viewportHeight; + VerticalScrollBar.SmallChange = Canvas.KeyHeight; + VerticalScrollBar.LargeChange = Canvas.KeyHeight * 4; + VerticalScrollBar.Value = Canvas.VerticalOffset; + } + else + { + VerticalScrollBar.Visibility = Visibility.Collapsed; + VerticalScrollBar.Value = 0; + Canvas.VerticalOffset = 0; + } + } + + private void OnVerticalScrollBarScroll(object sender, System.Windows.Controls.Primitives.ScrollEventArgs e) + { + if (Canvas != null) + { + Canvas.VerticalOffset = e.NewValue; + } + } + + public void AdjustKeyHeight(double delta) + { + var target = Math.Clamp(KeyHeight + delta, 6.0, 50.0); + KeyHeight = target; + KeyHeightAdjustRequested?.Invoke(this, delta); + } + + private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + { + // Horizontal time zoom (Ctrl+Wheel) + var delta = e.Delta > 0 ? 1.0 : -1.0; + if (ZoomAdjustRequested != null) + { + ZoomAdjustRequested.Invoke(this, delta); + } + else + { + var next = delta > 0 ? ZoomLevel + 0.25 : ZoomLevel - 0.25; + ZoomLevel = Math.Clamp(next, 0.25, 5.0); + } + e.Handled = true; + } + else if (Keyboard.Modifiers.HasFlag(ModifierKeys.Alt) || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) + { + // Vertical key height zoom (Alt+Wheel or Shift+Wheel) + var delta = e.Delta > 0 ? 2.0 : -2.0; + AdjustKeyHeight(delta); + e.Handled = true; + } + else + { + // Regular wheel: scroll piano roll vertically through notes (FL Studio style) + if (Canvas != null && Canvas.TotalContentHeight > Canvas.ActualHeight && Canvas.ActualHeight > 0) + { + var scrollDelta = e.Delta > 0 ? -Canvas.KeyHeight * 2 : Canvas.KeyHeight * 2; + Canvas.VerticalOffset = Math.Clamp(Canvas.VerticalOffset + scrollDelta, 0, Canvas.MaxVerticalOffset); + if (VerticalScrollBar != null) + { + VerticalScrollBar.Value = Canvas.VerticalOffset; + } + e.Handled = true; + } + } + } +} diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml new file mode 100644 index 0000000..6dc6cc0 --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs new file mode 100644 index 0000000..206b57d --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AutoMidiPlayer.Data.Entities; +using AutoMidiPlayer.Data.Midi; + +namespace AutoMidiPlayer.WPF.Controls.PianoRoll; + +public partial class InteractivePianoRollControl : UserControl +{ + public event EventHandler? ScrubRequested; + public event EventHandler? MarkerClicked; + + public static readonly DependencyProperty MidiFileProperty = + DependencyProperty.Register( + nameof(MidiFile), + typeof(MidiFile), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMidiFileChanged)); + + private static void OnMidiFileChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is InteractivePianoRollControl ctrl) + { + var file = (MidiFile?)e.NewValue; + file?.EnsureMidiLoaded(); + if (ctrl.PianoRoll != null) ctrl.PianoRoll.MidiFile = file; + if (ctrl.Timeline != null) ctrl.Timeline.MidiFile = file; + ctrl.RefreshNotesAndTimeline(); + ctrl.UpdateTimelineViewport(); + } + } + + public MidiFile? MidiFile + { + get => (MidiFile?)GetValue(MidiFileProperty); + set => SetValue(MidiFileProperty, value); + } + + public static readonly DependencyProperty CurrentTimeProperty = + DependencyProperty.Register( + nameof(CurrentTime), + typeof(TimeSpan), + typeof(InteractivePianoRollControl), + new PropertyMetadata(TimeSpan.Zero)); + + public TimeSpan CurrentTime + { + get => (TimeSpan)GetValue(CurrentTimeProperty); + set => SetValue(CurrentTimeProperty, value); + } + + public static readonly DependencyProperty DurationProperty = + DependencyProperty.Register( + nameof(Duration), + typeof(TimeSpan), + typeof(InteractivePianoRollControl), + new PropertyMetadata(TimeSpan.FromSeconds(1))); + + public TimeSpan Duration + { + get => (TimeSpan)GetValue(DurationProperty); + set => SetValue(DurationProperty, value); + } + + public static readonly DependencyProperty KeyChangeMarkersProperty = + DependencyProperty.Register( + nameof(KeyChangeMarkers), + typeof(IEnumerable), + typeof(InteractivePianoRollControl), + new PropertyMetadata(null)); + + public IEnumerable? KeyChangeMarkers + { + get => (IEnumerable?)GetValue(KeyChangeMarkersProperty); + set => SetValue(KeyChangeMarkersProperty, value); + } + + public static readonly DependencyProperty SelectedMarkerProperty = + DependencyProperty.Register( + nameof(SelectedMarker), + typeof(KeyChangeMarker), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + public KeyChangeMarker? SelectedMarker + { + get => (KeyChangeMarker?)GetValue(SelectedMarkerProperty); + set => SetValue(SelectedMarkerProperty, value); + } + + public static readonly DependencyProperty IsSyncEnabledProperty = + DependencyProperty.Register( + nameof(IsSyncEnabled), + typeof(bool), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsSyncEnabledChanged)); + + private static void OnIsSyncEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is InteractivePianoRollControl ctrl) + { + var isSync = (bool)e.NewValue; + if (isSync) + { + ctrl.ViewTime = null; + } + else + { + ctrl.ViewTime = ctrl.CurrentTime; + } + ctrl.UpdateTimelineViewport(); + } + } + + public bool IsSyncEnabled + { + get => (bool)GetValue(IsSyncEnabledProperty); + set => SetValue(IsSyncEnabledProperty, value); + } + + public static readonly DependencyProperty ViewTimeProperty = + DependencyProperty.Register( + nameof(ViewTime), + typeof(TimeSpan?), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnViewTimeChanged)); + + private static void OnViewTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is InteractivePianoRollControl ctrl) + { + ctrl.UpdateTimelineViewport(); + } + } + + public TimeSpan? ViewTime + { + get => (TimeSpan?)GetValue(ViewTimeProperty); + set => SetValue(ViewTimeProperty, value); + } + + private double _targetZoom = 1.0; + private double _animStartZoom = 1.0; + private long _animStartTimestamp; + private double _animDurationMs = 220.0; + private bool _isZoomAnimating; + + public static readonly DependencyProperty ZoomLevelProperty = + DependencyProperty.Register( + nameof(ZoomLevel), + typeof(double), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(1.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnZoomLevelChanged)); + + private static void OnZoomLevelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is InteractivePianoRollControl ctrl) + { + if (!ctrl._isZoomAnimating) + { + ctrl._targetZoom = (double)e.NewValue; + } + ctrl.UpdateTimelineViewport(); + } + } + + public double ZoomLevel + { + get => (double)GetValue(ZoomLevelProperty); + set => SetValue(ZoomLevelProperty, value); + } + + public static readonly DependencyProperty KeyHeightProperty = + DependencyProperty.Register( + nameof(KeyHeight), + typeof(double), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(16.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + + public double KeyHeight + { + get => (double)GetValue(KeyHeightProperty); + set => SetValue(KeyHeightProperty, Math.Clamp(value, 6.0, 50.0)); + } + + public InteractivePianoRollControl() + { + InitializeComponent(); + Loaded += (_, _) => + { + var file = MidiFile; + file?.EnsureMidiLoaded(); + if (PianoRoll != null && PianoRoll.MidiFile != file) PianoRoll.MidiFile = file; + if (Timeline != null && Timeline.MidiFile != file) Timeline.MidiFile = file; + UpdateTimelineViewport(); + RefreshNotesAndTimeline(); + }; + SizeChanged += (_, _) => UpdateTimelineViewport(); + PianoRoll.SizeChanged += (_, _) => UpdateTimelineViewport(); + PreviewMouseWheel += OnPreviewMouseWheel; + Unloaded += (_, _) => StopZoomAnimation(); + } + + public void UpdateTimelineViewport() + { + var canvasW = PianoRoll?.Canvas?.ActualWidth ?? 0; + var rollWidth = Math.Max(50.0, canvasW > 64.0 ? canvasW - 64.0 : (PianoRoll != null && PianoRoll.ActualWidth > 0 ? PianoRoll.ActualWidth - 64.0 : 800.0)); + var zoom = Math.Clamp(ZoomLevel, 0.25, 5.0); + var pixelsPerMs = 0.15 * zoom; + + var horizonMs = (long)(rollWidth / pixelsPerMs); + + Timeline.VisiblePastMs = 0L; + Timeline.VisibleHorizonMs = horizonMs; + Timeline.ViewTime = (!IsSyncEnabled) ? (ViewTime ?? CurrentTime) : null; + } + + public void RefreshNotesAndTimeline() + { + PianoRoll.RefreshNotes(); + Timeline.RefreshMinimap(); + } + + private void OnTimelineScrubRequested(object? sender, TimeSpan time) + { + ScrubRequested?.Invoke(this, time); + } + + private void OnTimelineMarkerClicked(object? sender, KeyChangeMarker marker) + { + SelectedMarker = marker; + MarkerClicked?.Invoke(this, marker); + } + + private void OnPianoRollScrubRequested(object? sender, TimeSpan time) + { + ScrubRequested?.Invoke(this, time); + } + + private void OnPianoRollMarkerClicked(object? sender, KeyChangeMarker marker) + { + SelectedMarker = marker; + MarkerClicked?.Invoke(this, marker); + } + + private void OnPianoRollHoverTimeChanged(object? sender, TimeSpan? time) + { + if (Timeline != null) + { + Timeline.HoverTime = time; + } + } + + private void OnTimelineHoverTimeChanged(object? sender, TimeSpan? time) + { + if (PianoRoll != null) + { + PianoRoll.HoverTime = time; + } + } + + private void OnTimelineSyncChanged(object? sender, bool isSync) + { + IsSyncEnabled = isSync; + } + + private void OnPianoRollViewTimeChanged(object? sender, TimeSpan? time) + { + ViewTime = time; + UpdateTimelineViewport(); + } + + private void OnTimelineZoomResetRequested(object? sender, EventArgs e) + { + ResetZoom(); + } + + #region Smooth Zoom Easing + + public static double GetNextZoomLevel(double currentZoom, bool zoomIn) + { + const double step = 0.25; + const double minZoom = 0.25; + const double maxZoom = 5.0; + + if (zoomIn) + { + var next = (Math.Floor((currentZoom + 0.005) / step) + 1.0) * step; + return Math.Clamp(Math.Round(next, 2), minZoom, maxZoom); + } + else + { + var prev = (Math.Ceiling((currentZoom - 0.005) / step) - 1.0) * step; + return Math.Clamp(Math.Round(prev, 2), minZoom, maxZoom); + } + } + + public void StepZoom(bool zoomIn) + { + var baseZoom = _isZoomAnimating ? _targetZoom : ZoomLevel; + var nextZoom = GetNextZoomLevel(baseZoom, zoomIn); + EaseZoomTo(nextZoom); + } + + public void ResetZoom() + { + EaseZoomTo(1.0); + } + + public void EaseZoomTo(double targetZoom, double durationMs = 220.0) + { + targetZoom = Math.Clamp(targetZoom, 0.25, 5.0); + if (Math.Abs(ZoomLevel - targetZoom) < 0.0001 && Math.Abs(_targetZoom - targetZoom) < 0.0001) + return; + + _targetZoom = targetZoom; + _animStartZoom = ZoomLevel; + _animDurationMs = Math.Max(50.0, durationMs); + _animStartTimestamp = Stopwatch.GetTimestamp(); + + if (!_isZoomAnimating) + { + _isZoomAnimating = true; + CompositionTarget.Rendering += OnZoomAnimationTick; + } + } + + public void EaseZoomDelta(double delta, double durationMs = 220.0) + { + var baseZoom = _isZoomAnimating ? _targetZoom : ZoomLevel; + EaseZoomTo(baseZoom + delta, durationMs); + } + + private void OnZoomAnimationTick(object? sender, EventArgs e) + { + if (!_isZoomAnimating) + return; + + var elapsedSeconds = (double)(Stopwatch.GetTimestamp() - _animStartTimestamp) / Stopwatch.Frequency; + var elapsedMs = elapsedSeconds * 1000.0; + var t = Math.Clamp(elapsedMs / _animDurationMs, 0.0, 1.0); + + // Cubic Ease-Out curve: 1 - (1 - t)^3 + var ease = 1.0 - Math.Pow(1.0 - t, 3.0); + var currentZoom = _animStartZoom + (_targetZoom - _animStartZoom) * ease; + + ZoomLevel = currentZoom; + + if (t >= 1.0 || Math.Abs(currentZoom - _targetZoom) < 0.0001) + { + ZoomLevel = _targetZoom; + StopZoomAnimation(); + } + } + + private void StopZoomAnimation() + { + if (!_isZoomAnimating) return; + _isZoomAnimating = false; + CompositionTarget.Rendering -= OnZoomAnimationTick; + } + + public void StepKeyHeight(bool increase) + { + var delta = increase ? 2.0 : -2.0; + KeyHeight = Math.Clamp(KeyHeight + delta, 6.0, 50.0); + } + + public void ResetKeyHeight() + { + KeyHeight = 16.0; + } + + private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + { + StepZoom(e.Delta > 0); + e.Handled = true; + } + else if (Keyboard.Modifiers.HasFlag(ModifierKeys.Alt) || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) + { + StepKeyHeight(e.Delta > 0); + e.Handled = true; + } + else if (PianoRoll != null && PianoRoll.IsMouseOver && PianoRoll.IsOverflowing) + { + // Allow PianoRoll to handle vertical scrolling when overflowing + } + else if (!IsSyncEnabled) + { + var pixelsPerMs = 0.15 * ZoomLevel; + var shiftMs = (long)((e.Delta / 120.0) * -150.0 / pixelsPerMs); + var current = ViewTime ?? CurrentTime; + var totalMs = (long)Duration.TotalMilliseconds; + var newMs = (long)current.TotalMilliseconds + shiftMs; + if (totalMs > 0) newMs = Math.Clamp(newMs, 0, totalMs); + else newMs = Math.Max(0, newMs); + ViewTime = TimeSpan.FromMilliseconds(newMs); + e.Handled = true; + } + } + + private void OnPianoRollZoomAdjustRequested(object? sender, double delta) + { + StepZoom(delta > 0); + } + + private void OnTimelineZoomAdjustRequested(object? sender, double delta) + { + StepZoom(delta > 0); + } + + #endregion +} diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml new file mode 100644 index 0000000..292f557 --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs new file mode 100644 index 0000000..18cd512 --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using AutoMidiPlayer.Data.Entities; +using AutoMidiPlayer.Data.Midi; + +namespace AutoMidiPlayer.WPF.Controls.PianoRoll; + +public partial class MidiTimelineScrubber : UserControl +{ + private bool _isDraggingScrubber; + + public event EventHandler? ScrubRequested; + public event EventHandler? MarkerClicked; + public event EventHandler? ZoomAdjustRequested; + public event EventHandler? ZoomResetRequested; + public event EventHandler? HoverTimeChanged; + public event EventHandler? SyncChanged; + + public static readonly DependencyProperty IsSyncEnabledProperty = + DependencyProperty.Register( + nameof(IsSyncEnabled), + typeof(bool), + typeof(MidiTimelineScrubber), + new PropertyMetadata(true, (d, e) => + { + if (d is MidiTimelineScrubber scrubber) + { + scrubber.UpdateSyncButtonState((bool)e.NewValue); + } + })); + + public bool IsSyncEnabled + { + get => (bool)GetValue(IsSyncEnabledProperty); + set => SetValue(IsSyncEnabledProperty, value); + } + + public static readonly DependencyProperty ViewTimeProperty = + DependencyProperty.Register( + nameof(ViewTime), + typeof(TimeSpan?), + typeof(MidiTimelineScrubber), + new PropertyMetadata(null, (d, e) => + { + if (d is MidiTimelineScrubber scrubber && scrubber.Canvas != null) + { + scrubber.Canvas.ViewTime = (TimeSpan?)e.NewValue; + } + })); + + public TimeSpan? ViewTime + { + get => (TimeSpan?)GetValue(ViewTimeProperty); + set => SetValue(ViewTimeProperty, value); + } + + public static readonly DependencyProperty HoverTimeProperty = + DependencyProperty.Register( + nameof(HoverTime), + typeof(TimeSpan?), + typeof(MidiTimelineScrubber), + new PropertyMetadata(null, (d, e) => + { + if (d is MidiTimelineScrubber scrubber && scrubber.Canvas != null) + { + scrubber.Canvas.HoverTime = (TimeSpan?)e.NewValue; + } + })); + + public TimeSpan? HoverTime + { + get => (TimeSpan?)GetValue(HoverTimeProperty); + set => SetValue(HoverTimeProperty, value); + } + + public static readonly DependencyProperty MidiFileProperty = + DependencyProperty.Register( + nameof(MidiFile), + typeof(MidiFile), + typeof(MidiTimelineScrubber), + new PropertyMetadata(null, (d, e) => + { + if (d is MidiTimelineScrubber scrubber && scrubber.Canvas != null) + { + scrubber.Canvas.MidiFile = (MidiFile?)e.NewValue; + } + })); + + public MidiFile? MidiFile + { + get => (MidiFile?)GetValue(MidiFileProperty); + set => SetValue(MidiFileProperty, value); + } + + public static readonly DependencyProperty CurrentTimeProperty = + DependencyProperty.Register( + nameof(CurrentTime), + typeof(TimeSpan), + typeof(MidiTimelineScrubber), + new PropertyMetadata(TimeSpan.Zero, (d, e) => ((MidiTimelineScrubber)d).Canvas.CurrentTime = (TimeSpan)e.NewValue)); + + public TimeSpan CurrentTime + { + get => (TimeSpan)GetValue(CurrentTimeProperty); + set => SetValue(CurrentTimeProperty, value); + } + + public static readonly DependencyProperty DurationProperty = + DependencyProperty.Register( + nameof(Duration), + typeof(TimeSpan), + typeof(MidiTimelineScrubber), + new PropertyMetadata(TimeSpan.FromSeconds(1), (d, e) => ((MidiTimelineScrubber)d).Canvas.Duration = (TimeSpan)e.NewValue)); + + public TimeSpan Duration + { + get => (TimeSpan)GetValue(DurationProperty); + set => SetValue(DurationProperty, value); + } + + public static readonly DependencyProperty KeyChangeMarkersProperty = + DependencyProperty.Register( + nameof(KeyChangeMarkers), + typeof(IEnumerable), + typeof(MidiTimelineScrubber), + new PropertyMetadata(null, (d, e) => ((MidiTimelineScrubber)d).Canvas.KeyChangeMarkers = (IEnumerable)e.NewValue)); + + public IEnumerable? KeyChangeMarkers + { + get => (IEnumerable?)GetValue(KeyChangeMarkersProperty); + set => SetValue(KeyChangeMarkersProperty, value); + } + + public static readonly DependencyProperty SelectedMarkerProperty = + DependencyProperty.Register( + nameof(SelectedMarker), + typeof(KeyChangeMarker), + typeof(MidiTimelineScrubber), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, (d, e) => ((MidiTimelineScrubber)d).Canvas.SelectedMarker = (KeyChangeMarker)e.NewValue)); + + public KeyChangeMarker? SelectedMarker + { + get => (KeyChangeMarker?)GetValue(SelectedMarkerProperty); + set => SetValue(SelectedMarkerProperty, value); + } + + public static readonly DependencyProperty ZoomLevelProperty = + DependencyProperty.Register( + nameof(ZoomLevel), + typeof(double), + typeof(MidiTimelineScrubber), + new FrameworkPropertyMetadata(1.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnZoomLevelChanged)); + + public double ZoomLevel + { + get => (double)GetValue(ZoomLevelProperty); + set => SetValue(ZoomLevelProperty, value); + } + + public static readonly DependencyProperty VisiblePastMsProperty = + DependencyProperty.Register( + nameof(VisiblePastMs), + typeof(long), + typeof(MidiTimelineScrubber), + new PropertyMetadata(0L, (d, e) => ((MidiTimelineScrubber)d).Canvas.VisiblePastMs = (long)e.NewValue)); + + public long VisiblePastMs + { + get => (long)GetValue(VisiblePastMsProperty); + set => SetValue(VisiblePastMsProperty, value); + } + + public static readonly DependencyProperty VisibleHorizonMsProperty = + DependencyProperty.Register( + nameof(VisibleHorizonMs), + typeof(long), + typeof(MidiTimelineScrubber), + new PropertyMetadata(6000L, (d, e) => ((MidiTimelineScrubber)d).Canvas.VisibleHorizonMs = (long)e.NewValue)); + + public long VisibleHorizonMs + { + get => (long)GetValue(VisibleHorizonMsProperty); + set => SetValue(VisibleHorizonMsProperty, value); + } + + public static readonly DependencyProperty KeyHeightProperty = + DependencyProperty.Register( + nameof(KeyHeight), + typeof(double), + typeof(MidiTimelineScrubber), + new FrameworkPropertyMetadata(16.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnKeyHeightChanged)); + + public double KeyHeight + { + get => (double)GetValue(KeyHeightProperty); + set => SetValue(KeyHeightProperty, value); + } + + public event EventHandler? KeyHeightAdjustRequested; + public event EventHandler? KeyHeightResetRequested; + + private static void OnKeyHeightChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is MidiTimelineScrubber scrubber) + { + var val = (double)e.NewValue; + scrubber.UpdateKeyHeightText(val); + } + } + + private void UpdateKeyHeightText(double height) + { + if (KeyHeightText != null) + { + KeyHeightText.Text = $"{Math.Round(height)}px"; + } + } + + private static void OnZoomLevelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is MidiTimelineScrubber scrubber) + { + var val = (double)e.NewValue; + scrubber.ZoomText.Text = $"{Math.Round(val * 100)}%"; + } + } + + public MidiTimelineScrubber() + { + InitializeComponent(); + ZoomText.Text = "100%"; + UpdateKeyHeightText(KeyHeight); + Loaded += (_, _) => + { + UpdateSyncButtonState(IsSyncEnabled); + UpdateKeyHeightText(KeyHeight); + if (Canvas != null && Canvas.MidiFile != MidiFile) + { + Canvas.MidiFile = MidiFile; + } + Canvas?.RebuildMinimap(); + }; + } + + public void RefreshMinimap() + { + Canvas.RebuildMinimap(); + } + + private void OnScrubberMouseDown(object sender, MouseButtonEventArgs e) + { + var pos = e.GetPosition(Canvas); + + // Check if user clicked on a marker + var clickedMarker = Canvas.HitTestMarker(pos); + if (clickedMarker != null) + { + SelectedMarker = clickedMarker; + MarkerClicked?.Invoke(this, clickedMarker); + // Also seek to the marker's time + ScrubRequested?.Invoke(this, TimeSpan.FromMilliseconds(clickedMarker.TimeMs)); + return; + } + + // Otherwise start scrubbing + _isDraggingScrubber = true; + ScrubberArea.CaptureMouse(); + SeekToPosition(pos.X); + } + + private void OnScrubberMouseMove(object sender, MouseEventArgs e) + { + var pos = e.GetPosition(Canvas); + if (_isDraggingScrubber && ScrubberArea.IsMouseCaptured) + { + SeekToPosition(pos.X); + } + + if (Canvas != null && Canvas.ActualWidth > 0 && pos.X >= 0 && pos.X <= Canvas.ActualWidth) + { + var width = Canvas.ActualWidth; + var ratio = Math.Clamp(pos.X / width, 0.0, 1.0); + var totalMs = Canvas.TotalDurationMs; + var targetMs = ratio * totalMs; + var time = TimeSpan.FromMilliseconds(targetMs); + Canvas.HoverTime = time; + HoverTimeChanged?.Invoke(this, time); + } + else + { + if (Canvas != null) Canvas.HoverTime = null; + HoverTimeChanged?.Invoke(this, null); + } + } + + private void OnScrubberMouseLeave(object sender, MouseEventArgs e) + { + if (!_isDraggingScrubber) + { + if (Canvas != null) Canvas.HoverTime = null; + HoverTimeChanged?.Invoke(this, null); + } + } + + private void OnScrubberMouseUp(object sender, MouseButtonEventArgs e) + { + if (_isDraggingScrubber) + { + _isDraggingScrubber = false; + ScrubberArea.ReleaseMouseCapture(); + + if (Canvas != null) + { + var pos = e.GetPosition(Canvas); + if (pos.X < 0 || pos.X > Canvas.ActualWidth || pos.Y < 0 || pos.Y > Canvas.ActualHeight) + { + Canvas.HoverTime = null; + HoverTimeChanged?.Invoke(this, null); + } + } + } + } + + private void SeekToPosition(double mouseX) + { + var width = Canvas.ActualWidth; + if (width <= 0) return; + + var ratio = Math.Clamp(mouseX / width, 0.0, 1.0); + var totalMs = Canvas != null ? Canvas.TotalDurationMs : (long)Duration.TotalMilliseconds; + var targetMs = ratio * totalMs; + ScrubRequested?.Invoke(this, TimeSpan.FromMilliseconds(targetMs)); + } + + private void OnZoomInClick(object sender, RoutedEventArgs e) + { + if (ZoomAdjustRequested != null) + { + ZoomAdjustRequested.Invoke(this, 0.25); + } + else + { + ZoomLevel = Math.Clamp(ZoomLevel + 0.25, 0.2, 5.0); + } + } + + private void OnZoomOutClick(object sender, RoutedEventArgs e) + { + if (ZoomAdjustRequested != null) + { + ZoomAdjustRequested.Invoke(this, -0.25); + } + else + { + ZoomLevel = Math.Clamp(ZoomLevel - 0.25, 0.2, 5.0); + } + } + + private void OnZoomTextClick(object sender, MouseButtonEventArgs e) + { + ZoomResetRequested?.Invoke(this, EventArgs.Empty); + } + + private void OnSyncButtonClick(object sender, RoutedEventArgs e) + { + IsSyncEnabled = !IsSyncEnabled; + UpdateSyncButtonState(IsSyncEnabled); + SyncChanged?.Invoke(this, IsSyncEnabled); + } + + public void UpdateSyncButtonState(bool isSync) + { + if (SyncButton != null) + { + SyncButton.Appearance = isSync ? Wpf.Ui.Controls.ControlAppearance.Primary : Wpf.Ui.Controls.ControlAppearance.Secondary; + SyncButton.ToolTip = isSync + ? "Follow Playhead: Locked (Click to unlock view)" + : "Follow Playhead: Unlocked (Click to lock to playhead)"; + } + if (SyncIcon != null) + { + SyncIcon.Symbol = isSync ? Wpf.Ui.Controls.SymbolRegular.LockClosed16 : Wpf.Ui.Controls.SymbolRegular.LockOpen16; + } + } + + private void OnKeyHeightDownClick(object sender, RoutedEventArgs e) + { + if (KeyHeightAdjustRequested != null) + { + KeyHeightAdjustRequested.Invoke(this, -2.0); + } + else + { + KeyHeight = Math.Clamp(KeyHeight - 2.0, 6.0, 50.0); + } + } + + private void OnKeyHeightUpClick(object sender, RoutedEventArgs e) + { + if (KeyHeightAdjustRequested != null) + { + KeyHeightAdjustRequested.Invoke(this, 2.0); + } + else + { + KeyHeight = Math.Clamp(KeyHeight + 2.0, 6.0, 50.0); + } + } + + private void OnKeyHeightTextClick(object sender, MouseButtonEventArgs e) + { + if (KeyHeightResetRequested != null) + { + KeyHeightResetRequested.Invoke(this, EventArgs.Empty); + } + else + { + KeyHeight = 16.0; + } + } +} diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs new file mode 100644 index 0000000..8bc1a8d --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs @@ -0,0 +1,1250 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using AutoMidiPlayer.Data; +using AutoMidiPlayer.Data.Entities; +using AutoMidiPlayer.WPF.Helpers; +using Melanchall.DryWetMidi.Core; +using Melanchall.DryWetMidi.Interaction; +using MidiFile = AutoMidiPlayer.Data.Midi.MidiFile; + +namespace AutoMidiPlayer.WPF.Controls.PianoRoll; + +public class PianoRollCanvas : FrameworkElement +{ + public readonly record struct RollNote(int NoteNumber, long StartMs, long LengthMs, int TrackIndex); + + public event EventHandler? ScrubRequested; + public event EventHandler? MarkerClicked; + + private static readonly HashSet BlackKeys = [1, 3, 6, 8, 10]; + private static readonly string[] NoteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + + // Frozen brushes and pens for 60+ FPS zero-allocation rendering + private static readonly SolidColorBrush[] TrackBrushes; + private static readonly SolidColorBrush WhiteKeyBrush; + private static readonly SolidColorBrush BlackKeyBrush; + private static readonly SolidColorBrush BlackKeyExtensionBrush; + private static readonly SolidColorBrush WhiteLaneBrush; + private static readonly SolidColorBrush BlackLaneBrush; + private static readonly SolidColorBrush WhiteKeyTextBrush; + private static readonly SolidColorBrush BlackKeyTextBrush; + private static readonly Pen GridLinePen; + private static readonly Pen KeyBorderPen; + private static readonly Typeface KeyFontTypeface; + private static readonly Pen MarkerLinePen; + private static readonly SolidColorBrush MarkerBadgeBrush; + private static readonly Pen MarkerBadgePen; + private static readonly Typeface MarkerTypeface; + private static readonly Pen HoverLinePen; + private static readonly SolidColorBrush HoverPillBrush; + private static readonly Pen HoverPillPen; + private static readonly SolidColorBrush HoverTextBrush; + private static readonly Typeface HoverTypeface; + private static readonly Pen PlayheadLinePen; + private static readonly SolidColorBrush PlayheadMarkerBrush; + private static readonly Geometry PlayheadMarkerGeometry; + + static PianoRollCanvas() + { + TrackBrushes = TrackColorPalette.AllBrushes; + + WhiteKeyBrush = CreateFrozenBrush(Color.FromArgb(220, 240, 240, 240)); + BlackKeyBrush = CreateFrozenBrush(Color.FromArgb(255, 26, 26, 30)); + BlackKeyExtensionBrush = CreateFrozenBrush(Color.FromArgb(255, 42, 42, 48)); + WhiteLaneBrush = CreateFrozenBrush(Color.FromArgb(12, 255, 255, 255)); + BlackLaneBrush = CreateFrozenBrush(Color.FromArgb(24, 0, 0, 0)); + WhiteKeyTextBrush = CreateFrozenBrush(Color.FromArgb(190, 20, 20, 20)); + BlackKeyTextBrush = CreateFrozenBrush(Color.FromArgb(190, 215, 215, 220)); + + GridLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(20, 255, 255, 255)), 1); + GridLinePen.Freeze(); + + KeyBorderPen = new Pen(CreateFrozenBrush(Color.FromArgb(40, 128, 128, 128)), 1); + KeyBorderPen.Freeze(); + + KeyFontTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal); + + MarkerLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(200, 255, 185, 0)), 1.5) + { + DashStyle = DashStyles.Dash + }; + MarkerLinePen.Freeze(); + + MarkerBadgeBrush = CreateFrozenBrush(Color.FromRgb(255, 185, 0)); + MarkerBadgePen = new Pen(CreateFrozenBrush(Color.FromRgb(220, 160, 0)), 1); + MarkerBadgePen.Freeze(); + + MarkerTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); + + HoverLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(120, 255, 255, 255)), 1.2); + HoverLinePen.Freeze(); + + HoverPillBrush = CreateFrozenBrush(Color.FromArgb(220, 20, 20, 24)); + HoverPillPen = new Pen(CreateFrozenBrush(Color.FromArgb(80, 255, 255, 255)), 1); + HoverPillPen.Freeze(); + + HoverTextBrush = CreateFrozenBrush(Color.FromArgb(235, 255, 255, 255)); + HoverTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Medium, FontStretches.Normal); + + PlayheadMarkerBrush = CreateFrozenBrush(Color.FromRgb(255, 255, 255)); + PlayheadLinePen = new Pen(PlayheadMarkerBrush, 1.8); + PlayheadLinePen.Freeze(); + + var markerGeo = new StreamGeometry(); + using (var ctx = markerGeo.Open()) + { + ctx.BeginFigure(new Point(-6, 0), isFilled: true, isClosed: true); + ctx.LineTo(new Point(6, 0), isStroked: false, isSmoothJoin: false); + ctx.LineTo(new Point(0, 10), isStroked: false, isSmoothJoin: false); + } + markerGeo.Freeze(); + PlayheadMarkerGeometry = markerGeo; + } + + private static SolidColorBrush CreateFrozenBrush(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + + private double? _hoverX; + private bool _isDragging; + private double _dragAnchorMouseX; + private long _dragAnchorTimeMs; + private Point _dragStartPos; + private bool _hasDragged; + + // Zero-allocation batched note rendering buffers per track brush + private readonly List[] _trackRectsSharp; + private readonly List[] _trackRectsRound; + private readonly double[] _lastPitchRight = new double[128]; + private readonly int[] _lastPitchTrack = new int[128]; + private readonly Dictionary _cachedMarkerLabels = new(); + private double _cachedDpi = -1; + + private RollNote[] _notes = Array.Empty(); + private long _maxNoteLengthMs = 0; + private int _minPitch = 36; + private int _maxPitch = 84; + private readonly bool[] _activeKeys = new bool[128]; + private readonly FormattedText?[] _cachedLabelsNormal = new FormattedText?[128]; + private readonly FormattedText?[] _cachedLabelsActive = new FormattedText?[128]; + private double _lastPitchHeight = -1; + private double _lastDpi = -1; + + // Cached DrawingGroups for static layers + private DrawingGroup? _cachedLanes; + private double _lastLanesWidth = -1; + private double _lastLanesPitchHeight = -1; + private int _lastLanesMinPitch = -1; + private int _lastLanesMaxPitch = -1; + private RectangleGeometry? _cachedLanesClip; + private double _lastLanesClipW = -1; + private double _lastLanesClipH = -1; + + private DrawingGroup? _cachedKeyboard; + private double _lastKeyboardPitchHeight = -1; + private int _lastKeyboardMinPitch = -1; + private int _lastKeyboardMaxPitch = -1; + private double _lastKeyboardWidth = -1; + private RectangleGeometry? _cachedKeyboardClip; + private double _lastKeyboardClipH = -1; + + private double _lastReportedMaxScroll = -1; + private bool _lastReportedIsOverflowing; + + private SolidColorBrush _activeKeyBrush = null!; + private SolidColorBrush _activeKeyTextBrush = null!; + private Color _lastAccentColor; + private int _trackCount = 1; + + public PianoRollCanvas() + { + _trackRectsSharp = new List[TrackBrushes.Length]; + _trackRectsRound = new List[TrackBrushes.Length]; + for (var i = 0; i < TrackBrushes.Length; i++) + { + _trackRectsSharp[i] = new List(256); + _trackRectsRound[i] = new List(256); + } + + EnsureAccentResources(); + Loaded += (_, _) => AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; + Unloaded += (_, _) => AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged; + } + + private void OnThemeResourcesChanged() + { + Dispatcher.BeginInvoke(() => + { + EnsureAccentResources(); + InvalidateVisual(); + }); + } + + private void EnsureAccentResources() + { + var accent = AutoMidiPlayer.WPF.Core.AccentColorHelper.GetAccentColor(); + if (_activeKeyBrush != null && accent == _lastAccentColor) + return; + + _lastAccentColor = accent; + _activeKeyBrush = CreateFrozenBrush(accent); + + var lum = (0.299 * accent.R + 0.587 * accent.G + 0.114 * accent.B) / 255.0; + _activeKeyTextBrush = CreateFrozenBrush(lum > 0.5 ? Color.FromRgb(18, 18, 20) : Colors.White); + + Array.Clear(_cachedLabelsActive, 0, 128); + } + + public static readonly DependencyProperty MidiFileProperty = + DependencyProperty.Register( + nameof(MidiFile), + typeof(MidiFile), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMidiFileChanged)); + + public MidiFile? MidiFile + { + get => (MidiFile?)GetValue(MidiFileProperty); + set => SetValue(MidiFileProperty, value); + } + + public static readonly DependencyProperty CurrentTimeProperty = + DependencyProperty.Register( + nameof(CurrentTime), + typeof(TimeSpan), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(TimeSpan.Zero, FrameworkPropertyMetadataOptions.AffectsRender)); + + public TimeSpan CurrentTime + { + get => (TimeSpan)GetValue(CurrentTimeProperty); + set => SetValue(CurrentTimeProperty, value); + } + + public static readonly DependencyProperty ZoomLevelProperty = + DependencyProperty.Register( + nameof(ZoomLevel), + typeof(double), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(1.0, FrameworkPropertyMetadataOptions.AffectsRender)); + + public double ZoomLevel + { + get => (double)GetValue(ZoomLevelProperty); + set => SetValue(ZoomLevelProperty, Math.Clamp(value, 0.2, 5.0)); + } + + public static readonly DependencyProperty KeyHeightProperty = + DependencyProperty.Register( + nameof(KeyHeight), + typeof(double), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(16.0, FrameworkPropertyMetadataOptions.AffectsRender, OnKeyHeightChanged)); + + private static void OnKeyHeightChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + var oldHeight = (double)e.OldValue; + var newHeight = (double)e.NewValue; + + if (oldHeight > 0 && Math.Abs(newHeight - oldHeight) > 0.001 && canvas.ActualHeight > 0) + { + var viewportHeight = canvas.ActualHeight; + var centerOffset = canvas.VerticalOffset + (viewportHeight / 2.0); + var ratio = newHeight / oldHeight; + var newCenter = centerOffset * ratio; + var targetOffset = newCenter - (viewportHeight / 2.0); + var newTotalHeight = (canvas._maxPitch - canvas._minPitch + 1) * newHeight; + var newMaxScroll = Math.Max(0.0, newTotalHeight - viewportHeight); + canvas.VerticalOffset = Math.Clamp(targetOffset, 0.0, newMaxScroll); + } + + canvas._cachedLanes = null; + canvas._cachedKeyboard = null; + canvas.UpdateScrollMetrics(); + canvas.ScrollBoundsChanged?.Invoke(canvas, EventArgs.Empty); + canvas.InvalidateVisual(); + } + } + + public double KeyHeight + { + get => (double)GetValue(KeyHeightProperty); + set => SetValue(KeyHeightProperty, Math.Clamp(value, 6.0, 50.0)); + } + + public static readonly DependencyProperty VerticalOffsetProperty = + DependencyProperty.Register( + nameof(VerticalOffset), + typeof(double), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender, OnVerticalOffsetChanged)); + + private static void OnVerticalOffsetChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas.InvalidateVisual(); + canvas.VerticalOffsetChanged?.Invoke(canvas, (double)e.NewValue); + } + } + + public double VerticalOffset + { + get => (double)GetValue(VerticalOffsetProperty); + set => SetValue(VerticalOffsetProperty, Math.Max(0.0, value)); + } + + public event EventHandler? VerticalOffsetChanged; + public event EventHandler? ScrollBoundsChanged; + + public double TotalContentHeight => (_maxPitch - _minPitch + 1) * KeyHeight; + public double MaxVerticalOffset => Math.Max(0.0, TotalContentHeight - ActualHeight); + public bool IsOverflowing => TotalContentHeight > ActualHeight && ActualHeight > 0; + + protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) + { + base.OnRenderSizeChanged(sizeInfo); + UpdateScrollMetrics(); + ScrollBoundsChanged?.Invoke(this, EventArgs.Empty); + } + + public void UpdateScrollMetrics() + { + var maxScroll = MaxVerticalOffset; + if (VerticalOffset > maxScroll) + { + VerticalOffset = maxScroll; + } + } + + public static readonly DependencyProperty KeyChangeMarkersProperty = + DependencyProperty.Register( + nameof(KeyChangeMarkers), + typeof(IEnumerable), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public IEnumerable? KeyChangeMarkers + { + get => (IEnumerable?)GetValue(KeyChangeMarkersProperty); + set => SetValue(KeyChangeMarkersProperty, value); + } + + public double KeyboardWidth { get; set; } = 64; + + public static readonly DependencyProperty HoverTimeProperty = + DependencyProperty.Register( + nameof(HoverTime), + typeof(TimeSpan?), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public TimeSpan? HoverTime + { + get => (TimeSpan?)GetValue(HoverTimeProperty); + set => SetValue(HoverTimeProperty, value); + } + + public event EventHandler? HoverTimeChanged; + + public static readonly DependencyProperty IsSyncEnabledProperty = + DependencyProperty.Register( + nameof(IsSyncEnabled), + typeof(bool), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(true, FrameworkPropertyMetadataOptions.AffectsRender, OnIsSyncEnabledChanged)); + + private static void OnIsSyncEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + var isSync = (bool)e.NewValue; + if (isSync) + { + canvas.ViewTime = null; + } + else + { + canvas.ViewTime = canvas.CurrentTime; + } + } + } + + public bool IsSyncEnabled + { + get => (bool)GetValue(IsSyncEnabledProperty); + set => SetValue(IsSyncEnabledProperty, value); + } + + public static readonly DependencyProperty ViewTimeProperty = + DependencyProperty.Register( + nameof(ViewTime), + typeof(TimeSpan?), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnViewTimeChanged)); + + private static void OnViewTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas.ViewTimeChanged?.Invoke(canvas, (TimeSpan?)e.NewValue); + } + } + + public TimeSpan? ViewTime + { + get => (TimeSpan?)GetValue(ViewTimeProperty); + set => SetValue(ViewTimeProperty, value); + } + + public event EventHandler? ViewTimeChanged; + + public long TotalDurationMs + { + get + { + if (MidiFile?.Duration.TotalMilliseconds > 0) + return (long)MidiFile.Duration.TotalMilliseconds; + + if (_notes.Length > 0) + return _notes.Max(n => n.StartMs + n.LengthMs); + + return 0; + } + } + + private static void OnMidiFileChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas.RebuildNoteIndex(); + } + } + + public void RebuildNoteIndex() + { + var file = MidiFile; + if (file == null) + { + _notes = Array.Empty(); + _maxNoteLengthMs = 0; + _trackCount = 1; + _minPitch = 36; + _maxPitch = 84; + _cachedLanes = null; + _cachedKeyboard = null; + InvalidateVisual(); + return; + } + + try + { + file.EnsureMidiLoaded(); + + if (file.Midi is null) + { + _notes = Array.Empty(); + _maxNoteLengthMs = 0; + _trackCount = 1; + _minPitch = 36; + _maxPitch = 84; + _cachedLanes = null; + _cachedKeyboard = null; + InvalidateVisual(); + return; + } + + var tempoMap = file.OriginalTempoMap ?? file.Midi.GetTempoMap(); + var trackChunks = file.Midi.GetTrackChunks().ToList(); + var noteList = new List(); + + if (trackChunks.Count > 0) + { + for (var t = 0; t < trackChunks.Count; t++) + { + foreach (var note in trackChunks[t].GetNotes()) + { + var startMs = (long)note.TimeAs(tempoMap).TotalMilliseconds; + var lengthMs = (long)note.LengthAs(tempoMap).TotalMilliseconds; + noteList.Add(new RollNote(note.NoteNumber, startMs, Math.Max(20, lengthMs), t)); + } + } + } + else + { + foreach (var note in file.Midi.GetNotes()) + { + var startMs = (long)note.TimeAs(tempoMap).TotalMilliseconds; + var lengthMs = (long)note.LengthAs(tempoMap).TotalMilliseconds; + noteList.Add(new RollNote(note.NoteNumber, startMs, Math.Max(20, lengthMs), (int)note.Channel)); + } + } + + _notes = noteList.OrderBy(n => n.StartMs).ToArray(); + _maxNoteLengthMs = _notes.Length > 0 ? _notes.Max(n => n.LengthMs) : 0; + _trackCount = _notes.Length > 0 ? _notes.Select(n => n.TrackIndex).Distinct().Count() : 1; + + if (_notes.Length > 0) + { + var min = _notes.Min(n => n.NoteNumber); + var max = _notes.Max(n => n.NoteNumber); + + _minPitch = Math.Max(21, min - 2); + _maxPitch = Math.Min(108, max + 2); + + if (_maxPitch - _minPitch < 24) + { + var diff = 24 - (_maxPitch - _minPitch); + _minPitch = Math.Max(21, _minPitch - diff / 2); + _maxPitch = Math.Min(108, _minPitch + 24); + } + } + else + { + _minPitch = 36; + _maxPitch = 84; + } + } + catch (Exception ex) + { + Logger.LogException(ex, "Failed to build piano roll note index."); + _notes = Array.Empty(); + _maxNoteLengthMs = 0; + _trackCount = 1; + } + + _cachedLanes = null; + _cachedKeyboard = null; + var totalHeight = (_maxPitch - _minPitch + 1) * KeyHeight; + if (ActualHeight > 0 && totalHeight > ActualHeight) + { + VerticalOffset = (totalHeight - ActualHeight) / 2.0; + } + else + { + VerticalOffset = 0.0; + } + ScrollBoundsChanged?.Invoke(this, EventArgs.Empty); + InvalidateVisual(); + } + + private DrawingGroup BuildStaticLanes(double width, double pitchHeight) + { + var group = new DrawingGroup(); + using (var dc = group.Open()) + { + var laneWidth = width - KeyboardWidth; + for (var p = _minPitch; p <= _maxPitch; p++) + { + var y = (_maxPitch - p) * pitchHeight; + var isBlack = BlackKeys.Contains(p % 12); + var laneBrush = isBlack ? BlackLaneBrush : WhiteLaneBrush; + + dc.DrawRectangle(laneBrush, null, new Rect(KeyboardWidth, y, laneWidth, pitchHeight)); + dc.DrawLine(GridLinePen, new Point(KeyboardWidth, y), new Point(width, y)); + } + } + group.Freeze(); + return group; + } + + private DrawingGroup BuildStaticKeyboard(double pitchHeight, double dpi) + { + var group = new DrawingGroup(); + using (var dc = group.Open()) + { + var fontSize = Math.Min(10, Math.Max(7.5, pitchHeight * 0.65)); + var blackCapW = KeyboardWidth * 0.62; + var extW = KeyboardWidth - blackCapW; + + for (var p = _minPitch; p <= _maxPitch; p++) + { + var y = (_maxPitch - p) * pitchHeight; + var pitchClass = p % 12; + var isBlack = BlackKeys.Contains(pitchClass); + var octave = (p / 12) - 1; + var label = $"{NoteNames[pitchClass]}{octave}"; + + if (isBlack) + { + // 1. Black key cap (0 to blackCapW) + dc.DrawRectangle(BlackKeyBrush, KeyBorderPen, new Rect(0, y, blackCapW, pitchHeight)); + + // 2. Lower grey extension of the black key (blackCapW to KeyboardWidth) + dc.DrawRectangle(BlackKeyExtensionBrush, KeyBorderPen, new Rect(blackCapW, y, extW, pitchHeight)); + + // 3. Key text on the lower grey part + if (pitchHeight >= 11) + { + var text = GetOrCreateLabel(p, label, BlackKeyTextBrush, fontSize, dpi, isNormal: true); + var textY = y + (pitchHeight - text.Height) / 2; + var textX = Math.Max(blackCapW + 2, KeyboardWidth - text.Width - 4); + dc.DrawText(text, new Point(textX, textY)); + } + } + else + { + // White key (0 to KeyboardWidth) + dc.DrawRectangle(WhiteKeyBrush, KeyBorderPen, new Rect(0, y, KeyboardWidth, pitchHeight)); + + // Note label for C keys or when pitchHeight is large enough + if (pitchClass == 0 || pitchHeight >= 11) + { + var text = GetOrCreateLabel(p, label, WhiteKeyTextBrush, fontSize, dpi, isNormal: true); + var textY = y + (pitchHeight - text.Height) / 2; + var textX = Math.Max(4, KeyboardWidth - text.Width - 4); + dc.DrawText(text, new Point(textX, textY)); + } + } + } + } + group.Freeze(); + return group; + } + + private FormattedText GetOrCreateLabel(int pitch, string label, Brush brush, double fontSize, double dpi, bool isNormal) + { + var cache = isNormal ? _cachedLabelsNormal : _cachedLabelsActive; + if (pitch >= 0 && pitch < 128 && cache[pitch] != null) + { + return cache[pitch]!; + } + + var text = new FormattedText( + label, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + KeyFontTypeface, + fontSize, + brush, + dpi); + + if (pitch >= 0 && pitch < 128) + { + cache[pitch] = text; + } + + return text; + } + + private FormattedText GetOrCreateMarkerLabel(string text, double dpi) + { + if (!_cachedMarkerLabels.TryGetValue(text, out var ft)) + { + ft = new FormattedText( + text, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + MarkerTypeface, + 9.5, + Brushes.Black, + dpi); + _cachedMarkerLabels[text] = ft; + } + return ft; + } + + protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) + { + base.OnDpiChanged(oldDpi, newDpi); + _cachedDpi = newDpi.PixelsPerDip; + _cachedMarkerLabels.Clear(); + Array.Clear(_cachedLabelsNormal, 0, 128); + Array.Clear(_cachedLabelsActive, 0, 128); + } + + protected override void OnRender(DrawingContext dc) + { + base.OnRender(dc); + + EnsureAccentResources(); + + var width = ActualWidth; + var height = ActualHeight; + if (width <= KeyboardWidth + 10 || height <= 20) + return; + + var pitchCount = _maxPitch - _minPitch + 1; + var pitchHeight = Math.Clamp(KeyHeight, 6.0, 50.0); + var totalHeight = pitchCount * pitchHeight; + var maxScroll = Math.Max(0.0, totalHeight - height); + var verticalOffset = Math.Clamp(VerticalOffset, 0.0, maxScroll); + if (Math.Abs(verticalOffset - VerticalOffset) > 0.001) + { + VerticalOffset = verticalOffset; + } + + if (Math.Abs(maxScroll - _lastReportedMaxScroll) > 0.5 || (totalHeight > height) != _lastReportedIsOverflowing) + { + _lastReportedMaxScroll = maxScroll; + _lastReportedIsOverflowing = totalHeight > height; + Dispatcher.BeginInvoke(() => ScrollBoundsChanged?.Invoke(this, EventArgs.Empty)); + } + + var currentTimeMs = (long)CurrentTime.TotalMilliseconds; + var effectiveViewTimeMs = (!IsSyncEnabled && ViewTime.HasValue) + ? (long)ViewTime.Value.TotalMilliseconds + : currentTimeMs; + var pixelsPerMs = 0.15 * ZoomLevel; // 150px per second at zoom 1.0 + + if (_cachedDpi <= 0) + { + _cachedDpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + } + var dpi = _cachedDpi; + + // Invalidate label caches if geometry or DPI changed + if (Math.Abs(pitchHeight - _lastPitchHeight) > 0.001 || Math.Abs(dpi - _lastDpi) > 0.001) + { + Array.Clear(_cachedLabelsNormal, 0, 128); + Array.Clear(_cachedLabelsActive, 0, 128); + _lastPitchHeight = pitchHeight; + _lastDpi = dpi; + _cachedKeyboard = null; + } + + // 1. Draw cached pitch lanes + if (_cachedLanes == null || Math.Abs(_lastLanesWidth - width) > 0.5 || Math.Abs(_lastLanesPitchHeight - pitchHeight) > 0.001 || _lastLanesMinPitch != _minPitch || _lastLanesMaxPitch != _maxPitch) + { + _cachedLanes = BuildStaticLanes(width, pitchHeight); + _lastLanesWidth = width; + _lastLanesPitchHeight = pitchHeight; + _lastLanesMinPitch = _minPitch; + _lastLanesMaxPitch = _maxPitch; + } + if (_cachedLanesClip == null || Math.Abs(_lastLanesClipW - width) > 0.5 || Math.Abs(_lastLanesClipH - height) > 0.5) + { + _cachedLanesClip = new RectangleGeometry(new Rect(KeyboardWidth, 0, Math.Max(0, width - KeyboardWidth), height)); + _cachedLanesClip.Freeze(); + _lastLanesClipW = width; + _lastLanesClipH = height; + } + dc.PushClip(_cachedLanesClip); + dc.PushTransform(new TranslateTransform(0, -verticalOffset)); + dc.DrawDrawing(_cachedLanes); + dc.Pop(); + dc.Pop(); + + // 2. Draw vertical time grid lines (every 1 second) + var msStep = 1000L; + if (ZoomLevel < 0.5) msStep = 2000L; + else if (ZoomLevel > 2.0) msStep = 500L; + + var firstGridMs = (effectiveViewTimeMs / msStep) * msStep; + var rollWidth = width - KeyboardWidth; + var visibleHorizonMs = (long)(rollWidth / pixelsPerMs); + + for (var gridMs = firstGridMs; gridMs <= effectiveViewTimeMs + visibleHorizonMs + msStep; gridMs += msStep) + { + var lineX = KeyboardWidth + (gridMs - effectiveViewTimeMs) * pixelsPerMs; + if (lineX >= KeyboardWidth && lineX <= width) + { + dc.DrawLine(GridLinePen, new Point(lineX, 0), new Point(lineX, height)); + } + } + + // Reset active keys + Array.Clear(_activeKeys, 0, _activeKeys.Length); + + // Clear pre-allocated track batch buffers + for (var t = 0; t < TrackBrushes.Length; t++) + { + _trackRectsSharp[t].Clear(); + _trackRectsRound[t].Clear(); + } + Array.Fill(_lastPitchRight, -1.0); + Array.Fill(_lastPitchTrack, -1); + + // 3. Viewport-culled batched note rendering + if (_notes.Length > 0) + { + var visibleEndMs = effectiveViewTimeMs + visibleHorizonMs + 500; + // Notes ending at or before effectiveViewTimeMs have their right edge <= KeyboardWidth (behind keyboard) + var startIndex = FindFirstPossibleNoteIndex(effectiveViewTimeMs); + + // Adaptive aesthetic rounding: only when individual notes are large enough to appreciate rounded corners + var allowRounding = ZoomLevel >= 0.5 && pitchHeight >= 5.0; + + for (var i = startIndex; i < _notes.Length; i++) + { + var note = _notes[i]; + if (note.StartMs > visibleEndMs) + break; + + var noteEndMs = note.StartMs + note.LengthMs; + if (noteEndMs <= effectiveViewTimeMs) + continue; + + // Check active hit state + if (note.StartMs <= currentTimeMs && currentTimeMs <= noteEndMs) + { + if (note.NoteNumber >= 0 && note.NoteNumber < _activeKeys.Length) + _activeKeys[note.NoteNumber] = true; + } + + // Compute position: moving right to left toward KeyboardWidth + var noteX = KeyboardWidth + (note.StartMs - effectiveViewTimeMs) * pixelsPerMs; + var noteW = Math.Max(3.0, note.LengthMs * pixelsPerMs); + + // Clip notes as they pass behind the keyboard + if (noteX < KeyboardWidth) + { + noteW -= (KeyboardWidth - noteX); + noteX = KeyboardWidth; + } + + if (noteW <= 0.5) + continue; + + var pitch = note.NoteNumber; + var trackIdx = Math.Abs(note.TrackIndex) % TrackBrushes.Length; + + // Sub-pixel coalescing for dense notes on the same track and pitch + if (pitch >= 0 && pitch < 128) + { + if (_lastPitchTrack[pitch] == trackIdx) + { + var lastRight = _lastPitchRight[pitch]; + if (noteX + noteW <= lastRight + 0.5) + { + // Completely covered by previous note on same track and pitch + continue; + } + + if (noteX < lastRight) + { + // Overlaps tail of previous note: trim overlapping segment + noteW -= (lastRight - noteX); + noteX = lastRight; + if (noteW <= 0.5) continue; + } + } + + _lastPitchTrack[pitch] = trackIdx; + _lastPitchRight[pitch] = noteX + noteW; + } + + var noteY = (_maxPitch - pitch) * pitchHeight - verticalOffset; + var noteH = Math.Max(2, pitchHeight - 1); + if (noteY + noteH < 0 || noteY > height) + continue; + + var rect = new Rect(noteX, noteY + 0.5, noteW, noteH); + + if (allowRounding && noteW >= 6.0) + { + _trackRectsRound[trackIdx].Add(rect); + } + else + { + _trackRectsSharp[trackIdx].Add(rect); + } + } + + // Draw batched notes track by track with single frozen brushes + for (var t = 0; t < TrackBrushes.Length; t++) + { + var brush = (_trackCount == 1) ? _activeKeyBrush : TrackBrushes[t]; + + var sharps = _trackRectsSharp[t]; + var sharpCount = sharps.Count; + for (var r = 0; r < sharpCount; r++) + { + dc.DrawRectangle(brush, null, sharps[r]); + } + + var rounds = _trackRectsRound[t]; + var roundCount = rounds.Count; + if (roundCount > 0) + { + var radius = Math.Min(6.0, Math.Max(2.5, pitchHeight * 0.45)); + for (var r = 0; r < roundCount; r++) + { + var rect = rounds[r]; + var cornerR = Math.Min(radius, rect.Width * 0.45); + dc.DrawRoundedRectangle(brush, null, rect, cornerR, cornerR); + } + } + } + } + + // 4. Draw key change markers scrolling with the notes + var markers = KeyChangeMarkers; + if (markers != null) + { + foreach (var marker in markers) + { + var markerX = KeyboardWidth + (marker.TimeMs - effectiveViewTimeMs) * pixelsPerMs; + if (markerX >= KeyboardWidth && markerX <= width) + { + // Vertical marker guide line + dc.DrawLine(MarkerLinePen, new Point(markerX, 0), new Point(markerX, height)); + + // Key badge at top + var badgeText = marker.NoteDisplay; + var formatted = GetOrCreateMarkerLabel(badgeText, dpi); + + var badgeW = formatted.Width + 8; + var badgeH = formatted.Height + 2; + var badgeRect = new Rect(Math.Max(KeyboardWidth + 1, markerX - (badgeW / 2)), 2, badgeW, badgeH); + + dc.DrawRoundedRectangle(MarkerBadgeBrush, MarkerBadgePen, badgeRect, 3, 3); + dc.DrawText(formatted, new Point(badgeRect.X + 4, badgeRect.Y + 1)); + } + } + } + + // 4.5. Draw hover guide line and timestamp pill + double? effectiveHoverX = null; + long hoverMs = 0; + + if (_hoverX.HasValue && _hoverX.Value >= KeyboardWidth && _hoverX.Value <= width) + { + effectiveHoverX = _hoverX.Value; + hoverMs = effectiveViewTimeMs + (long)((effectiveHoverX.Value - KeyboardWidth) / pixelsPerMs); + } + else if (HoverTime.HasValue) + { + var targetMs = (long)HoverTime.Value.TotalMilliseconds; + var targetX = KeyboardWidth + (targetMs - effectiveViewTimeMs) * pixelsPerMs; + if (targetX >= KeyboardWidth && targetX <= width) + { + effectiveHoverX = targetX; + hoverMs = targetMs; + } + } + + if (effectiveHoverX.HasValue) + { + var hx = effectiveHoverX.Value; + dc.DrawLine(HoverLinePen, new Point(hx, 0), new Point(hx, height)); + var totalMs = TotalDurationMs; + if (totalMs > 0) hoverMs = Math.Clamp(hoverMs, 0, totalMs); + else hoverMs = Math.Max(0, hoverMs); + + var hoverTime = TimeSpan.FromMilliseconds(hoverMs); + var timeText = hoverTime.TotalHours >= 1 + ? hoverTime.ToString(@"h\:mm\:ss\.f", CultureInfo.InvariantCulture) + : hoverTime.ToString(@"m\:ss\.f", CultureInfo.InvariantCulture); + + var formattedHover = new FormattedText( + timeText, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + HoverTypeface, + 9.5, + HoverTextBrush, + dpi); + + var pillW = formattedHover.Width + 8; + var pillH = formattedHover.Height + 2; + var pillX = Math.Clamp(hx - (pillW / 2), KeyboardWidth + 2, width - pillW - 2); + var pillY = height - pillH - 3; + var pillRect = new Rect(pillX, pillY, pillW, pillH); + + dc.DrawRoundedRectangle(HoverPillBrush, HoverPillPen, pillRect, 3, 3); + dc.DrawText(formattedHover, new Point(pillRect.X + 4, pillRect.Y + 1)); + } + + // 5. Draw cached stationary keyboard + if (_cachedKeyboard == null || Math.Abs(_lastKeyboardPitchHeight - pitchHeight) > 0.001 || _lastKeyboardMinPitch != _minPitch || _lastKeyboardMaxPitch != _maxPitch || Math.Abs(_lastKeyboardWidth - KeyboardWidth) > 0.1) + { + _cachedKeyboard = BuildStaticKeyboard(pitchHeight, dpi); + _lastKeyboardPitchHeight = pitchHeight; + _lastKeyboardMinPitch = _minPitch; + _lastKeyboardMaxPitch = _maxPitch; + _lastKeyboardWidth = KeyboardWidth; + } + + if (_cachedKeyboardClip == null || Math.Abs(_lastKeyboardClipH - height) > 0.5) + { + _cachedKeyboardClip = new RectangleGeometry(new Rect(0, 0, KeyboardWidth, height)); + _cachedKeyboardClip.Freeze(); + _lastKeyboardClipH = height; + } + dc.PushClip(_cachedKeyboardClip); + dc.PushTransform(new TranslateTransform(0, -verticalOffset)); + dc.DrawDrawing(_cachedKeyboard); + dc.Pop(); + dc.Pop(); + + // 5. Draw active key overlays on top (only for pressed keys) + var fontSize = Math.Min(10, Math.Max(7.5, pitchHeight * 0.65)); + for (var p = _minPitch; p <= _maxPitch; p++) + { + if (p < 0 || p >= _activeKeys.Length || !_activeKeys[p]) + continue; + + var y = (_maxPitch - p) * pitchHeight - verticalOffset; + if (y + pitchHeight < 0 || y > height) + continue; + + var pitchClass = p % 12; + var isBlack = BlackKeys.Contains(pitchClass); + var octave = (p / 12) - 1; + var label = $"{NoteNames[pitchClass]}{octave}"; + + // Highlight full key with active accent + dc.DrawRectangle(_activeKeyBrush, KeyBorderPen, new Rect(0, y, KeyboardWidth, pitchHeight)); + + if (!isBlack || pitchHeight >= 11) + { + var text = GetOrCreateLabel(p, label, _activeKeyTextBrush, fontSize, dpi, isNormal: false); + var textY = y + (pitchHeight - text.Height) / 2; + var textX = Math.Max(4, KeyboardWidth - text.Width - 4); + dc.DrawText(text, new Point(textX, textY)); + } + } + + // 6. Draw Subtle Keyboard Separator Border at KeyboardWidth + dc.DrawLine(KeyBorderPen, new Point(KeyboardWidth, 0), new Point(KeyboardWidth, height)); + + // 7. Draw Moving Playhead Marker when Follow Marker is OFF (!IsSyncEnabled) + if (!IsSyncEnabled) + { + var playheadX = KeyboardWidth + (currentTimeMs - effectiveViewTimeMs) * pixelsPerMs; + if (playheadX >= KeyboardWidth && playheadX <= width) + { + dc.DrawLine(PlayheadLinePen, new Point(playheadX, 0), new Point(playheadX, height)); + dc.PushTransform(new TranslateTransform(playheadX, 0)); + dc.DrawGeometry(PlayheadMarkerBrush, null, PlayheadMarkerGeometry); + dc.Pop(); + } + } + } + + private int FindFirstPossibleNoteIndex(long targetMs) + { + if (_notes.Length == 0) return 0; + var minStart = targetMs - _maxNoteLengthMs; + var low = 0; + var high = _notes.Length - 1; + var result = 0; + + while (low <= high) + { + var mid = low + (high - low) / 2; + if (_notes[mid].StartMs >= minStart) + { + result = mid; + high = mid - 1; + } + else + { + low = mid + 1; + } + } + + return result; + } + + #region Mouse & Scrub Interactions + + protected override HitTestResult HitTestCore(PointHitTestParameters hitTestParameters) + { + var pt = hitTestParameters.HitPoint; + if (pt.X >= 0 && pt.X <= ActualWidth && pt.Y >= 0 && pt.Y <= ActualHeight) + { + return new PointHitTestResult(this, pt); + } + return base.HitTestCore(hitTestParameters); + } + + protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) + { + base.OnMouseLeftButtonDown(e); + var pos = e.GetPosition(this); + if (pos.X >= KeyboardWidth && pos.X <= ActualWidth && ActualWidth > KeyboardWidth + 10) + { + _isDragging = true; + _hasDragged = false; + _dragStartPos = pos; + CaptureMouse(); + + var pixelsPerMs = 0.15 * ZoomLevel; + var currentTimeMs = (long)CurrentTime.TotalMilliseconds; + var effectiveViewTimeMs = (!IsSyncEnabled && ViewTime.HasValue) + ? (long)ViewTime.Value.TotalMilliseconds + : currentTimeMs; + var clickedMs = effectiveViewTimeMs + (long)((pos.X - KeyboardWidth) / pixelsPerMs); + + // Check if user clicked on / near a key change marker (within 8px) + if (KeyChangeMarkers != null) + { + foreach (var marker in KeyChangeMarkers) + { + var markerX = KeyboardWidth + (marker.TimeMs - effectiveViewTimeMs) * pixelsPerMs; + if (Math.Abs(pos.X - markerX) <= 8) + { + clickedMs = marker.TimeMs; + MarkerClicked?.Invoke(this, marker); + break; + } + } + } + + var totalMs = TotalDurationMs; + if (totalMs > 0) clickedMs = Math.Clamp(clickedMs, 0, totalMs); + else clickedMs = Math.Max(0, clickedMs); + + _dragAnchorTimeMs = effectiveViewTimeMs; + _dragAnchorMouseX = pos.X; + _hoverX = pos.X; + + if (IsSyncEnabled) + { + ScrubRequested?.Invoke(this, TimeSpan.FromMilliseconds(clickedMs)); + } + InvalidateVisual(); + e.Handled = true; + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + var pos = e.GetPosition(this); + + if (_isDragging && IsMouseCaptured) + { + var deltaX = pos.X - _dragAnchorMouseX; + var pixelsPerMs = 0.15 * ZoomLevel; + + if (Math.Abs(pos.X - _dragStartPos.X) > 3.0) + { + _hasDragged = true; + } + + if (IsSyncEnabled) + { + if (pixelsPerMs > 0 && Math.Abs(deltaX) >= 1.0) + { + var currentTimeMs = (long)CurrentTime.TotalMilliseconds; + var deltaMs = (long)(deltaX / pixelsPerMs); + var newTargetMs = currentTimeMs + deltaMs; + var totalMs = TotalDurationMs; + if (totalMs > 0) newTargetMs = Math.Clamp(newTargetMs, 0, totalMs); + else newTargetMs = Math.Max(0, newTargetMs); + + _dragAnchorMouseX = pos.X; + ScrubRequested?.Invoke(this, TimeSpan.FromMilliseconds(newTargetMs)); + } + } + else + { + if (pixelsPerMs > 0 && Math.Abs(deltaX) >= 1.0) + { + var currentViewMs = ViewTime.HasValue ? (long)ViewTime.Value.TotalMilliseconds : (long)CurrentTime.TotalMilliseconds; + var deltaMs = (long)(deltaX / pixelsPerMs); + var newViewMs = currentViewMs - deltaMs; + var totalMs = TotalDurationMs; + if (totalMs > 0) newViewMs = Math.Clamp(newViewMs, 0, totalMs); + else newViewMs = Math.Max(0, newViewMs); + + _dragAnchorMouseX = pos.X; + ViewTime = TimeSpan.FromMilliseconds(newViewMs); + } + } + + _hoverX = Math.Clamp(pos.X, KeyboardWidth, ActualWidth); + InvalidateVisual(); + e.Handled = true; + return; + } + + if (pos.X >= KeyboardWidth && pos.X <= ActualWidth) + { + _hoverX = pos.X; + Cursor = Cursors.Hand; + var pixelsPerMs = 0.15 * ZoomLevel; + var effectiveViewTimeMs = (!IsSyncEnabled && ViewTime.HasValue) + ? (long)ViewTime.Value.TotalMilliseconds + : (long)CurrentTime.TotalMilliseconds; + var hoverMs = effectiveViewTimeMs + (long)((pos.X - KeyboardWidth) / pixelsPerMs); + var totalMs = TotalDurationMs; + if (totalMs > 0) hoverMs = Math.Clamp(hoverMs, 0, totalMs); + else hoverMs = Math.Max(0, hoverMs); + HoverTimeChanged?.Invoke(this, TimeSpan.FromMilliseconds(hoverMs)); + InvalidateVisual(); + } + else + { + if (_hoverX.HasValue) + { + _hoverX = null; + Cursor = Cursors.Arrow; + HoverTimeChanged?.Invoke(this, null); + InvalidateVisual(); + } + } + } + + protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) + { + base.OnMouseLeftButtonUp(e); + if (_isDragging) + { + _isDragging = false; + ReleaseMouseCapture(); + + if (!IsSyncEnabled && !_hasDragged) + { + var pos = e.GetPosition(this); + if (pos.X >= KeyboardWidth && pos.X <= ActualWidth) + { + var pixelsPerMs = 0.15 * ZoomLevel; + var effectiveViewTimeMs = ViewTime.HasValue ? (long)ViewTime.Value.TotalMilliseconds : (long)CurrentTime.TotalMilliseconds; + var targetMs = effectiveViewTimeMs + (long)((pos.X - KeyboardWidth) / pixelsPerMs); + var totalMs = TotalDurationMs; + if (totalMs > 0) targetMs = Math.Clamp(targetMs, 0, totalMs); + else targetMs = Math.Max(0, targetMs); + + ScrubRequested?.Invoke(this, TimeSpan.FromMilliseconds(targetMs)); + } + } + + var posUp = e.GetPosition(this); + if (posUp.X < KeyboardWidth || posUp.X > ActualWidth) + { + _hoverX = null; + Cursor = Cursors.Arrow; + HoverTimeChanged?.Invoke(this, null); + } + InvalidateVisual(); + e.Handled = true; + } + } + + protected override void OnMouseLeave(MouseEventArgs e) + { + base.OnMouseLeave(e); + if (!_isDragging) + { + if (_hoverX.HasValue) + { + _hoverX = null; + Cursor = Cursors.Arrow; + HoverTimeChanged?.Invoke(this, null); + InvalidateVisual(); + } + } + } + + protected override void OnLostMouseCapture(MouseEventArgs e) + { + base.OnLostMouseCapture(e); + _isDragging = false; + } + + #endregion +} diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs new file mode 100644 index 0000000..1acf501 --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs @@ -0,0 +1,513 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using AutoMidiPlayer.Data; +using AutoMidiPlayer.Data.Entities; +using AutoMidiPlayer.Data.Midi; +using AutoMidiPlayer.WPF.Helpers; +using Melanchall.DryWetMidi.Core; +using Melanchall.DryWetMidi.Interaction; +using MidiFile = AutoMidiPlayer.Data.Midi.MidiFile; + +namespace AutoMidiPlayer.WPF.Controls.PianoRoll; + +public class TimelineCanvas : FrameworkElement +{ + public readonly record struct MinimapNote(int Pitch, long StartMs, long LengthMs, int TrackIndex); + + private static readonly SolidColorBrush TrackBackgroundBrush; + private static readonly SolidColorBrush[] TrackBrushes; + private static readonly SolidColorBrush PlayheadBrush; + private static readonly Pen PlayheadPen; + private static readonly SolidColorBrush MarkerPinBrush; + private static readonly Pen MarkerPen; + private static readonly SolidColorBrush RulerTextBrush; + private static readonly Typeface RulerTypeface; + private static readonly Typeface MarkerTypeface; + private static readonly SolidColorBrush ViewportFillBrush; + private static readonly Pen ViewportBorderPen; + private static readonly SolidColorBrush ViewportGripBrush; + private static readonly StreamGeometry PlayheadHandleGeometry; + private static readonly Pen HoverLinePen; + + static TimelineCanvas() + { + TrackBackgroundBrush = CreateFrozenBrush(Color.FromArgb(40, 0, 0, 0)); + TrackBrushes = TrackColorPalette.AllBrushes; + PlayheadBrush = CreateFrozenBrush(Color.FromRgb(255, 255, 255)); + PlayheadPen = new Pen(CreateFrozenBrush(Color.FromRgb(255, 255, 255)), 2); + PlayheadPen.Freeze(); + + HoverLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(120, 255, 255, 255)), 1.2); + HoverLinePen.Freeze(); + + MarkerPinBrush = CreateFrozenBrush(Color.FromRgb(255, 183, 3)); // Amber + MarkerPen = new Pen(MarkerPinBrush, 1.5); + MarkerPen.Freeze(); + + RulerTextBrush = CreateFrozenBrush(Color.FromArgb(140, 255, 255, 255)); + RulerTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal); + MarkerTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); + + ViewportFillBrush = CreateFrozenBrush(Color.FromArgb(32, 255, 255, 255)); + ViewportBorderPen = new Pen(CreateFrozenBrush(Color.FromArgb(200, 255, 255, 255)), 1.5); + ViewportBorderPen.Freeze(); + ViewportGripBrush = CreateFrozenBrush(Color.FromArgb(240, 255, 255, 255)); + + PlayheadHandleGeometry = new StreamGeometry(); + using (var ctx = PlayheadHandleGeometry.Open()) + { + ctx.BeginFigure(new Point(-5, 0), true, true); + ctx.LineTo(new Point(5, 0), true, false); + ctx.LineTo(new Point(5, 6), true, false); + ctx.LineTo(new Point(0, 11), true, false); + ctx.LineTo(new Point(-5, 6), true, false); + } + PlayheadHandleGeometry.Freeze(); + } + + private static SolidColorBrush CreateFrozenBrush(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + + private MinimapNote[] _notes = Array.Empty(); + private int _minPitch = 36; + private int _maxPitch = 84; + private long _totalDurationMs = 1; + private int _trackCount = 1; + private DrawingGroup? _staticBackgroundCache; + private double _lastCachedWidth = -1; + private double _lastCachedHeight = -1; + + public TimelineCanvas() + { + Loaded += (_, _) => AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; + Unloaded += (_, _) => AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged; + } + + private void OnThemeResourcesChanged() + { + Dispatcher.BeginInvoke(InvalidateVisualCache); + } + + public static readonly DependencyProperty VisiblePastMsProperty = + DependencyProperty.Register( + nameof(VisiblePastMs), + typeof(long), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(0L, FrameworkPropertyMetadataOptions.AffectsRender)); + + public long VisiblePastMs + { + get => (long)GetValue(VisiblePastMsProperty); + set => SetValue(VisiblePastMsProperty, value); + } + + public static readonly DependencyProperty VisibleHorizonMsProperty = + DependencyProperty.Register( + nameof(VisibleHorizonMs), + typeof(long), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(6000L, FrameworkPropertyMetadataOptions.AffectsRender)); + + public long VisibleHorizonMs + { + get => (long)GetValue(VisibleHorizonMsProperty); + set => SetValue(VisibleHorizonMsProperty, value); + } + + public void InvalidateVisualCache() + { + _staticBackgroundCache = null; + InvalidateVisual(); + } + + public static readonly DependencyProperty ViewTimeProperty = + DependencyProperty.Register( + nameof(ViewTime), + typeof(TimeSpan?), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public TimeSpan? ViewTime + { + get => (TimeSpan?)GetValue(ViewTimeProperty); + set => SetValue(ViewTimeProperty, value); + } + + public static readonly DependencyProperty HoverTimeProperty = + DependencyProperty.Register( + nameof(HoverTime), + typeof(TimeSpan?), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + + public TimeSpan? HoverTime + { + get => (TimeSpan?)GetValue(HoverTimeProperty); + set => SetValue(HoverTimeProperty, value); + } + + public static readonly DependencyProperty MidiFileProperty = + DependencyProperty.Register( + nameof(MidiFile), + typeof(MidiFile), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMidiFileChanged)); + + public MidiFile? MidiFile + { + get => (MidiFile?)GetValue(MidiFileProperty); + set => SetValue(MidiFileProperty, value); + } + + public static readonly DependencyProperty CurrentTimeProperty = + DependencyProperty.Register( + nameof(CurrentTime), + typeof(TimeSpan), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(TimeSpan.Zero, FrameworkPropertyMetadataOptions.AffectsRender)); + + public TimeSpan CurrentTime + { + get => (TimeSpan)GetValue(CurrentTimeProperty); + set => SetValue(CurrentTimeProperty, value); + } + + public static readonly DependencyProperty DurationProperty = + DependencyProperty.Register( + nameof(Duration), + typeof(TimeSpan), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(TimeSpan.FromSeconds(1), FrameworkPropertyMetadataOptions.AffectsRender, OnDurationChanged)); + + public TimeSpan Duration + { + get => (TimeSpan)GetValue(DurationProperty); + set => SetValue(DurationProperty, value); + } + + public static readonly DependencyProperty KeyChangeMarkersProperty = + DependencyProperty.Register( + nameof(KeyChangeMarkers), + typeof(IEnumerable), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMarkersChanged)); + + public IEnumerable? KeyChangeMarkers + { + get => (IEnumerable?)GetValue(KeyChangeMarkersProperty); + set => SetValue(KeyChangeMarkersProperty, value); + } + + public static readonly DependencyProperty SelectedMarkerProperty = + DependencyProperty.Register( + nameof(SelectedMarker), + typeof(KeyChangeMarker), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnSelectedMarkerChanged)); + + public KeyChangeMarker? SelectedMarker + { + get => (KeyChangeMarker?)GetValue(SelectedMarkerProperty); + set => SetValue(SelectedMarkerProperty, value); + } + + private static void OnMidiFileChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TimelineCanvas canvas) + { + canvas.RebuildMinimap(); + } + } + + private static void OnMarkersChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TimelineCanvas canvas) + { + canvas.InvalidateVisualCache(); + } + } + + private static void OnSelectedMarkerChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TimelineCanvas canvas) + { + canvas.InvalidateVisualCache(); + } + } + + private static void OnDurationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TimelineCanvas canvas) + { + var dur = (TimeSpan)e.NewValue; + canvas._totalDurationMs = Math.Max(1L, (long)dur.TotalMilliseconds); + canvas.InvalidateVisualCache(); + } + } + + public void RebuildMinimap() + { + var file = MidiFile; + if (file == null) + { + _notes = Array.Empty(); + _totalDurationMs = Math.Max(1L, (long)Duration.TotalMilliseconds); + _trackCount = 1; + InvalidateVisualCache(); + return; + } + + try + { + file.EnsureMidiLoaded(); + + if (file.Midi is null) + { + _notes = Array.Empty(); + _totalDurationMs = Math.Max(1L, (long)Duration.TotalMilliseconds); + _trackCount = 1; + InvalidateVisualCache(); + return; + } + + var tempoMap = file.OriginalTempoMap ?? file.Midi.GetTempoMap(); + var trackChunks = file.Midi.GetTrackChunks().ToList(); + var list = new List(); + + if (trackChunks.Count > 0) + { + for (var t = 0; t < trackChunks.Count; t++) + { + foreach (var note in trackChunks[t].GetNotes()) + { + var startMs = (long)note.TimeAs(tempoMap).TotalMilliseconds; + var lenMs = (long)note.LengthAs(tempoMap).TotalMilliseconds; + list.Add(new MinimapNote(note.NoteNumber, startMs, lenMs, t)); + } + } + } + else + { + foreach (var note in file.Midi.GetNotes()) + { + var startMs = (long)note.TimeAs(tempoMap).TotalMilliseconds; + var lenMs = (long)note.LengthAs(tempoMap).TotalMilliseconds; + list.Add(new MinimapNote(note.NoteNumber, startMs, lenMs, (int)note.Channel)); + } + } + + _notes = list.OrderBy(n => n.StartMs).ToArray(); + _trackCount = _notes.Length > 0 ? _notes.Select(n => n.TrackIndex).Distinct().Count() : 1; + if (_notes.Length > 0) + { + _minPitch = _notes.Min(n => n.Pitch); + _maxPitch = _notes.Max(n => n.Pitch); + var lastNote = _notes.Max(n => n.StartMs + n.LengthMs); + _totalDurationMs = Math.Max(_totalDurationMs, Math.Max(1L, lastNote)); + } + } + catch (Exception ex) + { + Logger.LogException(ex, "Failed to build timeline minimap notes."); + _notes = Array.Empty(); + _trackCount = 1; + } + + InvalidateVisualCache(); + } + + public long TotalDurationMs => Math.Max(1L, Math.Max(_totalDurationMs, (long)Duration.TotalMilliseconds)); + + public KeyChangeMarker? HitTestMarker(Point point) + { + var markers = KeyChangeMarkers; + var totalMs = TotalDurationMs; + if (markers is null || ActualWidth <= 0 || totalMs <= 0) + return null; + + var width = ActualWidth; + foreach (var marker in markers) + { + var x = (marker.TimeMs / (double)totalMs) * width; + if (Math.Abs(point.X - x) <= 12) + return marker; + } + + return null; + } + + protected override void OnRender(DrawingContext dc) + { + base.OnRender(dc); + + var width = ActualWidth; + var height = ActualHeight; + if (width <= 10 || height <= 10) + return; + + var totalMs = TotalDurationMs; + + if (_staticBackgroundCache == null || Math.Abs(_lastCachedWidth - width) > 0.5 || Math.Abs(_lastCachedHeight - height) > 0.5) + { + _staticBackgroundCache = BuildStaticBackground(width, height, totalMs); + _lastCachedWidth = width; + _lastCachedHeight = height; + } + + // Draw cached static background (minimap, ruler, markers) in a single fast call + dc.DrawDrawing(_staticBackgroundCache); + + var currentMs = (long)CurrentTime.TotalMilliseconds; + var playheadX = Math.Clamp((currentMs / (double)totalMs) * width, 0, width); + + // Draw Viewport Highlight Window (the span of notes currently visible in the piano roll ahead of playhead) + var viewCenterMs = ViewTime.HasValue ? (long)ViewTime.Value.TotalMilliseconds : currentMs; + if (VisibleHorizonMs > 0 && totalMs > 0) + { + var windowStartMs = Math.Clamp(viewCenterMs - VisiblePastMs, 0, totalMs); + var windowEndMs = Math.Clamp(viewCenterMs + VisibleHorizonMs, 0, totalMs); + + var xStart = (windowStartMs / (double)totalMs) * width; + var xEnd = (windowEndMs / (double)totalMs) * width; + var boxW = Math.Max(2, xEnd - xStart); + var boxRect = new Rect(xStart, 1, boxW, height - 2); + + // Shaded translucent white highlight window with rounded borders + dc.DrawRoundedRectangle(ViewportFillBrush, ViewportBorderPen, boxRect, 3, 3); + + // Right tactile grip line + if (boxW > 14) + { + dc.DrawRoundedRectangle(ViewportGripBrush, null, new Rect(xStart + boxW - 3, 4, 2, height - 8), 1, 1); + } + } + + // Draw faint hover guide line if hovering + if (HoverTime.HasValue && totalMs > 0) + { + var hx = Math.Clamp((HoverTime.Value.TotalMilliseconds / (double)totalMs) * width, 0, width); + dc.DrawLine(HoverLinePen, new Point(hx, 0), new Point(hx, height)); + } + + // Draw only the dynamic playhead (ultra-lightweight!) + // Vertical playhead line + dc.DrawLine(PlayheadPen, new Point(playheadX, 0), new Point(playheadX, height)); + + // Playhead handle at top + dc.PushTransform(new TranslateTransform(playheadX, 0)); + dc.DrawGeometry(PlayheadBrush, null, PlayheadHandleGeometry); + dc.Pop(); + } + + private DrawingGroup BuildStaticBackground(double width, double height, long totalMs) + { + var group = new DrawingGroup(); + using (var dc = group.Open()) + { + // 1. Background + dc.DrawRoundedRectangle(TrackBackgroundBrush, null, new Rect(0, 0, width, height), 4, 4); + + // 2. Minimap Notes + if (_notes.Length > 0) + { + var pitchSpan = Math.Max(12, _maxPitch - _minPitch); + var noteAreaH = Math.Max(10, height - 16); + var singleTrackBrush = _trackCount == 1 ? TrackColorPalette.GetBrush(0, 1) : null; + + foreach (var note in _notes) + { + var x = (note.StartMs / (double)totalMs) * width; + var y = (1.0 - (double)(note.Pitch - _minPitch) / pitchSpan) * (noteAreaH - 4) + 2; + var w = Math.Max(2.0, (note.LengthMs / (double)totalMs) * width); + var brush = singleTrackBrush ?? TrackBrushes[Math.Abs(note.TrackIndex) % TrackBrushes.Length]; + dc.DrawRoundedRectangle(brush, null, new Rect(x, y, w, 2.5), 1.0, 1.0); + } + } + + // 3. Ruler markings and timestamps + var durationSec = totalMs / 1000.0; + var stepSec = durationSec switch + { + <= 60 => 10, + <= 180 => 30, + <= 600 => 60, + _ => 120 + }; + + var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + for (var sec = 0; sec <= durationSec; sec += stepSec) + { + var x = (sec * 1000.0 / totalMs) * width; + if (x >= 0 && x <= width) + { + // Small tick + dc.DrawLine(MarkerPen, new Point(x, height - 12), new Point(x, height - 8)); + + var timeSpan = TimeSpan.FromSeconds(sec); + var timeText = timeSpan.ToString(@"m\:ss"); + var formatted = new FormattedText( + timeText, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + RulerTypeface, + 9, + RulerTextBrush, + dpi); + + var textX = Math.Clamp(x - (formatted.Width / 2), 2, width - formatted.Width - 2); + dc.DrawText(formatted, new Point(textX, height - 11)); + } + } + + // 4. Key Change Markers + var markers = KeyChangeMarkers; + if (markers != null) + { + var accentBrush = TrackColorPalette.GetBrush(0, 1); + var accentPen = new Pen(accentBrush, 2); + accentPen.Freeze(); + + foreach (var marker in markers) + { + var x = (marker.TimeMs / (double)totalMs) * width; + var isSelected = marker == SelectedMarker; + + var pen = isSelected ? accentPen : MarkerPen; + var brush = isSelected ? accentBrush : MarkerPinBrush; + + // Vertical marker line + dc.DrawLine(pen, new Point(x, 0), new Point(x, height - 6)); + + // Badge at top + var badgeText = marker.NoteDisplay; + var formatted = new FormattedText( + badgeText, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + MarkerTypeface, + 9.5, + Brushes.Black, + dpi); + + var badgeW = formatted.Width + 8; + var badgeH = formatted.Height + 2; + var badgeRect = new Rect(x - (badgeW / 2), 1, badgeW, badgeH); + + dc.DrawRoundedRectangle(brush, pen, badgeRect, 3, 3); + dc.DrawText(formatted, new Point(badgeRect.X + 4, badgeRect.Y + 1)); + } + } + } + + group.Freeze(); + return group; + } +} diff --git a/AutoMidiPlayer.WPF/Helpers/CachedViewManager.cs b/AutoMidiPlayer.WPF/Helpers/CachedViewManager.cs index f6107d0..ba73b01 100644 --- a/AutoMidiPlayer.WPF/Helpers/CachedViewManager.cs +++ b/AutoMidiPlayer.WPF/Helpers/CachedViewManager.cs @@ -26,7 +26,7 @@ public override UIElement CreateViewForModel(object model) // Only cache heavy main navigation pages to avoid memory leaks on dialogs bool shouldCache = isPageCachingEnabled && - model is SettingsPageViewModel or SongsViewModel or QueueViewModel or TrackViewModel or PianoSheetViewModel or InstrumentViewModel or AboutViewModel; + model is SettingsPageViewModel or SongsViewModel or QueueViewModel or TrackViewModel or StudioViewModel or PianoSheetViewModel or InstrumentViewModel or AboutViewModel; if (shouldCache && _viewCache.TryGetValue(modelType, out var cachedView)) { diff --git a/AutoMidiPlayer.WPF/Helpers/TrackColorPalette.cs b/AutoMidiPlayer.WPF/Helpers/TrackColorPalette.cs new file mode 100644 index 0000000..2d0adf5 --- /dev/null +++ b/AutoMidiPlayer.WPF/Helpers/TrackColorPalette.cs @@ -0,0 +1,145 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using AutoMidiPlayer.Data.Midi; +using AutoMidiPlayer.WPF.Core; + +namespace AutoMidiPlayer.WPF.Helpers; + +public static class TrackColorPalette +{ + public static readonly Color[] Colors = + [ + Color.FromRgb(0, 180, 216), // 0: Cyan + Color.FromRgb(6, 214, 160), // 1: Emerald + Color.FromRgb(255, 183, 3), // 2: Amber + Color.FromRgb(251, 133, 0), // 3: Coral Orange + Color.FromRgb(131, 56, 236), // 4: Purple + Color.FromRgb(255, 0, 110), // 5: Pink + Color.FromRgb(58, 134, 255), // 6: Blue + Color.FromRgb(112, 224, 0), // 7: Lime + Color.FromRgb(247, 37, 133), // 8: Magenta + Color.FromRgb(76, 201, 240), // 9: Sky Blue + Color.FromRgb(254, 228, 64), // 10: Bright Yellow + Color.FromRgb(243, 114, 44) // 11: Tangerine + ]; + + private static readonly SolidColorBrush[] Brushes; + private static readonly SolidColorBrush[] TextBrushes; + + static TrackColorPalette() + { + Brushes = new SolidColorBrush[Colors.Length]; + TextBrushes = new SolidColorBrush[Colors.Length]; + + for (var i = 0; i < Colors.Length; i++) + { + var b = new SolidColorBrush(Colors[i]); + b.Freeze(); + Brushes[i] = b; + + var c = Colors[i]; + var lum = (0.299 * c.R + 0.587 * c.G + 0.114 * c.B) / 255.0; + var tb = new SolidColorBrush(lum > 0.5 ? Color.FromRgb(18, 18, 20) : System.Windows.Media.Colors.White); + tb.Freeze(); + TextBrushes[i] = tb; + } + } + + public static Color GetColor(int trackIndex, int trackCount = 0) + { + if (trackCount == 1) + return AccentColorHelper.GetAccentColor(); + + return Colors[Math.Abs(trackIndex) % Colors.Length]; + } + + public static SolidColorBrush GetBrush(int trackIndex, int trackCount = 0) + { + if (trackCount == 1) + { + var brush = new SolidColorBrush(AccentColorHelper.GetAccentColor()); + brush.Freeze(); + return brush; + } + + return Brushes[Math.Abs(trackIndex) % Brushes.Length]; + } + + public static SolidColorBrush GetTextBrush(int trackIndex, int trackCount = 0) + { + if (trackCount == 1) + { + var c = AccentColorHelper.GetAccentColor(); + var lum = (0.299 * c.R + 0.587 * c.G + 0.114 * c.B) / 255.0; + var tb = new SolidColorBrush(lum > 0.5 ? Color.FromRgb(18, 18, 20) : System.Windows.Media.Colors.White); + tb.Freeze(); + return tb; + } + + return TextBrushes[Math.Abs(trackIndex) % TextBrushes.Length]; + } + + public static SolidColorBrush[] AllBrushes => Brushes; +} + +public class TrackBrushConverter : IValueConverter +{ + public static readonly TrackBrushConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is MidiTrack track) + { + return TrackColorPalette.GetBrush(track.Index, track.TotalTracks); + } + if (value is int trackIndex) + { + return TrackColorPalette.GetBrush(trackIndex); + } + return System.Windows.Media.Brushes.Transparent; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); +} + +public class TrackColorConverter : IValueConverter +{ + public static readonly TrackColorConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is MidiTrack track) + { + return TrackColorPalette.GetColor(track.Index, track.TotalTracks); + } + if (value is int trackIndex) + { + return TrackColorPalette.GetColor(trackIndex); + } + return System.Windows.Media.Colors.Transparent; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); +} + +public class TrackTextBrushConverter : IValueConverter +{ + public static readonly TrackTextBrushConverter Instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is MidiTrack track) + { + return TrackColorPalette.GetTextBrush(track.Index, track.TotalTracks); + } + if (value is int trackIndex) + { + return TrackColorPalette.GetTextBrush(trackIndex); + } + return System.Windows.Media.Brushes.Black; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); +} diff --git a/AutoMidiPlayer.WPF/Services/FileService.cs b/AutoMidiPlayer.WPF/Services/FileService.cs index e55fb9d..1e05478 100644 --- a/AutoMidiPlayer.WPF/Services/FileService.cs +++ b/AutoMidiPlayer.WPF/Services/FileService.cs @@ -1135,6 +1135,50 @@ private static int NormalizeDetectedKeyOffset(int pitchClass) private static int Mod12(int value) => ((value % 12) + 12) % 12; + public static List DetectKeyChangesFromMidi(Melanchall.DryWetMidi.Core.MidiFile midi, TempoMap tempoMap) + { + var result = new List(); + try + { + var timedEvents = midi.GetTimedEvents() + .Where(e => e.Event is KeySignatureEvent) + .OrderBy(e => e.Time); + + int? lastOffset = null; + foreach (var timedEvent in timedEvents) + { + var keySig = (KeySignatureEvent)timedEvent.Event; + var isMinor = keySig.Scale == 1; + var tonicPitchClass = Mod12((isMinor ? 9 : 0) + (keySig.Key * 7)); + var keyOffset = NormalizeDetectedKeyOffset(tonicPitchClass); + + if (lastOffset.HasValue && lastOffset.Value == keyOffset) + continue; + + var metricTime = TimeConverter.ConvertTo(timedEvent.Time, tempoMap); + var ms = (long)metricTime.TotalMilliseconds; + + var noteName = MusicConstants.GetNoteName(keyOffset); + var label = $"{noteName} {(isMinor ? "min" : "maj")}"; + + result.Add(new KeyChangeMarker + { + TimeMs = ms, + KeyOffset = keyOffset, + Label = label + }); + + lastOffset = keyOffset; + } + } + catch (Exception ex) + { + Logger.LogException(ex, "Failed to detect key changes from MIDI file."); + } + + return result; + } + private async Task AddFile( string fileName, bool notifyFileErrors = true, diff --git a/AutoMidiPlayer.WPF/Services/PlaybackService.cs b/AutoMidiPlayer.WPF/Services/PlaybackService.cs index 968d137..a287033 100644 --- a/AutoMidiPlayer.WPF/Services/PlaybackService.cs +++ b/AutoMidiPlayer.WPF/Services/PlaybackService.cs @@ -60,6 +60,7 @@ public class PlaybackEngineService : PropertyChangedBase, IHandle, IHa private readonly Dictionary _activeNotes = new(); private readonly Dictionary<(int Channel, int NoteNumber), int> _activeSpeakerNotes = new(); private readonly HashSet<(int Note, string Layout, string Instrument)> _activeGameNotes = new(); + private readonly Dictionary<(int Channel, int NoteNumber), (int OutputNote, string Layout, string Instrument)> _activePhysicalNotes = new(); private readonly Dictionary _chordPadsByEvent = new(); private readonly List _detectedChordPads = new(); @@ -304,6 +305,7 @@ public Task InitializePlayback() _activeNotes.Clear(); _activeSpeakerNotes.Clear(); _activeGameNotes.Clear(); + _activePhysicalNotes.Clear(); foreach (var pedal in AllPedals) pedal.Clear(); _loggedSongContextForNotes = false; playback.Finished += (_, _) => @@ -332,6 +334,7 @@ public Task InitializePlayback() _activeNotes.Clear(); _activeSpeakerNotes.Clear(); _activeGameNotes.Clear(); + _activePhysicalNotes.Clear(); _scheduledEventTicks = TimeConverter.ConvertFrom( playback.GetCurrentTime(), playback.TempoMap); @@ -640,6 +643,7 @@ private void ReleaseSustainIfActive() KeyboardPlayer.NoteUp(activeNote.Note, activeNote.Layout, activeNote.Instrument); } _activeGameNotes.Clear(); + _activePhysicalNotes.Clear(); } private void SilenceSpeakers() @@ -676,7 +680,31 @@ private void PlayNote(NoteEvent noteEvent) var instrument = InstrumentPage.SelectedInstrument.Key; var sourceNote = (int)noteEvent.NoteNumber; var isNoteOn = noteEvent.EventType == MidiEventType.NoteOn && noteEvent.Velocity > 0; - var noteForKeyboard = ApplyNoteSettings(instrument, noteEvent.NoteNumber); + var isNoteOff = noteEvent.EventType == MidiEventType.NoteOff || (noteEvent.EventType == MidiEventType.NoteOn && noteEvent.Velocity <= 0); + + var eventTime = GetCurrentEventMetricTime(); + var eventTimeUs = eventTime.Ticks / 10; + + int noteForKeyboard; + if (isNoteOn) + { + noteForKeyboard = ApplyNoteSettings(instrument, noteEvent.NoteNumber, eventTime); + _activePhysicalNotes[(noteEvent.Channel, noteEvent.NoteNumber)] = (noteForKeyboard, layout, instrument); + } + else + { + if (_activePhysicalNotes.Remove((noteEvent.Channel, noteEvent.NoteNumber), out var active)) + { + noteForKeyboard = active.OutputNote; + layout = active.Layout; + instrument = active.Instrument; + } + else + { + noteForKeyboard = ApplyNoteSettings(instrument, noteEvent.NoteNumber, eventTime); + } + } + var noteForListen = noteForKeyboard; // Listen mode plays the same note as keyboard output var hasMappedKey = KeyboardPlayer.TryGetKey(layout, instrument, noteForKeyboard, out var mappedKey); var transposeMode = Settings.TransposeNotes && SongSettings.Transpose is not null @@ -693,7 +721,7 @@ private void PlayNote(NoteEvent noteEvent) return; if (isNoteOn) - NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, Controls.CurrentTime.Ticks / 10)); + NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, eventTimeUs)); if (ShouldLogPlayedNotes) LogNoteInputOutput("speakers", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey); @@ -714,7 +742,7 @@ private void PlayNote(NoteEvent noteEvent) return; if (isNoteOn) - NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, Controls.CurrentTime.Ticks / 10)); + NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, eventTimeUs)); if (ShouldLogPlayedNotes) LogNoteInputOutput("auto-listen", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey); @@ -732,7 +760,7 @@ private void PlayNote(NoteEvent noteEvent) return; } - if (TryPlayChordPad(noteEvent, sourceNote, layout, instrument, noteForKeyboard)) + if (TryPlayChordPad(noteEvent, sourceNote, layout, instrument, noteForKeyboard, eventTimeUs)) return; var useHoldNotes = Queue.OpenedFile?.Song.HoldNotes ?? false; @@ -758,7 +786,7 @@ private void PlayNote(NoteEvent noteEvent) if (!hasMappedKey) return; - NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, Controls.CurrentTime.Ticks / 10)); + NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, eventTimeUs)); if (ShouldLogPlayedNotes) LogNoteInputOutput("game", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey); @@ -786,7 +814,8 @@ private bool TryPlayChordPad( int sourceNote, string layout, string instrument, - int noteForKeyboard) + int noteForKeyboard, + long eventTimeUs) { var eventKey = new ChordPadEventKey( _scheduledEventTicks, @@ -817,7 +846,7 @@ private bool TryPlayChordPad( // Keep the track view's glow state representative of the source MIDI even though the // output is a single game chord-pad key. - NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, Controls.CurrentTime.Ticks / 10)); + NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, eventTimeUs)); if (detectedChordPad.WasTriggered) return true; @@ -864,8 +893,9 @@ private void BuildChordPadEventMap(IEnumerable trackChunks, TempoMap foreach (var chord in chordTrack.GetChords(chordDetectionSettings)) { var notes = chord.Notes.ToArray(); + var chordMetricTime = (TimeSpan)chord.TimeAs(tempoMap); var pitchClasses = notes - .Select(note => Mod12(ApplyNoteSettings(instrument, (int)note.NoteNumber))) + .Select(note => Mod12(ApplyNoteSettings(instrument, (int)note.NoteNumber, chordMetricTime))) .ToHashSet(); var chordPad = instrumentConfig.ChordPads @@ -937,24 +967,57 @@ private static TrackChunk CreateChordDetectionTrack(IEnumerable trac (int)noteEvent.NoteNumber, (int)noteEvent.Channel); - private int ApplyNoteSettings(string instrumentId, int noteId) + private TimeSpan GetCurrentEventMetricTime() + { + var playback = Playback; + if (playback is { IsRunning: true }) + { + try + { + return (TimeSpan)playback.GetCurrentTime(); + } + catch + { + // Fall back if playback is resetting or transitioning + } + } + + var tempoMap = playback?.TempoMap ?? Queue?.OpenedFile?.OriginalTempoMap; + if (tempoMap is not null && _scheduledEventTicks > 0) + { + var metricSpan = TimeConverter.ConvertTo(_scheduledEventTicks, tempoMap); + return (TimeSpan)metricSpan; + } + return Controls.CurrentTime; + } + + private int ApplyNoteSettings(string instrumentId, int noteId, TimeSpan? eventTime = null) { var instrumentKeyCount = Keyboard.GetNotes(instrumentId).Count; var threshold = Settings.AutoCorrectThreshold; + var song = Queue.OpenedFile?.Song; + var targetTime = eventTime ?? GetCurrentEventMetricTime(); + + var dynamicMarker = song?.GetKeyMarkerAtTime(targetTime); + var activeKeyOffset = dynamicMarker?.KeyOffset ?? SongSettings.KeyOffset; + var activeTranspose = dynamicMarker?.Transpose ?? (Settings.TransposeNotes && SongSettings.Transpose is not null ? SongSettings.Transpose.Value.Key : (Transpose?)null); if (threshold > 0 && instrumentKeyCount <= threshold) { // Auto-correct: apply full base key + relative offset - noteId += SongSettings.GetEffectiveKeyOffset(Queue.OpenedFile?.Song); + var effectiveOffset = dynamicMarker != null + ? MusicConstants.GetEffectiveKeyOffset(activeKeyOffset, song?.BaseKey) + : SongSettings.GetEffectiveKeyOffset(song); + noteId += effectiveOffset; } else { // Wide-range instrument: only apply the relative user offset (no base key shift) - noteId += SongSettings.KeyOffset; + noteId += activeKeyOffset; } - return Settings.TransposeNotes && SongSettings.Transpose is not null - ? KeyboardPlayer.TransposeNote(instrumentId, ref noteId, SongSettings.Transpose.Value.Key) + return activeTranspose.HasValue + ? KeyboardPlayer.TransposeNote(instrumentId, ref noteId, activeTranspose.Value) : noteId; } diff --git a/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml b/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml index c30acf5..09aa4ea 100644 --- a/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml +++ b/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml @@ -583,6 +583,10 @@ Icon="{ui:SymbolIcon AppsListDetail24}" Tag="{Binding TrackView}" Click="{s:Action NavigateToItem}"/> + "About", "Tracks" => "Tracks", + "Studio" => "Studio", "Sheet" => "Sheet", "Instrument" => "Instrument", "Queue" => "Queue", @@ -244,6 +248,7 @@ public void UpdateTitle() { "About" => AboutView, "Tracks" => TrackView, + "Studio" => StudioView, "Sheet" => PianoSheetView, "Instrument" => InstrumentView, "Queue" => QueueView, diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml new file mode 100644 index 0000000..6fe4901 --- /dev/null +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml.cs new file mode 100644 index 0000000..fad4cf5 --- /dev/null +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace AutoMidiPlayer.WPF.Views; + +public partial class StudioSidebarControl : UserControl +{ + public StudioSidebarControl() + { + InitializeComponent(); + } +} diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml new file mode 100644 index 0000000..d13df46 --- /dev/null +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml @@ -0,0 +1,317 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs new file mode 100644 index 0000000..1953ea3 --- /dev/null +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs @@ -0,0 +1,203 @@ +using System; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Animation; +using AutoMidiPlayer.WPF.ViewModels; + +namespace AutoMidiPlayer.WPF.Views; + +public partial class StudioView : UserControl +{ + private const double SidebarWidth = 328.0; + private readonly TranslateTransform _dockedSidebarTransform = new(); + private readonly TranslateTransform _overlaySidebarTransform = new(); + private StudioViewModel? ViewModel => DataContext as StudioViewModel; + + public StudioView() + { + InitializeComponent(); + DockedSidebar.RenderTransform = _dockedSidebarTransform; + OverlaySidebarControl.RenderTransform = _overlaySidebarTransform; + DataContextChanged += OnDataContextChanged; + Loaded += OnViewLoaded; + } + + private void OnViewLoaded(object sender, RoutedEventArgs e) + { + UpdateResponsiveLayout(animate: false); + } + + private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (e.OldValue is INotifyPropertyChanged oldVm) + { + oldVm.PropertyChanged -= OnViewModelPropertyChanged; + } + + if (e.NewValue is INotifyPropertyChanged newVm) + { + newVm.PropertyChanged += OnViewModelPropertyChanged; + } + + UpdateResponsiveLayout(animate: false); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(StudioViewModel.IsSidebarOpen)) + { + UpdateResponsiveLayout(animate: true); + } + } + + private void OnViewSizeChanged(object sender, SizeChangedEventArgs e) + { + UpdateResponsiveLayout(animate: false); + } + + private void UpdateResponsiveLayout(bool animate = false) + { + var vm = ViewModel; + var isOpen = vm?.IsSidebarOpen ?? true; + var isWide = ActualWidth >= 1050; + + if (isWide) + { + // Reset and hide overlay drawer + OverlayDrawer.Visibility = Visibility.Collapsed; + _overlaySidebarTransform.BeginAnimation(TranslateTransform.XProperty, null); + _overlaySidebarTransform.X = SidebarWidth; + + double targetWidth = isOpen ? SidebarWidth : 0.0; + double targetX = isOpen ? 0.0 : SidebarWidth; + + if (!animate || !IsLoaded) + { + DockedSidebarHost.BeginAnimation(FrameworkElement.WidthProperty, null); + _dockedSidebarTransform.BeginAnimation(TranslateTransform.XProperty, null); + DockedSidebarHost.Width = targetWidth; + _dockedSidebarTransform.X = targetX; + DockedSidebarHost.Visibility = isOpen ? Visibility.Visible : Visibility.Collapsed; + return; + } + + // Animate docked sidebar slide + ease resize + DockedSidebarHost.Visibility = Visibility.Visible; + + double currentWidth = DockedSidebarHost.ActualWidth; + if (double.IsNaN(currentWidth) || currentWidth < 0) + { + currentWidth = DockedSidebarHost.Width > 0 ? DockedSidebarHost.Width : (isOpen ? 0.0 : SidebarWidth); + } + double currentX = _dockedSidebarTransform.X; + + DockedSidebarHost.BeginAnimation(FrameworkElement.WidthProperty, null); + DockedSidebarHost.Width = currentWidth; + _dockedSidebarTransform.BeginAnimation(TranslateTransform.XProperty, null); + _dockedSidebarTransform.X = currentX; + + var ease = new CubicEase + { + EasingMode = isOpen ? EasingMode.EaseOut : EasingMode.EaseInOut + }; + var duration = TimeSpan.FromMilliseconds(isOpen ? 280 : 240); + + var widthAnim = new DoubleAnimation + { + From = currentWidth, + To = targetWidth, + Duration = duration, + EasingFunction = ease, + FillBehavior = FillBehavior.Stop + }; + + widthAnim.Completed += (s, e) => + { + DockedSidebarHost.BeginAnimation(FrameworkElement.WidthProperty, null); + DockedSidebarHost.Width = targetWidth; + if (!isOpen) + { + DockedSidebarHost.Visibility = Visibility.Collapsed; + } + }; + + var transformAnim = new DoubleAnimation + { + From = currentX, + To = targetX, + Duration = duration, + EasingFunction = ease, + FillBehavior = FillBehavior.Stop + }; + + transformAnim.Completed += (s, e) => + { + _dockedSidebarTransform.BeginAnimation(TranslateTransform.XProperty, null); + _dockedSidebarTransform.X = targetX; + }; + + DockedSidebarHost.BeginAnimation(FrameworkElement.WidthProperty, widthAnim); + _dockedSidebarTransform.BeginAnimation(TranslateTransform.XProperty, transformAnim); + } + else + { + // Reset and hide docked sidebar + DockedSidebarHost.Visibility = Visibility.Collapsed; + DockedSidebarHost.BeginAnimation(FrameworkElement.WidthProperty, null); + DockedSidebarHost.Width = 0.0; + _dockedSidebarTransform.BeginAnimation(TranslateTransform.XProperty, null); + _dockedSidebarTransform.X = SidebarWidth; + + double targetX = isOpen ? 0.0 : SidebarWidth; + + if (!animate || !IsLoaded) + { + _overlaySidebarTransform.BeginAnimation(TranslateTransform.XProperty, null); + _overlaySidebarTransform.X = targetX; + OverlayDrawer.Visibility = isOpen ? Visibility.Visible : Visibility.Collapsed; + return; + } + + // Animate overlay drawer slide + OverlayDrawer.Visibility = Visibility.Visible; + double currentX = _overlaySidebarTransform.X; + + _overlaySidebarTransform.BeginAnimation(TranslateTransform.XProperty, null); + _overlaySidebarTransform.X = currentX; + + var ease = new CubicEase + { + EasingMode = isOpen ? EasingMode.EaseOut : EasingMode.EaseIn + }; + var duration = TimeSpan.FromMilliseconds(isOpen ? 260 : 200); + + var overlayAnim = new DoubleAnimation + { + From = currentX, + To = targetX, + Duration = duration, + EasingFunction = ease, + FillBehavior = FillBehavior.Stop + }; + + overlayAnim.Completed += (s, e) => + { + _overlaySidebarTransform.BeginAnimation(TranslateTransform.XProperty, null); + _overlaySidebarTransform.X = targetX; + if (!isOpen) + { + OverlayDrawer.Visibility = Visibility.Collapsed; + } + }; + + _overlaySidebarTransform.BeginAnimation(TranslateTransform.XProperty, overlayAnim); + } + } + + private void OnPianoRollScrubRequested(object? sender, TimeSpan time) + { + ViewModel?.ScrubTo(time); + } +} diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs new file mode 100644 index 0000000..8cac1be --- /dev/null +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs @@ -0,0 +1,650 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Media; +using AutoMidiPlayer.Data; +using AutoMidiPlayer.Data.Entities; +using AutoMidiPlayer.Data.Midi; +using AutoMidiPlayer.Data.Notification; +using AutoMidiPlayer.WPF.Controls.NoSongPlaceholder; +using AutoMidiPlayer.WPF.Controls.Snackbar; +using AutoMidiPlayer.WPF.Core; +using AutoMidiPlayer.WPF.Services; +using Melanchall.DryWetMidi.Interaction; +using Melanchall.DryWetMidi.Multimedia; +using Stylet; +using StyletIoC; +using IContainer = StyletIoC.IContainer; + +namespace AutoMidiPlayer.WPF.ViewModels; + +public class StudioViewModel : Screen, IHandle, IHandle +{ + private readonly IContainer _ioc; + private readonly MainWindowViewModel _main; + private readonly IEventAggregator _events; + private readonly PlaybackCurrentTimeWatcher _timeWatcher; + + private bool _isSidebarOpen = true; + private string _activeSidebarTab = "Tracks"; // "Tracks" or "Settings" + private KeyChangeMarker? _selectedMarker; + private MusicConstants.KeyOption? _selectedKeyOption; + private TimeSpan _currentTime = TimeSpan.Zero; + private double _zoomLevel = 1.0; + private double _keyHeight = 16.0; + private bool _isRenderingHooked; + private string _lastActiveKeyDisplay = string.Empty; + private KeyChangeMarker? _lastActiveMarker; + private int _lastActiveKey = int.MinValue; + + public StudioViewModel(IContainer ioc, MainWindowViewModel main, NoSongPlaceholderComponent placeholder) + { + _ioc = ioc; + _main = main; + Placeholder = placeholder; + _events = ioc.Get(); + _events.Subscribe(this); + _timeWatcher = PlaybackCurrentTimeWatcher.Instance; + + KeyOptions = MusicConstants.GenerateKeyOptions(); + _selectedKeyOption = KeyOptions.FirstOrDefault(k => k.Value == 0); + } + + #region Properties - Components & Delegates + + public NoSongPlaceholderComponent Placeholder { get; } + public MainWindowViewModel Main => _main; + public PlaybackControlsService Controls => _main.PlaybackControls; + public SongService SongSettings => _main.SongSettings; + public BindableCollection MidiTracks => _main.TrackView.MidiTracks; + + public MidiFile? CurrentFile => _main.QueueView.OpenedFile; + public bool HasSongOpen => CurrentFile is not null; + + public BindableCollection KeyChangeMarkers { get; } = new(); + + public KeyChangeMarker? SelectedMarker + { + get => _selectedMarker; + set + { + if (SetAndNotify(ref _selectedMarker, value)) + { + NotifyOfPropertyChange(nameof(HasSelectedMarker)); + if (value != null) + { + _selectedKeyOption = KeyOptions.FirstOrDefault(k => k.Value == value.KeyOffset); + NotifyOfPropertyChange(nameof(SelectedKeyOption)); + } + } + } + } + + public bool HasSelectedMarker => SelectedMarker != null; + + public List KeyOptions { get; } + + public MusicConstants.KeyOption? SelectedKeyOption + { + get => _selectedKeyOption; + set + { + if (SetAndNotify(ref _selectedKeyOption, value)) + { + if (SelectedMarker != null && value != null && SelectedMarker.KeyOffset != value.Value) + { + SelectedMarker.KeyOffset = value.Value; + var noteName = MusicConstants.GetNoteName(value.Value); + SelectedMarker.Label = $"{noteName} ({(value.Value >= 0 ? "+" : "")}{value.Value})"; + _ = SaveMarkersToSongAsync(); + } + } + } + } + + public TimeSpan CurrentTime + { + get => _currentTime; + set + { + if (SetAndNotify(ref _currentTime, value)) + { + UpdateActiveKeyDisplayIfNeeded(); + } + } + } + + private void UpdateActiveKeyDisplay() + { + var display = ActiveKeyAtPlayheadDisplay; + if (!string.Equals(_lastActiveKeyDisplay, display, StringComparison.Ordinal)) + { + _lastActiveKeyDisplay = display; + NotifyOfPropertyChange(nameof(ActiveKeyAtPlayheadDisplay)); + } + } + + private void UpdateActiveKeyDisplayIfNeeded() + { + if (KeyChangeMarkers.Count == 0) + { + // If there are no key change markers, the active key is static throughout playback + return; + } + + var song = CurrentFile?.Song; + if (song is null) return; + + var marker = song.GetKeyMarkerAtTime(CurrentTime); + var offset = marker?.KeyOffset ?? song.Key; + + if (marker != _lastActiveMarker || offset != _lastActiveKey) + { + _lastActiveMarker = marker; + _lastActiveKey = offset; + UpdateActiveKeyDisplay(); + } + } + + public TimeSpan Duration => Controls.MaximumTime; + + public double ZoomLevel + { + get => _zoomLevel; + set => SetAndNotify(ref _zoomLevel, Math.Clamp(value, 0.2, 5.0)); + } + + public double KeyHeight + { + get => _keyHeight; + set => SetAndNotify(ref _keyHeight, Math.Clamp(value, 6.0, 50.0)); + } + + public bool IsSidebarOpen + { + get => _isSidebarOpen; + set + { + if (SetAndNotify(ref _isSidebarOpen, value)) + { + NotifyOfPropertyChange(nameof(IsTracksDrawerActive)); + NotifyOfPropertyChange(nameof(IsSettingsDrawerActive)); + if (!IsTracksDrawerActive) StopTrackGlows(); + } + } + } + + public string ActiveSidebarTab + { + get => _activeSidebarTab; + set + { + if (SetAndNotify(ref _activeSidebarTab, value)) + { + NotifyOfPropertyChange(nameof(IsTracksTabActive)); + NotifyOfPropertyChange(nameof(IsSettingsTabActive)); + NotifyOfPropertyChange(nameof(IsTracksDrawerActive)); + NotifyOfPropertyChange(nameof(IsSettingsDrawerActive)); + NotifyOfPropertyChange(nameof(ActiveDrawerTitle)); + if (!IsTracksDrawerActive) StopTrackGlows(); + } + } + } + + public bool IsTracksTabActive => ActiveSidebarTab == "Tracks"; + public bool IsSettingsTabActive => ActiveSidebarTab == "Settings"; + + public bool IsTracksDrawerActive => IsSidebarOpen && ActiveSidebarTab == "Tracks"; + public bool IsSettingsDrawerActive => IsSidebarOpen && ActiveSidebarTab == "Settings"; + public string ActiveDrawerTitle => ActiveSidebarTab == "Tracks" ? "Tracks" : "Song Settings"; + + public string ActiveKeyAtPlayheadDisplay + { + get + { + if (CurrentFile?.Song is null) + return "Key: C (+0)"; + + var song = CurrentFile.Song; + var marker = song.GetKeyMarkerAtTime(CurrentTime); + var offset = marker?.KeyOffset ?? song.Key; + var noteName = MusicConstants.GetNoteName(offset); + var isModulated = marker != null; + + return isModulated + ? $"Active: {noteName} ({(offset >= 0 ? "+" : "")}{offset}) [Modulated at {marker!.TimeString}]" + : $"Active: {noteName} ({(offset >= 0 ? "+" : "")}{offset}) [Song Default]"; + } + } + + public bool HoldNotes + { + get => CurrentFile?.Song.HoldNotes ?? false; + set + { + if (CurrentFile?.Song is not null && CurrentFile.Song.HoldNotes != value) + { + CurrentFile.Song.HoldNotes = value; + NotifyOfPropertyChange(nameof(HoldNotes)); + _ = SaveSongPropertyAsync(); + } + } + } + + public bool AutoChord + { + get => CurrentFile?.Song.AutoChord ?? false; + set + { + if (CurrentFile?.Song is not null && CurrentFile.Song.AutoChord != value) + { + CurrentFile.Song.AutoChord = value; + NotifyOfPropertyChange(nameof(AutoChord)); + _ = SaveSongPropertyAsync(); + } + } + } + + private async Task SaveSongPropertyAsync() + { + if (CurrentFile?.Song is null) return; + try + { + using var db = _ioc.Get(); + db.Songs.Update(CurrentFile.Song); + await db.SaveChangesAsync(); + await _main.PlaybackEngine.RefreshCurrentSongRealtimeAsync(); + } + catch (Exception ex) + { + Logger.LogException(ex, "Failed to save song setting from Studio."); + } + } + + #endregion + + #region Lifecycle & Smooth Clock + + protected override void OnActivate() + { + base.OnActivate(); + Logger.LogPageVisit("Studio", source: "screen-activate"); + + _timeWatcher.CurrentTimeChanged += HandleCurrentTimeTick; + Controls.SongPositionChanged += HandleSongPositionChanged; + Controls.PropertyChanged += HandleControlsPropertyChanged; + _main.QueueView.PropertyChanged += HandleQueuePropertyChanged; + _main.PlaybackEngine.NotePlayed += HandleNotePlayed; + + ReloadSongData(); + + if (Controls.IsPlaying) + { + StartSmoothClock(); + } + } + + protected override void OnDeactivate() + { + base.OnDeactivate(); + StopSmoothClock(); + _timeWatcher.CurrentTimeChanged -= HandleCurrentTimeTick; + Controls.SongPositionChanged -= HandleSongPositionChanged; + Controls.PropertyChanged -= HandleControlsPropertyChanged; + _main.QueueView.PropertyChanged -= HandleQueuePropertyChanged; + _main.PlaybackEngine.NotePlayed -= HandleNotePlayed; + StopTrackGlows(); + } + + private void HandleNotePlayed(object? sender, NotePlayedEventArgs e) + { + if (!IsTracksDrawerActive) return; + + var matchingTracks = MidiTracks.Where(t => t.IsChecked && t.IsPlayingNoteAt(e.NoteNumber, e.CurrentTimeUs)).ToList(); + foreach (var track in matchingTracks) + { + track.TriggerGlow(); + } + } + + private void StopTrackGlows() + { + foreach (var track in MidiTracks) + { + track.StopGlow(); + } + } + + public void Handle(OpenedFileChangedNotification message) + { + ReloadSongData(); + } + + public void Handle(MidiFile message) + { + ReloadSongData(); + } + + private void HandleControlsPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(PlaybackControlsService.IsPlaying)) + { + if (Controls.IsPlaying) StartSmoothClock(); + else StopSmoothClock(); + } + } + + private void HandleQueuePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(QueueViewModel.OpenedFile)) + { + ReloadSongData(); + } + } + + private void StartSmoothClock() + { + if (_isRenderingHooked) return; + _isRenderingHooked = true; + CompositionTarget.Rendering += OnSmoothRenderTick; + } + + private void StopSmoothClock() + { + if (!_isRenderingHooked) return; + _isRenderingHooked = false; + CompositionTarget.Rendering -= OnSmoothRenderTick; + } + + private void OnSmoothRenderTick(object? sender, EventArgs e) + { + if (!Controls.IsPlaying) + { + StopSmoothClock(); + return; + } + + var playback = _main.PlaybackEngine.Playback; + if (playback is { IsRunning: true }) + { + try + { + var metric = playback.GetCurrentTime(); + CurrentTime = (TimeSpan)metric; + } + catch + { + // Playback might be resetting + } + } + } + + private void HandleCurrentTimeTick(object? sender, PlaybackCurrentTimeChangedEventArgs e) + { + if (!_isRenderingHooked) + { + if (Controls.IsPlaying) + { + StartSmoothClock(); + } + else + { + foreach (var playbackTime in e.Times) + { + CurrentTime = (TimeSpan)(MetricTimeSpan)playbackTime.Time; + break; + } + } + } + } + + private void HandleSongPositionChanged(object? sender, EventArgs e) + { + CurrentTime = Controls.CurrentTime; + } + + public void ReloadSongData() + { + CurrentFile?.EnsureMidiLoaded(); + NotifyOfPropertyChange(nameof(CurrentFile)); + NotifyOfPropertyChange(nameof(HasSongOpen)); + NotifyOfPropertyChange(nameof(Duration)); + + KeyChangeMarkers.Clear(); + if (CurrentFile?.Song is not null) + { + foreach (var m in CurrentFile.Song.KeyChangeMarkers.OrderBy(m => m.TimeMs)) + { + KeyChangeMarkers.Add(m); + } + } + SelectedMarker = null; + _lastActiveKeyDisplay = string.Empty; + UpdateActiveKeyDisplay(); + } + + #endregion + + #region Sidebar Controls + + public void ToggleTracksDrawer() + { + if (IsTracksDrawerActive) + { + IsSidebarOpen = false; + } + else + { + ActiveSidebarTab = "Tracks"; + IsSidebarOpen = true; + } + } + + public void ToggleSettingsDrawer() + { + if (IsSettingsDrawerActive) + { + IsSidebarOpen = false; + } + else + { + ActiveSidebarTab = "Settings"; + IsSidebarOpen = true; + } + } + + public void CloseSidebar() + { + IsSidebarOpen = false; + } + + public void ToggleSidebar() + { + IsSidebarOpen = !IsSidebarOpen; + } + + public void SelectTracksTab() + { + ActiveSidebarTab = "Tracks"; + IsSidebarOpen = true; + } + + public void SelectSettingsTab() + { + ActiveSidebarTab = "Settings"; + IsSidebarOpen = true; + } + + #endregion + + #region Scrubber & Marker Actions + + public void ScrubTo(TimeSpan time) + { + Controls.SongPosition = time.TotalSeconds; + Controls.OnSongPositionChanged(); + CurrentTime = time; + } + + public async Task AddOffsetAtPlayheadAsync() + { + if (CurrentFile?.Song is null) + return; + + var ms = (long)CurrentTime.TotalMilliseconds; + + // Snap to nearest note start within 150ms if available + if (CurrentFile.Midi != null) + { + try + { + var tempoMap = CurrentFile.OriginalTempoMap ?? TempoMap.Default; + var nearestNoteMs = CurrentFile.Midi.GetNotes() + .Select(n => (long)n.TimeAs(tempoMap).TotalMilliseconds) + .Where(t => Math.Abs(t - ms) <= 150) + .OrderBy(t => Math.Abs(t - ms)) + .Cast() + .FirstOrDefault(); + + if (nearestNoteMs.HasValue) + { + ms = nearestNoteMs.Value; + } + } + catch + { + // Fall back to exact playhead ms if note inspection fails + } + } + + var offset = SelectedKeyOption?.Value ?? SongSettings.KeyOffset; + var noteName = MusicConstants.GetNoteName(offset); + + // Remove any existing marker close to this time (within 300ms) + var existing = KeyChangeMarkers.FirstOrDefault(m => Math.Abs(m.TimeMs - ms) < 300); + if (existing != null) + { + KeyChangeMarkers.Remove(existing); + } + + var newMarker = new KeyChangeMarker + { + TimeMs = ms, + KeyOffset = offset, + Label = $"{noteName} ({(offset >= 0 ? "+" : "")}{offset})" + }; + + KeyChangeMarkers.Add(newMarker); + var sorted = KeyChangeMarkers.OrderBy(m => m.TimeMs).ToList(); + KeyChangeMarkers.Clear(); + foreach (var m in sorted) KeyChangeMarkers.Add(m); + + SelectedMarker = newMarker; + await SaveMarkersToSongAsync(); + + SnackbarService.Success("Key Offset Added", $"Set {newMarker.Label} at {newMarker.TimeString}"); + } + + public async Task AutoDetectKeyChangesAsync() + { + if (CurrentFile?.Midi is null) + return; + + var tempoMap = CurrentFile.OriginalTempoMap ?? TempoMap.Default; + var detected = FileService.DetectKeyChangesFromMidi(CurrentFile.Midi, tempoMap); + + if (detected.Count == 0) + { + SnackbarService.Info("Auto-Detect", "No Key Signature events found in this MIDI file."); + return; + } + + // Merge detected markers + foreach (var marker in detected) + { + var existing = KeyChangeMarkers.FirstOrDefault(m => Math.Abs(m.TimeMs - marker.TimeMs) < 500); + if (existing != null) + KeyChangeMarkers.Remove(existing); + + KeyChangeMarkers.Add(marker); + } + + var sorted = KeyChangeMarkers.OrderBy(m => m.TimeMs).ToList(); + KeyChangeMarkers.Clear(); + foreach (var m in sorted) KeyChangeMarkers.Add(m); + + await SaveMarkersToSongAsync(); + SnackbarService.Success("Auto-Detect Complete", $"Detected and added {detected.Count} key changes."); + } + + public async Task DeleteSelectedMarkerAsync() + { + if (SelectedMarker is null || CurrentFile?.Song is null) + return; + + var removed = SelectedMarker; + KeyChangeMarkers.Remove(removed); + SelectedMarker = null; + + await SaveMarkersToSongAsync(); + SnackbarService.Info("Marker Removed", $"Removed key offset marker at {removed.TimeString}"); + } + + public void NavigatePrevMarker() + { + if (KeyChangeMarkers.Count == 0) return; + + var currentMs = (long)CurrentTime.TotalMilliseconds; + var prev = KeyChangeMarkers + .Where(m => m.TimeMs < currentMs - 200) + .OrderByDescending(m => m.TimeMs) + .FirstOrDefault(); + + if (prev != null) + { + SelectedMarker = prev; + ScrubTo(TimeSpan.FromMilliseconds(prev.TimeMs)); + } + } + + public void NavigateNextMarker() + { + if (KeyChangeMarkers.Count == 0) return; + + var currentMs = (long)CurrentTime.TotalMilliseconds; + var next = KeyChangeMarkers + .Where(m => m.TimeMs > currentMs + 200) + .OrderBy(m => m.TimeMs) + .FirstOrDefault(); + + if (next != null) + { + SelectedMarker = next; + ScrubTo(TimeSpan.FromMilliseconds(next.TimeMs)); + } + } + + private async Task SaveMarkersToSongAsync() + { + if (CurrentFile?.Song is null) return; + + try + { + CurrentFile.Song.KeyChangeMarkers = KeyChangeMarkers.ToList(); + + using var db = _ioc.Get(); + db.Songs.Update(CurrentFile.Song); + await db.SaveChangesAsync(); + + NotifyOfPropertyChange(nameof(ActiveKeyAtPlayheadDisplay)); + } + catch (Exception ex) + { + Logger.LogException(ex, "Failed to persist key change markers."); + } + } + + #endregion +} diff --git a/AutoMidiPlayer.WPF/Views/Track/TrackViewModel.cs b/AutoMidiPlayer.WPF/Views/Track/TrackViewModel.cs index 8e4b89f..0cda4c9 100644 --- a/AutoMidiPlayer.WPF/Views/Track/TrackViewModel.cs +++ b/AutoMidiPlayer.WPF/Views/Track/TrackViewModel.cs @@ -240,6 +240,7 @@ public void InitializeTracks() var track = new MidiTrack(events, trackChunks[i], i, midiFile, isChecked); track.DisplayTrackNumber = i; + track.TotalTracks = trackChunks.Count; MidiTracks.Add(track); } From 7261141b341f0f9b979d005bc5e6197db42059de Mon Sep 17 00:00:00 2001 From: Jed556 Date: Mon, 5 Oct 2026 10:20:29 +0800 Subject: [PATCH 2/5] Performance optimizations & UI updates --- AutoMidiPlayer.Data/Midi/MidiTrack.cs | 14 +- .../PianoRoll/HorizontalPianoRoll.xaml.cs | 96 ++- .../InteractivePianoRollControl.xaml | 9 + .../InteractivePianoRollControl.xaml.cs | 172 ++++- .../PianoRoll/MidiTimelineScrubber.xaml.cs | 129 +++- .../Controls/PianoRoll/PianoRollCanvas.cs | 691 ++++++++++++++---- .../PianoRoll/PianoRollDisplayMode.cs | 80 ++ .../Controls/PianoRoll/TimelineCanvas.cs | 333 ++++++++- .../Services/PlaybackControlsService.cs | 20 +- AutoMidiPlayer.WPF/Services/SongService.cs | 11 +- .../Views/MainWindow/MainWindow.xaml | 4 +- .../Views/Studio/StudioSidebarControl.xaml | 44 +- .../Views/Studio/StudioView.xaml | 62 +- .../Views/Studio/StudioView.xaml.cs | 24 + .../Views/Studio/StudioViewModel.cs | 253 ++++++- 15 files changed, 1695 insertions(+), 247 deletions(-) create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollDisplayMode.cs diff --git a/AutoMidiPlayer.Data/Midi/MidiTrack.cs b/AutoMidiPlayer.Data/Midi/MidiTrack.cs index 4a9abd1..bc9c193 100644 --- a/AutoMidiPlayer.Data/Midi/MidiTrack.cs +++ b/AutoMidiPlayer.Data/Midi/MidiTrack.cs @@ -111,8 +111,10 @@ public MidiTrack(IEventAggregator events, TrackChunk track, int index, Melanchal Track = track; Index = index; - TrackName = track.Events.OfType().FirstOrDefault()?.Text; - InstrumentName = track.Events.OfType().FirstOrDefault()?.Text; + var rawTrackName = track.Events.OfType().FirstOrDefault()?.Text; + TrackName = string.IsNullOrWhiteSpace(rawTrackName) ? null : rawTrackName.Trim(); + var rawInstrumentName = track.Events.OfType().FirstOrDefault()?.Text; + InstrumentName = string.IsNullOrWhiteSpace(rawInstrumentName) ? null : rawInstrumentName.Trim(); if (string.IsNullOrWhiteSpace(InstrumentName)) { @@ -289,8 +291,12 @@ public bool IsChecked get => _isChecked; set { - _isChecked = value; - _events.Publish(this); + if (_isChecked != value) + { + _isChecked = value; + _events.Publish(this); + OnPropertyChanged(); + } } } diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs index 3421d21..ea5ff94 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs @@ -16,6 +16,63 @@ public partial class HorizontalPianoRoll : UserControl public event EventHandler? HoverTimeChanged; public event EventHandler? ViewTimeChanged; + public static readonly DependencyProperty DisplayModeProperty = + DependencyProperty.Register( + nameof(DisplayMode), + typeof(PianoRollDisplayMode), + typeof(HorizontalPianoRoll), + new PropertyMetadata(PianoRollDisplayMode.Auto, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.DisplayMode = (PianoRollDisplayMode)e.NewValue; + } + })); + + public PianoRollDisplayMode DisplayMode + { + get => (PianoRollDisplayMode)GetValue(DisplayModeProperty); + set => SetValue(DisplayModeProperty, value); + } + + public static readonly DependencyProperty InstrumentIdProperty = + DependencyProperty.Register( + nameof(InstrumentId), + typeof(string), + typeof(HorizontalPianoRoll), + new PropertyMetadata(null, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.InstrumentId = (string?)e.NewValue; + } + })); + + public string? InstrumentId + { + get => (string?)GetValue(InstrumentIdProperty); + set => SetValue(InstrumentIdProperty, value); + } + + public static readonly DependencyProperty PitchRevisionProperty = + DependencyProperty.Register( + nameof(PitchRevision), + typeof(int), + typeof(HorizontalPianoRoll), + new PropertyMetadata(0, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.PitchRevision = (int)e.NewValue; + } + })); + + public int PitchRevision + { + get => (int)GetValue(PitchRevisionProperty); + set => SetValue(PitchRevisionProperty, value); + } + public static readonly DependencyProperty IsSyncEnabledProperty = DependencyProperty.Register( nameof(IsSyncEnabled), @@ -72,6 +129,25 @@ public TimeSpan? HoverTime get => (TimeSpan?)GetValue(HoverTimeProperty); set => SetValue(HoverTimeProperty, value); } + public static readonly DependencyProperty MidiTracksProperty = + DependencyProperty.Register( + nameof(MidiTracks), + typeof(IEnumerable), + typeof(HorizontalPianoRoll), + new PropertyMetadata(null, (d, e) => + { + if (d is HorizontalPianoRoll roll && roll.Canvas != null) + { + roll.Canvas.MidiTracks = (IEnumerable?)e.NewValue; + } + })); + + public IEnumerable? MidiTracks + { + get => (IEnumerable?)GetValue(MidiTracksProperty); + set => SetValue(MidiTracksProperty, value); + } + public static readonly DependencyProperty MidiFileProperty = DependencyProperty.Register( nameof(MidiFile), @@ -190,6 +266,8 @@ public HorizontalPianoRoll() Canvas.IsSyncEnabled = IsSyncEnabled; Canvas.ViewTime = ViewTime; Canvas.KeyHeight = KeyHeight; + Canvas.InstrumentId = InstrumentId; + Canvas.PitchRevision = PitchRevision; } Canvas?.RebuildNoteIndex(); UpdateScrollBounds(); @@ -198,6 +276,7 @@ public HorizontalPianoRoll() public bool IsOverflowing => Canvas?.IsOverflowing ?? false; public PianoRollCanvas RollCanvas => Canvas; + public int PitchCount => Canvas?.PitchCount ?? 49; public void RefreshNotes() { @@ -205,13 +284,28 @@ public void RefreshNotes() UpdateScrollBounds(); } + public void FitKeyHeightToViewport() + { + if (Canvas == null) return; + var pitchCount = Canvas.PitchCount; + var viewportHeight = Canvas.ActualHeight; + + if (pitchCount <= 0 || viewportHeight <= 0) + return; + + var target = Math.Clamp(viewportHeight / pitchCount, 6.0, 50.0); + KeyHeight = target; + Canvas.KeyHeight = target; + UpdateScrollBounds(); + } + private void UpdateScrollBounds() { if (Canvas == null || VerticalScrollBar == null) return; var totalHeight = Canvas.TotalContentHeight; var viewportHeight = Canvas.ActualHeight; - if (totalHeight > viewportHeight && viewportHeight > 0) + if (totalHeight > viewportHeight + 1.0 && viewportHeight > 0) { VerticalScrollBar.Visibility = Visibility.Visible; VerticalScrollBar.Maximum = totalHeight - viewportHeight; diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml index 6dc6cc0..0fae026 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml @@ -21,9 +21,13 @@ diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs index 206b57d..0671e33 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs @@ -32,9 +32,102 @@ private static void OnMidiFileChanged(DependencyObject d, DependencyPropertyChan if (ctrl.Timeline != null) ctrl.Timeline.MidiFile = file; ctrl.RefreshNotesAndTimeline(); ctrl.UpdateTimelineViewport(); + ctrl.FitKeyHeightToViewport(); } } + public static readonly DependencyProperty DisplayModeProperty = + DependencyProperty.Register( + nameof(DisplayMode), + typeof(PianoRollDisplayMode), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(PianoRollDisplayMode.Auto, (d, e) => + { + if (d is InteractivePianoRollControl ctrl) + { + var mode = (PianoRollDisplayMode)e.NewValue; + if (ctrl.PianoRoll != null) ctrl.PianoRoll.DisplayMode = mode; + if (ctrl.Timeline != null) ctrl.Timeline.DisplayMode = mode; + ctrl.UpdateNotePitches(); + ctrl.FitKeyHeightToViewport(); + } + })); + + public PianoRollDisplayMode DisplayMode + { + get => (PianoRollDisplayMode)GetValue(DisplayModeProperty); + set => SetValue(DisplayModeProperty, value); + } + + public static readonly DependencyProperty InstrumentIdProperty = + DependencyProperty.Register( + nameof(InstrumentId), + typeof(string), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(null, (d, e) => + { + if (d is InteractivePianoRollControl ctrl) + { + var id = (string?)e.NewValue; + if (ctrl.PianoRoll != null) ctrl.PianoRoll.InstrumentId = id; + if (ctrl.Timeline != null) ctrl.Timeline.InstrumentId = id; + ctrl.UpdateNotePitches(); + ctrl.FitKeyHeightToViewport(); + } + })); + + public string? InstrumentId + { + get => (string?)GetValue(InstrumentIdProperty); + set => SetValue(InstrumentIdProperty, value); + } + + public static readonly DependencyProperty PitchRevisionProperty = + DependencyProperty.Register( + nameof(PitchRevision), + typeof(int), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(0, (d, e) => + { + if (d is InteractivePianoRollControl ctrl) + { + var rev = (int)e.NewValue; + if (ctrl.PianoRoll != null) ctrl.PianoRoll.PitchRevision = rev; + if (ctrl.Timeline != null) ctrl.Timeline.PitchRevision = rev; + ctrl.UpdateNotePitches(); + ctrl.FitKeyHeightToViewport(); + } + })); + + public int PitchRevision + { + get => (int)GetValue(PitchRevisionProperty); + set => SetValue(PitchRevisionProperty, value); + } + + public static readonly DependencyProperty MidiTracksProperty = + DependencyProperty.Register( + nameof(MidiTracks), + typeof(IEnumerable), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMidiTracksChanged)); + + private static void OnMidiTracksChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is InteractivePianoRollControl ctrl) + { + var tracks = (IEnumerable?)e.NewValue; + if (ctrl.PianoRoll != null) ctrl.PianoRoll.MidiTracks = tracks; + if (ctrl.Timeline != null) ctrl.Timeline.MidiTracks = tracks; + } + } + + public IEnumerable? MidiTracks + { + get => (IEnumerable?)GetValue(MidiTracksProperty); + set => SetValue(MidiTracksProperty, value); + } + public MidiFile? MidiFile { get => (MidiFile?)GetValue(MidiFileProperty); @@ -180,7 +273,23 @@ public double ZoomLevel nameof(KeyHeight), typeof(double), typeof(InteractivePianoRollControl), - new FrameworkPropertyMetadata(16.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); + new FrameworkPropertyMetadata(16.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnKeyHeightChanged)); + + private static void OnKeyHeightChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is InteractivePianoRollControl ctrl) + { + var val = (double)e.NewValue; + if (ctrl.PianoRoll != null && Math.Abs(ctrl.PianoRoll.KeyHeight - val) > 0.001) + { + ctrl.PianoRoll.SetCurrentValue(HorizontalPianoRoll.KeyHeightProperty, val); + } + if (ctrl.Timeline != null && Math.Abs(ctrl.Timeline.KeyHeight - val) > 0.001) + { + ctrl.Timeline.SetCurrentValue(MidiTimelineScrubber.KeyHeightProperty, val); + } + } + } public double KeyHeight { @@ -199,9 +308,27 @@ public InteractivePianoRollControl() if (Timeline != null && Timeline.MidiFile != file) Timeline.MidiFile = file; UpdateTimelineViewport(); RefreshNotesAndTimeline(); + FitKeyHeightToViewport(); }; SizeChanged += (_, _) => UpdateTimelineViewport(); - PianoRoll.SizeChanged += (_, _) => UpdateTimelineViewport(); + PianoRoll.SizeChanged += (_, _) => + { + UpdateTimelineViewport(); + if (_pendingFitOnLayout && PianoRoll.Canvas?.ActualHeight > 0) + { + FitKeyHeightToViewport(); + } + }; + if (PianoRoll.Canvas != null) + { + PianoRoll.Canvas.SizeChanged += (_, _) => + { + if (_pendingFitOnLayout && PianoRoll.Canvas.ActualHeight > 0) + { + FitKeyHeightToViewport(); + } + }; + } PreviewMouseWheel += OnPreviewMouseWheel; Unloaded += (_, _) => StopZoomAnimation(); } @@ -226,6 +353,12 @@ public void RefreshNotesAndTimeline() Timeline.RefreshMinimap(); } + public void UpdateNotePitches() + { + PianoRoll?.Canvas?.UpdateNotePitches(); + Timeline?.Canvas?.UpdateNotePitches(); + } + private void OnTimelineScrubRequested(object? sender, TimeSpan time) { ScrubRequested?.Invoke(this, time); @@ -373,7 +506,40 @@ public void StepKeyHeight(bool increase) public void ResetKeyHeight() { - KeyHeight = 16.0; + FitKeyHeightToViewport(); + } + + private void OnTimelineKeyHeightResetRequested(object? sender, EventArgs e) + { + FitKeyHeightToViewport(); + } + + private bool _pendingFitOnLayout; + + public void FitKeyHeightToViewport() + { + if (MidiFile == null || PianoRoll?.Canvas == null) + { + _pendingFitOnLayout = false; + return; + } + + var canvas = PianoRoll.Canvas; + var pitchCount = canvas.PitchCount; + var viewportHeight = canvas.ActualHeight; + + if (viewportHeight <= 0) + { + _pendingFitOnLayout = true; + return; + } + + _pendingFitOnLayout = false; + if (pitchCount <= 0) return; + + var target = Math.Clamp(viewportHeight / pitchCount, 6.0, 50.0); + KeyHeight = target; + canvas.VerticalOffset = 0.0; } private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e) diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs index 18cd512..c433370 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs @@ -19,6 +19,63 @@ public partial class MidiTimelineScrubber : UserControl public event EventHandler? HoverTimeChanged; public event EventHandler? SyncChanged; + public static readonly DependencyProperty DisplayModeProperty = + DependencyProperty.Register( + nameof(DisplayMode), + typeof(PianoRollDisplayMode), + typeof(MidiTimelineScrubber), + new PropertyMetadata(PianoRollDisplayMode.Auto, (d, e) => + { + if (d is MidiTimelineScrubber scrubber && scrubber.Canvas != null) + { + scrubber.Canvas.DisplayMode = (PianoRollDisplayMode)e.NewValue; + } + })); + + public PianoRollDisplayMode DisplayMode + { + get => (PianoRollDisplayMode)GetValue(DisplayModeProperty); + set => SetValue(DisplayModeProperty, value); + } + + public static readonly DependencyProperty InstrumentIdProperty = + DependencyProperty.Register( + nameof(InstrumentId), + typeof(string), + typeof(MidiTimelineScrubber), + new PropertyMetadata(null, (d, e) => + { + if (d is MidiTimelineScrubber scrubber && scrubber.Canvas != null) + { + scrubber.Canvas.InstrumentId = (string?)e.NewValue; + } + })); + + public string? InstrumentId + { + get => (string?)GetValue(InstrumentIdProperty); + set => SetValue(InstrumentIdProperty, value); + } + + public static readonly DependencyProperty PitchRevisionProperty = + DependencyProperty.Register( + nameof(PitchRevision), + typeof(int), + typeof(MidiTimelineScrubber), + new PropertyMetadata(0, (d, e) => + { + if (d is MidiTimelineScrubber scrubber && scrubber.Canvas != null) + { + scrubber.Canvas.PitchRevision = (int)e.NewValue; + } + })); + + public int PitchRevision + { + get => (int)GetValue(PitchRevisionProperty); + set => SetValue(PitchRevisionProperty, value); + } + public static readonly DependencyProperty IsSyncEnabledProperty = DependencyProperty.Register( nameof(IsSyncEnabled), @@ -76,6 +133,25 @@ public TimeSpan? HoverTime set => SetValue(HoverTimeProperty, value); } + public static readonly DependencyProperty MidiTracksProperty = + DependencyProperty.Register( + nameof(MidiTracks), + typeof(IEnumerable), + typeof(MidiTimelineScrubber), + new PropertyMetadata(null, (d, e) => + { + if (d is MidiTimelineScrubber scrubber && scrubber.Canvas != null) + { + scrubber.Canvas.MidiTracks = (IEnumerable?)e.NewValue; + } + })); + + public IEnumerable? MidiTracks + { + get => (IEnumerable?)GetValue(MidiTracksProperty); + set => SetValue(MidiTracksProperty, value); + } + public static readonly DependencyProperty MidiFileProperty = DependencyProperty.Register( nameof(MidiFile), @@ -100,7 +176,14 @@ public MidiFile? MidiFile nameof(CurrentTime), typeof(TimeSpan), typeof(MidiTimelineScrubber), - new PropertyMetadata(TimeSpan.Zero, (d, e) => ((MidiTimelineScrubber)d).Canvas.CurrentTime = (TimeSpan)e.NewValue)); + new PropertyMetadata(TimeSpan.Zero, (d, e) => + { + var scrubber = (MidiTimelineScrubber)d; + if (!scrubber._isDraggingScrubber) + { + scrubber.Canvas.CurrentTime = (TimeSpan)e.NewValue; + } + })); public TimeSpan CurrentTime { @@ -231,6 +314,7 @@ private static void OnZoomLevelChanged(DependencyObject d, DependencyPropertyCha public MidiTimelineScrubber() { InitializeComponent(); + ScrubberArea.LostMouseCapture += OnScrubberLostMouseCapture; ZoomText.Text = "100%"; UpdateKeyHeightText(KeyHeight); Loaded += (_, _) => @@ -241,6 +325,11 @@ public MidiTimelineScrubber() { Canvas.MidiFile = MidiFile; } + if (Canvas != null) + { + Canvas.InstrumentId = InstrumentId; + Canvas.PitchRevision = PitchRevision; + } Canvas?.RebuildMinimap(); }; } @@ -250,6 +339,14 @@ public void RefreshMinimap() Canvas.RebuildMinimap(); } + private void OnScrubberLostMouseCapture(object sender, MouseEventArgs e) + { + if (_isDraggingScrubber) + { + _isDraggingScrubber = false; + } + } + private void OnScrubberMouseDown(object sender, MouseButtonEventArgs e) { var pos = e.GetPosition(Canvas); @@ -265,10 +362,10 @@ private void OnScrubberMouseDown(object sender, MouseButtonEventArgs e) return; } - // Otherwise start scrubbing + // Otherwise start scrubbing (drag preview until mouse up) _isDraggingScrubber = true; ScrubberArea.CaptureMouse(); - SeekToPosition(pos.X); + UpdateScrubberPreview(pos.X); } private void OnScrubberMouseMove(object sender, MouseEventArgs e) @@ -276,7 +373,7 @@ private void OnScrubberMouseMove(object sender, MouseEventArgs e) var pos = e.GetPosition(Canvas); if (_isDraggingScrubber && ScrubberArea.IsMouseCaptured) { - SeekToPosition(pos.X); + UpdateScrubberPreview(pos.X); } if (Canvas != null && Canvas.ActualWidth > 0 && pos.X >= 0 && pos.X <= Canvas.ActualWidth) @@ -312,9 +409,11 @@ private void OnScrubberMouseUp(object sender, MouseButtonEventArgs e) _isDraggingScrubber = false; ScrubberArea.ReleaseMouseCapture(); + var pos = e.GetPosition(Canvas); + SeekToPosition(pos.X); + if (Canvas != null) { - var pos = e.GetPosition(Canvas); if (pos.X < 0 || pos.X > Canvas.ActualWidth || pos.Y < 0 || pos.Y > Canvas.ActualHeight) { Canvas.HoverTime = null; @@ -324,15 +423,31 @@ private void OnScrubberMouseUp(object sender, MouseButtonEventArgs e) } } + private void UpdateScrubberPreview(double mouseX) + { + if (Canvas == null || Canvas.ActualWidth <= 0) return; + + var ratio = Math.Clamp(mouseX / Canvas.ActualWidth, 0.0, 1.0); + var totalMs = Canvas.TotalDurationMs > 0 ? Canvas.TotalDurationMs : (long)Duration.TotalMilliseconds; + var targetMs = ratio * totalMs; + var time = TimeSpan.FromMilliseconds(targetMs); + Canvas.CurrentTime = time; + } + private void SeekToPosition(double mouseX) { var width = Canvas.ActualWidth; if (width <= 0) return; var ratio = Math.Clamp(mouseX / width, 0.0, 1.0); - var totalMs = Canvas != null ? Canvas.TotalDurationMs : (long)Duration.TotalMilliseconds; + var totalMs = Canvas != null && Canvas.TotalDurationMs > 0 ? Canvas.TotalDurationMs : (long)Duration.TotalMilliseconds; var targetMs = ratio * totalMs; - ScrubRequested?.Invoke(this, TimeSpan.FromMilliseconds(targetMs)); + var time = TimeSpan.FromMilliseconds(targetMs); + if (Canvas != null) + { + Canvas.CurrentTime = time; + } + ScrubRequested?.Invoke(this, time); } private void OnZoomInClick(object sender, RoutedEventArgs e) diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs index 8bc1a8d..50ef9fb 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs @@ -2,21 +2,27 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Input; using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Collections.Specialized; +using System.ComponentModel; using AutoMidiPlayer.Data; using AutoMidiPlayer.Data.Entities; +using AutoMidiPlayer.Data.Midi; using AutoMidiPlayer.WPF.Helpers; using Melanchall.DryWetMidi.Core; using Melanchall.DryWetMidi.Interaction; using MidiFile = AutoMidiPlayer.Data.Midi.MidiFile; +using SkiaSharp; namespace AutoMidiPlayer.WPF.Controls.PianoRoll; public class PianoRollCanvas : FrameworkElement { - public readonly record struct RollNote(int NoteNumber, long StartMs, long LengthMs, int TrackIndex); + public readonly record struct RollNote(int RawNoteNumber, int NoteNumber, long StartMs, long LengthMs, int TrackIndex); public event EventHandler? ScrubRequested; public event EventHandler? MarkerClicked; @@ -26,6 +32,8 @@ public class PianoRollCanvas : FrameworkElement // Frozen brushes and pens for 60+ FPS zero-allocation rendering private static readonly SolidColorBrush[] TrackBrushes; + private static readonly SKColor[] TrackSkColors; + private static readonly SKColor[] DisabledTrackSkColors; private static readonly SolidColorBrush WhiteKeyBrush; private static readonly SolidColorBrush BlackKeyBrush; private static readonly SolidColorBrush BlackKeyExtensionBrush; @@ -52,6 +60,14 @@ public class PianoRollCanvas : FrameworkElement static PianoRollCanvas() { TrackBrushes = TrackColorPalette.AllBrushes; + TrackSkColors = new SKColor[TrackBrushes.Length]; + DisabledTrackSkColors = new SKColor[TrackBrushes.Length]; + for (var i = 0; i < TrackBrushes.Length; i++) + { + var c = TrackBrushes[i].Color; + TrackSkColors[i] = new SKColor(c.R, c.G, c.B, c.A); + DisabledTrackSkColors[i] = new SKColor(c.R, c.G, c.B, (byte)(c.A * 0.28)); + } WhiteKeyBrush = CreateFrozenBrush(Color.FromArgb(220, 240, 240, 240)); BlackKeyBrush = CreateFrozenBrush(Color.FromArgb(255, 26, 26, 30)); @@ -126,6 +142,9 @@ private static SolidColorBrush CreateFrozenBrush(Color color) private readonly double[] _lastPitchRight = new double[128]; private readonly int[] _lastPitchTrack = new int[128]; private readonly Dictionary _cachedMarkerLabels = new(); + private FormattedText? _cachedHoverLabel; + private string? _cachedHoverText; + private double _cachedHoverDpi = -1; private double _cachedDpi = -1; private RollNote[] _notes = Array.Empty(); @@ -156,6 +175,20 @@ private static SolidColorBrush CreateFrozenBrush(Color color) private RectangleGeometry? _cachedKeyboardClip; private double _lastKeyboardClipH = -1; + private BitmapSource? _cachedNoteBitmap; + private long _cachedNoteViewTimeMs; + private double _cachedNoteZoom = -1; + private double _cachedNotePitchHeight = -1; + private double _cachedNoteVerticalOffset = -1; + private double _cachedNoteWidth = -1; + private double _cachedNoteHeight = -1; + private const double NoteBitmapPaddingPx = 256; + private const double NoteBitmapRebuildDistancePx = NoteBitmapPaddingPx * 0.75; + // Keep the expensive note raster surface bounded while retaining the + // control's logical size for scrolling, clipping, and input coordinates. + private const double NoteBitmapMaxPixelWidth = 1920; + private const double NoteBitmapMaxPixelHeight = 1080; + private double _lastReportedMaxScroll = -1; private bool _lastReportedIsOverflowing; @@ -164,6 +197,19 @@ private static SolidColorBrush CreateFrozenBrush(Color color) private Color _lastAccentColor; private int _trackCount = 1; + private readonly HashSet _disabledTrackIndices = new(); + private readonly bool[] _disabledTrackLookup = new bool[256]; + private readonly List _subscribedTrackItems = new(); + private INotifyCollectionChanged? _subscribedTrackCollection; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsTrackDisabled(int trackIndex) + { + return (uint)trackIndex < (uint)_disabledTrackLookup.Length + ? _disabledTrackLookup[trackIndex] + : _disabledTrackIndices.Contains(trackIndex); + } + public PianoRollCanvas() { _trackRectsSharp = new List[TrackBrushes.Length]; @@ -175,8 +221,16 @@ public PianoRollCanvas() } EnsureAccentResources(); - Loaded += (_, _) => AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; - Unloaded += (_, _) => AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged; + Loaded += (_, _) => + { + AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; + UpdateTrackSubscriptions(); + }; + Unloaded += (_, _) => + { + AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged; + UnsubscribeTrackEvents(); + }; } private void OnThemeResourcesChanged() @@ -201,6 +255,101 @@ private void EnsureAccentResources() _activeKeyTextBrush = CreateFrozenBrush(lum > 0.5 ? Color.FromRgb(18, 18, 20) : Colors.White); Array.Clear(_cachedLabelsActive, 0, 128); + InvalidateNoteBitmap(); + } + + public static readonly DependencyProperty MidiTracksProperty = + DependencyProperty.Register( + nameof(MidiTracks), + typeof(IEnumerable), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMidiTracksChanged)); + + private static void OnMidiTracksChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas.UpdateTrackSubscriptions(); + } + } + + public IEnumerable? MidiTracks + { + get => (IEnumerable?)GetValue(MidiTracksProperty); + set => SetValue(MidiTracksProperty, value); + } + + private void UpdateTrackSubscriptions() + { + UnsubscribeTrackEvents(); + + if (MidiTracks is INotifyCollectionChanged notifyCollection) + { + _subscribedTrackCollection = notifyCollection; + notifyCollection.CollectionChanged += OnTrackCollectionChanged; + } + + if (MidiTracks != null) + { + foreach (var track in MidiTracks) + { + track.PropertyChanged += OnTrackItemPropertyChanged; + _subscribedTrackItems.Add(track); + } + } + + UpdateDisabledTrackIndices(); + } + + private void UnsubscribeTrackEvents() + { + if (_subscribedTrackCollection != null) + { + _subscribedTrackCollection.CollectionChanged -= OnTrackCollectionChanged; + _subscribedTrackCollection = null; + } + + foreach (var item in _subscribedTrackItems) + { + item.PropertyChanged -= OnTrackItemPropertyChanged; + } + _subscribedTrackItems.Clear(); + } + + private void OnTrackCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateTrackSubscriptions(); + } + + private void OnTrackItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == nameof(MidiTrack.IsChecked)) + { + UpdateDisabledTrackIndices(); + } + } + + private void UpdateDisabledTrackIndices() + { + _disabledTrackIndices.Clear(); + Array.Clear(_disabledTrackLookup, 0, _disabledTrackLookup.Length); + if (MidiTracks != null) + { + foreach (var t in MidiTracks) + { + if (!t.IsChecked) + { + _disabledTrackIndices.Add(t.Index); + if ((uint)t.Index < (uint)_disabledTrackLookup.Length) + { + _disabledTrackLookup[t.Index] = true; + } + } + } + } + + InvalidateNoteBitmap(); + InvalidateVisual(); } public static readonly DependencyProperty MidiFileProperty = @@ -270,6 +419,7 @@ private static void OnKeyHeightChanged(DependencyObject d, DependencyPropertyCha canvas._cachedLanes = null; canvas._cachedKeyboard = null; + canvas.InvalidateNoteBitmap(); canvas.UpdateScrollMetrics(); canvas.ScrollBoundsChanged?.Invoke(canvas, EventArgs.Empty); canvas.InvalidateVisual(); @@ -307,9 +457,12 @@ public double VerticalOffset public event EventHandler? VerticalOffsetChanged; public event EventHandler? ScrollBoundsChanged; - public double TotalContentHeight => (_maxPitch - _minPitch + 1) * KeyHeight; + public int MinPitch => _minPitch; + public int MaxPitch => _maxPitch; + public int PitchCount => Math.Max(1, _maxPitch - _minPitch + 1); + public double TotalContentHeight => PitchCount * KeyHeight; public double MaxVerticalOffset => Math.Max(0.0, TotalContentHeight - ActualHeight); - public bool IsOverflowing => TotalContentHeight > ActualHeight && ActualHeight > 0; + public bool IsOverflowing => TotalContentHeight > ActualHeight + 1.0 && ActualHeight > 0; protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo) { @@ -327,12 +480,103 @@ public void UpdateScrollMetrics() } } + public static readonly DependencyProperty DisplayModeProperty = + DependencyProperty.Register( + nameof(DisplayMode), + typeof(PianoRollDisplayMode), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(PianoRollDisplayMode.Auto, FrameworkPropertyMetadataOptions.AffectsRender, OnDisplayModeChanged)); + + private static void OnDisplayModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas.UpdateNotePitches(); + } + } + + public PianoRollDisplayMode DisplayMode + { + get => (PianoRollDisplayMode)GetValue(DisplayModeProperty); + set => SetValue(DisplayModeProperty, value); + } + + public static readonly DependencyProperty InstrumentIdProperty = + DependencyProperty.Register( + nameof(InstrumentId), + typeof(string), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnInstrumentIdChanged)); + + private static void OnInstrumentIdChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas.UpdateNotePitches(); + } + } + + public string? InstrumentId + { + get => (string?)GetValue(InstrumentIdProperty); + set => SetValue(InstrumentIdProperty, value); + } + + public static readonly DependencyProperty PitchRevisionProperty = + DependencyProperty.Register( + nameof(PitchRevision), + typeof(int), + typeof(PianoRollCanvas), + new FrameworkPropertyMetadata(0, FrameworkPropertyMetadataOptions.AffectsRender, OnPitchRevisionChanged)); + + private static void OnPitchRevisionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas.UpdateNotePitches(); + } + } + + public int PitchRevision + { + get => (int)GetValue(PitchRevisionProperty); + set => SetValue(PitchRevisionProperty, value); + } + public static readonly DependencyProperty KeyChangeMarkersProperty = DependencyProperty.Register( nameof(KeyChangeMarkers), typeof(IEnumerable), typeof(PianoRollCanvas), - new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnKeyChangeMarkersChanged)); + + private static void OnKeyChangeMarkersChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + if (e.OldValue is System.Collections.Specialized.INotifyCollectionChanged oldColl) + { + oldColl.CollectionChanged -= canvas.OnMarkersCollectionChanged; + } + if (e.NewValue is System.Collections.Specialized.INotifyCollectionChanged newColl) + { + newColl.CollectionChanged += canvas.OnMarkersCollectionChanged; + } + + if (canvas.DisplayMode == PianoRollDisplayMode.Auto) + { + canvas.UpdateNotePitches(); + } + } + } + + private void OnMarkersCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if (DisplayMode == PianoRollDisplayMode.Auto) + { + UpdateNotePitches(); + } + } public IEnumerable? KeyChangeMarkers { @@ -443,6 +687,7 @@ public void RebuildNoteIndex() _maxPitch = 84; _cachedLanes = null; _cachedKeyboard = null; + InvalidateNoteBitmap(); InvalidateVisual(); return; } @@ -460,6 +705,7 @@ public void RebuildNoteIndex() _maxPitch = 84; _cachedLanes = null; _cachedKeyboard = null; + InvalidateNoteBitmap(); InvalidateVisual(); return; } @@ -476,7 +722,7 @@ public void RebuildNoteIndex() { var startMs = (long)note.TimeAs(tempoMap).TotalMilliseconds; var lengthMs = (long)note.LengthAs(tempoMap).TotalMilliseconds; - noteList.Add(new RollNote(note.NoteNumber, startMs, Math.Max(20, lengthMs), t)); + noteList.Add(new RollNote(note.NoteNumber, note.NoteNumber, startMs, Math.Max(20, lengthMs), t)); } } } @@ -486,7 +732,7 @@ public void RebuildNoteIndex() { var startMs = (long)note.TimeAs(tempoMap).TotalMilliseconds; var lengthMs = (long)note.LengthAs(tempoMap).TotalMilliseconds; - noteList.Add(new RollNote(note.NoteNumber, startMs, Math.Max(20, lengthMs), (int)note.Channel)); + noteList.Add(new RollNote(note.NoteNumber, note.NoteNumber, startMs, Math.Max(20, lengthMs), (int)note.Channel)); } } @@ -494,26 +740,7 @@ public void RebuildNoteIndex() _maxNoteLengthMs = _notes.Length > 0 ? _notes.Max(n => n.LengthMs) : 0; _trackCount = _notes.Length > 0 ? _notes.Select(n => n.TrackIndex).Distinct().Count() : 1; - if (_notes.Length > 0) - { - var min = _notes.Min(n => n.NoteNumber); - var max = _notes.Max(n => n.NoteNumber); - - _minPitch = Math.Max(21, min - 2); - _maxPitch = Math.Min(108, max + 2); - - if (_maxPitch - _minPitch < 24) - { - var diff = 24 - (_maxPitch - _minPitch); - _minPitch = Math.Max(21, _minPitch - diff / 2); - _maxPitch = Math.Min(108, _minPitch + 24); - } - } - else - { - _minPitch = 36; - _maxPitch = 84; - } + UpdateNotePitchesInternal(); } catch (Exception ex) { @@ -525,6 +752,7 @@ public void RebuildNoteIndex() _cachedLanes = null; _cachedKeyboard = null; + InvalidateNoteBitmap(); var totalHeight = (_maxPitch - _minPitch + 1) * KeyHeight; if (ActualHeight > 0 && totalHeight > ActualHeight) { @@ -538,6 +766,79 @@ public void RebuildNoteIndex() InvalidateVisual(); } + public void UpdateNotePitches() + { + UpdateNotePitchesInternal(); + _cachedLanes = null; + _cachedKeyboard = null; + InvalidateNoteBitmap(); + UpdateScrollMetrics(); + ScrollBoundsChanged?.Invoke(this, EventArgs.Empty); + InvalidateVisual(); + } + + private void UpdateNotePitchesInternal() + { + if (_notes.Length == 0) + { + _minPitch = 36; + _maxPitch = 84; + return; + } + + var song = MidiFile?.Song; + var mode = DisplayMode; + var settings = AutoMidiPlayer.Data.Properties.Settings.Default; + var instrumentId = !string.IsNullOrWhiteSpace(InstrumentId) + ? InstrumentId + : ResolveCurrentInstrumentId(); + var instrumentKeyCount = AutoMidiPlayer.WPF.Core.Keyboard.GetNotes(instrumentId).Count; + var threshold = settings.AutoCorrectThreshold; + var isAutoCorrect = threshold > 0 + && instrumentKeyCount <= threshold + && (song?.Transpose == Transpose.Smart) + && (song?.BaseKey is not null and not 0); + + for (var i = 0; i < _notes.Length; i++) + { + var note = _notes[i]; + var displayPitch = PianoRollNoteCalculator.CalculateDisplayPitch( + note.RawNoteNumber, note.StartMs, mode, song, instrumentId, isAutoCorrect); + + _notes[i] = new RollNote(note.RawNoteNumber, displayPitch, note.StartMs, note.LengthMs, note.TrackIndex); + } + + var min = _notes.Min(n => n.NoteNumber); + var max = _notes.Max(n => n.NoteNumber); + + _minPitch = Math.Max(21, min - 2); + _maxPitch = Math.Min(108, max + 2); + + if (_maxPitch - _minPitch < 24) + { + var diff = 24 - (_maxPitch - _minPitch); + _minPitch = Math.Max(21, _minPitch - diff / 2); + _maxPitch = Math.Min(108, _minPitch + 24); + } + } + + internal static string ResolveCurrentInstrumentId() + { + try + { + if (Application.Current?.MainWindow?.DataContext is AutoMidiPlayer.WPF.ViewModels.MainWindowViewModel main && main.InstrumentView != null) + { + var key = main.InstrumentView.SelectedInstrument.Key; + if (!string.IsNullOrWhiteSpace(key)) + return key; + } + } + catch { } + + var list = AutoMidiPlayer.WPF.Core.Keyboard.InstrumentNames.ToList(); + return list.Count > 0 ? list[0].Key : string.Empty; + } + private DrawingGroup BuildStaticLanes(double width, double pitchHeight) { var group = new DrawingGroup(); @@ -756,25 +1057,12 @@ protected override void OnRender(DrawingContext dc) // Reset active keys Array.Clear(_activeKeys, 0, _activeKeys.Length); - // Clear pre-allocated track batch buffers - for (var t = 0; t < TrackBrushes.Length; t++) - { - _trackRectsSharp[t].Clear(); - _trackRectsRound[t].Clear(); - } - Array.Fill(_lastPitchRight, -1.0); - Array.Fill(_lastPitchTrack, -1); - - // 3. Viewport-culled batched note rendering + // 3. Raster-cached note rendering and lightweight active-key detection if (_notes.Length > 0) { var visibleEndMs = effectiveViewTimeMs + visibleHorizonMs + 500; - // Notes ending at or before effectiveViewTimeMs have their right edge <= KeyboardWidth (behind keyboard) var startIndex = FindFirstPossibleNoteIndex(effectiveViewTimeMs); - // Adaptive aesthetic rounding: only when individual notes are large enough to appreciate rounded corners - var allowRounding = ZoomLevel >= 0.5 && pitchHeight >= 5.0; - for (var i = startIndex; i < _notes.Length; i++) { var note = _notes[i]; @@ -785,96 +1073,21 @@ protected override void OnRender(DrawingContext dc) if (noteEndMs <= effectiveViewTimeMs) continue; - // Check active hit state if (note.StartMs <= currentTimeMs && currentTimeMs <= noteEndMs) { - if (note.NoteNumber >= 0 && note.NoteNumber < _activeKeys.Length) + if (!IsTrackDisabled(note.TrackIndex) && note.NoteNumber >= 0 && note.NoteNumber < _activeKeys.Length) _activeKeys[note.NoteNumber] = true; } - - // Compute position: moving right to left toward KeyboardWidth - var noteX = KeyboardWidth + (note.StartMs - effectiveViewTimeMs) * pixelsPerMs; - var noteW = Math.Max(3.0, note.LengthMs * pixelsPerMs); - - // Clip notes as they pass behind the keyboard - if (noteX < KeyboardWidth) - { - noteW -= (KeyboardWidth - noteX); - noteX = KeyboardWidth; - } - - if (noteW <= 0.5) - continue; - - var pitch = note.NoteNumber; - var trackIdx = Math.Abs(note.TrackIndex) % TrackBrushes.Length; - - // Sub-pixel coalescing for dense notes on the same track and pitch - if (pitch >= 0 && pitch < 128) - { - if (_lastPitchTrack[pitch] == trackIdx) - { - var lastRight = _lastPitchRight[pitch]; - if (noteX + noteW <= lastRight + 0.5) - { - // Completely covered by previous note on same track and pitch - continue; - } - - if (noteX < lastRight) - { - // Overlaps tail of previous note: trim overlapping segment - noteW -= (lastRight - noteX); - noteX = lastRight; - if (noteW <= 0.5) continue; - } - } - - _lastPitchTrack[pitch] = trackIdx; - _lastPitchRight[pitch] = noteX + noteW; - } - - var noteY = (_maxPitch - pitch) * pitchHeight - verticalOffset; - var noteH = Math.Max(2, pitchHeight - 1); - if (noteY + noteH < 0 || noteY > height) - continue; - - var rect = new Rect(noteX, noteY + 0.5, noteW, noteH); - - if (allowRounding && noteW >= 6.0) - { - _trackRectsRound[trackIdx].Add(rect); - } - else - { - _trackRectsSharp[trackIdx].Add(rect); - } } - // Draw batched notes track by track with single frozen brushes - for (var t = 0; t < TrackBrushes.Length; t++) + EnsureNoteBitmap(effectiveViewTimeMs, width, height, pitchHeight, verticalOffset, pixelsPerMs, dpi); + if (_cachedNoteBitmap != null) { - var brush = (_trackCount == 1) ? _activeKeyBrush : TrackBrushes[t]; - - var sharps = _trackRectsSharp[t]; - var sharpCount = sharps.Count; - for (var r = 0; r < sharpCount; r++) - { - dc.DrawRectangle(brush, null, sharps[r]); - } - - var rounds = _trackRectsRound[t]; - var roundCount = rounds.Count; - if (roundCount > 0) - { - var radius = Math.Min(6.0, Math.Max(2.5, pitchHeight * 0.45)); - for (var r = 0; r < roundCount; r++) - { - var rect = rounds[r]; - var cornerR = Math.Min(radius, rect.Width * 0.45); - dc.DrawRoundedRectangle(brush, null, rect, cornerR, cornerR); - } - } + var bitmapOffsetX = KeyboardWidth - (effectiveViewTimeMs - _cachedNoteViewTimeMs) * pixelsPerMs; + var bitmapOffsetY = verticalOffset - _cachedNoteVerticalOffset; + dc.PushClip(_cachedLanesClip!); + dc.DrawImage(_cachedNoteBitmap, new Rect(bitmapOffsetX, bitmapOffsetY, _cachedNoteWidth, _cachedNoteHeight)); + dc.Pop(); } } @@ -937,14 +1150,23 @@ protected override void OnRender(DrawingContext dc) ? hoverTime.ToString(@"h\:mm\:ss\.f", CultureInfo.InvariantCulture) : hoverTime.ToString(@"m\:ss\.f", CultureInfo.InvariantCulture); - var formattedHover = new FormattedText( - timeText, - CultureInfo.InvariantCulture, - FlowDirection.LeftToRight, - HoverTypeface, - 9.5, - HoverTextBrush, - dpi); + if (_cachedHoverLabel == null || + !string.Equals(_cachedHoverText, timeText, StringComparison.Ordinal) || + Math.Abs(_cachedHoverDpi - dpi) > 0.001) + { + _cachedHoverLabel = new FormattedText( + timeText, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + HoverTypeface, + 9.5, + HoverTextBrush, + dpi); + _cachedHoverText = timeText; + _cachedHoverDpi = dpi; + } + + var formattedHover = _cachedHoverLabel; var pillW = formattedHover.Width + 8; var pillH = formattedHover.Height + 2; @@ -1023,6 +1245,191 @@ protected override void OnRender(DrawingContext dc) } } + private void InvalidateNoteBitmap() + { + _cachedNoteBitmap = null; + _cachedNoteZoom = -1; + _cachedNotePitchHeight = -1; + _cachedNoteVerticalOffset = -1; + _cachedNoteWidth = -1; + _cachedNoteHeight = -1; + } + + private void EnsureNoteBitmap( + long effectiveViewTimeMs, + double width, + double height, + double pitchHeight, + double verticalOffset, + double pixelsPerMs, + double dpi) + { + var rollWidth = Math.Max(1, width - KeyboardWidth); + var noteWidth = Math.Ceiling(rollWidth + NoteBitmapPaddingPx * 2); + var noteHeight = Math.Ceiling(height); + var cachedCenterTimeMs = _cachedNoteBitmap == null + ? 0 + : _cachedNoteViewTimeMs + (long)(NoteBitmapPaddingPx / pixelsPerMs); + var horizontalDrift = _cachedNoteBitmap == null + ? double.PositiveInfinity + : Math.Abs(effectiveViewTimeMs - cachedCenterTimeMs) * pixelsPerMs; + + var needsRebuild = _cachedNoteBitmap == null || + horizontalDrift > NoteBitmapRebuildDistancePx || + Math.Abs(_cachedNoteZoom - ZoomLevel) > 0.001 || + Math.Abs(_cachedNotePitchHeight - pitchHeight) > 0.001 || + Math.Abs(_cachedNoteVerticalOffset - verticalOffset) > 0.001 || + Math.Abs(_cachedNoteWidth - noteWidth) > 0.5 || + Math.Abs(_cachedNoteHeight - noteHeight) > 0.5; + + if (!needsRebuild) + return; + + var cacheStartTimeMs = effectiveViewTimeMs - (long)(NoteBitmapPaddingPx / pixelsPerMs); + var rasterScale = Math.Min( + dpi, + Math.Min( + NoteBitmapMaxPixelWidth / noteWidth, + NoteBitmapMaxPixelHeight / noteHeight)); + rasterScale = Math.Max(0.25, rasterScale); + var pixelWidth = (int)Math.Max(1, Math.Ceiling(noteWidth * rasterScale)); + var pixelHeight = (int)Math.Max(1, Math.Ceiling(noteHeight * rasterScale)); + var imageInfo = new SKImageInfo(pixelWidth, pixelHeight, SKColorType.Bgra8888, SKAlphaType.Premul); + using var surface = SKSurface.Create(imageInfo); + if (surface == null) + { + throw new InvalidOperationException("Unable to create the Skia note cache surface."); + } + + var skiaCanvas = surface.Canvas; + skiaCanvas.Clear(SKColors.Transparent); + skiaCanvas.Scale((float)rasterScale); + + using var paint = new SKPaint + { + Style = SKPaintStyle.Fill, + IsAntialias = true + }; + + var visibleEndMs = cacheStartTimeMs + (long)(noteWidth / pixelsPerMs) + 500; + var startIndex = FindFirstPossibleNoteIndex(cacheStartTimeMs); + var allowRounding = ZoomLevel >= 0.5 && pitchHeight >= 5.0; + var radius = Math.Min(6.0, Math.Max(2.5, pitchHeight * 0.45)); + + var singleTrackColor = _trackCount == 1 ? ToSkColor(_activeKeyBrush.Color) : default; + var disabledSingleTrackColor = _trackCount == 1 + ? new SKColor(_activeKeyBrush.Color.R, _activeKeyBrush.Color.G, _activeKeyBrush.Color.B, (byte)(_activeKeyBrush.Color.A * 0.28)) + : default; + + void DrawNote(RollNote note, bool isDisabled) + { + var noteEndMs = note.StartMs + note.LengthMs; + if (noteEndMs <= cacheStartTimeMs) + return; + + var noteX = (note.StartMs - cacheStartTimeMs) * pixelsPerMs; + var noteW = Math.Max(3.0, note.LengthMs * pixelsPerMs); + if (noteX < 0) + { + noteW += noteX; + noteX = 0; + } + + if (noteW <= 0.5) + return; + + var noteY = (_maxPitch - note.NoteNumber) * pitchHeight - verticalOffset; + var noteH = Math.Max(2, pitchHeight - 1); + if (noteY + noteH < 0 || noteY > height) + return; + + var trackIdx = Math.Abs(note.TrackIndex) % TrackBrushes.Length; + + paint.Color = isDisabled + ? (_trackCount == 1 ? disabledSingleTrackColor : DisabledTrackSkColors[trackIdx]) + : (_trackCount == 1 ? singleTrackColor : TrackSkColors[trackIdx]); + + var rect = new SKRect((float)noteX, (float)(noteY + 0.5), (float)(noteX + noteW), (float)(noteY + noteH + 0.5)); + + if (allowRounding && noteW >= 6.0) + { + var cornerRadius = (float)Math.Min(radius, noteW * 0.45); + skiaCanvas.DrawRoundRect(rect, cornerRadius, cornerRadius, paint); + } + else + { + skiaCanvas.DrawRect(rect, paint); + } + } + + if (_disabledTrackIndices.Count > 0) + { + // Pass 1: Draw disabled notes FIRST at the very back with low opacity + for (var i = startIndex; i < _notes.Length; i++) + { + var note = _notes[i]; + if (note.StartMs > visibleEndMs) + break; + + if (IsTrackDisabled(note.TrackIndex)) + { + DrawNote(note, isDisabled: true); + } + } + + // Pass 2: Draw enabled notes SECOND on top with normal opacity + for (var i = startIndex; i < _notes.Length; i++) + { + var note = _notes[i]; + if (note.StartMs > visibleEndMs) + break; + + if (!IsTrackDisabled(note.TrackIndex)) + { + DrawNote(note, isDisabled: false); + } + } + } + else + { + // Fast path when all tracks are enabled + for (var i = startIndex; i < _notes.Length; i++) + { + var note = _notes[i]; + if (note.StartMs > visibleEndMs) + break; + + DrawNote(note, isDisabled: false); + } + } + + surface.Flush(); + var pixelBytes = new byte[pixelWidth * pixelHeight * 4]; + var pixmap = surface.PeekPixels() ?? throw new InvalidOperationException("Skia surface pixels are unavailable."); + pixmap.GetPixelSpan().CopyTo(pixelBytes); + var bitmap = BitmapSource.Create( + pixelWidth, + pixelHeight, + 96 * rasterScale, + 96 * rasterScale, + PixelFormats.Pbgra32, + null, + pixelBytes, + pixelWidth * 4); + bitmap.Freeze(); + + _cachedNoteBitmap = bitmap; + _cachedNoteViewTimeMs = cacheStartTimeMs; + _cachedNoteZoom = ZoomLevel; + _cachedNotePitchHeight = pitchHeight; + _cachedNoteVerticalOffset = verticalOffset; + _cachedNoteWidth = noteWidth; + _cachedNoteHeight = noteHeight; + } + + private static SKColor ToSkColor(Color color) + => new(color.R, color.G, color.B, color.A); + private int FindFirstPossibleNoteIndex(long targetMs) { if (_notes.Length == 0) return 0; @@ -1101,10 +1508,6 @@ protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) _dragAnchorMouseX = pos.X; _hoverX = pos.X; - if (IsSyncEnabled) - { - ScrubRequested?.Invoke(this, TimeSpan.FromMilliseconds(clickedMs)); - } InvalidateVisual(); e.Handled = true; } @@ -1129,15 +1532,7 @@ protected override void OnMouseMove(MouseEventArgs e) { if (pixelsPerMs > 0 && Math.Abs(deltaX) >= 1.0) { - var currentTimeMs = (long)CurrentTime.TotalMilliseconds; - var deltaMs = (long)(deltaX / pixelsPerMs); - var newTargetMs = currentTimeMs + deltaMs; - var totalMs = TotalDurationMs; - if (totalMs > 0) newTargetMs = Math.Clamp(newTargetMs, 0, totalMs); - else newTargetMs = Math.Max(0, newTargetMs); - _dragAnchorMouseX = pos.X; - ScrubRequested?.Invoke(this, TimeSpan.FromMilliseconds(newTargetMs)); } } else @@ -1197,13 +1592,15 @@ protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) _isDragging = false; ReleaseMouseCapture(); - if (!IsSyncEnabled && !_hasDragged) + if (!_hasDragged || IsSyncEnabled) { var pos = e.GetPosition(this); if (pos.X >= KeyboardWidth && pos.X <= ActualWidth) { var pixelsPerMs = 0.15 * ZoomLevel; - var effectiveViewTimeMs = ViewTime.HasValue ? (long)ViewTime.Value.TotalMilliseconds : (long)CurrentTime.TotalMilliseconds; + var effectiveViewTimeMs = (!IsSyncEnabled && ViewTime.HasValue) + ? (long)ViewTime.Value.TotalMilliseconds + : (long)CurrentTime.TotalMilliseconds; var targetMs = effectiveViewTimeMs + (long)((pos.X - KeyboardWidth) / pixelsPerMs); var totalMs = TotalDurationMs; if (totalMs > 0) targetMs = Math.Clamp(targetMs, 0, totalMs); diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollDisplayMode.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollDisplayMode.cs new file mode 100644 index 0000000..3900dc7 --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollDisplayMode.cs @@ -0,0 +1,80 @@ +using System; +using AutoMidiPlayer.Data; +using AutoMidiPlayer.Data.Entities; +using AutoMidiPlayer.WPF.Core; + +namespace AutoMidiPlayer.WPF.Controls.PianoRoll; + +/// +/// Display mode for notes in the Piano Roll and Timeline minimap. +/// +public enum PianoRollDisplayMode +{ + /// + /// Displays notes dynamically following timeline key markers and transpositions. + /// + Auto, + + /// + /// Displays transposed note equivalents following song key offset and song transpose mode. + /// + Transpose, + + /// + /// Displays the original sequence exactly as authored in the MIDI file. + /// + SongDefault +} + +/// +/// Calculates the rendered note pitch for the piano roll and timeline minimap based on display mode. +/// +public static class PianoRollNoteCalculator +{ + public static int CalculateDisplayPitch( + int rawNoteNumber, + long startMs, + PianoRollDisplayMode mode, + Song? song, + string? instrumentId, + bool isAutoCorrect) + { + if (song is null || mode == PianoRollDisplayMode.SongDefault) + return rawNoteNumber; + + if (mode == PianoRollDisplayMode.Transpose) + { + var songKeyOffset = isAutoCorrect + ? MusicConstants.GetEffectiveKeyOffset(song.Key, song.BaseKey) + : song.Key; + + var noteId = rawNoteNumber + songKeyOffset; + var songTranspose = song.Transpose ?? Transpose.Ignore; + if (songTranspose != Transpose.Ignore && !string.IsNullOrEmpty(instrumentId)) + { + KeyboardPlayer.TransposeNote(instrumentId, ref noteId, songTranspose); + } + + return Math.Clamp(noteId, 0, 127); + } + + // Auto mode: dynamic timeline key markers + var marker = song.GetKeyMarkerAtTime(TimeSpan.FromMilliseconds(startMs)); + var activeKeyOffset = marker?.KeyOffset ?? song.Key; + var effectiveOffset = isAutoCorrect + ? (marker != null + ? MusicConstants.GetEffectiveKeyOffset(activeKeyOffset, song.BaseKey) + : MusicConstants.GetEffectiveKeyOffset(song.Key, song.BaseKey)) + : activeKeyOffset; + + var autoNoteId = rawNoteNumber + effectiveOffset; + var activeTranspose = marker?.Transpose ?? song.Transpose ?? Transpose.Ignore; + + if (activeTranspose != Transpose.Ignore && !string.IsNullOrEmpty(instrumentId)) + { + KeyboardPlayer.TransposeNote(instrumentId, ref autoNoteId, activeTranspose); + } + + return Math.Clamp(autoNoteId, 0, 127); + } +} diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs index 1acf501..cf2af36 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs @@ -2,11 +2,15 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Media; +using System.Collections.Specialized; +using System.ComponentModel; using AutoMidiPlayer.Data; using AutoMidiPlayer.Data.Entities; using AutoMidiPlayer.Data.Midi; +using AutoMidiPlayer.WPF.Core; using AutoMidiPlayer.WPF.Helpers; using Melanchall.DryWetMidi.Core; using Melanchall.DryWetMidi.Interaction; @@ -16,10 +20,11 @@ namespace AutoMidiPlayer.WPF.Controls.PianoRoll; public class TimelineCanvas : FrameworkElement { - public readonly record struct MinimapNote(int Pitch, long StartMs, long LengthMs, int TrackIndex); + public readonly record struct MinimapNote(int RawPitch, int Pitch, long StartMs, long LengthMs, int TrackIndex); private static readonly SolidColorBrush TrackBackgroundBrush; private static readonly SolidColorBrush[] TrackBrushes; + private static readonly SolidColorBrush[] DisabledTrackBrushes; private static readonly SolidColorBrush PlayheadBrush; private static readonly Pen PlayheadPen; private static readonly SolidColorBrush MarkerPinBrush; @@ -37,6 +42,12 @@ static TimelineCanvas() { TrackBackgroundBrush = CreateFrozenBrush(Color.FromArgb(40, 0, 0, 0)); TrackBrushes = TrackColorPalette.AllBrushes; + DisabledTrackBrushes = new SolidColorBrush[TrackBrushes.Length]; + for (var i = 0; i < TrackBrushes.Length; i++) + { + var c = TrackBrushes[i].Color; + DisabledTrackBrushes[i] = CreateFrozenBrush(Color.FromArgb((byte)(c.A * 0.25), c.R, c.G, c.B)); + } PlayheadBrush = CreateFrozenBrush(Color.FromRgb(255, 255, 255)); PlayheadPen = new Pen(CreateFrozenBrush(Color.FromRgb(255, 255, 255)), 2); PlayheadPen.Freeze(); @@ -84,11 +95,33 @@ private static SolidColorBrush CreateFrozenBrush(Color color) private DrawingGroup? _staticBackgroundCache; private double _lastCachedWidth = -1; private double _lastCachedHeight = -1; + private readonly TranslateTransform _playheadTransform = new(); + + private readonly HashSet _disabledTrackIndices = new(); + private readonly bool[] _disabledTrackLookup = new bool[256]; + private readonly List _subscribedTrackItems = new(); + private INotifyCollectionChanged? _subscribedTrackCollection; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsTrackDisabled(int trackIndex) + { + return (uint)trackIndex < (uint)_disabledTrackLookup.Length + ? _disabledTrackLookup[trackIndex] + : _disabledTrackIndices.Contains(trackIndex); + } public TimelineCanvas() { - Loaded += (_, _) => AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; - Unloaded += (_, _) => AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged; + Loaded += (_, _) => + { + AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; + UpdateTrackSubscriptions(); + }; + Unloaded += (_, _) => + { + AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged; + UnsubscribeTrackEvents(); + }; } private void OnThemeResourcesChanged() @@ -96,6 +129,99 @@ private void OnThemeResourcesChanged() Dispatcher.BeginInvoke(InvalidateVisualCache); } + public static readonly DependencyProperty MidiTracksProperty = + DependencyProperty.Register( + nameof(MidiTracks), + typeof(IEnumerable), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnMidiTracksChanged)); + + private static void OnMidiTracksChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TimelineCanvas canvas) + { + canvas.UpdateTrackSubscriptions(); + } + } + + public IEnumerable? MidiTracks + { + get => (IEnumerable?)GetValue(MidiTracksProperty); + set => SetValue(MidiTracksProperty, value); + } + + private void UpdateTrackSubscriptions() + { + UnsubscribeTrackEvents(); + + if (MidiTracks is INotifyCollectionChanged notifyCollection) + { + _subscribedTrackCollection = notifyCollection; + notifyCollection.CollectionChanged += OnTrackCollectionChanged; + } + + if (MidiTracks != null) + { + foreach (var track in MidiTracks) + { + track.PropertyChanged += OnTrackItemPropertyChanged; + _subscribedTrackItems.Add(track); + } + } + + UpdateDisabledTrackIndices(); + } + + private void UnsubscribeTrackEvents() + { + if (_subscribedTrackCollection != null) + { + _subscribedTrackCollection.CollectionChanged -= OnTrackCollectionChanged; + _subscribedTrackCollection = null; + } + + foreach (var item in _subscribedTrackItems) + { + item.PropertyChanged -= OnTrackItemPropertyChanged; + } + _subscribedTrackItems.Clear(); + } + + private void OnTrackCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + UpdateTrackSubscriptions(); + } + + private void OnTrackItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == nameof(MidiTrack.IsChecked)) + { + UpdateDisabledTrackIndices(); + } + } + + private void UpdateDisabledTrackIndices() + { + _disabledTrackIndices.Clear(); + Array.Clear(_disabledTrackLookup, 0, _disabledTrackLookup.Length); + if (MidiTracks != null) + { + foreach (var t in MidiTracks) + { + if (!t.IsChecked) + { + _disabledTrackIndices.Add(t.Index); + if ((uint)t.Index < (uint)_disabledTrackLookup.Length) + { + _disabledTrackLookup[t.Index] = true; + } + } + } + } + + InvalidateVisualCache(); + } + public static readonly DependencyProperty VisiblePastMsProperty = DependencyProperty.Register( nameof(VisiblePastMs), @@ -193,6 +319,69 @@ public TimeSpan Duration set => SetValue(DurationProperty, value); } + public static readonly DependencyProperty DisplayModeProperty = + DependencyProperty.Register( + nameof(DisplayMode), + typeof(PianoRollDisplayMode), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(PianoRollDisplayMode.Auto, FrameworkPropertyMetadataOptions.AffectsRender, OnDisplayModeChanged)); + + private static void OnDisplayModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TimelineCanvas canvas) + { + canvas.UpdateNotePitches(); + } + } + + public PianoRollDisplayMode DisplayMode + { + get => (PianoRollDisplayMode)GetValue(DisplayModeProperty); + set => SetValue(DisplayModeProperty, value); + } + + public static readonly DependencyProperty InstrumentIdProperty = + DependencyProperty.Register( + nameof(InstrumentId), + typeof(string), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender, OnInstrumentIdChanged)); + + private static void OnInstrumentIdChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TimelineCanvas canvas) + { + canvas.UpdateNotePitches(); + } + } + + public string? InstrumentId + { + get => (string?)GetValue(InstrumentIdProperty); + set => SetValue(InstrumentIdProperty, value); + } + + public static readonly DependencyProperty PitchRevisionProperty = + DependencyProperty.Register( + nameof(PitchRevision), + typeof(int), + typeof(TimelineCanvas), + new FrameworkPropertyMetadata(0, FrameworkPropertyMetadataOptions.AffectsRender, OnPitchRevisionChanged)); + + private static void OnPitchRevisionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is TimelineCanvas canvas) + { + canvas.UpdateNotePitches(); + } + } + + public int PitchRevision + { + get => (int)GetValue(PitchRevisionProperty); + set => SetValue(PitchRevisionProperty, value); + } + public static readonly DependencyProperty KeyChangeMarkersProperty = DependencyProperty.Register( nameof(KeyChangeMarkers), @@ -231,7 +420,35 @@ private static void OnMarkersChanged(DependencyObject d, DependencyPropertyChang { if (d is TimelineCanvas canvas) { - canvas.InvalidateVisualCache(); + if (e.OldValue is System.Collections.Specialized.INotifyCollectionChanged oldColl) + { + oldColl.CollectionChanged -= canvas.OnMarkersCollectionChanged; + } + if (e.NewValue is System.Collections.Specialized.INotifyCollectionChanged newColl) + { + newColl.CollectionChanged += canvas.OnMarkersCollectionChanged; + } + + if (canvas.DisplayMode == PianoRollDisplayMode.Auto) + { + canvas.UpdateNotePitches(); + } + else + { + canvas.InvalidateVisualCache(); + } + } + } + + private void OnMarkersCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if (DisplayMode == PianoRollDisplayMode.Auto) + { + UpdateNotePitches(); + } + else + { + InvalidateVisualCache(); } } @@ -290,7 +507,7 @@ public void RebuildMinimap() { var startMs = (long)note.TimeAs(tempoMap).TotalMilliseconds; var lenMs = (long)note.LengthAs(tempoMap).TotalMilliseconds; - list.Add(new MinimapNote(note.NoteNumber, startMs, lenMs, t)); + list.Add(new MinimapNote(note.NoteNumber, note.NoteNumber, startMs, lenMs, t)); } } } @@ -300,16 +517,16 @@ public void RebuildMinimap() { var startMs = (long)note.TimeAs(tempoMap).TotalMilliseconds; var lenMs = (long)note.LengthAs(tempoMap).TotalMilliseconds; - list.Add(new MinimapNote(note.NoteNumber, startMs, lenMs, (int)note.Channel)); + list.Add(new MinimapNote(note.NoteNumber, note.NoteNumber, startMs, lenMs, (int)note.Channel)); } } _notes = list.OrderBy(n => n.StartMs).ToArray(); _trackCount = _notes.Length > 0 ? _notes.Select(n => n.TrackIndex).Distinct().Count() : 1; + UpdateNotePitchesInternal(); + if (_notes.Length > 0) { - _minPitch = _notes.Min(n => n.Pitch); - _maxPitch = _notes.Max(n => n.Pitch); var lastNote = _notes.Max(n => n.StartMs + n.LengthMs); _totalDurationMs = Math.Max(_totalDurationMs, Math.Max(1L, lastNote)); } @@ -324,6 +541,43 @@ public void RebuildMinimap() InvalidateVisualCache(); } + public void UpdateNotePitches() + { + UpdateNotePitchesInternal(); + InvalidateVisualCache(); + InvalidateVisual(); + } + + private void UpdateNotePitchesInternal() + { + if (_notes.Length == 0) return; + + var song = MidiFile?.Song; + var mode = DisplayMode; + var settings = AutoMidiPlayer.Data.Properties.Settings.Default; + var instrumentId = !string.IsNullOrWhiteSpace(InstrumentId) + ? InstrumentId + : PianoRollCanvas.ResolveCurrentInstrumentId(); + var instrumentKeyCount = Keyboard.GetNotes(instrumentId).Count; + var threshold = settings.AutoCorrectThreshold; + var isAutoCorrect = threshold > 0 + && instrumentKeyCount <= threshold + && (song?.Transpose == Transpose.Smart) + && (song?.BaseKey is not null and not 0); + + for (var i = 0; i < _notes.Length; i++) + { + var note = _notes[i]; + var displayPitch = PianoRollNoteCalculator.CalculateDisplayPitch( + note.RawPitch, note.StartMs, mode, song, instrumentId, isAutoCorrect); + + _notes[i] = new MinimapNote(note.RawPitch, displayPitch, note.StartMs, note.LengthMs, note.TrackIndex); + } + + _minPitch = _notes.Min(n => n.Pitch); + _maxPitch = _notes.Max(n => n.Pitch); + } + public long TotalDurationMs => Math.Max(1L, Math.Max(_totalDurationMs, (long)Duration.TotalMilliseconds)); public KeyChangeMarker? HitTestMarker(Point point) @@ -402,7 +656,8 @@ protected override void OnRender(DrawingContext dc) dc.DrawLine(PlayheadPen, new Point(playheadX, 0), new Point(playheadX, height)); // Playhead handle at top - dc.PushTransform(new TranslateTransform(playheadX, 0)); + _playheadTransform.X = playheadX; + dc.PushTransform(_playheadTransform); dc.DrawGeometry(PlayheadBrush, null, PlayheadHandleGeometry); dc.Pop(); } @@ -420,15 +675,65 @@ private DrawingGroup BuildStaticBackground(double width, double height, long tot { var pitchSpan = Math.Max(12, _maxPitch - _minPitch); var noteAreaH = Math.Max(10, height - 16); - var singleTrackBrush = _trackCount == 1 ? TrackColorPalette.GetBrush(0, 1) : null; - - foreach (var note in _notes) + var isSingleTrack = _trackCount == 1; + var singleTrackBrush = isSingleTrack ? TrackColorPalette.GetBrush(0, 1) : null; + var disabledSingleTrackBrush = isSingleTrack && singleTrackBrush != null + ? CreateFrozenBrush(Color.FromArgb((byte)(singleTrackBrush.Color.A * 0.25), singleTrackBrush.Color.R, singleTrackBrush.Color.G, singleTrackBrush.Color.B)) + : null; + var lastRightByTrackPitch = new double[TrackBrushes.Length * 128]; + Array.Fill(lastRightByTrackPitch, -1.0); + + void DrawTimelineNote(MinimapNote note, bool isDisabled) { var x = (note.StartMs / (double)totalMs) * width; var y = (1.0 - (double)(note.Pitch - _minPitch) / pitchSpan) * (noteAreaH - 4) + 2; var w = Math.Max(2.0, (note.LengthMs / (double)totalMs) * width); - var brush = singleTrackBrush ?? TrackBrushes[Math.Abs(note.TrackIndex) % TrackBrushes.Length]; - dc.DrawRoundedRectangle(brush, null, new Rect(x, y, w, 2.5), 1.0, 1.0); + var trackIdx = isSingleTrack ? 0 : (Math.Abs(note.TrackIndex) % TrackBrushes.Length); + var pitch = Math.Clamp(note.Pitch, 0, 127); + var trackerKey = (trackIdx << 7) | pitch; + + var lastRight = lastRightByTrackPitch[trackerKey]; + if (x + w <= lastRight + 0.5) + return; + + if (x < lastRight) + { + w -= (lastRight - x); + x = lastRight; + if (w <= 0.5) return; + } + + lastRightByTrackPitch[trackerKey] = x + w; + var brush = isDisabled + ? (disabledSingleTrackBrush ?? DisabledTrackBrushes[trackIdx]) + : (singleTrackBrush ?? TrackBrushes[trackIdx]); + + dc.DrawRectangle(brush, null, new Rect(x, y, w, 2.0)); + } + + if (_disabledTrackIndices.Count > 0) + { + // Pass 1: Disabled notes first (at the back with low opacity) + foreach (var note in _notes) + { + if (IsTrackDisabled(note.TrackIndex)) + DrawTimelineNote(note, isDisabled: true); + } + // Reset coalescing tracker for enabled pass so enabled notes draw properly over disabled ones + Array.Fill(lastRightByTrackPitch, -1.0); + // Pass 2: Enabled notes on top + foreach (var note in _notes) + { + if (!IsTrackDisabled(note.TrackIndex)) + DrawTimelineNote(note, isDisabled: false); + } + } + else + { + foreach (var note in _notes) + { + DrawTimelineNote(note, isDisabled: false); + } } } diff --git a/AutoMidiPlayer.WPF/Services/PlaybackControlsService.cs b/AutoMidiPlayer.WPF/Services/PlaybackControlsService.cs index a525cd3..d05cd08 100644 --- a/AutoMidiPlayer.WPF/Services/PlaybackControlsService.cs +++ b/AutoMidiPlayer.WPF/Services/PlaybackControlsService.cs @@ -364,6 +364,16 @@ public async void Previous() #region Slider & Time + public void Seek(TimeSpan time) + { + _ignoreSliderChange = true; + SongPosition = time.TotalSeconds; + SetDisplaySongPosition(time); + _ignoreSliderChange = false; + + SeekPlayback(time); + } + public void OnSongPositionChanged() { if (_ignoreSliderChange) @@ -372,6 +382,12 @@ public void OnSongPositionChanged() return; } + SeekPlayback(_songPosition); + _ignoreSliderChange = false; + } + + private void SeekPlayback(TimeSpan time) + { var pb = Engine.Playback; if (pb is null) return; @@ -380,14 +396,12 @@ public void OnSongPositionChanged() { var isRunning = pb.IsRunning; pb.Stop(); - pb.MoveToTime(new MetricTimeSpan(_songPosition)); + pb.MoveToTime(new MetricTimeSpan(time)); Engine.ResetChordPadsTriggered(); if (Settings.UseSpeakers && isRunning) pb.Start(); } catch (ObjectDisposedException) { } - - _ignoreSliderChange = false; } public void OnSongTick(object? sender, PlaybackCurrentTimeChangedEventArgs e) diff --git a/AutoMidiPlayer.WPF/Services/SongService.cs b/AutoMidiPlayer.WPF/Services/SongService.cs index acd489d..ff1d2de 100644 --- a/AutoMidiPlayer.WPF/Services/SongService.cs +++ b/AutoMidiPlayer.WPF/Services/SongService.cs @@ -303,10 +303,17 @@ public void SyncFromEditedSong(Song song) /// public int GetEffectiveKeyOffset(Song? song = null) { + var isAutoCorrect = IsAutoCorrectActiveForCurrentInstrument(); if (song is not null) - return MusicConstants.GetEffectiveKeyOffset(song.Key, song.BaseKey); + { + return isAutoCorrect + ? MusicConstants.GetEffectiveKeyOffset(song.Key, song.BaseKey) + : song.Key; + } - return MusicConstants.GetEffectiveKeyOffset(KeyOffset, CurrentFile?.Song.BaseKey); + return isAutoCorrect + ? MusicConstants.GetEffectiveKeyOffset(KeyOffset, CurrentFile?.Song.BaseKey) + : KeyOffset; } private void UpdateKeyOptionsForCurrentSong() diff --git a/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml b/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml index 09aa4ea..2625178 100644 --- a/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml +++ b/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml @@ -584,11 +584,11 @@ Tag="{Binding TrackView}" Click="{s:Action NavigateToItem}"/> - @@ -187,31 +186,40 @@ - - - - + + - + ToolTip="{Binding TrackName, TargetNullValue=Unknown}" /> + TextTrimming="CharacterEllipsis"> + + + + diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml index d13df46..5ca2082 100644 --- a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml @@ -33,6 +33,12 @@ Foreground="{DynamicResource TextFillColorPrimaryBrush}"/> + + + + + + @@ -76,27 +82,29 @@ TextTrimming="CharacterEllipsis" /> - - - - + + + + + - - - + FontWeight="Medium" + VerticalAlignment="Center" /> + + + - + @@ -168,6 +176,7 @@ @@ -204,7 +216,7 @@ SelectedItem="{Binding SelectedKeyOption, Mode=TwoWay}" ItemTemplate="{StaticResource OffsetNoteTemplate}" SelectedItemTemplate="{StaticResource OffsetNoteTemplate}" - Width="135" + Width="92" VerticalAlignment="Center" Margin="0,0,8,0" /> @@ -215,7 +227,7 @@ ToolTip="Add or update key offset at current playhead position"> - + @@ -234,14 +246,14 @@ Command="{s:Action AutoDetectKeyChangesAsync}" Padding="8,6" Margin="0,0,6,0" - ToolTip="Auto-detect key signature modulations from MIDI events"> + ToolTip="Scan the MIDI file for embedded key signatures and add offset markers automatically"> - + diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs index 1953ea3..96c0088 100644 --- a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs @@ -27,6 +27,7 @@ public StudioView() private void OnViewLoaded(object sender, RoutedEventArgs e) { UpdateResponsiveLayout(animate: false); + InteractiveRoll?.FitKeyHeightToViewport(); } private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) @@ -36,14 +37,37 @@ private void OnDataContextChanged(object sender, DependencyPropertyChangedEventA oldVm.PropertyChanged -= OnViewModelPropertyChanged; } + if (e.OldValue is StudioViewModel oldStudioVm) + { + oldStudioVm.SongDataReloaded -= OnSongDataReloaded; + } + if (e.NewValue is INotifyPropertyChanged newVm) { newVm.PropertyChanged += OnViewModelPropertyChanged; } + if (e.NewValue is StudioViewModel newStudioVm) + { + newStudioVm.SongDataReloaded += OnSongDataReloaded; + } + UpdateResponsiveLayout(animate: false); } + private void OnSongDataReloaded(object? sender, EventArgs e) + { + if (Dispatcher.CheckAccess()) + { + InteractiveRoll?.RefreshNotesAndTimeline(); + InteractiveRoll?.FitKeyHeightToViewport(); + } + else + { + Dispatcher.BeginInvoke(new Action(() => OnSongDataReloaded(sender, e))); + } + } + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(StudioViewModel.IsSidebarOpen)) diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs index 8cac1be..9e0adb5 100644 --- a/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs @@ -3,12 +3,14 @@ using System.ComponentModel; using System.Linq; using System.Threading.Tasks; +using System.Windows; using System.Windows.Media; using AutoMidiPlayer.Data; using AutoMidiPlayer.Data.Entities; using AutoMidiPlayer.Data.Midi; using AutoMidiPlayer.Data.Notification; using AutoMidiPlayer.WPF.Controls.NoSongPlaceholder; +using AutoMidiPlayer.WPF.Controls.PianoRoll; using AutoMidiPlayer.WPF.Controls.Snackbar; using AutoMidiPlayer.WPF.Core; using AutoMidiPlayer.WPF.Services; @@ -20,7 +22,11 @@ namespace AutoMidiPlayer.WPF.ViewModels; -public class StudioViewModel : Screen, IHandle, IHandle +public class StudioViewModel : Screen, + IHandle, + IHandle, + IHandle, + IHandle { private readonly IContainer _ioc; private readonly MainWindowViewModel _main; @@ -36,6 +42,7 @@ public class StudioViewModel : Screen, IHandle, I private double _keyHeight = 16.0; private bool _isRenderingHooked; private string _lastActiveKeyDisplay = string.Empty; + private string _lastActiveDisplayModeText = string.Empty; private KeyChangeMarker? _lastActiveMarker; private int _lastActiveKey = int.MinValue; @@ -48,8 +55,13 @@ public StudioViewModel(IContainer ioc, MainWindowViewModel main, NoSongPlacehold _events.Subscribe(this); _timeWatcher = PlaybackCurrentTimeWatcher.Instance; + SongSettings.SettingsRebuildRequired += HandleSongSettingsRebuildRequired; + SongSettings.PropertyChanged += HandleSongSettingsPropertyChanged; + _main.ActiveGamesChanged += HandleActiveGamesChanged; + KeyOptions = MusicConstants.GenerateKeyOptions(); _selectedKeyOption = KeyOptions.FirstOrDefault(k => k.Value == 0); + _selectedDisplayMode = DisplayModes[0]; } #region Properties - Components & Delegates @@ -58,6 +70,29 @@ public StudioViewModel(IContainer ioc, MainWindowViewModel main, NoSongPlacehold public MainWindowViewModel Main => _main; public PlaybackControlsService Controls => _main.PlaybackControls; public SongService SongSettings => _main.SongSettings; + public string CurrentInstrumentId => _main.InstrumentView?.SelectedInstrument.Key ?? string.Empty; + public int PitchRevision { get; private set; } + + public void NotifyPitchUpdateRequired() + { + if (Application.Current?.Dispatcher?.CheckAccess() == false) + { + Application.Current.Dispatcher.BeginInvoke(NotifyPitchUpdateRequired); + return; + } + + _lastActiveMarker = null; + _lastActiveKey = int.MinValue; + _lastActiveKeyDisplay = string.Empty; + _lastActiveDisplayModeText = string.Empty; + UpdateActiveKeyDisplay(); + NotifyOfPropertyChange(nameof(CurrentInstrumentId)); + PitchRevision++; + NotifyOfPropertyChange(nameof(PitchRevision)); + } + + public event EventHandler? SongDataReloaded; + public BindableCollection MidiTracks => _main.TrackView.MidiTracks; public MidiFile? CurrentFile => _main.QueueView.OpenedFile; @@ -89,19 +124,7 @@ public KeyChangeMarker? SelectedMarker public MusicConstants.KeyOption? SelectedKeyOption { get => _selectedKeyOption; - set - { - if (SetAndNotify(ref _selectedKeyOption, value)) - { - if (SelectedMarker != null && value != null && SelectedMarker.KeyOffset != value.Value) - { - SelectedMarker.KeyOffset = value.Value; - var noteName = MusicConstants.GetNoteName(value.Value); - SelectedMarker.Label = $"{noteName} ({(value.Value >= 0 ? "+" : "")}{value.Value})"; - _ = SaveMarkersToSongAsync(); - } - } - } + set => SetAndNotify(ref _selectedKeyOption, value); } public TimeSpan CurrentTime @@ -119,10 +142,15 @@ public TimeSpan CurrentTime private void UpdateActiveKeyDisplay() { var display = ActiveKeyAtPlayheadDisplay; - if (!string.Equals(_lastActiveKeyDisplay, display, StringComparison.Ordinal)) + var modeText = ActiveDisplayModeText; + if (!string.Equals(_lastActiveKeyDisplay, display, StringComparison.Ordinal) || + !string.Equals(_lastActiveDisplayModeText, modeText, StringComparison.Ordinal)) { _lastActiveKeyDisplay = display; + _lastActiveDisplayModeText = modeText; NotifyOfPropertyChange(nameof(ActiveKeyAtPlayheadDisplay)); + NotifyOfPropertyChange(nameof(ActiveDisplayModeText)); + NotifyOfPropertyChange(nameof(ActiveDisplayModeTooltip)); } } @@ -200,6 +228,126 @@ public string ActiveSidebarTab public bool IsSettingsDrawerActive => IsSidebarOpen && ActiveSidebarTab == "Settings"; public string ActiveDrawerTitle => ActiveSidebarTab == "Tracks" ? "Tracks" : "Song Settings"; + public List DisplayModes { get; } = + [ + new() + { + Mode = PianoRollDisplayMode.Auto, + Name = "Auto", + Description = "Display according to markers & transposes" + }, + new() + { + Mode = PianoRollDisplayMode.Transpose, + Name = "Transpose", + Description = "Display transposed equivalents (Up, Smart, Down, Ignore)" + }, + new() + { + Mode = PianoRollDisplayMode.SongDefault, + Name = "Song Default", + Description = "Original sequence (unmodified MIDI)" + } + ]; + + private NoteDisplayModeOption _selectedDisplayMode = null!; + public NoteDisplayModeOption SelectedDisplayMode + { + get => _selectedDisplayMode; + set + { + if (SetAndNotify(ref _selectedDisplayMode, value)) + { + _lastActiveDisplayModeText = ActiveDisplayModeText; + NotifyOfPropertyChange(nameof(ActiveDisplayModeText)); + NotifyOfPropertyChange(nameof(ActiveDisplayModeTooltip)); + NotifyPitchUpdateRequired(); + } + } + } + + private static string FormatTimestampWithMs(TimeSpan time) + { + if (time < TimeSpan.Zero) + time = TimeSpan.Zero; + + return (int)time.TotalHours > 0 + ? $"{(int)time.TotalHours}:{time.Minutes:D2}:{time.Seconds:D2}:{time.Milliseconds:D3}" + : $"{time.Minutes:D2}:{time.Seconds:D2}:{time.Milliseconds:D3}"; + } + + public string ActiveDisplayModeText + { + get + { + if (CurrentFile?.Song is null) + return $"Active: Auto ({MusicConstants.GetNoteName(0)} +0) from Song Default"; + + var song = CurrentFile.Song; + var marker = song.GetKeyMarkerAtTime(CurrentTime); + + var hasTranspose = SongSettings.IsTransposeActive + || SongSettings.KeyOffset != 0 + || (song.Transpose != null && song.Transpose != Transpose.Ignore) + || song.Key != 0 + || SongSettings.IsAutoCorrectActive; + + var songOffset = hasTranspose ? SongSettings.EffectiveKeyOffset : 0; + var songNoteName = MusicConstants.GetNoteName(songOffset); + var defaultNoteName = MusicConstants.GetNoteName(0); + var fallbackSource = hasTranspose ? "Transpose Mode" : "Song Default"; + + return SelectedDisplayMode?.Mode switch + { + PianoRollDisplayMode.SongDefault => + $"Active: Song Default ({defaultNoteName} +0) from Song Default", + + PianoRollDisplayMode.Transpose => + $"Active: Transpose ({songNoteName} {(songOffset >= 0 ? "+" : "")}{songOffset}) from {fallbackSource}", + + _ => marker != null + ? $"Active: Auto ({MusicConstants.GetNoteName(marker.KeyOffset)} {(marker.KeyOffset >= 0 ? "+" : "")}{marker.KeyOffset}) from Marker at {FormatTimestampWithMs(marker.Time)}" + : $"Active: Auto ({songNoteName} {(songOffset >= 0 ? "+" : "")}{songOffset}) from {fallbackSource}" + }; + } + } + + public string ActiveDisplayModeTooltip + { + get + { + if (CurrentFile?.Song is null) + return "Note Display Mode"; + + var song = CurrentFile.Song; + var marker = song.GetKeyMarkerAtTime(CurrentTime); + + var hasTranspose = SongSettings.IsTransposeActive + || SongSettings.KeyOffset != 0 + || (song.Transpose != null && song.Transpose != Transpose.Ignore) + || song.Key != 0 + || SongSettings.IsAutoCorrectActive; + + var songOffset = hasTranspose ? SongSettings.EffectiveKeyOffset : 0; + var songNoteName = MusicConstants.GetNoteName(songOffset); + var fallbackSource = hasTranspose ? "Transpose Mode" : "Song Default"; + + var offset = marker?.KeyOffset ?? songOffset; + var noteName = MusicConstants.GetNoteName(offset); + var isModulated = marker != null; + + return SelectedDisplayMode?.Mode switch + { + PianoRollDisplayMode.SongDefault => + "Song Default: Displaying original sequence exactly as authored in the MIDI file.", + PianoRollDisplayMode.Transpose => + $"Transpose: Displaying notes transposed by song key offset ({songNoteName} {(songOffset >= 0 ? "+" : "")}{songOffset}) and {SongSettings.TransposeMode} mode.", + _ => + $"Auto: Displaying notes dynamically according to timeline markers and transpositions.\nActive at playhead: {noteName} ({(offset >= 0 ? "+" : "")}{offset}) [{(isModulated ? $"Marker at {FormatTimestampWithMs(marker!.Time)}" : fallbackSource)}]" + }; + } + } + public string ActiveKeyAtPlayheadDisplay { get @@ -209,13 +357,20 @@ public string ActiveKeyAtPlayheadDisplay var song = CurrentFile.Song; var marker = song.GetKeyMarkerAtTime(CurrentTime); - var offset = marker?.KeyOffset ?? song.Key; + var hasTranspose = SongSettings.IsTransposeActive + || SongSettings.KeyOffset != 0 + || (song.Transpose != null && song.Transpose != Transpose.Ignore) + || song.Key != 0 + || SongSettings.IsAutoCorrectActive; + + var fallbackSource = hasTranspose ? "Transpose Mode" : "Song Default"; + var offset = marker?.KeyOffset ?? (hasTranspose ? SongSettings.EffectiveKeyOffset : 0); var noteName = MusicConstants.GetNoteName(offset); var isModulated = marker != null; return isModulated ? $"Active: {noteName} ({(offset >= 0 ? "+" : "")}{offset}) [Modulated at {marker!.TimeString}]" - : $"Active: {noteName} ({(offset >= 0 ? "+" : "")}{offset}) [Song Default]"; + : $"Active: {noteName} ({(offset >= 0 ? "+" : "")}{offset}) [{fallbackSource}]"; } } @@ -327,6 +482,46 @@ public void Handle(MidiFile message) ReloadSongData(); } + public void Handle(InstrumentViewModel message) + { + NotifyPitchUpdateRequired(); + } + + public void Handle(SettingsPageViewModel message) + { + NotifyPitchUpdateRequired(); + } + + private void HandleSongSettingsRebuildRequired() + { + NotifyPitchUpdateRequired(); + } + + private void HandleSongSettingsPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(SongService.KeyOffset) + or nameof(SongService.EffectiveKeyOffset) + or nameof(SongService.Transpose) + or nameof(SongService.TransposeMode) + or nameof(SongService.SelectedKeyOption)) + { + NotifyPitchUpdateRequired(); + } + } + + private void HandleActiveGamesChanged() + { + NotifyPitchUpdateRequired(); + } + + protected override void OnClose() + { + base.OnClose(); + SongSettings.SettingsRebuildRequired -= HandleSongSettingsRebuildRequired; + SongSettings.PropertyChanged -= HandleSongSettingsPropertyChanged; + _main.ActiveGamesChanged -= HandleActiveGamesChanged; + } + private void HandleControlsPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(PlaybackControlsService.IsPlaying)) @@ -407,6 +602,12 @@ private void HandleSongPositionChanged(object? sender, EventArgs e) public void ReloadSongData() { + if (Application.Current?.Dispatcher?.CheckAccess() == false) + { + Application.Current.Dispatcher.BeginInvoke(ReloadSongData); + return; + } + CurrentFile?.EnsureMidiLoaded(); NotifyOfPropertyChange(nameof(CurrentFile)); NotifyOfPropertyChange(nameof(HasSongOpen)); @@ -422,7 +623,10 @@ public void ReloadSongData() } SelectedMarker = null; _lastActiveKeyDisplay = string.Empty; + _lastActiveDisplayModeText = string.Empty; UpdateActiveKeyDisplay(); + NotifyPitchUpdateRequired(); + SongDataReloaded?.Invoke(this, EventArgs.Empty); } #endregion @@ -483,8 +687,7 @@ public void SelectSettingsTab() public void ScrubTo(TimeSpan time) { - Controls.SongPosition = time.TotalSeconds; - Controls.OnSongPositionChanged(); + Controls.Seek(time); CurrentTime = time; } @@ -557,7 +760,7 @@ public async Task AutoDetectKeyChangesAsync() if (detected.Count == 0) { - SnackbarService.Info("Auto-Detect", "No Key Signature events found in this MIDI file."); + SnackbarService.Info("Scan Keys", "No Key Signature events found in this MIDI file."); return; } @@ -576,7 +779,7 @@ public async Task AutoDetectKeyChangesAsync() foreach (var m in sorted) KeyChangeMarkers.Add(m); await SaveMarkersToSongAsync(); - SnackbarService.Success("Auto-Detect Complete", $"Detected and added {detected.Count} key changes."); + SnackbarService.Success("Scan Keys Complete", $"Detected and added {detected.Count} key changes."); } public async Task DeleteSelectedMarkerAsync() @@ -639,6 +842,7 @@ private async Task SaveMarkersToSongAsync() await db.SaveChangesAsync(); NotifyOfPropertyChange(nameof(ActiveKeyAtPlayheadDisplay)); + NotifyPitchUpdateRequired(); } catch (Exception ex) { @@ -648,3 +852,10 @@ private async Task SaveMarkersToSongAsync() #endregion } + +public class NoteDisplayModeOption +{ + public PianoRollDisplayMode Mode { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; +} From f4a3b19ecdbf7898f409fc86ca00cc7ca9cc95b9 Mon Sep 17 00:00:00 2001 From: Jed556 Date: Sun, 11 Oct 2026 02:02:13 +0800 Subject: [PATCH 3/5] UI updates & performance optimizations --- AutoMidiPlayer.Data/Entities/Song.cs | 43 +- AutoMidiPlayer.Data/Midi/MidiTrack.cs | 9 +- AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj | 2 +- .../MarkerActionPopup/MarkerActionPopup.xaml | 77 ++ .../MarkerActionPopup.xaml.cs | 140 +++ .../PianoRoll/HorizontalPianoRoll.xaml | 22 + .../PianoRoll/HorizontalPianoRoll.xaml.cs | 234 ++++- .../InteractivePianoRollControl.xaml | 6 + .../InteractivePianoRollControl.xaml.cs | 98 +- .../PianoRoll/MidiTimelineScrubber.xaml | 13 + .../PianoRoll/MidiTimelineScrubber.xaml.cs | 181 +++- .../Controls/PianoRoll/PianoRollCanvas.cs | 922 +++++++++++------- .../PianoRoll/PianoRollHoverOverlay.cs | 210 ++++ .../Controls/PianoRoll/TimelineCanvas.cs | 134 ++- .../AudioSettingsControl.xaml | 18 +- AutoMidiPlayer.WPF/Core/Games/GameRegistry.cs | 46 +- AutoMidiPlayer.WPF/Core/Keyboard.cs | 42 +- AutoMidiPlayer.WPF/Core/KeyboardPlayer.cs | 98 +- AutoMidiPlayer.WPF/Core/WindowHelper.cs | 81 +- .../EditMarkerDialogView.xaml | 51 + .../EditMarkerDialogView.xaml.cs | 32 + .../Services/GarbageManService.cs | 11 +- .../Services/PlaybackControlsService.cs | 3 +- .../Services/PlaybackService.cs | 166 +++- AutoMidiPlayer.WPF/Services/SongService.cs | 168 +++- .../Views/Queue/QueueViewModel.cs | 12 +- .../Views/Studio/StudioSidebarControl.xaml | 98 +- .../Views/Studio/StudioSidebarControl.xaml.cs | 208 ++++ .../Views/Studio/StudioView.xaml | 308 +++--- .../Views/Studio/StudioView.xaml.cs | 155 +++ .../Views/Studio/StudioViewModel.cs | 136 ++- 31 files changed, 3009 insertions(+), 715 deletions(-) create mode 100644 AutoMidiPlayer.WPF/Controls/MarkerActionPopup/MarkerActionPopup.xaml create mode 100644 AutoMidiPlayer.WPF/Controls/MarkerActionPopup/MarkerActionPopup.xaml.cs create mode 100644 AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollHoverOverlay.cs create mode 100644 AutoMidiPlayer.WPF/Dialogs/EditMarkerDialog/EditMarkerDialogView.xaml create mode 100644 AutoMidiPlayer.WPF/Dialogs/EditMarkerDialog/EditMarkerDialogView.xaml.cs diff --git a/AutoMidiPlayer.Data/Entities/Song.cs b/AutoMidiPlayer.Data/Entities/Song.cs index 127c109..b80ddcc 100644 --- a/AutoMidiPlayer.Data/Entities/Song.cs +++ b/AutoMidiPlayer.Data/Entities/Song.cs @@ -81,7 +81,7 @@ public Song(string path, int key) public uint? ChordDetectionMilliseconds { get; set; } private string? _keyChanges; - private List? _cachedKeyChangeMarkers; + private volatile List? _cachedKeyChangeMarkers; /// /// JSON-serialized list of timed key modulation markers. @@ -103,22 +103,51 @@ public List KeyChangeMarkers { if (_cachedKeyChangeMarkers != null) return _cachedKeyChangeMarkers; if (string.IsNullOrWhiteSpace(KeyChanges)) return _cachedKeyChangeMarkers = new(); - try { return _cachedKeyChangeMarkers = JsonSerializer.Deserialize>(KeyChanges) ?? new(); } + try + { + var list = JsonSerializer.Deserialize>(KeyChanges) ?? new(); + list.Sort((a, b) => a.TimeMs.CompareTo(b.TimeMs)); + return _cachedKeyChangeMarkers = list; + } catch { return _cachedKeyChangeMarkers = new(); } } set { + if (value != null) + value.Sort((a, b) => a.TimeMs.CompareTo(b.TimeMs)); _cachedKeyChangeMarkers = value; - KeyChanges = value is { Count: > 0 } ? JsonSerializer.Serialize(value) : null; + _keyChanges = value is { Count: > 0 } ? JsonSerializer.Serialize(value) : null; } } - public KeyChangeMarker? GetKeyMarkerAtTime(TimeSpan time, long lookaheadMs = 35) + public KeyChangeMarker? GetKeyMarkerAtTime(TimeSpan time, long lookaheadMs = 0) { var markers = KeyChangeMarkers; - if (markers.Count == 0) return null; - var ms = (long)time.TotalMilliseconds + lookaheadMs; - return markers.Where(m => m.TimeMs <= ms).MaxBy(m => m.TimeMs); + var count = markers.Count; + if (count == 0) return null; + + var ms = (long)Math.Round(time.TotalMilliseconds) + lookaheadMs; + + // Binary search for the latest marker with TimeMs <= ms + int low = 0; + int high = count - 1; + int bestIndex = -1; + + while (low <= high) + { + int mid = low + ((high - low) >> 1); + if (markers[mid].TimeMs <= ms) + { + bestIndex = mid; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + + return bestIndex >= 0 ? markers[bestIndex] : null; } public int GetEffectiveKeyOffsetAtTime(TimeSpan time) diff --git a/AutoMidiPlayer.Data/Midi/MidiTrack.cs b/AutoMidiPlayer.Data/Midi/MidiTrack.cs index bc9c193..cf135bb 100644 --- a/AutoMidiPlayer.Data/Midi/MidiTrack.cs +++ b/AutoMidiPlayer.Data/Midi/MidiTrack.cs @@ -352,8 +352,11 @@ public void TriggerGlow() IsActive = true; // Create timer to turn off glow (DispatcherTimer runs on UI thread) - _glowTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(GlowDurationMs) }; - _glowTimer.Tick += GlowTimer_Tick; + if (_glowTimer is null) + { + _glowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(GlowDurationMs) }; + _glowTimer.Tick += GlowTimer_Tick; + } _glowTimer.Start(); } catch (Exception ex) @@ -367,8 +370,6 @@ private void GlowTimer_Tick(object? sender, EventArgs e) try { _glowTimer?.Stop(); - if (_glowTimer is not null) - _glowTimer.Tick -= GlowTimer_Tick; IsActive = false; } catch (Exception ex) diff --git a/AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj b/AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj index 393237f..42acb3e 100644 --- a/AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj +++ b/AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj @@ -6,7 +6,7 @@ true AutoMidiPlayer.WPF.App app.manifest - 7.14.0 + 7.14.1 Resources\logo.ico enable https://github.com/Jed556/AutoMidiPlayer diff --git a/AutoMidiPlayer.WPF/Controls/MarkerActionPopup/MarkerActionPopup.xaml b/AutoMidiPlayer.WPF/Controls/MarkerActionPopup/MarkerActionPopup.xaml new file mode 100644 index 0000000..003f904 --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/MarkerActionPopup/MarkerActionPopup.xaml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AutoMidiPlayer.WPF/Controls/MarkerActionPopup/MarkerActionPopup.xaml.cs b/AutoMidiPlayer.WPF/Controls/MarkerActionPopup/MarkerActionPopup.xaml.cs new file mode 100644 index 0000000..17f72c0 --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/MarkerActionPopup/MarkerActionPopup.xaml.cs @@ -0,0 +1,140 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media.Animation; +using AutoMidiPlayer.Data.Entities; + +namespace AutoMidiPlayer.WPF.Controls; + +public partial class MarkerActionPopup : UserControl +{ + private double _translateYStart = 8; + + public KeyChangeMarker? CurrentMarker { get; private set; } + + public event EventHandler? DeleteRequested; + public event EventHandler? EditRequested; + + public MarkerActionPopup() + { + InitializeComponent(); + Opacity = 0; + } + + public bool IsMarkerSelected + { + get => EditButton.ColorMode == ButtonColorMode.Accent; + set + { + EditButton.ColorMode = value ? ButtonColorMode.Accent : ButtonColorMode.Default; + EditButton.ToolTip = value ? "Deselect marker" : "Edit marker"; + } + } + + public void SetMarker(KeyChangeMarker marker, bool isAbove, bool isSelected = false) + { + CurrentMarker = marker; + MarkerTitleText.Text = !string.IsNullOrWhiteSpace(marker.Label) ? marker.Label : marker.NoteDisplay; + MarkerTimeText.Text = marker.TimeString; + IsMarkerSelected = isSelected; + + if (isAbove) + { + RenderTransformOrigin = new Point(0.5, 1.0); + OuterBorder.Padding = new Thickness(0, 0, 0, 6); + _translateYStart = 8; + } + else + { + RenderTransformOrigin = new Point(0.5, 0.0); + OuterBorder.Padding = new Thickness(0, 6, 0, 0); + _translateYStart = -8; + } + } + + public void PlayEntranceAnimation() + { + Opacity = 0; + EntranceScale.ScaleX = 0.85; + EntranceScale.ScaleY = 0.85; + EntranceTranslate.Y = _translateYStart; + + var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(220)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + FillBehavior = FillBehavior.HoldEnd + }; + fadeIn.Completed += (_, _) => Opacity = 1; + + var scaleIn = new DoubleAnimation(0.85, 1, TimeSpan.FromMilliseconds(220)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + FillBehavior = FillBehavior.HoldEnd + }; + scaleIn.Completed += (_, _) => + { + EntranceScale.ScaleX = 1; + EntranceScale.ScaleY = 1; + }; + + var translateIn = new DoubleAnimation(_translateYStart, 0, TimeSpan.FromMilliseconds(220)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + FillBehavior = FillBehavior.HoldEnd + }; + translateIn.Completed += (_, _) => EntranceTranslate.Y = 0; + + BeginAnimation(OpacityProperty, fadeIn, HandoffBehavior.SnapshotAndReplace); + EntranceScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, scaleIn, HandoffBehavior.SnapshotAndReplace); + EntranceScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleYProperty, scaleIn, HandoffBehavior.SnapshotAndReplace); + EntranceTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, translateIn, HandoffBehavior.SnapshotAndReplace); + } + + public void PlayExitAnimation(Action onComplete) + { + var fadeOut = new DoubleAnimation(Opacity, 0, TimeSpan.FromMilliseconds(150)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }, + FillBehavior = FillBehavior.HoldEnd + }; + fadeOut.Completed += (_, _) => + { + Opacity = 0; + onComplete?.Invoke(); + }; + + var scaleOut = new DoubleAnimation(EntranceScale.ScaleX, 0.85, TimeSpan.FromMilliseconds(150)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }, + FillBehavior = FillBehavior.HoldEnd + }; + + var translateOut = new DoubleAnimation(EntranceTranslate.Y, _translateYStart, TimeSpan.FromMilliseconds(150)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }, + FillBehavior = FillBehavior.HoldEnd + }; + + BeginAnimation(OpacityProperty, fadeOut, HandoffBehavior.SnapshotAndReplace); + EntranceScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, scaleOut, HandoffBehavior.SnapshotAndReplace); + EntranceScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleYProperty, scaleOut, HandoffBehavior.SnapshotAndReplace); + EntranceTranslate.BeginAnimation(System.Windows.Media.TranslateTransform.YProperty, translateOut, HandoffBehavior.SnapshotAndReplace); + } + + private void OnDeleteClick(object sender, RoutedEventArgs e) + { + if (CurrentMarker != null) + { + DeleteRequested?.Invoke(this, CurrentMarker); + } + } + + private void OnEditClick(object sender, RoutedEventArgs e) + { + if (CurrentMarker != null) + { + IsMarkerSelected = !IsMarkerSelected; + EditRequested?.Invoke(this, CurrentMarker); + } + } +} diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml index 7d36dcf..a658001 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:AutoMidiPlayer.WPF.Controls.PianoRoll" + xmlns:controls="clr-namespace:AutoMidiPlayer.WPF.Controls" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" mc:Ignorable="d" d:DesignHeight="400" d:DesignWidth="800" @@ -21,6 +22,27 @@ VerticalAlignment="Stretch" ClipToBounds="True" /> + + + + + + + + ? ScrubRequested; public event EventHandler? MarkerClicked; + public event EventHandler? MarkerDeleteRequested; + public event EventHandler? MarkerEditRequested; public event EventHandler? ZoomAdjustRequested; public event EventHandler? HoverTimeChanged; public event EventHandler? ViewTimeChanged; + private readonly System.Windows.Threading.DispatcherTimer _markerHideTimer = new() + { + Interval = TimeSpan.FromMilliseconds(250) + }; + private bool _isMarkerHovered; + private bool _isPopupHovered; + private KeyChangeMarker? _activeHoverMarker; + public static readonly DependencyProperty DisplayModeProperty = DependencyProperty.Register( nameof(DisplayMode), @@ -73,6 +83,32 @@ public int PitchRevision set => SetValue(PitchRevisionProperty, value); } + public static readonly DependencyProperty IsPlayingProperty = + DependencyProperty.Register( + nameof(IsPlaying), + typeof(bool), + typeof(HorizontalPianoRoll), + new PropertyMetadata(false, (d, e) => + { + if (d is HorizontalPianoRoll roll) + { + if (roll.Canvas != null) + { + roll.Canvas.IsPlaying = (bool)e.NewValue; + } + if ((bool)e.NewValue) + { + roll.HideMarkerPopupImmediate(); + } + } + })); + + public bool IsPlaying + { + get => (bool)GetValue(IsPlayingProperty); + set => SetValue(IsPlayingProperty, value); + } + public static readonly DependencyProperty IsSyncEnabledProperty = DependencyProperty.Register( nameof(IsSyncEnabled), @@ -172,7 +208,15 @@ public MidiFile? MidiFile nameof(CurrentTime), typeof(TimeSpan), typeof(HorizontalPianoRoll), - new PropertyMetadata(TimeSpan.Zero, (d, e) => ((HorizontalPianoRoll)d).Canvas.CurrentTime = (TimeSpan)e.NewValue)); + new PropertyMetadata(TimeSpan.Zero, (d, e) => + { + var roll = (HorizontalPianoRoll)d; + if (roll.Canvas != null) + { + roll.Canvas.CurrentTime = (TimeSpan)e.NewValue; + roll.UpdateMarkerPopupPositionDuringPlayback(); + } + })); public TimeSpan CurrentTime { @@ -199,6 +243,29 @@ public IEnumerable? KeyChangeMarke set => SetValue(KeyChangeMarkersProperty, value); } + public static readonly DependencyProperty SelectedMarkerProperty = + DependencyProperty.Register( + nameof(SelectedMarker), + typeof(KeyChangeMarker), + typeof(HorizontalPianoRoll), + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedMarkerChanged)); + + private static void OnSelectedMarkerChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var roll = (HorizontalPianoRoll)d; + var selected = (KeyChangeMarker?)e.NewValue; + if (roll.MarkerPopup?.IsOpen == true && roll._activeHoverMarker != null) + { + roll.MarkerPopupContent.IsMarkerSelected = (roll._activeHoverMarker == selected); + } + } + + public KeyChangeMarker? SelectedMarker + { + get => (KeyChangeMarker?)GetValue(SelectedMarkerProperty); + set => SetValue(SelectedMarkerProperty, value); + } + public static readonly DependencyProperty ZoomLevelProperty = DependencyProperty.Register( nameof(ZoomLevel), @@ -236,9 +303,15 @@ public double KeyHeight public HorizontalPianoRoll() { InitializeComponent(); + _markerHideTimer.Tick += OnMarkerHideTimerTick; Canvas.ScrubRequested += (s, time) => ScrubRequested?.Invoke(this, time); Canvas.MarkerClicked += (s, marker) => MarkerClicked?.Invoke(this, marker); + Canvas.MarkerHoverChanged += OnCanvasMarkerHoverChanged; Canvas.HoverTimeChanged += (s, time) => HoverTimeChanged?.Invoke(this, time); + Canvas.HoverPositionChanged += (s, e) => + { + HoverOverlay?.SetHover(e.HoverX, e.HoverMs, Canvas.KeyboardWidth, Canvas.TotalDurationMs); + }; Canvas.ViewTimeChanged += (s, time) => { ViewTime = time; @@ -268,9 +341,14 @@ public HorizontalPianoRoll() Canvas.KeyHeight = KeyHeight; Canvas.InstrumentId = InstrumentId; Canvas.PitchRevision = PitchRevision; + Canvas.CurrentTime = CurrentTime; + Canvas.InvalidateVisual(); + } + if (Canvas?.HasNotes != true) + { + Canvas?.RebuildNoteIndex(); } - Canvas?.RebuildNoteIndex(); - UpdateScrollBounds(); + Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, UpdateScrollBounds); }; } @@ -305,14 +383,20 @@ private void UpdateScrollBounds() var totalHeight = Canvas.TotalContentHeight; var viewportHeight = Canvas.ActualHeight; - if (totalHeight > viewportHeight + 1.0 && viewportHeight > 0) + if (viewportHeight <= 0) + { + // Layout arrangement not yet complete; do not destroy scroll offset + return; + } + + if (totalHeight > viewportHeight + 1.0) { VerticalScrollBar.Visibility = Visibility.Visible; VerticalScrollBar.Maximum = totalHeight - viewportHeight; VerticalScrollBar.ViewportSize = viewportHeight; VerticalScrollBar.SmallChange = Canvas.KeyHeight; VerticalScrollBar.LargeChange = Canvas.KeyHeight * 4; - VerticalScrollBar.Value = Canvas.VerticalOffset; + VerticalScrollBar.Value = Math.Clamp(Canvas.VerticalOffset, 0, VerticalScrollBar.Maximum); } else { @@ -376,4 +460,144 @@ private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e) } } } + + private void OnCanvasMarkerHoverChanged(object? sender, (KeyChangeMarker Marker, double MarkerX, Point MousePos)? e) + { + if (e.HasValue) + { + ShowMarkerPopup(e.Value.Marker, e.Value.MarkerX, e.Value.MousePos.Y); + } + else + { + if (_isMarkerHovered) + { + _isMarkerHovered = false; + if (!_isPopupHovered) + { + StartMarkerHideTimer(); + } + } + } + } + + private void ShowMarkerPopup(KeyChangeMarker marker, double markerX, double mouseY) + { + if (IsPlaying || (Canvas != null && Canvas.IsPlaybackActive)) + return; + + _isMarkerHovered = true; + _markerHideTimer.Stop(); + + if (MarkerPopup == null || MarkerPopupContent == null || Canvas == null) + return; + + if (_activeHoverMarker != marker) + { + _activeHoverMarker = marker; + MarkerPopupContent.SetMarker(marker, isAbove: false, isSelected: marker == SelectedMarker); + } + else + { + MarkerPopupContent.IsMarkerSelected = (marker == SelectedMarker); + } + + const double popupWidth = 84.0; + var clampedX = Math.Clamp(markerX - (popupWidth / 2.0), Canvas.KeyboardWidth, Math.Max(Canvas.KeyboardWidth, Canvas.ActualWidth - popupWidth)); + var anchorY = mouseY <= 50 ? 22.0 : Math.Min(mouseY + 4, Math.Max(22.0, Canvas.ActualHeight - 70)); + + MarkerPopup.HorizontalOffset = clampedX; + MarkerPopup.VerticalOffset = anchorY; + + if (!MarkerPopup.IsOpen) + { + MarkerPopup.IsOpen = true; + MarkerPopupContent.PlayEntranceAnimation(); + } + } + + public void UpdateMarkerPopupPositionDuringPlayback() + { + if (MarkerPopup?.IsOpen == true && (IsPlaying || (Canvas != null && Canvas.IsPlaybackActive))) + { + HideMarkerPopupImmediate(); + return; + } + + if (MarkerPopup?.IsOpen != true || _activeHoverMarker == null || _isPopupHovered || Canvas == null) + return; + + var pixelsPerMs = 0.15 * ZoomLevel; + var effectiveViewTimeMs = (!IsSyncEnabled && ViewTime.HasValue) + ? (long)ViewTime.Value.TotalMilliseconds + : (long)CurrentTime.TotalMilliseconds; + + var markerX = Canvas.KeyboardWidth + (_activeHoverMarker.TimeMs - effectiveViewTimeMs) * pixelsPerMs; + if (markerX < Canvas.KeyboardWidth || markerX > Canvas.ActualWidth) + { + HideMarkerPopupImmediate(); + } + else + { + var popupWidth = MarkerPopupContent.ActualWidth > 0 ? MarkerPopupContent.ActualWidth : 80; + var clampedX = Math.Clamp(markerX - (popupWidth / 2), Canvas.KeyboardWidth, Math.Max(Canvas.KeyboardWidth, Canvas.ActualWidth - popupWidth)); + MarkerPopup.HorizontalOffset = clampedX; + } + } + + private void StartMarkerHideTimer() + { + _markerHideTimer.Stop(); + _markerHideTimer.Start(); + } + + private void OnMarkerHideTimerTick(object? sender, EventArgs e) + { + _markerHideTimer.Stop(); + if (!_isMarkerHovered && !_isPopupHovered && MarkerPopup.IsOpen) + { + MarkerPopupContent.PlayExitAnimation(() => + { + MarkerPopup.IsOpen = false; + _activeHoverMarker = null; + }); + } + } + + private void HideMarkerPopupImmediate() + { + _markerHideTimer.Stop(); + _isMarkerHovered = false; + _isPopupHovered = false; + _activeHoverMarker = null; + if (MarkerPopup != null) + { + MarkerPopup.IsOpen = false; + } + } + + private void OnMarkerPopupMouseEnter(object sender, MouseEventArgs e) + { + _isPopupHovered = true; + _markerHideTimer.Stop(); + } + + private void OnMarkerPopupMouseLeave(object sender, MouseEventArgs e) + { + _isPopupHovered = false; + if (!_isMarkerHovered) + { + StartMarkerHideTimer(); + } + } + + private void OnMarkerPopupDeleteRequested(object? sender, KeyChangeMarker marker) + { + HideMarkerPopupImmediate(); + MarkerDeleteRequested?.Invoke(this, marker); + } + + private void OnMarkerPopupEditRequested(object? sender, KeyChangeMarker marker) + { + MarkerEditRequested?.Invoke(this, marker); + } } diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml index 0fae026..3aa95f3 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml @@ -25,6 +25,7 @@ MidiFile="{Binding MidiFile, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}}" CurrentTime="{Binding CurrentTime, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}}" KeyChangeMarkers="{Binding KeyChangeMarkers, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}}" + SelectedMarker="{Binding SelectedMarker, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}, Mode=TwoWay}" DisplayMode="{Binding DisplayMode, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}}" InstrumentId="{Binding InstrumentId, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}}" PitchRevision="{Binding PitchRevision, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}}" @@ -32,8 +33,11 @@ KeyHeight="{Binding KeyHeight, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}, Mode=TwoWay}" IsSyncEnabled="{Binding IsSyncEnabled, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}, Mode=TwoWay}" ViewTime="{Binding ViewTime, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}, Mode=TwoWay}" + IsPlaying="{Binding IsPlaying, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}}" ScrubRequested="OnPianoRollScrubRequested" MarkerClicked="OnPianoRollMarkerClicked" + MarkerDeleteRequested="OnPianoRollMarkerDeleteRequested" + MarkerEditRequested="OnPianoRollMarkerEditRequested" ZoomAdjustRequested="OnPianoRollZoomAdjustRequested" HoverTimeChanged="OnPianoRollHoverTimeChanged" ViewTimeChanged="OnPianoRollViewTimeChanged" /> @@ -58,6 +62,8 @@ ViewTime="{Binding ViewTime, RelativeSource={RelativeSource AncestorType=local:InteractivePianoRollControl}}" ScrubRequested="OnTimelineScrubRequested" MarkerClicked="OnTimelineMarkerClicked" + MarkerDeleteRequested="OnTimelineMarkerDeleteRequested" + MarkerEditRequested="OnTimelineMarkerEditRequested" ZoomAdjustRequested="OnTimelineZoomAdjustRequested" ZoomResetRequested="OnTimelineZoomResetRequested" KeyHeightResetRequested="OnTimelineKeyHeightResetRequested" diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs index 0671e33..1ef9003 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs @@ -14,6 +14,8 @@ public partial class InteractivePianoRollControl : UserControl { public event EventHandler? ScrubRequested; public event EventHandler? MarkerClicked; + public event EventHandler? MarkerDeleteRequested; + public event EventHandler? MarkerEditRequested; public static readonly DependencyProperty MidiFileProperty = DependencyProperty.Register( @@ -30,9 +32,10 @@ private static void OnMidiFileChanged(DependencyObject d, DependencyPropertyChan file?.EnsureMidiLoaded(); if (ctrl.PianoRoll != null) ctrl.PianoRoll.MidiFile = file; if (ctrl.Timeline != null) ctrl.Timeline.MidiFile = file; + ctrl._hasFittedKeyHeightForCurrentFile = false; ctrl.RefreshNotesAndTimeline(); ctrl.UpdateTimelineViewport(); - ctrl.FitKeyHeightToViewport(); + ctrl.FitKeyHeightToViewport(force: true); } } @@ -105,6 +108,26 @@ public int PitchRevision set => SetValue(PitchRevisionProperty, value); } + public static readonly DependencyProperty IsPlayingProperty = + DependencyProperty.Register( + nameof(IsPlaying), + typeof(bool), + typeof(InteractivePianoRollControl), + new FrameworkPropertyMetadata(false, (d, e) => + { + if (d is InteractivePianoRollControl ctrl) + { + var isPlaying = (bool)e.NewValue; + if (ctrl.PianoRoll != null) ctrl.PianoRoll.IsPlaying = isPlaying; + } + })); + + public bool IsPlaying + { + get => (bool)GetValue(IsPlayingProperty); + set => SetValue(IsPlayingProperty, value); + } + public static readonly DependencyProperty MidiTracksProperty = DependencyProperty.Register( nameof(MidiTracks), @@ -139,7 +162,17 @@ public MidiFile? MidiFile nameof(CurrentTime), typeof(TimeSpan), typeof(InteractivePianoRollControl), - new PropertyMetadata(TimeSpan.Zero)); + new FrameworkPropertyMetadata(TimeSpan.Zero, OnCurrentTimeChanged)); + + private static void OnCurrentTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is InteractivePianoRollControl ctrl) + { + var time = (TimeSpan)e.NewValue; + if (ctrl.PianoRoll != null) ctrl.PianoRoll.CurrentTime = time; + if (ctrl.Timeline != null) ctrl.Timeline.CurrentTime = time; + } + } public TimeSpan CurrentTime { @@ -304,11 +337,26 @@ public InteractivePianoRollControl() { var file = MidiFile; file?.EnsureMidiLoaded(); - if (PianoRoll != null && PianoRoll.MidiFile != file) PianoRoll.MidiFile = file; - if (Timeline != null && Timeline.MidiFile != file) Timeline.MidiFile = file; + var fileChanged = false; + if (PianoRoll != null && PianoRoll.MidiFile != file) + { + PianoRoll.MidiFile = file; + fileChanged = true; + _hasFittedKeyHeightForCurrentFile = false; + } + if (Timeline != null && Timeline.MidiFile != file) + { + Timeline.MidiFile = file; + fileChanged = true; + } + if (PianoRoll != null) PianoRoll.CurrentTime = CurrentTime; + if (Timeline != null) Timeline.CurrentTime = CurrentTime; UpdateTimelineViewport(); - RefreshNotesAndTimeline(); - FitKeyHeightToViewport(); + if (fileChanged || PianoRoll?.Canvas?.HasNotes != true) + { + RefreshNotesAndTimeline(); + FitKeyHeightToViewport(force: true); + } }; SizeChanged += (_, _) => UpdateTimelineViewport(); PianoRoll.SizeChanged += (_, _) => @@ -316,7 +364,7 @@ public InteractivePianoRollControl() UpdateTimelineViewport(); if (_pendingFitOnLayout && PianoRoll.Canvas?.ActualHeight > 0) { - FitKeyHeightToViewport(); + FitKeyHeightToViewport(force: true); } }; if (PianoRoll.Canvas != null) @@ -325,7 +373,7 @@ public InteractivePianoRollControl() { if (_pendingFitOnLayout && PianoRoll.Canvas.ActualHeight > 0) { - FitKeyHeightToViewport(); + FitKeyHeightToViewport(force: true); } }; } @@ -370,8 +418,19 @@ private void OnTimelineMarkerClicked(object? sender, KeyChangeMarker marker) MarkerClicked?.Invoke(this, marker); } + private void OnTimelineMarkerDeleteRequested(object? sender, KeyChangeMarker marker) + { + MarkerDeleteRequested?.Invoke(this, marker); + } + + private void OnTimelineMarkerEditRequested(object? sender, KeyChangeMarker marker) + { + MarkerEditRequested?.Invoke(this, marker); + } + private void OnPianoRollScrubRequested(object? sender, TimeSpan time) { + SelectedMarker = null; ScrubRequested?.Invoke(this, time); } @@ -381,6 +440,16 @@ private void OnPianoRollMarkerClicked(object? sender, KeyChangeMarker marker) MarkerClicked?.Invoke(this, marker); } + private void OnPianoRollMarkerDeleteRequested(object? sender, KeyChangeMarker marker) + { + MarkerDeleteRequested?.Invoke(this, marker); + } + + private void OnPianoRollMarkerEditRequested(object? sender, KeyChangeMarker marker) + { + MarkerEditRequested?.Invoke(this, marker); + } + private void OnPianoRollHoverTimeChanged(object? sender, TimeSpan? time) { if (Timeline != null) @@ -506,17 +575,18 @@ public void StepKeyHeight(bool increase) public void ResetKeyHeight() { - FitKeyHeightToViewport(); + FitKeyHeightToViewport(force: true); } private void OnTimelineKeyHeightResetRequested(object? sender, EventArgs e) { - FitKeyHeightToViewport(); + FitKeyHeightToViewport(force: true); } private bool _pendingFitOnLayout; + private bool _hasFittedKeyHeightForCurrentFile; - public void FitKeyHeightToViewport() + public void FitKeyHeightToViewport(bool force = false) { if (MidiFile == null || PianoRoll?.Canvas == null) { @@ -524,6 +594,11 @@ public void FitKeyHeightToViewport() return; } + if (!force && _hasFittedKeyHeightForCurrentFile) + { + return; + } + var canvas = PianoRoll.Canvas; var pitchCount = canvas.PitchCount; var viewportHeight = canvas.ActualHeight; @@ -540,6 +615,7 @@ public void FitKeyHeightToViewport() var target = Math.Clamp(viewportHeight / pitchCount, 6.0, 50.0); KeyHeight = target; canvas.VerticalOffset = 0.0; + _hasFittedKeyHeightForCurrentFile = true; } private void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e) diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml index 292f557..2533550 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml @@ -35,6 +35,19 @@ VerticalAlignment="Stretch" /> + + + + + diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs index c433370..068bd11 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs @@ -14,11 +14,22 @@ public partial class MidiTimelineScrubber : UserControl public event EventHandler? ScrubRequested; public event EventHandler? MarkerClicked; + public event EventHandler? MarkerDeleteRequested; + public event EventHandler? MarkerEditRequested; public event EventHandler? ZoomAdjustRequested; public event EventHandler? ZoomResetRequested; public event EventHandler? HoverTimeChanged; public event EventHandler? SyncChanged; + private readonly System.Windows.Threading.DispatcherTimer _markerHideTimer = new() + { + Interval = TimeSpan.FromMilliseconds(250) + }; + private bool _isMarkerHovered; + private bool _isPopupHovered; + private KeyChangeMarker? _activeHoverMarker; + private double? _lastReportedScrubberHoverMs; + public static readonly DependencyProperty DisplayModeProperty = DependencyProperty.Register( nameof(DisplayMode), @@ -222,7 +233,18 @@ public IEnumerable? KeyChangeMarkers nameof(SelectedMarker), typeof(KeyChangeMarker), typeof(MidiTimelineScrubber), - new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, (d, e) => ((MidiTimelineScrubber)d).Canvas.SelectedMarker = (KeyChangeMarker)e.NewValue)); + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedMarkerChanged)); + + private static void OnSelectedMarkerChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var scrubber = (MidiTimelineScrubber)d; + var selected = (KeyChangeMarker?)e.NewValue; + scrubber.Canvas.SelectedMarker = selected; + if (scrubber.MarkerPopup?.IsOpen == true && scrubber._activeHoverMarker != null) + { + scrubber.MarkerPopupContent.IsMarkerSelected = (scrubber._activeHoverMarker == selected); + } + } public KeyChangeMarker? SelectedMarker { @@ -314,6 +336,7 @@ private static void OnZoomLevelChanged(DependencyObject d, DependencyPropertyCha public MidiTimelineScrubber() { InitializeComponent(); + _markerHideTimer.Tick += OnMarkerHideTimerTick; ScrubberArea.LostMouseCapture += OnScrubberLostMouseCapture; ZoomText.Text = "100%"; UpdateKeyHeightText(KeyHeight); @@ -329,6 +352,7 @@ public MidiTimelineScrubber() { Canvas.InstrumentId = InstrumentId; Canvas.PitchRevision = PitchRevision; + Canvas.CurrentTime = CurrentTime; } Canvas?.RebuildMinimap(); }; @@ -349,6 +373,8 @@ private void OnScrubberLostMouseCapture(object sender, MouseEventArgs e) private void OnScrubberMouseDown(object sender, MouseButtonEventArgs e) { + e.Handled = true; + HideMarkerPopupImmediate(); var pos = e.GetPosition(Canvas); // Check if user clicked on a marker @@ -363,6 +389,7 @@ private void OnScrubberMouseDown(object sender, MouseButtonEventArgs e) } // Otherwise start scrubbing (drag preview until mouse up) + SelectedMarker = null; _isDraggingScrubber = true; ScrubberArea.CaptureMouse(); UpdateScrubberPreview(pos.X); @@ -370,9 +397,11 @@ private void OnScrubberMouseDown(object sender, MouseButtonEventArgs e) private void OnScrubberMouseMove(object sender, MouseEventArgs e) { + e.Handled = true; var pos = e.GetPosition(Canvas); if (_isDraggingScrubber && ScrubberArea.IsMouseCaptured) { + HideMarkerPopupImmediate(); UpdateScrubberPreview(pos.X); } @@ -382,26 +411,160 @@ private void OnScrubberMouseMove(object sender, MouseEventArgs e) var ratio = Math.Clamp(pos.X / width, 0.0, 1.0); var totalMs = Canvas.TotalDurationMs; var targetMs = ratio * totalMs; - var time = TimeSpan.FromMilliseconds(targetMs); - Canvas.HoverTime = time; - HoverTimeChanged?.Invoke(this, time); + + if (!_lastReportedScrubberHoverMs.HasValue || Math.Abs(targetMs - _lastReportedScrubberHoverMs.Value) >= 10) + { + _lastReportedScrubberHoverMs = targetMs; + var time = TimeSpan.FromMilliseconds(targetMs); + Canvas.HoverTime = time; + HoverTimeChanged?.Invoke(this, time); + } + + if (!_isDraggingScrubber) + { + var hitMarker = Canvas.HitTestMarker(pos); + if (hitMarker != null) + { + var markerX = (hitMarker.TimeMs / (double)totalMs) * width; + ShowMarkerPopup(hitMarker, markerX); + } + else if (_isMarkerHovered) + { + _isMarkerHovered = false; + if (!_isPopupHovered) + { + StartMarkerHideTimer(); + } + } + } } else { - if (Canvas != null) Canvas.HoverTime = null; - HoverTimeChanged?.Invoke(this, null); + if (_lastReportedScrubberHoverMs.HasValue) + { + _lastReportedScrubberHoverMs = null; + if (Canvas != null) Canvas.HoverTime = null; + HoverTimeChanged?.Invoke(this, null); + } + + if (_isMarkerHovered) + { + _isMarkerHovered = false; + if (!_isPopupHovered) + { + StartMarkerHideTimer(); + } + } } } private void OnScrubberMouseLeave(object sender, MouseEventArgs e) { + e.Handled = true; if (!_isDraggingScrubber) { - if (Canvas != null) Canvas.HoverTime = null; - HoverTimeChanged?.Invoke(this, null); + if (_lastReportedScrubberHoverMs.HasValue) + { + _lastReportedScrubberHoverMs = null; + if (Canvas != null) Canvas.HoverTime = null; + HoverTimeChanged?.Invoke(this, null); + } + + _isMarkerHovered = false; + if (!_isPopupHovered) + { + StartMarkerHideTimer(); + } + } + } + + private void ShowMarkerPopup(KeyChangeMarker marker, double markerX) + { + _isMarkerHovered = true; + _markerHideTimer.Stop(); + + if (MarkerPopup == null || MarkerPopupContent == null || Canvas == null) + return; + + if (_activeHoverMarker != marker) + { + _activeHoverMarker = marker; + MarkerPopupContent.SetMarker(marker, isAbove: true, isSelected: marker == SelectedMarker); + } + else + { + MarkerPopupContent.IsMarkerSelected = (marker == SelectedMarker); + } + + const double popupWidth = 84.0; + var clampedX = Math.Clamp(markerX - (popupWidth / 2.0), 0, Math.Max(0, Canvas.ActualWidth - popupWidth)); + MarkerPopup.HorizontalOffset = clampedX; + MarkerPopup.VerticalOffset = 4; + + if (!MarkerPopup.IsOpen) + { + MarkerPopup.IsOpen = true; + MarkerPopupContent.PlayEntranceAnimation(); + } + } + + private void StartMarkerHideTimer() + { + _markerHideTimer.Stop(); + _markerHideTimer.Start(); + } + + private void OnMarkerHideTimerTick(object? sender, EventArgs e) + { + _markerHideTimer.Stop(); + if (!_isMarkerHovered && !_isPopupHovered && MarkerPopup.IsOpen) + { + MarkerPopupContent.PlayExitAnimation(() => + { + MarkerPopup.IsOpen = false; + _activeHoverMarker = null; + }); + } + } + + private void HideMarkerPopupImmediate() + { + _markerHideTimer.Stop(); + _isMarkerHovered = false; + _isPopupHovered = false; + _activeHoverMarker = null; + if (MarkerPopup != null) + { + MarkerPopup.IsOpen = false; + } + } + + private void OnMarkerPopupMouseEnter(object sender, MouseEventArgs e) + { + _isPopupHovered = true; + _markerHideTimer.Stop(); + } + + private void OnMarkerPopupMouseLeave(object sender, MouseEventArgs e) + { + _isPopupHovered = false; + if (!_isMarkerHovered) + { + StartMarkerHideTimer(); } } + private void OnMarkerPopupDeleteRequested(object? sender, KeyChangeMarker marker) + { + HideMarkerPopupImmediate(); + MarkerDeleteRequested?.Invoke(this, marker); + } + + private void OnMarkerPopupEditRequested(object? sender, KeyChangeMarker marker) + { + MarkerEditRequested?.Invoke(this, marker); + } + private void OnScrubberMouseUp(object sender, MouseButtonEventArgs e) { if (_isDraggingScrubber) @@ -416,11 +579,13 @@ private void OnScrubberMouseUp(object sender, MouseButtonEventArgs e) { if (pos.X < 0 || pos.X > Canvas.ActualWidth || pos.Y < 0 || pos.Y > Canvas.ActualHeight) { + _lastReportedScrubberHoverMs = null; Canvas.HoverTime = null; HoverTimeChanged?.Invoke(this, null); } } } + e.Handled = true; } private void UpdateScrubberPreview(double mouseX) diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs index 50ef9fb..4c58cfe 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs @@ -16,7 +16,6 @@ using Melanchall.DryWetMidi.Core; using Melanchall.DryWetMidi.Interaction; using MidiFile = AutoMidiPlayer.Data.Midi.MidiFile; -using SkiaSharp; namespace AutoMidiPlayer.WPF.Controls.PianoRoll; @@ -26,60 +25,52 @@ public class PianoRollCanvas : FrameworkElement public event EventHandler? ScrubRequested; public event EventHandler? MarkerClicked; + public event EventHandler<(KeyChangeMarker Marker, double MarkerX, Point MousePos)?>? MarkerHoverChanged; + public event EventHandler<(double? HoverX, long HoverMs)>? HoverPositionChanged; private static readonly HashSet BlackKeys = [1, 3, 6, 8, 10]; private static readonly string[] NoteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + private static readonly string[] MidiPitchLabels = new string[128]; // Frozen brushes and pens for 60+ FPS zero-allocation rendering private static readonly SolidColorBrush[] TrackBrushes; - private static readonly SKColor[] TrackSkColors; - private static readonly SKColor[] DisabledTrackSkColors; + private static readonly SolidColorBrush[] DisabledTrackBrushes; private static readonly SolidColorBrush WhiteKeyBrush; private static readonly SolidColorBrush BlackKeyBrush; private static readonly SolidColorBrush BlackKeyExtensionBrush; - private static readonly SolidColorBrush WhiteLaneBrush; - private static readonly SolidColorBrush BlackLaneBrush; private static readonly SolidColorBrush WhiteKeyTextBrush; private static readonly SolidColorBrush BlackKeyTextBrush; - private static readonly Pen GridLinePen; private static readonly Pen KeyBorderPen; private static readonly Typeface KeyFontTypeface; private static readonly Pen MarkerLinePen; private static readonly SolidColorBrush MarkerBadgeBrush; private static readonly Pen MarkerBadgePen; private static readonly Typeface MarkerTypeface; - private static readonly Pen HoverLinePen; - private static readonly SolidColorBrush HoverPillBrush; - private static readonly Pen HoverPillPen; - private static readonly SolidColorBrush HoverTextBrush; - private static readonly Typeface HoverTypeface; - private static readonly Pen PlayheadLinePen; - private static readonly SolidColorBrush PlayheadMarkerBrush; private static readonly Geometry PlayheadMarkerGeometry; static PianoRollCanvas() { + for (var p = 0; p < 128; p++) + { + var pitchClass = p % 12; + var octave = (p / 12) - 1; + MidiPitchLabels[p] = $"{NoteNames[pitchClass]}{octave}"; + } + TrackBrushes = TrackColorPalette.AllBrushes; - TrackSkColors = new SKColor[TrackBrushes.Length]; - DisabledTrackSkColors = new SKColor[TrackBrushes.Length]; + DisabledTrackBrushes = new SolidColorBrush[TrackBrushes.Length]; for (var i = 0; i < TrackBrushes.Length; i++) { var c = TrackBrushes[i].Color; - TrackSkColors[i] = new SKColor(c.R, c.G, c.B, c.A); - DisabledTrackSkColors[i] = new SKColor(c.R, c.G, c.B, (byte)(c.A * 0.28)); + DisabledTrackBrushes[i] = CreateFrozenBrush(Color.FromArgb((byte)(c.A * 0.28), c.R, c.G, c.B)); } WhiteKeyBrush = CreateFrozenBrush(Color.FromArgb(220, 240, 240, 240)); BlackKeyBrush = CreateFrozenBrush(Color.FromArgb(255, 26, 26, 30)); BlackKeyExtensionBrush = CreateFrozenBrush(Color.FromArgb(255, 42, 42, 48)); - WhiteLaneBrush = CreateFrozenBrush(Color.FromArgb(12, 255, 255, 255)); - BlackLaneBrush = CreateFrozenBrush(Color.FromArgb(24, 0, 0, 0)); WhiteKeyTextBrush = CreateFrozenBrush(Color.FromArgb(190, 20, 20, 20)); BlackKeyTextBrush = CreateFrozenBrush(Color.FromArgb(190, 215, 215, 220)); - GridLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(20, 255, 255, 255)), 1); - GridLinePen.Freeze(); - KeyBorderPen = new Pen(CreateFrozenBrush(Color.FromArgb(40, 128, 128, 128)), 1); KeyBorderPen.Freeze(); @@ -97,20 +88,6 @@ static PianoRollCanvas() MarkerTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); - HoverLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(120, 255, 255, 255)), 1.2); - HoverLinePen.Freeze(); - - HoverPillBrush = CreateFrozenBrush(Color.FromArgb(220, 20, 20, 24)); - HoverPillPen = new Pen(CreateFrozenBrush(Color.FromArgb(80, 255, 255, 255)), 1); - HoverPillPen.Freeze(); - - HoverTextBrush = CreateFrozenBrush(Color.FromArgb(235, 255, 255, 255)); - HoverTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Medium, FontStretches.Normal); - - PlayheadMarkerBrush = CreateFrozenBrush(Color.FromRgb(255, 255, 255)); - PlayheadLinePen = new Pen(PlayheadMarkerBrush, 1.8); - PlayheadLinePen.Freeze(); - var markerGeo = new StreamGeometry(); using (var ctx = markerGeo.Open()) { @@ -137,16 +114,22 @@ private static SolidColorBrush CreateFrozenBrush(Color color) private bool _hasDragged; // Zero-allocation batched note rendering buffers per track brush - private readonly List[] _trackRectsSharp; - private readonly List[] _trackRectsRound; - private readonly double[] _lastPitchRight = new double[128]; - private readonly int[] _lastPitchTrack = new int[128]; + private readonly List[] _trackRects; + private readonly double[] _lastPitchStart = new double[TrackColorPalette.AllBrushes.Length * 128]; + private readonly double[] _lastPitchEnd = new double[TrackColorPalette.AllBrushes.Length * 128]; + private readonly TranslateTransform _lanesTransform = new(); + private readonly TranslateTransform _keyboardTransform = new(); + private readonly TranslateTransform _playheadTransform = new(); private readonly Dictionary _cachedMarkerLabels = new(); - private FormattedText? _cachedHoverLabel; - private string? _cachedHoverText; - private double _cachedHoverDpi = -1; + private long _lastRenderTimestamp; + private long _lastCurrentTimeChangeTimestamp; + private long? _lastReportedHoverMs; + private KeyChangeMarker? _lastHitMarker; private double _cachedDpi = -1; + public bool IsPlaybackActive => + System.Diagnostics.Stopwatch.GetElapsedTime(_lastCurrentTimeChangeTimestamp).TotalMilliseconds < 150; + private RollNote[] _notes = Array.Empty(); private long _maxNoteLengthMs = 0; private int _minPitch = 36; @@ -163,38 +146,27 @@ private static SolidColorBrush CreateFrozenBrush(Color color) private double _lastLanesPitchHeight = -1; private int _lastLanesMinPitch = -1; private int _lastLanesMaxPitch = -1; - private RectangleGeometry? _cachedLanesClip; - private double _lastLanesClipW = -1; - private double _lastLanesClipH = -1; private DrawingGroup? _cachedKeyboard; private double _lastKeyboardPitchHeight = -1; private int _lastKeyboardMinPitch = -1; private int _lastKeyboardMaxPitch = -1; private double _lastKeyboardWidth = -1; - private RectangleGeometry? _cachedKeyboardClip; - private double _lastKeyboardClipH = -1; - - private BitmapSource? _cachedNoteBitmap; - private long _cachedNoteViewTimeMs; - private double _cachedNoteZoom = -1; - private double _cachedNotePitchHeight = -1; - private double _cachedNoteVerticalOffset = -1; - private double _cachedNoteWidth = -1; - private double _cachedNoteHeight = -1; - private const double NoteBitmapPaddingPx = 256; - private const double NoteBitmapRebuildDistancePx = NoteBitmapPaddingPx * 0.75; - // Keep the expensive note raster surface bounded while retaining the - // control's logical size for scrolling, clipping, and input coordinates. - private const double NoteBitmapMaxPixelWidth = 1920; - private const double NoteBitmapMaxPixelHeight = 1080; private double _lastReportedMaxScroll = -1; private bool _lastReportedIsOverflowing; private SolidColorBrush _activeKeyBrush = null!; + private SolidColorBrush _disabledActiveKeyBrush = null!; private SolidColorBrush _activeKeyTextBrush = null!; + private SolidColorBrush _playheadMarkerBrush = null!; + private Pen _playheadLinePen = null!; + private Pen _gridLinePen = null!; + private SolidColorBrush _whiteLaneBrush = null!; + private SolidColorBrush _blackLaneBrush = null!; private Color _lastAccentColor; + private bool _lastIsDark; + private bool _themeInitialized; private int _trackCount = 1; private readonly HashSet _disabledTrackIndices = new(); @@ -212,19 +184,19 @@ private bool IsTrackDisabled(int trackIndex) public PianoRollCanvas() { - _trackRectsSharp = new List[TrackBrushes.Length]; - _trackRectsRound = new List[TrackBrushes.Length]; + _trackRects = new List[TrackBrushes.Length]; for (var i = 0; i < TrackBrushes.Length; i++) { - _trackRectsSharp[i] = new List(256); - _trackRectsRound[i] = new List(256); + _trackRects[i] = new List(1024); } - EnsureAccentResources(); + EnsureThemeResources(); Loaded += (_, _) => { AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; UpdateTrackSubscriptions(); + EnsureThemeResources(); + InvalidateVisual(); }; Unloaded += (_, _) => { @@ -237,24 +209,64 @@ private void OnThemeResourcesChanged() { Dispatcher.BeginInvoke(() => { - EnsureAccentResources(); + EnsureThemeResources(); InvalidateVisual(); }); } - private void EnsureAccentResources() + private void EnsureThemeResources() { var accent = AutoMidiPlayer.WPF.Core.AccentColorHelper.GetAccentColor(); - if (_activeKeyBrush != null && accent == _lastAccentColor) + var theme = Wpf.Ui.Appearance.ApplicationThemeManager.GetAppTheme(); + var isDark = theme switch + { + Wpf.Ui.Appearance.ApplicationTheme.Dark => true, + Wpf.Ui.Appearance.ApplicationTheme.Light => false, + _ => AutoMidiPlayer.WPF.Services.SystemThemeService.GetSystemTheme() == Wpf.Ui.Appearance.ApplicationTheme.Dark + }; + + if (_themeInitialized && accent == _lastAccentColor && isDark == _lastIsDark) return; _lastAccentColor = accent; + _lastIsDark = isDark; + _themeInitialized = true; + _activeKeyBrush = CreateFrozenBrush(accent); + _disabledActiveKeyBrush = CreateFrozenBrush(Color.FromArgb((byte)(accent.A * 0.28), accent.R, accent.G, accent.B)); var lum = (0.299 * accent.R + 0.587 * accent.G + 0.114 * accent.B) / 255.0; _activeKeyTextBrush = CreateFrozenBrush(lum > 0.5 ? Color.FromRgb(18, 18, 20) : Colors.White); + if (isDark) + { + // Dark Mode: White / Light shades + _playheadMarkerBrush = CreateFrozenBrush(Color.FromRgb(255, 255, 255)); + _playheadLinePen = new Pen(_playheadMarkerBrush, 1.8); + _playheadLinePen.Freeze(); + + _gridLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(20, 255, 255, 255)), 1); + _gridLinePen.Freeze(); + _whiteLaneBrush = CreateFrozenBrush(Color.FromArgb(12, 255, 255, 255)); + _blackLaneBrush = CreateFrozenBrush(Color.FromArgb(24, 0, 0, 0)); + } + else + { + // Light Mode: Black / Dark shades + _playheadMarkerBrush = CreateFrozenBrush(Color.FromRgb(24, 24, 28)); + _playheadLinePen = new Pen(_playheadMarkerBrush, 1.8); + _playheadLinePen.Freeze(); + + _gridLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(20, 0, 0, 0)), 1); + _gridLinePen.Freeze(); + _whiteLaneBrush = CreateFrozenBrush(Color.FromArgb(8, 0, 0, 0)); + _blackLaneBrush = CreateFrozenBrush(Color.FromArgb(18, 0, 0, 0)); + } + _cachedLanes = null; + _cachedKeyboard = null; + Array.Clear(_cachedLabelsNormal, 0, 128); Array.Clear(_cachedLabelsActive, 0, 128); + _cachedMarkerLabels.Clear(); InvalidateNoteBitmap(); } @@ -370,7 +382,42 @@ public MidiFile? MidiFile nameof(CurrentTime), typeof(TimeSpan), typeof(PianoRollCanvas), - new FrameworkPropertyMetadata(TimeSpan.Zero, FrameworkPropertyMetadataOptions.AffectsRender)); + new FrameworkPropertyMetadata(TimeSpan.Zero, FrameworkPropertyMetadataOptions.AffectsRender, OnCurrentTimePropertyChanged)); + + private static void OnCurrentTimePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas._lastCurrentTimeChangeTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + if (canvas.IsPlaybackActive || canvas.IsPlaying) + { + if (canvas._lastHitMarker != null) + { + canvas._lastHitMarker = null; + canvas.MarkerHoverChanged?.Invoke(canvas, null); + } + } + + if (canvas._hoverX.HasValue) + { + var pixelsPerMs = 0.15 * canvas.ZoomLevel; + var currentTimeMs = (long)canvas.CurrentTime.TotalMilliseconds; + var effectiveViewTimeMs = (!canvas.IsSyncEnabled && canvas.ViewTime.HasValue) + ? (long)canvas.ViewTime.Value.TotalMilliseconds + : currentTimeMs; + var hoverMs = effectiveViewTimeMs + (long)((canvas._hoverX.Value - canvas.KeyboardWidth) / pixelsPerMs); + var totalMs = canvas.TotalDurationMs; + if (totalMs > 0) hoverMs = Math.Clamp(hoverMs, 0, totalMs); + else hoverMs = Math.Max(0, hoverMs); + + canvas.HoverPositionChanged?.Invoke(canvas, (canvas._hoverX.Value, hoverMs)); + } + else if (canvas.HoverTime.HasValue) + { + canvas.UpdateHoverFromExternal(); + } + } + } public TimeSpan CurrentTime { @@ -460,6 +507,7 @@ public double VerticalOffset public int MinPitch => _minPitch; public int MaxPitch => _maxPitch; public int PitchCount => Math.Max(1, _maxPitch - _minPitch + 1); + public bool HasNotes => _notes.Length > 0; public double TotalContentHeight => PitchCount * KeyHeight; public double MaxVerticalOffset => Math.Max(0.0, TotalContentHeight - ActualHeight); public bool IsOverflowing => TotalContentHeight > ActualHeight + 1.0 && ActualHeight > 0; @@ -543,6 +591,29 @@ public int PitchRevision set => SetValue(PitchRevisionProperty, value); } + public static readonly DependencyProperty IsPlayingProperty = + DependencyProperty.Register( + nameof(IsPlaying), + typeof(bool), + typeof(PianoRollCanvas), + new PropertyMetadata(false, (d, e) => + { + if (d is PianoRollCanvas canvas && (bool)e.NewValue) + { + if (canvas._lastHitMarker != null) + { + canvas._lastHitMarker = null; + canvas.MarkerHoverChanged?.Invoke(canvas, null); + } + } + })); + + public bool IsPlaying + { + get => (bool)GetValue(IsPlayingProperty); + set => SetValue(IsPlayingProperty, value); + } + public static readonly DependencyProperty KeyChangeMarkersProperty = DependencyProperty.Register( nameof(KeyChangeMarkers), @@ -576,6 +647,34 @@ private void OnMarkersCollectionChanged(object? sender, System.Collections.Speci { UpdateNotePitches(); } + InvalidateVisual(); + } + + public (KeyChangeMarker marker, double markerX)? HitTestMarker(Point point) + { + var markers = KeyChangeMarkers; + if (markers is null || ActualWidth <= 0) + return null; + + var pixelsPerMs = 0.15 * ZoomLevel; + var currentTimeMs = (long)CurrentTime.TotalMilliseconds; + var effectiveViewTimeMs = (!IsSyncEnabled && ViewTime.HasValue) + ? (long)ViewTime.Value.TotalMilliseconds + : currentTimeMs; + + foreach (var marker in markers) + { + var markerX = KeyboardWidth + (marker.TimeMs - effectiveViewTimeMs) * pixelsPerMs; + if (markerX >= KeyboardWidth && markerX <= ActualWidth) + { + if (Math.Abs(point.X - markerX) <= 12) + { + return (marker, markerX); + } + } + } + + return null; } public IEnumerable? KeyChangeMarkers @@ -591,7 +690,44 @@ public IEnumerable? KeyChangeMarke nameof(HoverTime), typeof(TimeSpan?), typeof(PianoRollCanvas), - new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); + new PropertyMetadata(null, OnHoverTimePropertyChanged)); + + private static void OnHoverTimePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is PianoRollCanvas canvas) + { + canvas.UpdateHoverFromExternal(); + } + } + + private void UpdateHoverFromExternal() + { + if (_hoverX.HasValue) + return; + + if (HoverTime.HasValue) + { + var targetMs = (long)HoverTime.Value.TotalMilliseconds; + var pixelsPerMs = 0.15 * ZoomLevel; + var currentTimeMs = (long)CurrentTime.TotalMilliseconds; + var effectiveViewTimeMs = (!IsSyncEnabled && ViewTime.HasValue) + ? (long)ViewTime.Value.TotalMilliseconds + : currentTimeMs; + var targetX = KeyboardWidth + (targetMs - effectiveViewTimeMs) * pixelsPerMs; + if (targetX >= KeyboardWidth && targetX <= ActualWidth) + { + HoverPositionChanged?.Invoke(this, (targetX, targetMs)); + } + else + { + HoverPositionChanged?.Invoke(this, (null, 0)); + } + } + else + { + HoverPositionChanged?.Invoke(this, (null, 0)); + } + } public TimeSpan? HoverTime { @@ -736,9 +872,20 @@ public void RebuildNoteIndex() } } - _notes = noteList.OrderBy(n => n.StartMs).ToArray(); - _maxNoteLengthMs = _notes.Length > 0 ? _notes.Max(n => n.LengthMs) : 0; - _trackCount = _notes.Length > 0 ? _notes.Select(n => n.TrackIndex).Distinct().Count() : 1; + var notesArray = noteList.ToArray(); + Array.Sort(notesArray, static (a, b) => a.StartMs.CompareTo(b.StartMs)); + _notes = notesArray; + + long maxLen = 0; + var uniqueTracks = new HashSet(); + for (var i = 0; i < notesArray.Length; i++) + { + if (notesArray[i].LengthMs > maxLen) + maxLen = notesArray[i].LengthMs; + uniqueTracks.Add(notesArray[i].TrackIndex); + } + _maxNoteLengthMs = maxLen; + _trackCount = Math.Max(1, uniqueTracks.Count); UpdateNotePitchesInternal(); } @@ -849,10 +996,10 @@ private DrawingGroup BuildStaticLanes(double width, double pitchHeight) { var y = (_maxPitch - p) * pitchHeight; var isBlack = BlackKeys.Contains(p % 12); - var laneBrush = isBlack ? BlackLaneBrush : WhiteLaneBrush; + var laneBrush = isBlack ? _blackLaneBrush : _whiteLaneBrush; dc.DrawRectangle(laneBrush, null, new Rect(KeyboardWidth, y, laneWidth, pitchHeight)); - dc.DrawLine(GridLinePen, new Point(KeyboardWidth, y), new Point(width, y)); + dc.DrawLine(_gridLinePen, new Point(KeyboardWidth, y), new Point(width, y)); } } group.Freeze(); @@ -873,8 +1020,7 @@ private DrawingGroup BuildStaticKeyboard(double pitchHeight, double dpi) var y = (_maxPitch - p) * pitchHeight; var pitchClass = p % 12; var isBlack = BlackKeys.Contains(pitchClass); - var octave = (p / 12) - 1; - var label = $"{NoteNames[pitchClass]}{octave}"; + var label = (p >= 0 && p < 128) ? MidiPitchLabels[p] : $"{NoteNames[pitchClass]}{(p / 12) - 1}"; if (isBlack) { @@ -967,8 +1113,8 @@ protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) protected override void OnRender(DrawingContext dc) { base.OnRender(dc); - - EnsureAccentResources(); + EnsureThemeResources(); + _lastRenderTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); var width = ActualWidth; var height = ActualHeight; @@ -980,10 +1126,6 @@ protected override void OnRender(DrawingContext dc) var totalHeight = pitchCount * pitchHeight; var maxScroll = Math.Max(0.0, totalHeight - height); var verticalOffset = Math.Clamp(VerticalOffset, 0.0, maxScroll); - if (Math.Abs(verticalOffset - VerticalOffset) > 0.001) - { - VerticalOffset = verticalOffset; - } if (Math.Abs(maxScroll - _lastReportedMaxScroll) > 0.5 || (totalHeight > height) != _lastReportedIsOverflowing) { @@ -1023,18 +1165,10 @@ protected override void OnRender(DrawingContext dc) _lastLanesMinPitch = _minPitch; _lastLanesMaxPitch = _maxPitch; } - if (_cachedLanesClip == null || Math.Abs(_lastLanesClipW - width) > 0.5 || Math.Abs(_lastLanesClipH - height) > 0.5) - { - _cachedLanesClip = new RectangleGeometry(new Rect(KeyboardWidth, 0, Math.Max(0, width - KeyboardWidth), height)); - _cachedLanesClip.Freeze(); - _lastLanesClipW = width; - _lastLanesClipH = height; - } - dc.PushClip(_cachedLanesClip); - dc.PushTransform(new TranslateTransform(0, -verticalOffset)); + _lanesTransform.Y = -verticalOffset; + dc.PushTransform(_lanesTransform); dc.DrawDrawing(_cachedLanes); dc.Pop(); - dc.Pop(); // 2. Draw vertical time grid lines (every 1 second) var msStep = 1000L; @@ -1050,44 +1184,282 @@ protected override void OnRender(DrawingContext dc) var lineX = KeyboardWidth + (gridMs - effectiveViewTimeMs) * pixelsPerMs; if (lineX >= KeyboardWidth && lineX <= width) { - dc.DrawLine(GridLinePen, new Point(lineX, 0), new Point(lineX, height)); + dc.DrawLine(_gridLinePen, new Point(lineX, 0), new Point(lineX, height)); } } // Reset active keys Array.Clear(_activeKeys, 0, _activeKeys.Length); - // 3. Raster-cached note rendering and lightweight active-key detection + // 3. Direct hardware-accelerated zero-allocation note rendering and active-key detection if (_notes.Length > 0) { - var visibleEndMs = effectiveViewTimeMs + visibleHorizonMs + 500; + var visibleEndMs = effectiveViewTimeMs + (long)(rollWidth / pixelsPerMs) + 500; var startIndex = FindFirstPossibleNoteIndex(effectiveViewTimeMs); + var activeStartIndex = FindFirstPossibleNoteIndex(currentTimeMs); - for (var i = startIndex; i < _notes.Length; i++) + // Detect active keys at playhead + for (var i = activeStartIndex; i < _notes.Length; i++) { var note = _notes[i]; - if (note.StartMs > visibleEndMs) + if (note.StartMs > currentTimeMs) break; - var noteEndMs = note.StartMs + note.LengthMs; - if (noteEndMs <= effectiveViewTimeMs) - continue; - - if (note.StartMs <= currentTimeMs && currentTimeMs <= noteEndMs) + if (currentTimeMs <= note.StartMs + note.LengthMs) { - if (!IsTrackDisabled(note.TrackIndex) && note.NoteNumber >= 0 && note.NoteNumber < _activeKeys.Length) + if (!IsTrackDisabled(note.TrackIndex) && (uint)note.NoteNumber < (uint)_activeKeys.Length) _activeKeys[note.NoteNumber] = true; } } - EnsureNoteBitmap(effectiveViewTimeMs, width, height, pitchHeight, verticalOffset, pixelsPerMs, dpi); - if (_cachedNoteBitmap != null) + var radius = Math.Min(6.0, Math.Max(2.5, pitchHeight * 0.45)); + Array.Fill(_lastPitchStart, -1.0); + Array.Fill(_lastPitchEnd, -1.0); + + if (_disabledTrackIndices.Count > 0) { - var bitmapOffsetX = KeyboardWidth - (effectiveViewTimeMs - _cachedNoteViewTimeMs) * pixelsPerMs; - var bitmapOffsetY = verticalOffset - _cachedNoteVerticalOffset; - dc.PushClip(_cachedLanesClip!); - dc.DrawImage(_cachedNoteBitmap, new Rect(bitmapOffsetX, bitmapOffsetY, _cachedNoteWidth, _cachedNoteHeight)); - dc.Pop(); + // Pass 1: Disabled notes first (at the back with lower opacity) + for (var i = startIndex; i < _notes.Length; i++) + { + var note = _notes[i]; + if (note.StartMs > visibleEndMs) + break; + + if (!IsTrackDisabled(note.TrackIndex)) + continue; + + var noteEndMs = note.StartMs + note.LengthMs; + if (noteEndMs <= effectiveViewTimeMs) + continue; + + var noteX = KeyboardWidth + (note.StartMs - effectiveViewTimeMs) * pixelsPerMs; + var noteW = Math.Max(3.0, note.LengthMs * pixelsPerMs); + if (noteX < KeyboardWidth) + { + noteW -= (KeyboardWidth - noteX); + noteX = KeyboardWidth; + } + + if (noteW <= 0.5 || noteX > width) + continue; + + if (noteX + noteW > width) + { + noteW = width - noteX; + if (noteW <= 0.5) continue; + } + + var noteY = (_maxPitch - note.NoteNumber) * pitchHeight - verticalOffset; + var noteH = Math.Max(2, pitchHeight - 1); + if (noteY + noteH < 0 || noteY > height) + continue; + + if (noteY < 0) + { + noteH += noteY; + noteY = 0; + if (noteH <= 0.5) continue; + } + + if (noteY + noteH > height) + { + noteH = height - noteY; + if (noteH <= 0.5) continue; + } + + var trackIdx = Math.Abs(note.TrackIndex) % TrackBrushes.Length; + var pitch = Math.Clamp(note.NoteNumber, 0, 127); + var trackerKey = (trackIdx << 7) | pitch; + + var lastStart = _lastPitchStart[trackerKey]; + var lastEnd = _lastPitchEnd[trackerKey]; + if (Math.Abs(noteX - lastStart) < 0.75 && (noteX + noteW) <= lastEnd + 0.75) + continue; + + _lastPitchStart[trackerKey] = noteX; + _lastPitchEnd[trackerKey] = noteX + noteW; + + _trackRects[trackIdx].Add(new Rect(noteX, noteY + 0.5, noteW, noteH)); + } + + for (var t = 0; t < TrackBrushes.Length; t++) + { + var rects = _trackRects[t]; + var count = rects.Count; + if (count == 0) continue; + + var brush = (_trackCount == 1) ? _disabledActiveKeyBrush : DisabledTrackBrushes[t]; + for (var r = 0; r < count; r++) + { + var rect = rects[r]; + var cornerR = Math.Min(radius, Math.Min(rect.Width * 0.45, rect.Height * 0.45)); + dc.DrawRoundedRectangle(brush, null, rect, cornerR, cornerR); + } + rects.Clear(); + } + + // Reset coalescing tracker for Pass 2 (enabled notes) + Array.Fill(_lastPitchStart, -1.0); + Array.Fill(_lastPitchEnd, -1.0); + + // Pass 2: Enabled notes on top + for (var i = startIndex; i < _notes.Length; i++) + { + var note = _notes[i]; + if (note.StartMs > visibleEndMs) + break; + + if (IsTrackDisabled(note.TrackIndex)) + continue; + + var noteEndMs = note.StartMs + note.LengthMs; + if (noteEndMs <= effectiveViewTimeMs) + continue; + + var noteX = KeyboardWidth + (note.StartMs - effectiveViewTimeMs) * pixelsPerMs; + var noteW = Math.Max(3.0, note.LengthMs * pixelsPerMs); + if (noteX < KeyboardWidth) + { + noteW -= (KeyboardWidth - noteX); + noteX = KeyboardWidth; + } + + if (noteW <= 0.5 || noteX > width) + continue; + + if (noteX + noteW > width) + { + noteW = width - noteX; + if (noteW <= 0.5) continue; + } + + var noteY = (_maxPitch - note.NoteNumber) * pitchHeight - verticalOffset; + var noteH = Math.Max(2, pitchHeight - 1); + if (noteY + noteH < 0 || noteY > height) + continue; + + if (noteY < 0) + { + noteH += noteY; + noteY = 0; + if (noteH <= 0.5) continue; + } + + if (noteY + noteH > height) + { + noteH = height - noteY; + if (noteH <= 0.5) continue; + } + + var trackIdx = Math.Abs(note.TrackIndex) % TrackBrushes.Length; + var pitch = Math.Clamp(note.NoteNumber, 0, 127); + var trackerKey = (trackIdx << 7) | pitch; + + var lastStart = _lastPitchStart[trackerKey]; + var lastEnd = _lastPitchEnd[trackerKey]; + if (Math.Abs(noteX - lastStart) < 0.75 && (noteX + noteW) <= lastEnd + 0.75) + continue; + + _lastPitchStart[trackerKey] = noteX; + _lastPitchEnd[trackerKey] = noteX + noteW; + + _trackRects[trackIdx].Add(new Rect(noteX, noteY + 0.5, noteW, noteH)); + } + + for (var t = 0; t < TrackBrushes.Length; t++) + { + var rects = _trackRects[t]; + var count = rects.Count; + if (count == 0) continue; + + var brush = (_trackCount == 1) ? _activeKeyBrush : TrackBrushes[t]; + for (var r = 0; r < count; r++) + { + var rect = rects[r]; + var cornerR = Math.Min(radius, Math.Min(rect.Width * 0.45, rect.Height * 0.45)); + dc.DrawRoundedRectangle(brush, null, rect, cornerR, cornerR); + } + rects.Clear(); + } + } + else + { + // Fast path when all tracks are active + for (var i = startIndex; i < _notes.Length; i++) + { + var note = _notes[i]; + if (note.StartMs > visibleEndMs) + break; + + var noteEndMs = note.StartMs + note.LengthMs; + if (noteEndMs <= effectiveViewTimeMs) + continue; + + var noteX = KeyboardWidth + (note.StartMs - effectiveViewTimeMs) * pixelsPerMs; + var noteW = Math.Max(3.0, note.LengthMs * pixelsPerMs); + if (noteX < KeyboardWidth) + { + noteW -= (KeyboardWidth - noteX); + noteX = KeyboardWidth; + } + + if (noteW <= 0.5 || noteX > width) + continue; + + if (noteX + noteW > width) + { + noteW = width - noteX; + if (noteW <= 0.5) continue; + } + + var noteY = (_maxPitch - note.NoteNumber) * pitchHeight - verticalOffset; + var noteH = Math.Max(2, pitchHeight - 1); + if (noteY + noteH < 0 || noteY > height) + continue; + + if (noteY < 0) + { + noteH += noteY; + noteY = 0; + if (noteH <= 0.5) continue; + } + + if (noteY + noteH > height) + { + noteH = height - noteY; + if (noteH <= 0.5) continue; + } + + var trackIdx = Math.Abs(note.TrackIndex) % TrackBrushes.Length; + var pitch = Math.Clamp(note.NoteNumber, 0, 127); + var trackerKey = (trackIdx << 7) | pitch; + + var lastStart = _lastPitchStart[trackerKey]; + var lastEnd = _lastPitchEnd[trackerKey]; + if (Math.Abs(noteX - lastStart) < 0.75 && (noteX + noteW) <= lastEnd + 0.75) + continue; + + _lastPitchStart[trackerKey] = noteX; + _lastPitchEnd[trackerKey] = noteX + noteW; + + _trackRects[trackIdx].Add(new Rect(noteX, noteY + 0.5, noteW, noteH)); + } + + for (var t = 0; t < TrackBrushes.Length; t++) + { + var rects = _trackRects[t]; + var count = rects.Count; + if (count == 0) continue; + + var brush = (_trackCount == 1) ? _activeKeyBrush : TrackBrushes[t]; + for (var r = 0; r < count; r++) + { + var rect = rects[r]; + var cornerR = Math.Min(radius, Math.Min(rect.Width * 0.45, rect.Height * 0.45)); + dc.DrawRoundedRectangle(brush, null, rect, cornerR, cornerR); + } + rects.Clear(); + } } } @@ -1117,67 +1489,6 @@ protected override void OnRender(DrawingContext dc) } } - // 4.5. Draw hover guide line and timestamp pill - double? effectiveHoverX = null; - long hoverMs = 0; - - if (_hoverX.HasValue && _hoverX.Value >= KeyboardWidth && _hoverX.Value <= width) - { - effectiveHoverX = _hoverX.Value; - hoverMs = effectiveViewTimeMs + (long)((effectiveHoverX.Value - KeyboardWidth) / pixelsPerMs); - } - else if (HoverTime.HasValue) - { - var targetMs = (long)HoverTime.Value.TotalMilliseconds; - var targetX = KeyboardWidth + (targetMs - effectiveViewTimeMs) * pixelsPerMs; - if (targetX >= KeyboardWidth && targetX <= width) - { - effectiveHoverX = targetX; - hoverMs = targetMs; - } - } - - if (effectiveHoverX.HasValue) - { - var hx = effectiveHoverX.Value; - dc.DrawLine(HoverLinePen, new Point(hx, 0), new Point(hx, height)); - var totalMs = TotalDurationMs; - if (totalMs > 0) hoverMs = Math.Clamp(hoverMs, 0, totalMs); - else hoverMs = Math.Max(0, hoverMs); - - var hoverTime = TimeSpan.FromMilliseconds(hoverMs); - var timeText = hoverTime.TotalHours >= 1 - ? hoverTime.ToString(@"h\:mm\:ss\.f", CultureInfo.InvariantCulture) - : hoverTime.ToString(@"m\:ss\.f", CultureInfo.InvariantCulture); - - if (_cachedHoverLabel == null || - !string.Equals(_cachedHoverText, timeText, StringComparison.Ordinal) || - Math.Abs(_cachedHoverDpi - dpi) > 0.001) - { - _cachedHoverLabel = new FormattedText( - timeText, - CultureInfo.InvariantCulture, - FlowDirection.LeftToRight, - HoverTypeface, - 9.5, - HoverTextBrush, - dpi); - _cachedHoverText = timeText; - _cachedHoverDpi = dpi; - } - - var formattedHover = _cachedHoverLabel; - - var pillW = formattedHover.Width + 8; - var pillH = formattedHover.Height + 2; - var pillX = Math.Clamp(hx - (pillW / 2), KeyboardWidth + 2, width - pillW - 2); - var pillY = height - pillH - 3; - var pillRect = new Rect(pillX, pillY, pillW, pillH); - - dc.DrawRoundedRectangle(HoverPillBrush, HoverPillPen, pillRect, 3, 3); - dc.DrawText(formattedHover, new Point(pillRect.X + 4, pillRect.Y + 1)); - } - // 5. Draw cached stationary keyboard if (_cachedKeyboard == null || Math.Abs(_lastKeyboardPitchHeight - pitchHeight) > 0.001 || _lastKeyboardMinPitch != _minPitch || _lastKeyboardMaxPitch != _maxPitch || Math.Abs(_lastKeyboardWidth - KeyboardWidth) > 0.1) { @@ -1188,17 +1499,10 @@ protected override void OnRender(DrawingContext dc) _lastKeyboardWidth = KeyboardWidth; } - if (_cachedKeyboardClip == null || Math.Abs(_lastKeyboardClipH - height) > 0.5) - { - _cachedKeyboardClip = new RectangleGeometry(new Rect(0, 0, KeyboardWidth, height)); - _cachedKeyboardClip.Freeze(); - _lastKeyboardClipH = height; - } - dc.PushClip(_cachedKeyboardClip); - dc.PushTransform(new TranslateTransform(0, -verticalOffset)); + _keyboardTransform.Y = -verticalOffset; + dc.PushTransform(_keyboardTransform); dc.DrawDrawing(_cachedKeyboard); dc.Pop(); - dc.Pop(); // 5. Draw active key overlays on top (only for pressed keys) var fontSize = Math.Min(10, Math.Max(7.5, pitchHeight * 0.65)); @@ -1213,8 +1517,7 @@ protected override void OnRender(DrawingContext dc) var pitchClass = p % 12; var isBlack = BlackKeys.Contains(pitchClass); - var octave = (p / 12) - 1; - var label = $"{NoteNames[pitchClass]}{octave}"; + var label = (p >= 0 && p < 128) ? MidiPitchLabels[p] : $"{NoteNames[pitchClass]}{(p / 12) - 1}"; // Highlight full key with active accent dc.DrawRectangle(_activeKeyBrush, KeyBorderPen, new Rect(0, y, KeyboardWidth, pitchHeight)); @@ -1237,9 +1540,11 @@ protected override void OnRender(DrawingContext dc) var playheadX = KeyboardWidth + (currentTimeMs - effectiveViewTimeMs) * pixelsPerMs; if (playheadX >= KeyboardWidth && playheadX <= width) { - dc.DrawLine(PlayheadLinePen, new Point(playheadX, 0), new Point(playheadX, height)); - dc.PushTransform(new TranslateTransform(playheadX, 0)); - dc.DrawGeometry(PlayheadMarkerBrush, null, PlayheadMarkerGeometry); + dc.DrawLine(_playheadLinePen, new Point(playheadX, 0), new Point(playheadX, height)); + _playheadTransform.X = playheadX; + _playheadTransform.Y = 0; + dc.PushTransform(_playheadTransform); + dc.DrawGeometry(_playheadMarkerBrush, null, PlayheadMarkerGeometry); dc.Pop(); } } @@ -1247,189 +1552,8 @@ protected override void OnRender(DrawingContext dc) private void InvalidateNoteBitmap() { - _cachedNoteBitmap = null; - _cachedNoteZoom = -1; - _cachedNotePitchHeight = -1; - _cachedNoteVerticalOffset = -1; - _cachedNoteWidth = -1; - _cachedNoteHeight = -1; } - private void EnsureNoteBitmap( - long effectiveViewTimeMs, - double width, - double height, - double pitchHeight, - double verticalOffset, - double pixelsPerMs, - double dpi) - { - var rollWidth = Math.Max(1, width - KeyboardWidth); - var noteWidth = Math.Ceiling(rollWidth + NoteBitmapPaddingPx * 2); - var noteHeight = Math.Ceiling(height); - var cachedCenterTimeMs = _cachedNoteBitmap == null - ? 0 - : _cachedNoteViewTimeMs + (long)(NoteBitmapPaddingPx / pixelsPerMs); - var horizontalDrift = _cachedNoteBitmap == null - ? double.PositiveInfinity - : Math.Abs(effectiveViewTimeMs - cachedCenterTimeMs) * pixelsPerMs; - - var needsRebuild = _cachedNoteBitmap == null || - horizontalDrift > NoteBitmapRebuildDistancePx || - Math.Abs(_cachedNoteZoom - ZoomLevel) > 0.001 || - Math.Abs(_cachedNotePitchHeight - pitchHeight) > 0.001 || - Math.Abs(_cachedNoteVerticalOffset - verticalOffset) > 0.001 || - Math.Abs(_cachedNoteWidth - noteWidth) > 0.5 || - Math.Abs(_cachedNoteHeight - noteHeight) > 0.5; - - if (!needsRebuild) - return; - - var cacheStartTimeMs = effectiveViewTimeMs - (long)(NoteBitmapPaddingPx / pixelsPerMs); - var rasterScale = Math.Min( - dpi, - Math.Min( - NoteBitmapMaxPixelWidth / noteWidth, - NoteBitmapMaxPixelHeight / noteHeight)); - rasterScale = Math.Max(0.25, rasterScale); - var pixelWidth = (int)Math.Max(1, Math.Ceiling(noteWidth * rasterScale)); - var pixelHeight = (int)Math.Max(1, Math.Ceiling(noteHeight * rasterScale)); - var imageInfo = new SKImageInfo(pixelWidth, pixelHeight, SKColorType.Bgra8888, SKAlphaType.Premul); - using var surface = SKSurface.Create(imageInfo); - if (surface == null) - { - throw new InvalidOperationException("Unable to create the Skia note cache surface."); - } - - var skiaCanvas = surface.Canvas; - skiaCanvas.Clear(SKColors.Transparent); - skiaCanvas.Scale((float)rasterScale); - - using var paint = new SKPaint - { - Style = SKPaintStyle.Fill, - IsAntialias = true - }; - - var visibleEndMs = cacheStartTimeMs + (long)(noteWidth / pixelsPerMs) + 500; - var startIndex = FindFirstPossibleNoteIndex(cacheStartTimeMs); - var allowRounding = ZoomLevel >= 0.5 && pitchHeight >= 5.0; - var radius = Math.Min(6.0, Math.Max(2.5, pitchHeight * 0.45)); - - var singleTrackColor = _trackCount == 1 ? ToSkColor(_activeKeyBrush.Color) : default; - var disabledSingleTrackColor = _trackCount == 1 - ? new SKColor(_activeKeyBrush.Color.R, _activeKeyBrush.Color.G, _activeKeyBrush.Color.B, (byte)(_activeKeyBrush.Color.A * 0.28)) - : default; - - void DrawNote(RollNote note, bool isDisabled) - { - var noteEndMs = note.StartMs + note.LengthMs; - if (noteEndMs <= cacheStartTimeMs) - return; - - var noteX = (note.StartMs - cacheStartTimeMs) * pixelsPerMs; - var noteW = Math.Max(3.0, note.LengthMs * pixelsPerMs); - if (noteX < 0) - { - noteW += noteX; - noteX = 0; - } - - if (noteW <= 0.5) - return; - - var noteY = (_maxPitch - note.NoteNumber) * pitchHeight - verticalOffset; - var noteH = Math.Max(2, pitchHeight - 1); - if (noteY + noteH < 0 || noteY > height) - return; - - var trackIdx = Math.Abs(note.TrackIndex) % TrackBrushes.Length; - - paint.Color = isDisabled - ? (_trackCount == 1 ? disabledSingleTrackColor : DisabledTrackSkColors[trackIdx]) - : (_trackCount == 1 ? singleTrackColor : TrackSkColors[trackIdx]); - - var rect = new SKRect((float)noteX, (float)(noteY + 0.5), (float)(noteX + noteW), (float)(noteY + noteH + 0.5)); - - if (allowRounding && noteW >= 6.0) - { - var cornerRadius = (float)Math.Min(radius, noteW * 0.45); - skiaCanvas.DrawRoundRect(rect, cornerRadius, cornerRadius, paint); - } - else - { - skiaCanvas.DrawRect(rect, paint); - } - } - - if (_disabledTrackIndices.Count > 0) - { - // Pass 1: Draw disabled notes FIRST at the very back with low opacity - for (var i = startIndex; i < _notes.Length; i++) - { - var note = _notes[i]; - if (note.StartMs > visibleEndMs) - break; - - if (IsTrackDisabled(note.TrackIndex)) - { - DrawNote(note, isDisabled: true); - } - } - - // Pass 2: Draw enabled notes SECOND on top with normal opacity - for (var i = startIndex; i < _notes.Length; i++) - { - var note = _notes[i]; - if (note.StartMs > visibleEndMs) - break; - - if (!IsTrackDisabled(note.TrackIndex)) - { - DrawNote(note, isDisabled: false); - } - } - } - else - { - // Fast path when all tracks are enabled - for (var i = startIndex; i < _notes.Length; i++) - { - var note = _notes[i]; - if (note.StartMs > visibleEndMs) - break; - - DrawNote(note, isDisabled: false); - } - } - - surface.Flush(); - var pixelBytes = new byte[pixelWidth * pixelHeight * 4]; - var pixmap = surface.PeekPixels() ?? throw new InvalidOperationException("Skia surface pixels are unavailable."); - pixmap.GetPixelSpan().CopyTo(pixelBytes); - var bitmap = BitmapSource.Create( - pixelWidth, - pixelHeight, - 96 * rasterScale, - 96 * rasterScale, - PixelFormats.Pbgra32, - null, - pixelBytes, - pixelWidth * 4); - bitmap.Freeze(); - - _cachedNoteBitmap = bitmap; - _cachedNoteViewTimeMs = cacheStartTimeMs; - _cachedNoteZoom = ZoomLevel; - _cachedNotePitchHeight = pitchHeight; - _cachedNoteVerticalOffset = verticalOffset; - _cachedNoteWidth = noteWidth; - _cachedNoteHeight = noteHeight; - } - - private static SKColor ToSkColor(Color color) - => new(color.R, color.G, color.B, color.A); - private int FindFirstPossibleNoteIndex(long targetMs) { if (_notes.Length == 0) return 0; @@ -1470,6 +1594,7 @@ protected override HitTestResult HitTestCore(PointHitTestParameters hitTestParam protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) { base.OnMouseLeftButtonDown(e); + MarkerHoverChanged?.Invoke(this, null); var pos = e.GetPosition(this); if (pos.X >= KeyboardWidth && pos.X <= ActualWidth && ActualWidth > KeyboardWidth + 10) { @@ -1507,6 +1632,7 @@ protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) _dragAnchorTimeMs = effectiveViewTimeMs; _dragAnchorMouseX = pos.X; _hoverX = pos.X; + HoverPositionChanged?.Invoke(this, (pos.X, clickedMs)); InvalidateVisual(); e.Handled = true; @@ -1516,10 +1642,12 @@ protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); + e.Handled = true; var pos = e.GetPosition(this); if (_isDragging && IsMouseCaptured) { + MarkerHoverChanged?.Invoke(this, null); var deltaX = pos.X - _dragAnchorMouseX; var pixelsPerMs = 0.15 * ZoomLevel; @@ -1552,15 +1680,32 @@ protected override void OnMouseMove(MouseEventArgs e) } _hoverX = Math.Clamp(pos.X, KeyboardWidth, ActualWidth); + var currentEffectiveMs = (!IsSyncEnabled && ViewTime.HasValue) ? (long)ViewTime.Value.TotalMilliseconds : (long)CurrentTime.TotalMilliseconds; + var currentHoverMs = currentEffectiveMs + (long)((_hoverX.Value - KeyboardWidth) / pixelsPerMs); + HoverPositionChanged?.Invoke(this, (_hoverX.Value, currentHoverMs)); InvalidateVisual(); - e.Handled = true; return; } if (pos.X >= KeyboardWidth && pos.X <= ActualWidth) { _hoverX = pos.X; - Cursor = Cursors.Hand; + if (Cursor != Cursors.Hand) Cursor = Cursors.Hand; + + var hitMarker = HitTestMarker(pos); + if (hitMarker?.marker != _lastHitMarker) + { + _lastHitMarker = hitMarker?.marker; + if (hitMarker.HasValue) + { + MarkerHoverChanged?.Invoke(this, (hitMarker.Value.marker, hitMarker.Value.markerX, pos)); + } + else + { + MarkerHoverChanged?.Invoke(this, null); + } + } + var pixelsPerMs = 0.15 * ZoomLevel; var effectiveViewTimeMs = (!IsSyncEnabled && ViewTime.HasValue) ? (long)ViewTime.Value.TotalMilliseconds @@ -1569,17 +1714,29 @@ protected override void OnMouseMove(MouseEventArgs e) var totalMs = TotalDurationMs; if (totalMs > 0) hoverMs = Math.Clamp(hoverMs, 0, totalMs); else hoverMs = Math.Max(0, hoverMs); - HoverTimeChanged?.Invoke(this, TimeSpan.FromMilliseconds(hoverMs)); - InvalidateVisual(); + + if (!_lastReportedHoverMs.HasValue || Math.Abs(hoverMs - _lastReportedHoverMs.Value) >= 10) + { + _lastReportedHoverMs = hoverMs; + HoverTimeChanged?.Invoke(this, TimeSpan.FromMilliseconds(hoverMs)); + } + + HoverPositionChanged?.Invoke(this, (pos.X, hoverMs)); } else { + if (_lastHitMarker != null) + { + _lastHitMarker = null; + MarkerHoverChanged?.Invoke(this, null); + } if (_hoverX.HasValue) { _hoverX = null; - Cursor = Cursors.Arrow; + _lastReportedHoverMs = null; + if (Cursor != Cursors.Arrow) Cursor = Cursors.Arrow; HoverTimeChanged?.Invoke(this, null); - InvalidateVisual(); + HoverPositionChanged?.Invoke(this, (null, 0)); } } } @@ -1614,8 +1771,10 @@ protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) if (posUp.X < KeyboardWidth || posUp.X > ActualWidth) { _hoverX = null; - Cursor = Cursors.Arrow; + _lastReportedHoverMs = null; + if (Cursor != Cursors.Arrow) Cursor = Cursors.Arrow; HoverTimeChanged?.Invoke(this, null); + HoverPositionChanged?.Invoke(this, (null, 0)); } InvalidateVisual(); e.Handled = true; @@ -1625,14 +1784,21 @@ protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e) protected override void OnMouseLeave(MouseEventArgs e) { base.OnMouseLeave(e); + e.Handled = true; + if (_lastHitMarker != null) + { + _lastHitMarker = null; + MarkerHoverChanged?.Invoke(this, null); + } if (!_isDragging) { if (_hoverX.HasValue) { _hoverX = null; - Cursor = Cursors.Arrow; + _lastReportedHoverMs = null; + if (Cursor != Cursors.Arrow) Cursor = Cursors.Arrow; HoverTimeChanged?.Invoke(this, null); - InvalidateVisual(); + HoverPositionChanged?.Invoke(this, (null, 0)); } } } diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollHoverOverlay.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollHoverOverlay.cs new file mode 100644 index 0000000..937f26b --- /dev/null +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollHoverOverlay.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows; +using System.Windows.Media; + +namespace AutoMidiPlayer.WPF.Controls.PianoRoll; + +/// +/// A lightweight, independent overlay layer that renders only the hover guide line and timestamp pill. +/// By keeping this in a separate visual element, hover updates never trigger redraws of PianoRollCanvas +/// or its thousands of notes, ensuring 100% fluid, zero-overhead mouse interactions. +/// +public class PianoRollHoverOverlay : FrameworkElement +{ + private static readonly Typeface HoverTypeface; + + static PianoRollHoverOverlay() + { + HoverTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Medium, FontStretches.Normal); + } + + private static SolidColorBrush CreateFrozenBrush(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + + private double? _hoverX; + private long _hoverMs; + private double _keyboardWidth = 64; + private long _totalDurationMs; + private double _cachedDpi = -1; + + private SolidColorBrush _hoverPillBrush = null!; + private Pen _hoverPillPen = null!; + private SolidColorBrush _hoverTextBrush = null!; + private Pen _hoverLinePen = null!; + private bool _lastIsDark; + private bool _themeInitialized; + + private readonly Dictionary _hoverLabelCache = new(256); + private double _hoverLabelCacheDpi = -1; + + public PianoRollHoverOverlay() + { + EnsureThemeResources(); + Loaded += (_, _) => + { + AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; + EnsureThemeResources(); + InvalidateVisual(); + }; + Unloaded += (_, _) => + { + AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged; + }; + } + + private void OnThemeResourcesChanged() + { + Dispatcher.BeginInvoke(() => + { + EnsureThemeResources(); + InvalidateVisual(); + }); + } + + private void EnsureThemeResources() + { + var theme = Wpf.Ui.Appearance.ApplicationThemeManager.GetAppTheme(); + var isDark = theme switch + { + Wpf.Ui.Appearance.ApplicationTheme.Dark => true, + Wpf.Ui.Appearance.ApplicationTheme.Light => false, + _ => AutoMidiPlayer.WPF.Services.SystemThemeService.GetSystemTheme() == Wpf.Ui.Appearance.ApplicationTheme.Dark + }; + + if (_themeInitialized && isDark == _lastIsDark) + return; + + _lastIsDark = isDark; + _themeInitialized = true; + + if (isDark) + { + _hoverLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(120, 255, 255, 255)), 1.2); + _hoverLinePen.Freeze(); + + _hoverPillBrush = CreateFrozenBrush(Color.FromArgb(220, 20, 20, 24)); + _hoverPillPen = new Pen(CreateFrozenBrush(Color.FromArgb(80, 255, 255, 255)), 1); + _hoverPillPen.Freeze(); + _hoverTextBrush = CreateFrozenBrush(Color.FromArgb(235, 255, 255, 255)); + } + else + { + _hoverLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(120, 0, 0, 0)), 1.2); + _hoverLinePen.Freeze(); + + _hoverPillBrush = CreateFrozenBrush(Color.FromArgb(230, 250, 250, 252)); + _hoverPillPen = new Pen(CreateFrozenBrush(Color.FromArgb(80, 0, 0, 0)), 1); + _hoverPillPen.Freeze(); + _hoverTextBrush = CreateFrozenBrush(Color.FromArgb(235, 20, 20, 24)); + } + + _hoverLabelCache.Clear(); + } + + public void SetHover(double? hoverX, long hoverMs, double keyboardWidth, long totalDurationMs) + { + if (_hoverX == hoverX && _hoverMs == hoverMs && Math.Abs(_keyboardWidth - keyboardWidth) < 0.1 && _totalDurationMs == totalDurationMs) + return; + + _hoverX = hoverX; + _hoverMs = hoverMs; + _keyboardWidth = keyboardWidth; + _totalDurationMs = totalDurationMs; + + InvalidateVisual(); + } + + protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) + { + base.OnDpiChanged(oldDpi, newDpi); + _cachedDpi = newDpi.PixelsPerDip; + _hoverLabelCache.Clear(); + } + + protected override void OnRender(DrawingContext dc) + { + base.OnRender(dc); + + var width = ActualWidth; + var height = ActualHeight; + if (width <= _keyboardWidth + 10 || height <= 20) + return; + + if (!_hoverX.HasValue || _hoverX.Value < _keyboardWidth || _hoverX.Value > width) + return; + + EnsureThemeResources(); + + var hx = Math.Round(_hoverX.Value); + dc.DrawLine(_hoverLinePen, new Point(hx, 0), new Point(hx, height)); + + var hoverMs = _hoverMs; + if (_totalDurationMs > 0) hoverMs = Math.Clamp(hoverMs, 0, _totalDurationMs); + else hoverMs = Math.Max(0, hoverMs); + + if (_cachedDpi <= 0) + { + _cachedDpi = VisualTreeHelper.GetDpi(this).PixelsPerDip; + } + + var cachedHover = GetOrCreateHoverLabel(hoverMs, _cachedDpi); + + var pillW = Math.Round(cachedHover.Width + 8); + var pillH = Math.Round(cachedHover.Height + 2); + var pillX = Math.Round(Math.Clamp(hx - (pillW / 2), _keyboardWidth + 2, width - pillW - 2)); + var pillY = Math.Round(height - pillH - 3); + var pillRect = new Rect(pillX, pillY, pillW, pillH); + + dc.DrawRoundedRectangle(_hoverPillBrush, _hoverPillPen, pillRect, 3, 3); + dc.DrawText(cachedHover.Text, new Point(pillX + 4, pillY + 1)); + } + + private (FormattedText Text, double Width, double Height) GetOrCreateHoverLabel(long hoverMs, double dpi) + { + if (Math.Abs(_hoverLabelCacheDpi - dpi) > 0.001) + { + _hoverLabelCache.Clear(); + _hoverLabelCacheDpi = dpi; + } + + var totalSeconds = (int)Math.Max(0, hoverMs / 1000L); + if (_hoverLabelCache.TryGetValue(totalSeconds, out var cached)) + { + return cached; + } + + var minutes = totalSeconds / 60; + var seconds = totalSeconds % 60; + var hours = minutes / 60; + minutes %= 60; + + string timeText = hours > 0 + ? $"{hours}:{minutes:D2}:{seconds:D2}" + : $"{minutes}:{seconds:D2}"; + + var formatted = new FormattedText( + timeText, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + HoverTypeface, + 9.5, + _hoverTextBrush, + dpi); + + var item = (formatted, formatted.Width, formatted.Height); + + if (_hoverLabelCache.Count >= 1000) + { + _hoverLabelCache.Clear(); + } + + _hoverLabelCache[totalSeconds] = item; + return item; + } +} diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs index cf2af36..4cc0440 100644 --- a/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs +++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs @@ -25,18 +25,11 @@ public class TimelineCanvas : FrameworkElement private static readonly SolidColorBrush TrackBackgroundBrush; private static readonly SolidColorBrush[] TrackBrushes; private static readonly SolidColorBrush[] DisabledTrackBrushes; - private static readonly SolidColorBrush PlayheadBrush; - private static readonly Pen PlayheadPen; private static readonly SolidColorBrush MarkerPinBrush; private static readonly Pen MarkerPen; - private static readonly SolidColorBrush RulerTextBrush; private static readonly Typeface RulerTypeface; private static readonly Typeface MarkerTypeface; - private static readonly SolidColorBrush ViewportFillBrush; - private static readonly Pen ViewportBorderPen; - private static readonly SolidColorBrush ViewportGripBrush; private static readonly StreamGeometry PlayheadHandleGeometry; - private static readonly Pen HoverLinePen; static TimelineCanvas() { @@ -48,26 +41,14 @@ static TimelineCanvas() var c = TrackBrushes[i].Color; DisabledTrackBrushes[i] = CreateFrozenBrush(Color.FromArgb((byte)(c.A * 0.25), c.R, c.G, c.B)); } - PlayheadBrush = CreateFrozenBrush(Color.FromRgb(255, 255, 255)); - PlayheadPen = new Pen(CreateFrozenBrush(Color.FromRgb(255, 255, 255)), 2); - PlayheadPen.Freeze(); - - HoverLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(120, 255, 255, 255)), 1.2); - HoverLinePen.Freeze(); MarkerPinBrush = CreateFrozenBrush(Color.FromRgb(255, 183, 3)); // Amber MarkerPen = new Pen(MarkerPinBrush, 1.5); MarkerPen.Freeze(); - RulerTextBrush = CreateFrozenBrush(Color.FromArgb(140, 255, 255, 255)); RulerTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal); MarkerTypeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Bold, FontStretches.Normal); - ViewportFillBrush = CreateFrozenBrush(Color.FromArgb(32, 255, 255, 255)); - ViewportBorderPen = new Pen(CreateFrozenBrush(Color.FromArgb(200, 255, 255, 255)), 1.5); - ViewportBorderPen.Freeze(); - ViewportGripBrush = CreateFrozenBrush(Color.FromArgb(240, 255, 255, 255)); - PlayheadHandleGeometry = new StreamGeometry(); using (var ctx = PlayheadHandleGeometry.Open()) { @@ -87,6 +68,17 @@ private static SolidColorBrush CreateFrozenBrush(Color color) return brush; } + private SolidColorBrush _playheadBrush = null!; + private Pen _playheadPen = null!; + private Pen _hoverLinePen = null!; + private SolidColorBrush _rulerTextBrush = null!; + private SolidColorBrush _viewportFillBrush = null!; + private Pen _viewportBorderPen = null!; + private SolidColorBrush _viewportGripBrush = null!; + + private bool _lastIsDark; + private bool _themeInitialized; + private MinimapNote[] _notes = Array.Empty(); private int _minPitch = 36; private int _maxPitch = 84; @@ -112,10 +104,13 @@ private bool IsTrackDisabled(int trackIndex) public TimelineCanvas() { + EnsureThemeResources(); Loaded += (_, _) => { AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged; UpdateTrackSubscriptions(); + EnsureThemeResources(); + InvalidateVisual(); }; Unloaded += (_, _) => { @@ -126,7 +121,65 @@ public TimelineCanvas() private void OnThemeResourcesChanged() { - Dispatcher.BeginInvoke(InvalidateVisualCache); + Dispatcher.BeginInvoke(() => + { + EnsureThemeResources(); + InvalidateVisualCache(); + }); + } + + private void EnsureThemeResources() + { + var theme = Wpf.Ui.Appearance.ApplicationThemeManager.GetAppTheme(); + var isDark = theme switch + { + Wpf.Ui.Appearance.ApplicationTheme.Dark => true, + Wpf.Ui.Appearance.ApplicationTheme.Light => false, + _ => AutoMidiPlayer.WPF.Services.SystemThemeService.GetSystemTheme() == Wpf.Ui.Appearance.ApplicationTheme.Dark + }; + + if (_themeInitialized && isDark == _lastIsDark) + return; + + _lastIsDark = isDark; + _themeInitialized = true; + + if (isDark) + { + // Dark Mode: White / Light shades + _playheadBrush = CreateFrozenBrush(Color.FromRgb(255, 255, 255)); + _playheadPen = new Pen(_playheadBrush, 2.0); + _playheadPen.Freeze(); + + _hoverLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(120, 255, 255, 255)), 1.2); + _hoverLinePen.Freeze(); + + _viewportFillBrush = CreateFrozenBrush(Color.FromArgb(32, 255, 255, 255)); + _viewportBorderPen = new Pen(CreateFrozenBrush(Color.FromArgb(200, 255, 255, 255)), 1.5); + _viewportBorderPen.Freeze(); + + _viewportGripBrush = CreateFrozenBrush(Color.FromArgb(240, 255, 255, 255)); + _rulerTextBrush = CreateFrozenBrush(Color.FromArgb(140, 255, 255, 255)); + } + else + { + // Light Mode: Black / Dark shades + _playheadBrush = CreateFrozenBrush(Color.FromRgb(24, 24, 28)); + _playheadPen = new Pen(_playheadBrush, 2.0); + _playheadPen.Freeze(); + + _hoverLinePen = new Pen(CreateFrozenBrush(Color.FromArgb(120, 0, 0, 0)), 1.2); + _hoverLinePen.Freeze(); + + _viewportFillBrush = CreateFrozenBrush(Color.FromArgb(32, 0, 0, 0)); + _viewportBorderPen = new Pen(CreateFrozenBrush(Color.FromArgb(180, 0, 0, 0)), 1.5); + _viewportBorderPen.Freeze(); + + _viewportGripBrush = CreateFrozenBrush(Color.FromArgb(220, 0, 0, 0)); + _rulerTextBrush = CreateFrozenBrush(Color.FromArgb(140, 0, 0, 0)); + } + + _staticBackgroundCache = null; } public static readonly DependencyProperty MidiTracksProperty = @@ -521,15 +574,25 @@ public void RebuildMinimap() } } - _notes = list.OrderBy(n => n.StartMs).ToArray(); - _trackCount = _notes.Length > 0 ? _notes.Select(n => n.TrackIndex).Distinct().Count() : 1; - UpdateNotePitchesInternal(); + var notesArray = list.ToArray(); + Array.Sort(notesArray, static (a, b) => a.StartMs.CompareTo(b.StartMs)); + _notes = notesArray; - if (_notes.Length > 0) + long lastNote = 0; + var uniqueTracks = new HashSet(); + for (var i = 0; i < notesArray.Length; i++) + { + var end = notesArray[i].StartMs + notesArray[i].LengthMs; + if (end > lastNote) + lastNote = end; + uniqueTracks.Add(notesArray[i].TrackIndex); + } + _trackCount = Math.Max(1, uniqueTracks.Count); + if (notesArray.Length > 0) { - var lastNote = _notes.Max(n => n.StartMs + n.LengthMs); _totalDurationMs = Math.Max(_totalDurationMs, Math.Max(1L, lastNote)); } + UpdateNotePitchesInternal(); } catch (Exception ex) { @@ -601,6 +664,7 @@ private void UpdateNotePitchesInternal() protected override void OnRender(DrawingContext dc) { base.OnRender(dc); + EnsureThemeResources(); var width = ActualWidth; var height = ActualHeight; @@ -634,31 +698,31 @@ protected override void OnRender(DrawingContext dc) var boxW = Math.Max(2, xEnd - xStart); var boxRect = new Rect(xStart, 1, boxW, height - 2); - // Shaded translucent white highlight window with rounded borders - dc.DrawRoundedRectangle(ViewportFillBrush, ViewportBorderPen, boxRect, 3, 3); + // Shaded translucent highlight window with crisp accent border + dc.DrawRectangle(_viewportFillBrush, _viewportBorderPen, boxRect); // Right tactile grip line if (boxW > 14) { - dc.DrawRoundedRectangle(ViewportGripBrush, null, new Rect(xStart + boxW - 3, 4, 2, height - 8), 1, 1); + dc.DrawRectangle(_viewportGripBrush, null, new Rect(xStart + boxW - 3, 4, 2, height - 8)); } } - // Draw faint hover guide line if hovering + // Draw hover guide line if hovering if (HoverTime.HasValue && totalMs > 0) { var hx = Math.Clamp((HoverTime.Value.TotalMilliseconds / (double)totalMs) * width, 0, width); - dc.DrawLine(HoverLinePen, new Point(hx, 0), new Point(hx, height)); + dc.DrawLine(_hoverLinePen, new Point(hx, 0), new Point(hx, height)); } // Draw only the dynamic playhead (ultra-lightweight!) // Vertical playhead line - dc.DrawLine(PlayheadPen, new Point(playheadX, 0), new Point(playheadX, height)); + dc.DrawLine(_playheadPen, new Point(playheadX, 0), new Point(playheadX, height)); // Playhead handle at top _playheadTransform.X = playheadX; dc.PushTransform(_playheadTransform); - dc.DrawGeometry(PlayheadBrush, null, PlayheadHandleGeometry); + dc.DrawGeometry(_playheadBrush, null, PlayheadHandleGeometry); dc.Pop(); } @@ -668,7 +732,7 @@ private DrawingGroup BuildStaticBackground(double width, double height, long tot using (var dc = group.Open()) { // 1. Background - dc.DrawRoundedRectangle(TrackBackgroundBrush, null, new Rect(0, 0, width, height), 4, 4); + dc.DrawRectangle(TrackBackgroundBrush, null, new Rect(0, 0, width, height)); // 2. Minimap Notes if (_notes.Length > 0) @@ -764,7 +828,7 @@ void DrawTimelineNote(MinimapNote note, bool isDisabled) FlowDirection.LeftToRight, RulerTypeface, 9, - RulerTextBrush, + _rulerTextBrush, dpi); var textX = Math.Clamp(x - (formatted.Width / 2), 2, width - formatted.Width - 2); @@ -806,7 +870,7 @@ void DrawTimelineNote(MinimapNote note, bool isDisabled) var badgeH = formatted.Height + 2; var badgeRect = new Rect(x - (badgeW / 2), 1, badgeW, badgeH); - dc.DrawRoundedRectangle(brush, pen, badgeRect, 3, 3); + dc.DrawRectangle(brush, pen, badgeRect); dc.DrawText(formatted, new Point(badgeRect.X + 4, badgeRect.Y + 1)); } } diff --git a/AutoMidiPlayer.WPF/Controls/PlayerControlPanel/AudioSettingsControl/AudioSettingsControl.xaml b/AutoMidiPlayer.WPF/Controls/PlayerControlPanel/AudioSettingsControl/AudioSettingsControl.xaml index a0a68a0..64bcf54 100644 --- a/AutoMidiPlayer.WPF/Controls/PlayerControlPanel/AudioSettingsControl/AudioSettingsControl.xaml +++ b/AutoMidiPlayer.WPF/Controls/PlayerControlPanel/AudioSettingsControl/AudioSettingsControl.xaml @@ -112,22 +112,30 @@ - + - + + + Margin="1,1,0,0" + Visibility="{Binding SongSettings.IsMarkerAdjustmentActive, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"/> diff --git a/AutoMidiPlayer.WPF/Core/Games/GameRegistry.cs b/AutoMidiPlayer.WPF/Core/Games/GameRegistry.cs index 3d114f4..0d9484f 100644 --- a/AutoMidiPlayer.WPF/Core/Games/GameRegistry.cs +++ b/AutoMidiPlayer.WPF/Core/Games/GameRegistry.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Runtime.InteropServices; using AutoMidiPlayer.Data.Properties; +using AutoMidiPlayer.WPF.Services; namespace AutoMidiPlayer.WPF.Core.Games; @@ -206,10 +207,11 @@ private static HashSet GetRunningProcessesSnapshot() { var now = Stopwatch.GetTimestamp(); var nowMs = (long)(now * 1000.0 / Stopwatch.Frequency); + var ttl = (GarbageManService.IsPlaybackActive?.Invoke() == true) ? 5000 : 3000; lock (_snapshotLock) { - if (_cachedRunningProcesses is not null && (nowMs - _lastSnapshotTimestampMs) < SnapshotCacheTtlMs) + if (_cachedRunningProcesses is not null && (nowMs - _lastSnapshotTimestampMs) < ttl) { return _cachedRunningProcesses; } @@ -319,23 +321,28 @@ public static bool IsGameRunning(GameDefinition game) private static bool IsGameRunningCore(GameDefinition game, HashSet runningProcesses) { - var processNames = new HashSet(game.ProcessNames, StringComparer.OrdinalIgnoreCase); - - // Also check configured location process name - var configuredPath = game.GetLocation(); - if (!string.IsNullOrWhiteSpace(configuredPath)) + var matched = false; + foreach (var name in game.ProcessNames) { - var configuredName = Path.GetFileNameWithoutExtension(configuredPath); - if (!string.IsNullOrWhiteSpace(configuredName)) - processNames.Add(configuredName); + if (!string.IsNullOrWhiteSpace(name) && runningProcesses.Contains(name)) + { + matched = true; + break; + } } - if (processNames.Count == 0) - return false; + if (!matched) + { + var configuredPath = game.GetLocation(); + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + var configuredName = Path.GetFileNameWithoutExtension(configuredPath); + if (!string.IsNullOrWhiteSpace(configuredName) && runningProcesses.Contains(configuredName)) + matched = true; + } + } - // Check if any process name matches the running process snapshot - var isAnyProcessRunning = processNames.Any(name => runningProcesses.Contains(name)); - if (!isAnyProcessRunning) + if (!matched) return false; // If the game definition does not require window title matching, we are done @@ -343,7 +350,16 @@ private static bool IsGameRunningCore(GameDefinition game, HashSet runni return true; // For games with specific WindowNames (e.g. Roblox, HPMA launcher), verify window title - return CheckGameWindowMatches(game, processNames); + var allNames = new List(game.ProcessNames); + var confPath = game.GetLocation(); + if (!string.IsNullOrWhiteSpace(confPath)) + { + var confName = Path.GetFileNameWithoutExtension(confPath); + if (!string.IsNullOrWhiteSpace(confName)) + allNames.Add(confName); + } + + return CheckGameWindowMatches(game, allNames); } private static bool CheckGameWindowMatches(GameDefinition game, IEnumerable processNames) diff --git a/AutoMidiPlayer.WPF/Core/Keyboard.cs b/AutoMidiPlayer.WPF/Core/Keyboard.cs index 66335fa..8477e79 100644 --- a/AutoMidiPlayer.WPF/Core/Keyboard.cs +++ b/AutoMidiPlayer.WPF/Core/Keyboard.cs @@ -525,23 +525,51 @@ public static bool TryGetChordPadKeyStroke( ); } + private static readonly Dictionary> _overriddenPedalKeyCache = new(); + private static VirtualKeyCode? GetOverriddenPedalKey(string layoutName, string jsonDict, VirtualKeyCode? defaultKey) { if (string.IsNullOrWhiteSpace(jsonDict)) return defaultKey; - try + + if (!_overriddenPedalKeyCache.TryGetValue(jsonDict, out var dict)) { - var dict = System.Text.Json.JsonSerializer.Deserialize>(jsonDict); - if (dict != null && dict.TryGetValue(layoutName, out var overrideKey)) + try + { + dict = System.Text.Json.JsonSerializer.Deserialize>(jsonDict); + } + catch { - // If it exists in the dictionary, it overrides the default (even if it's explicitly set to null/unassigned) - return overrideKey; + dict = null; } + _overriddenPedalKeyCache[jsonDict] = dict ?? new(); + } + + if (dict != null && dict.TryGetValue(layoutName, out var overrideKey)) + { + // If it exists in the dictionary, it overrides the default (even if it's explicitly set to null/unassigned) + return overrideKey; } - catch { /* Ignore invalid JSON */ } - + return defaultKey; } + private static readonly Dictionary> _instrumentNoteSets = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Get the pre-computed set of MIDI notes for fast O(1) containment checks without allocations. + /// + public static HashSet GetNoteSet(string? instrumentId) + { + var key = instrumentId ?? string.Empty; + if (_instrumentNoteSets.TryGetValue(key, out var set)) + return set; + + var notes = GetNotes(instrumentId); + set = new HashSet(notes); + _instrumentNoteSets[key] = set; + return set; + } + /// /// Get the MIDI notes for the specified instrument id /// diff --git a/AutoMidiPlayer.WPF/Core/KeyboardPlayer.cs b/AutoMidiPlayer.WPF/Core/KeyboardPlayer.cs index 645303c..ecb34f9 100644 --- a/AutoMidiPlayer.WPF/Core/KeyboardPlayer.cs +++ b/AutoMidiPlayer.WPF/Core/KeyboardPlayer.cs @@ -29,9 +29,14 @@ private enum KeyAction } // Win32 API for direct keyboard input (more compatible with games) + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint nInputs, [In] ref INPUT pInputs, int cbSize); + [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); + private static readonly int InputSize = Marshal.SizeOf(typeof(INPUT)); + [DllImport("user32.dll")] private static extern uint MapVirtualKey(uint uCode, uint uMapType); @@ -94,22 +99,45 @@ private struct KEYBDINPUT /// public static bool UseWindowMessage { get; set; } = false; + private static readonly System.Collections.Concurrent.ConcurrentDictionary<(string InstrumentId, Transpose Direction), int[]> _transposeLookupTables = new(); + private static readonly System.Collections.Concurrent.ConcurrentDictionary<(string InstrumentId, int Note), int> _smartTransposeCache = new(); + public static int TransposeNote( string instrumentId, ref int noteId, Transpose direction = Transpose.Ignore) { if (direction is Transpose.Ignore) return noteId; - var notes = Keyboard.GetNotes(instrumentId); - if (notes.Count == 0) - return noteId; - var noteSet = new HashSet(notes); - if (direction is Transpose.Smart) + if ((uint)noteId < 128) { - noteId = SmartTransposeNote(notes, noteSet, noteId); - return noteId; + var table = GetOrBuildTransposeTable(instrumentId, direction); + if (table != null) + { + noteId = table[noteId]; + return noteId; + } } + noteId = TransposeNoteUncached(instrumentId, noteId, direction); + return noteId; + } + + private static int[]? GetOrBuildTransposeTable(string instrumentId, Transpose direction) + { + if (string.IsNullOrEmpty(instrumentId) || direction == Transpose.Ignore) + return null; + + var key = (instrumentId, direction); + if (_transposeLookupTables.TryGetValue(key, out var cachedTable)) + return cachedTable; + + var notes = Keyboard.GetNotes(instrumentId); + if (notes.Count == 0) + return null; + + var noteSet = Keyboard.GetNoteSet(instrumentId); + var table = new int[128]; + var minNote = notes[0]; var maxNote = notes[0]; for (var i = 1; i < notes.Count; i++) @@ -118,6 +146,24 @@ public static int TransposeNote( if (notes[i] > maxNote) maxNote = notes[i]; } + for (int n = 0; n < 128; n++) + { + if (direction == Transpose.Smart) + { + table[n] = SmartTransposeNote(instrumentId, notes, noteSet, n); + } + else + { + table[n] = TransposeLinear(notes, noteSet, minNote, maxNote, n, direction); + } + } + + _transposeLookupTables[key] = table; + return table; + } + + private static int TransposeLinear(IList notes, HashSet noteSet, int minNote, int maxNote, int noteId, Transpose direction) + { while (true) { if (noteSet.Contains(noteId)) @@ -139,11 +185,35 @@ public static int TransposeNote( } } - private static int SmartTransposeNote(IList notes, HashSet noteSet, int originalNote) + private static int TransposeNoteUncached(string instrumentId, int noteId, Transpose direction) + { + var notes = Keyboard.GetNotes(instrumentId); + if (notes.Count == 0) return noteId; + var noteSet = Keyboard.GetNoteSet(instrumentId); + + if (direction is Transpose.Smart) + return SmartTransposeNote(instrumentId, notes, noteSet, noteId); + + var minNote = notes[0]; + var maxNote = notes[0]; + for (var i = 1; i < notes.Count; i++) + { + if (notes[i] < minNote) minNote = notes[i]; + if (notes[i] > maxNote) maxNote = notes[i]; + } + + return TransposeLinear(notes, noteSet, minNote, maxNote, noteId, direction); + } + + private static int SmartTransposeNote(string instrumentId, IList notes, HashSet noteSet, int originalNote) { if (noteSet.Contains(originalNote)) return originalNote; + var cacheKey = (instrumentId, originalNote); + if (_smartTransposeCache.TryGetValue(cacheKey, out var cachedNote)) + return cachedNote; + var minNote = notes[0]; var maxNote = notes[0]; for (var i = 1; i < notes.Count; i++) @@ -157,7 +227,10 @@ private static int SmartTransposeNote(IList notes, HashSet noteSet, in var targetNote = FoldNoteIntoRangeByOctaves(originalNote, minNote, maxNote); if (noteSet.Contains(targetNote)) + { + _smartTransposeCache[cacheKey] = targetNote; return targetNote; + } var (tonic, modeIndex) = DetectBestScale(notes, targetNote); var scalePitchClasses = BuildScalePitchClassSet(tonic, modeIndex); @@ -181,6 +254,7 @@ private static int SmartTransposeNote(IList notes, HashSet noteSet, in } } + _smartTransposeCache[cacheKey] = best; return best; } @@ -393,7 +467,7 @@ private static void SendKeyDirect(VirtualKeyCode key, bool keyUp) } }; - _ = SendInput(1, [input], Marshal.SizeOf(typeof(INPUT))); + _ = SendInput(1, ref input, InputSize); } private static void SendKeyStrokeWindow(Keyboard.KeyStroke keyStroke, IntPtr hWnd, KeyAction action) @@ -439,18 +513,18 @@ private static void ScheduleDelayedAction(Action action) return; } - _ = Task.Run(async () => + // Direct timer callback avoids Task.Run + async state machine overhead on every keystroke + _ = new Timer(_ => { try { - await Task.Delay(delayMs).ConfigureAwait(false); action(); } catch { // Best-effort delayed key-up dispatch. } - }); + }, null, delayMs, Timeout.Infinite); } /// diff --git a/AutoMidiPlayer.WPF/Core/WindowHelper.cs b/AutoMidiPlayer.WPF/Core/WindowHelper.cs index c1d25e2..52fef6c 100644 --- a/AutoMidiPlayer.WPF/Core/WindowHelper.cs +++ b/AutoMidiPlayer.WPF/Core/WindowHelper.cs @@ -15,14 +15,45 @@ public static class WindowHelper .OpenSubKey(@"SOFTWARE\launcher", false) ?.GetValue("InstPath") as string; + private static string[]? _cachedActiveGameProcessNames; + private static long _lastProcessNamesRefreshTimestampMs; + private const long ProcessNamesCacheTtlMs = 3000; + + private static IntPtr _lastForegroundWindow = IntPtr.Zero; + private static uint _lastForegroundProcessId = 0; + private static bool _lastFocusVerdict = false; + private static long _lastFocusCheckTimestampMs = 0; + private const long FocusCheckTtlMs = 120; + + public static void InvalidateProcessNamesCache() + { + _cachedActiveGameProcessNames = null; + _lastForegroundWindow = IntPtr.Zero; + _lastForegroundProcessId = 0; + _lastFocusVerdict = false; + _lastFocusCheckTimestampMs = 0; + } + private static string[] ActiveGameProcessNames { get { + var now = Stopwatch.GetTimestamp(); + var nowMs = (long)(now * 1000.0 / Stopwatch.Frequency); + + if (_cachedActiveGameProcessNames != null && (nowMs - _lastProcessNamesRefreshTimestampMs) < ProcessNamesCacheTtlMs) + { + return _cachedActiveGameProcessNames; + } + var activeGame = GameRegistry.AllGames.FirstOrDefault(game => game.GetIsActive()); if (activeGame is null) - return []; + { + _cachedActiveGameProcessNames = []; + _lastProcessNamesRefreshTimestampMs = nowMs; + return _cachedActiveGameProcessNames; + } var processNames = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -33,33 +64,61 @@ private static string[] ActiveGameProcessNames foreach (var processName in activeGame.ProcessNames.Where(name => !string.IsNullOrWhiteSpace(name))) processNames.Add(processName); - return [.. processNames]; + var result = processNames.ToArray(); + _cachedActiveGameProcessNames = result; + _lastProcessNamesRefreshTimestampMs = nowMs; + return result; } } public static bool IsGameFocused() { - var processNames = ActiveGameProcessNames; - if (processNames.Length == 0) - return false; - var foregroundWindow = GetForegroundWindow(); if (foregroundWindow == IntPtr.Zero) return false; + var now = Stopwatch.GetTimestamp(); + var nowMs = (long)(now * 1000.0 / Stopwatch.Frequency); + + // Fast path: If foreground window is the exact same handle and recently checked, return cached result immediately + if (foregroundWindow == _lastForegroundWindow && (nowMs - _lastFocusCheckTimestampMs) < FocusCheckTtlMs) + { + return _lastFocusVerdict; + } + + var processNames = ActiveGameProcessNames; + if (processNames.Length == 0) + return false; + GetWindowThreadProcessId(foregroundWindow, out var processId); if (processId == 0) return false; - try + bool isFocused; + // If window handle and process ID haven't changed, retain the verified verdict without re-inspecting process + if (foregroundWindow == _lastForegroundWindow && processId == _lastForegroundProcessId) { - using var process = Process.GetProcessById((int)processId); - return processNames.Contains(process.ProcessName, StringComparer.OrdinalIgnoreCase); + isFocused = _lastFocusVerdict; } - catch + else { - return false; + try + { + using var process = Process.GetProcessById((int)processId); + isFocused = processNames.Contains(process.ProcessName, StringComparer.OrdinalIgnoreCase); + } + catch + { + isFocused = false; + } } + + _lastForegroundWindow = foregroundWindow; + _lastForegroundProcessId = processId; + _lastFocusVerdict = isFocused; + _lastFocusCheckTimestampMs = nowMs; + + return isFocused; } public static void EnsureGameOnTop() diff --git a/AutoMidiPlayer.WPF/Dialogs/EditMarkerDialog/EditMarkerDialogView.xaml b/AutoMidiPlayer.WPF/Dialogs/EditMarkerDialog/EditMarkerDialogView.xaml new file mode 100644 index 0000000..e1bc70c --- /dev/null +++ b/AutoMidiPlayer.WPF/Dialogs/EditMarkerDialog/EditMarkerDialogView.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AutoMidiPlayer.WPF/Dialogs/EditMarkerDialog/EditMarkerDialogView.xaml.cs b/AutoMidiPlayer.WPF/Dialogs/EditMarkerDialog/EditMarkerDialogView.xaml.cs new file mode 100644 index 0000000..8e35f02 --- /dev/null +++ b/AutoMidiPlayer.WPF/Dialogs/EditMarkerDialog/EditMarkerDialogView.xaml.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using System.Windows.Controls; +using AutoMidiPlayer.Data; +using AutoMidiPlayer.Data.Entities; + +namespace AutoMidiPlayer.WPF.Dialogs; + +public partial class EditMarkerDialogView : UserControl +{ + public MusicConstants.KeyOption? SelectedKeyOption + { + get => KeyDropdown.SelectedItem as MusicConstants.KeyOption; + set => KeyDropdown.SelectedItem = value; + } + + public string MarkerLabel + { + get => LabelTextBox.Text?.Trim() ?? string.Empty; + set => LabelTextBox.Text = value; + } + + public EditMarkerDialogView(KeyChangeMarker marker, IEnumerable keyOptions) + { + InitializeComponent(); + TimeTextBlock.Text = $"Timestamp: {marker.TimeString} ({marker.TimeMs} ms)"; + var optionsList = keyOptions.ToList(); + KeyDropdown.ItemsSource = optionsList; + SelectedKeyOption = optionsList.FirstOrDefault(k => k.Value == marker.KeyOffset); + LabelTextBox.Text = marker.Label ?? string.Empty; + } +} diff --git a/AutoMidiPlayer.WPF/Services/GarbageManService.cs b/AutoMidiPlayer.WPF/Services/GarbageManService.cs index 74928b6..8ca6348 100644 --- a/AutoMidiPlayer.WPF/Services/GarbageManService.cs +++ b/AutoMidiPlayer.WPF/Services/GarbageManService.cs @@ -31,6 +31,15 @@ public static class GarbageManService /// Use sparingly, as this halts the execution engine. /// public static void TakeOutTheTrash(bool aggressive = false) + { + _ = TakeOutTheTrashAsync(aggressive); + } + + /// + /// Performs a full Gen 2 garbage collection with finalizer drain and working set trimming asynchronously. + /// Awaitable so callers (e.g. song loading) can guarantee the sweep completes before starting playback. + /// + public static async Task TakeOutTheTrashAsync(bool aggressive = false) { if (IsPlaybackActive?.Invoke() == true) return; @@ -55,7 +64,7 @@ public static void TakeOutTheTrash(bool aggressive = false) return; // Don't overwork the GarbageMan _lastCollection = now; - _ = Task.Run(DoSweep); + await Task.Run(DoSweep); } [System.Runtime.InteropServices.DllImport("psapi.dll")] diff --git a/AutoMidiPlayer.WPF/Services/PlaybackControlsService.cs b/AutoMidiPlayer.WPF/Services/PlaybackControlsService.cs index d05cd08..433d914 100644 --- a/AutoMidiPlayer.WPF/Services/PlaybackControlsService.cs +++ b/AutoMidiPlayer.WPF/Services/PlaybackControlsService.cs @@ -398,6 +398,7 @@ private void SeekPlayback(TimeSpan time) pb.Stop(); pb.MoveToTime(new MetricTimeSpan(time)); Engine.ResetChordPadsTriggered(); + Engine.NotifySeek(time); if (Settings.UseSpeakers && isRunning) pb.Start(); } @@ -416,7 +417,7 @@ public void OnSongTick(object? sender, PlaybackCurrentTimeChangedEventArgs e) if (_savePositionCounter >= 50) { _savePositionCounter = 0; - Queue.SaveCurrentSong(time.TotalSeconds); + Queue.UpdateCurrentSongPosition(time.TotalSeconds); } } } diff --git a/AutoMidiPlayer.WPF/Services/PlaybackService.cs b/AutoMidiPlayer.WPF/Services/PlaybackService.cs index a287033..1411cce 100644 --- a/AutoMidiPlayer.WPF/Services/PlaybackService.cs +++ b/AutoMidiPlayer.WPF/Services/PlaybackService.cs @@ -3,8 +3,10 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using System.Windows; +using System.Windows.Threading; using AutoMidiPlayer.Data; using AutoMidiPlayer.Data.Entities; using AutoMidiPlayer.Data.Midi; @@ -55,6 +57,60 @@ public class PlaybackEngineService : PropertyChangedBase, IHandle, IHa private DateTime _suppressFocusLossUntilUtc = DateTime.MinValue; private DateTime _playbackStartedAtUtc = DateTime.MinValue; private long _scheduledEventTicks; + private TimeSpan _scheduledEventMetricTime = TimeSpan.Zero; + private readonly Stopwatch _smoothStopwatch = new(); + private TimeSpan _smoothBaseTime = TimeSpan.Zero; + private double _smoothSpeed = 1.0; + private bool _cachedIsGameRunning = true; + private long _lastGameRunningCheckTimestampMs = 0; + private const long GameRunningCheckThrottleMs = 2500; + + /// + /// Thread-safe, lock-free, microsecond-accurate playback time for smooth UI rendering and timeline tracking. + /// Does not lock DryWetMidi's Playback object. + /// + public TimeSpan AccuratePlaybackTime + { + get + { + if (Playback is not { IsRunning: true }) + return Controls?.CurrentTime ?? TimeSpan.Zero; + + var elapsedTicks = (long)(_smoothStopwatch.ElapsedTicks * _smoothSpeed); + return _smoothBaseTime + TimeSpan.FromTicks(elapsedTicks); + } + } + + private bool IsGameRunningFast(GameDefinition selectedGame) + { + var now = Stopwatch.GetTimestamp(); + var nowMs = (long)(now * 1000.0 / Stopwatch.Frequency); + + if ((nowMs - _lastGameRunningCheckTimestampMs) < GameRunningCheckThrottleMs) + { + return _cachedIsGameRunning; + } + + var isRunning = GameRegistry.IsGameRunning(selectedGame); + _cachedIsGameRunning = isRunning; + _lastGameRunningCheckTimestampMs = nowMs; + return isRunning; + } + + private void RaiseNotePlayed(int sourceNote, long eventTimeUs) + { + var handler = NotePlayed; + if (handler is null) return; + + ThreadPool.UnsafeQueueUserWorkItem(static state => + { + if (state is (PlaybackEngineService svc, int note, long time)) + { + svc.NotePlayed?.Invoke(svc, new NotePlayedEventArgs(note, time)); + } + }, (this, sourceNote, eventTimeUs)); + } + private bool _loggedSongContextForNotes; private bool _pedalStateNeedsResync; private readonly Dictionary _activeNotes = new(); @@ -299,9 +355,17 @@ public Task InitializePlayback() playback.EventCallback = (rawEvent, rawTime, playbackTime) => { _scheduledEventTicks = rawTime; + _scheduledEventMetricTime = playbackTime; return rawEvent; }; + playback.NoteCallback = (rawNoteData, rawTime, rawLength, playbackTime) => + { + _scheduledEventTicks = rawTime; + _scheduledEventMetricTime = playbackTime; + return rawNoteData; + }; _scheduledEventTicks = 0; + _scheduledEventMetricTime = TimeSpan.Zero; _activeNotes.Clear(); _activeSpeakerNotes.Clear(); _activeGameNotes.Clear(); @@ -313,6 +377,7 @@ public Task InitializePlayback() // DryWetMidi does NOT fire the Stopped event when playback finishes // naturally — only Finished is raised. Stop the time watcher here so // it doesn't keep overriding the slider position after the song ends. + _smoothStopwatch.Stop(); _timeWatcher.Stop(); ReleaseSustainIfActive(); SilenceSpeakers(); @@ -330,14 +395,24 @@ public Task InitializePlayback() playback.Started += (_, _) => { _playbackStartedAtUtc = DateTime.UtcNow; - _scheduledEventTicks = 0; + _smoothBaseTime = Controls.CurrentTime; + _smoothSpeed = playback.Speed; + _smoothStopwatch.Restart(); _activeNotes.Clear(); _activeSpeakerNotes.Clear(); _activeGameNotes.Clear(); _activePhysicalNotes.Clear(); - _scheduledEventTicks = TimeConverter.ConvertFrom( - playback.GetCurrentTime(), - playback.TempoMap); + try + { + var metric = (TimeSpan)playback.GetCurrentTime(); + _scheduledEventMetricTime = metric; + _scheduledEventTicks = TimeConverter.ConvertFrom(new MetricTimeSpan(metric), playback.TempoMap); + } + catch + { + _scheduledEventMetricTime = Controls.CurrentTime; + _scheduledEventTicks = 0; + } ResetChordPadsTriggered(); foreach (var pedal in AllPedals) pedal.Clear(); _loggedSongContextForNotes = false; @@ -393,6 +468,7 @@ public Task InitializePlayback() playback.Stopped += (_, _) => { + _smoothStopwatch.Stop(); _timeWatcher.Stop(); ReleaseSustainIfActive(); SilenceSpeakers(); @@ -416,6 +492,9 @@ public Task InitializePlayback() // Enumeration already finished - playback has no events } SavedPosition = null; + _smoothBaseTime = time; + _smoothSpeed = playback.Speed; + _smoothStopwatch.Restart(); Controls.UpdateButtons(); Controls.MoveSlider(time); @@ -426,6 +505,34 @@ public Task InitializePlayback() return Task.CompletedTask; } + /// + /// Synchronizes internal event metric time and smooth clock immediately on seek. + /// + public void NotifySeek(TimeSpan time) + { + var tempoMap = Playback?.TempoMap ?? Queue?.OpenedFile?.OriginalTempoMap; + _scheduledEventMetricTime = time; + if (tempoMap != null) + { + try + { + _scheduledEventTicks = TimeConverter.ConvertFrom(new MetricTimeSpan(time), tempoMap); + } + catch + { + _scheduledEventTicks = 0; + } + } + else + { + _scheduledEventTicks = 0; + } + + _smoothBaseTime = time; + _smoothSpeed = Playback?.Speed ?? SongSettings.Speed; + _smoothStopwatch.Restart(); + } + /// /// Applies the combined per-song playback speed: /// base speed option multiplied by custom BPM ratio (if configured). @@ -446,6 +553,9 @@ private void ApplyEffectivePlaybackSpeed() } Playback.Speed = speed; + _smoothBaseTime = AccuratePlaybackTime; + _smoothSpeed = speed; + _smoothStopwatch.Restart(); } #endregion @@ -454,8 +564,11 @@ private void ApplyEffectivePlaybackSpeed() private void OnNoteEvent(object? sender, MidiEventPlayedEventArgs e) { - ReconcilePedalStates(); - SyncPedalStatesToUI(); + if (_pedalStateNeedsResync) + { + ReconcilePedalStates(); + SyncPedalStatesToUI(); + } if (e.Event is ControlChangeEvent ccEvent && (ccEvent.ControlNumber == 64 || ccEvent.ControlNumber == 66 || ccEvent.ControlNumber == 67)) { @@ -721,7 +834,7 @@ private void PlayNote(NoteEvent noteEvent) return; if (isNoteOn) - NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, eventTimeUs)); + RaiseNotePlayed(sourceNote, eventTimeUs); if (ShouldLogPlayedNotes) LogNoteInputOutput("speakers", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey); @@ -731,7 +844,7 @@ private void PlayNote(NoteEvent noteEvent) } var selectedGame = _main.SelectedGame?.Definition; - var isGameRunning = selectedGame is not null && GameRegistry.IsGameRunning(selectedGame); + var isGameRunning = selectedGame is not null && IsGameRunningFast(selectedGame); if (!isGameRunning) { @@ -742,7 +855,7 @@ private void PlayNote(NoteEvent noteEvent) return; if (isNoteOn) - NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, eventTimeUs)); + RaiseNotePlayed(sourceNote, eventTimeUs); if (ShouldLogPlayedNotes) LogNoteInputOutput("auto-listen", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey); @@ -786,7 +899,7 @@ private void PlayNote(NoteEvent noteEvent) if (!hasMappedKey) return; - NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, eventTimeUs)); + RaiseNotePlayed(sourceNote, eventTimeUs); if (ShouldLogPlayedNotes) LogNoteInputOutput("game", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey); @@ -846,7 +959,7 @@ private bool TryPlayChordPad( // Keep the track view's glow state representative of the source MIDI even though the // output is a single game chord-pad key. - NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, eventTimeUs)); + RaiseNotePlayed(sourceNote, eventTimeUs); if (detectedChordPad.WasTriggered) return true; @@ -970,6 +1083,11 @@ private static TrackChunk CreateChordDetectionTrack(IEnumerable trac private TimeSpan GetCurrentEventMetricTime() { var playback = Playback; + if (playback is { IsRunning: true } && _scheduledEventTicks >= 0) + { + return _scheduledEventMetricTime; + } + if (playback is { IsRunning: true }) { try @@ -982,12 +1100,6 @@ private TimeSpan GetCurrentEventMetricTime() } } - var tempoMap = playback?.TempoMap ?? Queue?.OpenedFile?.OriginalTempoMap; - if (tempoMap is not null && _scheduledEventTicks > 0) - { - var metricSpan = TimeConverter.ConvertTo(_scheduledEventTicks, tempoMap); - return (TimeSpan)metricSpan; - } return Controls.CurrentTime; } @@ -998,7 +1110,7 @@ private int ApplyNoteSettings(string instrumentId, int noteId, TimeSpan? eventTi var song = Queue.OpenedFile?.Song; var targetTime = eventTime ?? GetCurrentEventMetricTime(); - var dynamicMarker = song?.GetKeyMarkerAtTime(targetTime); + var dynamicMarker = song?.GetKeyMarkerAtTime(targetTime, lookaheadMs: 10); var activeKeyOffset = dynamicMarker?.KeyOffset ?? SongSettings.KeyOffset; var activeTranspose = dynamicMarker?.Transpose ?? (Settings.TransposeNotes && SongSettings.Transpose is not null ? SongSettings.Transpose.Value.Key : (Transpose?)null); @@ -1187,6 +1299,8 @@ public async Task StartPlayback(Playback playback) { var selectedGame = _main.SelectedGame?.Definition; var isGameRunning = selectedGame is not null && GameRegistry.IsGameRunning(selectedGame); + _cachedIsGameRunning = isGameRunning; + _lastGameRunningCheckTimestampMs = (long)(Stopwatch.GetTimestamp() * 1000.0 / Stopwatch.Frequency); Logger.LogStep( "PLAYBACK_ENGINE_START_ATTEMPT", @@ -1248,6 +1362,7 @@ public async Task LoadFileAsync(MidiFile file, bool autoPlay = false) { _loggedSongContextForNotes = false; _scheduledEventTicks = 0; + _scheduledEventMetricTime = TimeSpan.Zero; _activeNotes.Clear(); _activeSpeakerNotes.Clear(); foreach (var pedal in AllPedals) pedal.Clear(); @@ -1362,6 +1477,21 @@ public async Task LoadFileAsync(MidiFile file, bool autoPlay = false) _events.Publish(new OpenedFileChangedNotification(file)); + // Yield to allow UI Dispatcher to process OpenedFileChangedNotification + // and finish rebuilding the piano roll and timeline note indices. + if (Application.Current?.Dispatcher is { } dispatcher) + { + try + { + await dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); + } + catch (TaskCanceledException) { } + } + + // Sweep up the heavy MIDI loading and note indexing debris so the heap + // is pristine and compacted before playback starts or user interacts. + await GarbageManService.TakeOutTheTrashAsync(aggressive: true); + // Only auto-play if this is still the most recent load request if (autoPlay && epoch == _loadEpoch && Playback is not null) { diff --git a/AutoMidiPlayer.WPF/Services/SongService.cs b/AutoMidiPlayer.WPF/Services/SongService.cs index ff1d2de..1cb1dcf 100644 --- a/AutoMidiPlayer.WPF/Services/SongService.cs +++ b/AutoMidiPlayer.WPF/Services/SongService.cs @@ -38,6 +38,9 @@ public class SongService(IContainer ioc) : PropertyChangedBase private MusicConstants.SpeedOption? _selectedSpeedOption; private bool _suppressSongPersistenceAndEvents; private bool _isAutoCorrectActive; + private KeyChangeMarker? _activeMarker; + private int _lastActiveMarkerOffset; + private bool _isMarkerAdjustmentActive; #region Static Data @@ -82,6 +85,8 @@ public int KeyOffset NotifyOfPropertyChange(nameof(EffectiveKeyOffset)); NotifyOfPropertyChange(nameof(AutoCorrectRawNote)); NotifyOfPropertyChange(nameof(AutoCorrectEffectiveNote)); + NotifyOfPropertyChange(nameof(AutoCorrectTooltip)); + NotifyOfPropertyChange(nameof(AdjustmentTooltip)); // Persist + notify for playback rebuild SaveCurrentSongKey(); @@ -199,8 +204,84 @@ public string AutoCorrectEffectiveNote /// /// Tooltip explaining that smart transpose auto-correction is active. /// - public string AutoCorrectTooltip => - "Smart transpose is auto-correcting the key based on the detected song key"; + public string AutoCorrectTooltip + { + get + { + var baseKey = CurrentFile?.Song.BaseKey; + var effectiveOffset = MusicConstants.GetEffectiveKeyOffset(KeyOffset, baseKey); + var note = MusicConstants.GetNoteName(effectiveOffset); + var offset = (effectiveOffset >= 0 ? "+" : "") + effectiveOffset; + return $"Smart transpose is auto-correcting the key to {note} {offset} based on the detected song key"; + } + } + + /// + /// The active timeline key change marker at the current playback time, if any. + /// + public KeyChangeMarker? ActiveMarker => _activeMarker; + + /// + /// True when playback is currently at a timeline key change marker that adjusts pitch. + /// + public bool IsMarkerAdjustmentActive + { + get => _isMarkerAdjustmentActive; + private set => SetAndNotify(ref _isMarkerAdjustmentActive, value); + } + + /// + /// True if either a timeline key change marker or smart transpose auto-correction + /// is actively modifying the pitch at the current playback position. + /// + public bool IsAdjustmentActive => IsMarkerAdjustmentActive || IsAutoCorrectActive; + + /// + /// Display text showing the currently active adjusted note (from marker or auto-correction). + /// + public string AdjustmentNoteText + { + get + { + if (IsMarkerAdjustmentActive && _activeMarker != null) + return MusicConstants.GetNoteName(_activeMarker.KeyOffset); + + if (IsAutoCorrectActive) + return AutoCorrectEffectiveNote; + + return string.Empty; + } + } + + /// + /// Tooltip explaining where the active pitch adjustment came from. + /// Matches the Studio View active mode display dropdown. + /// + public string AdjustmentTooltip + { + get + { + if (IsMarkerAdjustmentActive && _activeMarker != null) + { + var note = MusicConstants.GetNoteName(_activeMarker.KeyOffset); + var offset = (_activeMarker.KeyOffset >= 0 ? "+" : "") + _activeMarker.KeyOffset; + var timeStr = FormatTimestampWithMs(_activeMarker.Time); + return $"Set to {note} {offset}\nMarker at {timeStr}"; + } + + if (IsAutoCorrectActive) + return AutoCorrectTooltip; + + return string.Empty; + } + } + + private static string FormatTimestampWithMs(TimeSpan time) + { + return time.Hours > 0 + ? $"{time.Hours:D2}:{time.Minutes:D2}:{time.Seconds:D2}:{time.Milliseconds:D3}" + : $"{time.Minutes:D2}:{time.Seconds:D2}:{time.Milliseconds:D3}"; + } #endregion @@ -254,9 +335,14 @@ public void ApplyPerSongSettings(MidiFile file) public void ClearSettings() { CurrentFile = null; + _activeMarker = null; + _lastActiveMarkerOffset = 0; + _isMarkerAdjustmentActive = false; UpdateKeyOptionsForCurrentSong(); Transpose = null; NotifyOfPropertyChange(nameof(TransposeDisplayText)); + NotifyOfPropertyChange(nameof(ActiveMarker)); + NotifyOfPropertyChange(nameof(IsMarkerAdjustmentActive)); UpdateAutoCorrectState(); } @@ -346,12 +432,15 @@ public void UpdateAutoCorrectState() IsAutoCorrectActive = IsAutoCorrectActiveForCurrentInstrument(); NotifyOfPropertyChange(nameof(AutoCorrectRawNote)); NotifyOfPropertyChange(nameof(AutoCorrectEffectiveNote)); + NotifyOfPropertyChange(nameof(AutoCorrectTooltip)); NotifyOfPropertyChange(nameof(KeyDisplayText)); // If auto-correct state changed, regenerate key options so dropdown items // show the correct note names (effective vs. raw). if (wasActive != _isAutoCorrectActive) UpdateKeyOptionsForCurrentSong(); + + UpdateAdjustmentState(); } /// @@ -435,9 +524,80 @@ private async Task SaveSongAsync(Song song) /// /// Called by MainWindowViewModel after construction to provide the back-reference - /// needed for cross-ViewModel operations (edit, delete). + /// needed for cross-ViewModel operations (edit, delete) and playback tracking. + /// + public void SetMain(MainWindowViewModel main) + { + if (_main?.PlaybackControls != null) + { + _main.PlaybackControls.SongPositionChanged -= HandleSongPositionChanged; + } + + _main = main; + + if (_main?.PlaybackControls != null) + { + _main.PlaybackControls.SongPositionChanged += HandleSongPositionChanged; + } + + UpdateAdjustmentState(); + } + + private void HandleSongPositionChanged(object? sender, EventArgs e) + { + if (_activeMarker != null || (CurrentFile?.Song?.KeyChangeMarkers is { Count: > 0 })) + { + UpdateAdjustmentState(); + } + } + + /// + /// Updates the active marker and adjustment indicator state based on the current playback time. /// - public void SetMain(MainWindowViewModel main) => _main = main; + public void UpdateAdjustmentState() + { + if (Application.Current?.Dispatcher?.CheckAccess() == false) + { + Application.Current.Dispatcher.BeginInvoke(UpdateAdjustmentState); + return; + } + + var markers = CurrentFile?.Song?.KeyChangeMarkers; + var hasMarkers = markers is { Count: > 0 }; + + KeyChangeMarker? newMarker = null; + if (hasMarkers && _main?.PlaybackControls != null) + { + newMarker = CurrentFile!.Song.GetKeyMarkerAtTime(_main.PlaybackControls.CurrentTime); + } + + var markerChanged = !ReferenceEquals(_activeMarker, newMarker) + || (newMarker != null && newMarker.KeyOffset != _lastActiveMarkerOffset); + + if (markerChanged) + { + _activeMarker = newMarker; + _lastActiveMarkerOffset = newMarker?.KeyOffset ?? 0; + _isMarkerAdjustmentActive = newMarker != null; + NotifyOfPropertyChange(nameof(ActiveMarker)); + NotifyOfPropertyChange(nameof(IsMarkerAdjustmentActive)); + } + + var wasAutoCorrect = _isAutoCorrectActive; + var isAutoCorrect = IsAutoCorrectActiveForCurrentInstrument(); + var autoCorrectChanged = wasAutoCorrect != isAutoCorrect; + if (autoCorrectChanged) + { + IsAutoCorrectActive = isAutoCorrect; + } + + if (markerChanged || autoCorrectChanged) + { + NotifyOfPropertyChange(nameof(IsAdjustmentActive)); + NotifyOfPropertyChange(nameof(AdjustmentNoteText)); + NotifyOfPropertyChange(nameof(AdjustmentTooltip)); + } + } #endregion diff --git a/AutoMidiPlayer.WPF/Views/Queue/QueueViewModel.cs b/AutoMidiPlayer.WPF/Views/Queue/QueueViewModel.cs index 64c4da1..de5950c 100644 --- a/AutoMidiPlayer.WPF/Views/Queue/QueueViewModel.cs +++ b/AutoMidiPlayer.WPF/Views/Queue/QueueViewModel.cs @@ -477,9 +477,9 @@ public void RestoreQueue(IEnumerable availableTracks) } /// - /// Save the currently playing song ID and position + /// Update the current song position in memory without hitting the disk synchronously. /// - public void SaveCurrentSong(double positionSeconds) + public void UpdateCurrentSongPosition(double positionSeconds) { if (OpenedFile is not null) { @@ -491,6 +491,14 @@ public void SaveCurrentSong(double positionSeconds) Settings.CurrentSongId = string.Empty; Settings.CurrentSongPosition = 0; } + } + + /// + /// Save the currently playing song ID and position + /// + public void SaveCurrentSong(double positionSeconds) + { + UpdateCurrentSongPosition(positionSeconds); Settings.Save(); } diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml index 19dd878..abd77db 100644 --- a/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml @@ -39,7 +39,8 @@ BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="8" - Padding="12"> + Padding="12" + ClipToBounds="True"> @@ -54,11 +55,14 @@ - + - + + + + - + + + + - + - - + + + + + @@ -237,32 +249,48 @@ - - + + + + + - - - + + + + + + + - - - + + + + + + + + + + + + DataContext as StudioViewModel; + public StudioSidebarControl() { InitializeComponent(); + DataContextChanged += OnDataContextChanged; + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + UpdateTabState(animate: false); + } + + private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) + { + if (e.OldValue is INotifyPropertyChanged oldVm) + { + oldVm.PropertyChanged -= OnViewModelPropertyChanged; + } + + if (e.NewValue is INotifyPropertyChanged newVm) + { + newVm.PropertyChanged += OnViewModelPropertyChanged; + } + + UpdateTabState(animate: false); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(StudioViewModel.ActiveSidebarTab)) + { + var vm = ViewModel; + bool isDrawerOpen = vm?.IsSidebarOpen ?? true; + UpdateTabState(animate: IsLoaded && isDrawerOpen); + } + else if (e.PropertyName == nameof(StudioViewModel.IsSidebarOpen)) + { + var vm = ViewModel; + if (vm != null && !vm.IsSidebarOpen) + { + UpdateTabState(animate: false); + } + } + } + + private void UpdateTabState(bool animate) + { + var vm = ViewModel; + string targetTab = vm?.ActiveSidebarTab ?? "Tracks"; + + if (!animate || !IsLoaded) + { + _currentTab = targetTab; + SnapToTab(targetTab); + return; + } + + if (string.Equals(_currentTab, targetTab, StringComparison.Ordinal)) + { + return; + } + + string previousTab = _currentTab; + _currentTab = targetTab; + + AnimateTransition(previousTab, targetTab); + } + + private void SnapToTab(string tab) + { + TracksContentTransform.BeginAnimation(TranslateTransform.XProperty, null); + SettingsContentTransform.BeginAnimation(TranslateTransform.XProperty, null); + TracksHeaderTransform.BeginAnimation(TranslateTransform.XProperty, null); + SettingsHeaderTransform.BeginAnimation(TranslateTransform.XProperty, null); + TracksHeaderPanel.BeginAnimation(UIElement.OpacityProperty, null); + SettingsHeaderPanel.BeginAnimation(UIElement.OpacityProperty, null); + + double width = ActualWidth > 0 ? ActualWidth : (Width > 0 ? Width : 320.0); + const double headerOffset = 30.0; + + if (tab == "Tracks") + { + TracksContentTransform.X = 0; + TracksContentGrid.Visibility = Visibility.Visible; + + SettingsContentTransform.X = width; + SettingsContentGrid.Visibility = Visibility.Collapsed; + + TracksHeaderTransform.X = 0; + TracksHeaderPanel.Opacity = 1; + TracksHeaderPanel.Visibility = Visibility.Visible; + + SettingsHeaderTransform.X = headerOffset; + SettingsHeaderPanel.Opacity = 0; + SettingsHeaderPanel.Visibility = Visibility.Collapsed; + } + else + { + SettingsContentTransform.X = 0; + SettingsContentGrid.Visibility = Visibility.Visible; + + TracksContentTransform.X = -width; + TracksContentGrid.Visibility = Visibility.Collapsed; + + SettingsHeaderTransform.X = 0; + SettingsHeaderPanel.Opacity = 1; + SettingsHeaderPanel.Visibility = Visibility.Visible; + + TracksHeaderTransform.X = -headerOffset; + TracksHeaderPanel.Opacity = 0; + TracksHeaderPanel.Visibility = Visibility.Collapsed; + } + } + + private void AnimateTransition(string fromTab, string toTab) + { + double width = ActualWidth > 0 ? ActualWidth : (Width > 0 ? Width : 320.0); + const double headerOffset = 30.0; + var ease = new CubicEase { EasingMode = EasingMode.EaseOut }; + var duration = TimeSpan.FromMilliseconds(240); + + bool movingToSettings = toTab == "Settings"; + + TracksContentTransform.BeginAnimation(TranslateTransform.XProperty, null); + SettingsContentTransform.BeginAnimation(TranslateTransform.XProperty, null); + TracksHeaderTransform.BeginAnimation(TranslateTransform.XProperty, null); + SettingsHeaderTransform.BeginAnimation(TranslateTransform.XProperty, null); + TracksHeaderPanel.BeginAnimation(UIElement.OpacityProperty, null); + SettingsHeaderPanel.BeginAnimation(UIElement.OpacityProperty, null); + + if (movingToSettings) + { + TracksContentGrid.Visibility = Visibility.Visible; + SettingsContentGrid.Visibility = Visibility.Visible; + SettingsContentTransform.X = width; + + TracksHeaderPanel.Visibility = Visibility.Visible; + SettingsHeaderPanel.Visibility = Visibility.Visible; + SettingsHeaderTransform.X = headerOffset; + SettingsHeaderPanel.Opacity = 0; + + var tracksAnim = new DoubleAnimation { To = -width, Duration = duration, EasingFunction = ease }; + var settingsAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = ease }; + + var tracksHeaderAnim = new DoubleAnimation { To = -headerOffset, Duration = duration, EasingFunction = ease }; + var tracksHeaderFade = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = ease }; + var settingsHeaderAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = ease }; + var settingsHeaderFade = new DoubleAnimation { To = 1, Duration = duration, EasingFunction = ease }; + + settingsAnim.Completed += (s, e) => + { + if (_currentTab == "Settings") + { + TracksContentGrid.Visibility = Visibility.Collapsed; + TracksHeaderPanel.Visibility = Visibility.Collapsed; + } + }; + + TracksContentTransform.BeginAnimation(TranslateTransform.XProperty, tracksAnim); + SettingsContentTransform.BeginAnimation(TranslateTransform.XProperty, settingsAnim); + + TracksHeaderTransform.BeginAnimation(TranslateTransform.XProperty, tracksHeaderAnim); + TracksHeaderPanel.BeginAnimation(UIElement.OpacityProperty, tracksHeaderFade); + SettingsHeaderTransform.BeginAnimation(TranslateTransform.XProperty, settingsHeaderAnim); + SettingsHeaderPanel.BeginAnimation(UIElement.OpacityProperty, settingsHeaderFade); + } + else + { + TracksContentGrid.Visibility = Visibility.Visible; + SettingsContentGrid.Visibility = Visibility.Visible; + TracksContentTransform.X = -width; + + TracksHeaderPanel.Visibility = Visibility.Visible; + SettingsHeaderPanel.Visibility = Visibility.Visible; + TracksHeaderTransform.X = -headerOffset; + TracksHeaderPanel.Opacity = 0; + + var settingsAnim = new DoubleAnimation { To = width, Duration = duration, EasingFunction = ease }; + var tracksAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = ease }; + + var settingsHeaderAnim = new DoubleAnimation { To = headerOffset, Duration = duration, EasingFunction = ease }; + var settingsHeaderFade = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = ease }; + var tracksHeaderAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = ease }; + var tracksHeaderFade = new DoubleAnimation { To = 1, Duration = duration, EasingFunction = ease }; + + tracksAnim.Completed += (s, e) => + { + if (_currentTab == "Tracks") + { + SettingsContentGrid.Visibility = Visibility.Collapsed; + SettingsHeaderPanel.Visibility = Visibility.Collapsed; + } + }; + + TracksContentTransform.BeginAnimation(TranslateTransform.XProperty, tracksAnim); + SettingsContentTransform.BeginAnimation(TranslateTransform.XProperty, settingsAnim); + + SettingsHeaderTransform.BeginAnimation(TranslateTransform.XProperty, settingsHeaderAnim); + SettingsHeaderPanel.BeginAnimation(UIElement.OpacityProperty, settingsHeaderFade); + TracksHeaderTransform.BeginAnimation(TranslateTransform.XProperty, tracksHeaderAnim); + TracksHeaderPanel.BeginAnimation(UIElement.OpacityProperty, tracksHeaderFade); + } } } diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml index 5ca2082..7706940 100644 --- a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml @@ -39,6 +39,9 @@ + + + @@ -86,9 +89,11 @@ @@ -108,53 +113,76 @@ - - - - - - - - - - - + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -187,109 +215,137 @@ DisplayMode="{Binding SelectedDisplayMode.Mode}" InstrumentId="{Binding CurrentInstrumentId}" PitchRevision="{Binding PitchRevision}" - ScrubRequested="OnPianoRollScrubRequested" /> + IsPlaying="{Binding IsPlaying}" + ScrubRequested="OnPianoRollScrubRequested" + MarkerDeleteRequested="OnInteractiveRollMarkerDeleteRequested" + MarkerEditRequested="OnInteractiveRollMarkerEditRequested" /> - - - - - - - + - - + - + - - + + + + - + - - - - - - - - - - - + + + + - + + - + + - + + - - - + + + + + + + + + + + + + + + diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs index 96c0088..cbf80d9 100644 --- a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs @@ -20,13 +20,24 @@ public StudioView() InitializeComponent(); DockedSidebar.RenderTransform = _dockedSidebarTransform; OverlaySidebarControl.RenderTransform = _overlaySidebarTransform; + TracksDrawerButton.SizeChanged += OnDrawerButtonSizeChanged; + SettingsDrawerButton.SizeChanged += OnDrawerButtonSizeChanged; DataContextChanged += OnDataContextChanged; Loaded += OnViewLoaded; } + private void OnDrawerButtonSizeChanged(object sender, SizeChangedEventArgs e) + { + if (e.NewSize.Width > 0) + { + UpdateDrawerSwitcherIndicator(animate: false); + } + } + private void OnViewLoaded(object sender, RoutedEventArgs e) { UpdateResponsiveLayout(animate: false); + UpdateDrawerSwitcherIndicator(animate: false); InteractiveRoll?.FitKeyHeightToViewport(); } @@ -53,6 +64,7 @@ private void OnDataContextChanged(object sender, DependencyPropertyChangedEventA } UpdateResponsiveLayout(animate: false); + UpdateDrawerSwitcherIndicator(animate: false); } private void OnSongDataReloaded(object? sender, EventArgs e) @@ -73,12 +85,20 @@ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs if (e.PropertyName is nameof(StudioViewModel.IsSidebarOpen)) { UpdateResponsiveLayout(animate: true); + UpdateDrawerSwitcherIndicator(animate: true); + } + else if (e.PropertyName is nameof(StudioViewModel.ActiveSidebarTab) + or nameof(StudioViewModel.IsTracksDrawerActive) + or nameof(StudioViewModel.IsSettingsDrawerActive)) + { + UpdateDrawerSwitcherIndicator(animate: true); } } private void OnViewSizeChanged(object sender, SizeChangedEventArgs e) { UpdateResponsiveLayout(animate: false); + UpdateDrawerSwitcherIndicator(animate: false); } private void UpdateResponsiveLayout(bool animate = false) @@ -224,4 +244,139 @@ private void OnPianoRollScrubRequested(object? sender, TimeSpan time) { ViewModel?.ScrubTo(time); } + + private async void OnInteractiveRollMarkerDeleteRequested(object? sender, AutoMidiPlayer.Data.Entities.KeyChangeMarker marker) + { + if (ViewModel != null) + { + await ViewModel.DeleteMarkerAsync(marker); + } + } + + private async void OnInteractiveRollMarkerEditRequested(object? sender, AutoMidiPlayer.Data.Entities.KeyChangeMarker marker) + { + if (ViewModel != null) + { + await ViewModel.EditMarkerAsync(marker); + } + } + + private void UpdateDrawerSwitcherIndicator(bool animate = true) + { + var vm = ViewModel; + if (vm == null || TracksDrawerButton == null || SettingsDrawerButton == null || DrawerActiveIndicator == null || DrawerSwitcherContainer == null) + return; + + bool isTracksActive = vm.IsTracksDrawerActive; + bool isSettingsActive = vm.IsSettingsDrawerActive; + bool isActive = isTracksActive || isSettingsActive; + + if (!isActive) + { + if (!animate || !IsLoaded) + { + DrawerActiveIndicator.BeginAnimation(UIElement.OpacityProperty, null); + DrawerActiveIndicator.Opacity = 0.0; + } + else + { + var fadeOut = new DoubleAnimation + { + To = 0.0, + Duration = TimeSpan.FromMilliseconds(180), + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }; + DrawerActiveIndicator.BeginAnimation(UIElement.OpacityProperty, fadeOut); + } + return; + } + + FrameworkElement targetButton = isSettingsActive ? SettingsDrawerButton : TracksDrawerButton; + + if (!targetButton.IsLoaded || targetButton.ActualWidth <= 0 || !targetButton.IsDescendantOf(DrawerSwitcherContainer)) + { + return; + } + + Point relativePos; + try + { + relativePos = targetButton.TranslatePoint(new Point(0, 0), DrawerSwitcherContainer); + } + catch + { + return; + } + + double targetX = relativePos.X; + double targetWidth = targetButton.ActualWidth; + + if (!animate || !IsLoaded || DrawerActiveIndicator.Opacity < 0.05) + { + if (Math.Abs(DrawerActiveIndicator.Width - targetWidth) < 0.5 && + Math.Abs(DrawerActiveIndicatorTransform.X - targetX) < 0.5 && + Math.Abs(DrawerActiveIndicator.Opacity - 1.0) < 0.05) + { + return; + } + + DrawerActiveIndicatorTransform.BeginAnimation(TranslateTransform.XProperty, null); + DrawerActiveIndicator.BeginAnimation(FrameworkElement.WidthProperty, null); + DrawerActiveIndicator.BeginAnimation(UIElement.OpacityProperty, null); + + DrawerActiveIndicatorTransform.X = targetX; + DrawerActiveIndicator.Width = targetWidth; + + if (DrawerActiveIndicator.Opacity < 0.95) + { + if (animate && IsLoaded) + { + var fadeIn = new DoubleAnimation + { + From = 0.0, + To = 1.0, + Duration = TimeSpan.FromMilliseconds(180), + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } + }; + DrawerActiveIndicator.BeginAnimation(UIElement.OpacityProperty, fadeIn); + } + else + { + DrawerActiveIndicator.Opacity = 1.0; + } + } + return; + } + + var ease = new CubicEase { EasingMode = EasingMode.EaseOut }; + var duration = TimeSpan.FromMilliseconds(240); + + var moveAnim = new DoubleAnimation + { + To = targetX, + Duration = duration, + EasingFunction = ease + }; + + var widthAnim = new DoubleAnimation + { + To = targetWidth, + Duration = duration, + EasingFunction = ease + }; + + DrawerActiveIndicatorTransform.BeginAnimation(TranslateTransform.XProperty, moveAnim); + DrawerActiveIndicator.BeginAnimation(FrameworkElement.WidthProperty, widthAnim); + + if (DrawerActiveIndicator.Opacity < 0.95) + { + var fadeIn = new DoubleAnimation + { + To = 1.0, + Duration = TimeSpan.FromMilliseconds(180), + EasingFunction = ease + }; + DrawerActiveIndicator.BeginAnimation(UIElement.OpacityProperty, fadeIn); + } + } } diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs index 9e0adb5..b56f044 100644 --- a/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs +++ b/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs @@ -13,11 +13,14 @@ using AutoMidiPlayer.WPF.Controls.PianoRoll; using AutoMidiPlayer.WPF.Controls.Snackbar; using AutoMidiPlayer.WPF.Core; +using AutoMidiPlayer.WPF.Dialogs; +using AutoMidiPlayer.WPF.Helpers; using AutoMidiPlayer.WPF.Services; using Melanchall.DryWetMidi.Interaction; using Melanchall.DryWetMidi.Multimedia; using Stylet; using StyletIoC; +using Wpf.Ui.Controls; using IContainer = StyletIoC.IContainer; namespace AutoMidiPlayer.WPF.ViewModels; @@ -69,6 +72,7 @@ public StudioViewModel(IContainer ioc, MainWindowViewModel main, NoSongPlacehold public NoSongPlaceholderComponent Placeholder { get; } public MainWindowViewModel Main => _main; public PlaybackControlsService Controls => _main.PlaybackControls; + public bool IsPlaying => Controls.IsPlaying; public SongService SongSettings => _main.SongSettings; public string CurrentInstrumentId => _main.InstrumentView?.SelectedInstrument.Key ?? string.Empty; public int PitchRevision { get; private set; } @@ -108,6 +112,9 @@ public KeyChangeMarker? SelectedMarker if (SetAndNotify(ref _selectedMarker, value)) { NotifyOfPropertyChange(nameof(HasSelectedMarker)); + NotifyOfPropertyChange(nameof(OffsetButtonText)); + NotifyOfPropertyChange(nameof(OffsetButtonIcon)); + NotifyOfPropertyChange(nameof(OffsetButtonTooltip)); if (value != null) { _selectedKeyOption = KeyOptions.FirstOrDefault(k => k.Value == value.KeyOffset); @@ -119,6 +126,12 @@ public KeyChangeMarker? SelectedMarker public bool HasSelectedMarker => SelectedMarker != null; + public string OffsetButtonText => HasSelectedMarker ? "Set" : "Add"; + public SymbolRegular OffsetButtonIcon => HasSelectedMarker ? SymbolRegular.Edit16 : SymbolRegular.Add16; + public string OffsetButtonTooltip => HasSelectedMarker + ? "Update key offset of selected marker" + : "Add key offset at current playhead position"; + public List KeyOptions { get; } public MusicConstants.KeyOption? SelectedKeyOption @@ -166,7 +179,7 @@ private void UpdateActiveKeyDisplayIfNeeded() if (song is null) return; var marker = song.GetKeyMarkerAtTime(CurrentTime); - var offset = marker?.KeyOffset ?? song.Key; + var offset = marker?.KeyOffset ?? SongSettings.KeyOffset; if (marker != _lastActiveMarker || offset != _lastActiveKey) { @@ -281,7 +294,15 @@ public string ActiveDisplayModeText get { if (CurrentFile?.Song is null) - return $"Active: Auto ({MusicConstants.GetNoteName(0)} +0) from Song Default"; + { + var defaultNote = MusicConstants.GetNoteName(0); + return SelectedDisplayMode?.Mode switch + { + PianoRollDisplayMode.SongDefault => $"Active: Song Default ({defaultNote} +0)", + PianoRollDisplayMode.Transpose => $"Active: Transpose ({defaultNote} +0)", + _ => $"Active: Auto ({defaultNote} +0) from Song Default" + }; + } var song = CurrentFile.Song; var marker = song.GetKeyMarkerAtTime(CurrentTime); @@ -300,10 +321,10 @@ public string ActiveDisplayModeText return SelectedDisplayMode?.Mode switch { PianoRollDisplayMode.SongDefault => - $"Active: Song Default ({defaultNoteName} +0) from Song Default", + $"Active: Song Default ({defaultNoteName} +0)", PianoRollDisplayMode.Transpose => - $"Active: Transpose ({songNoteName} {(songOffset >= 0 ? "+" : "")}{songOffset}) from {fallbackSource}", + $"Active: Transpose ({songNoteName} {(songOffset >= 0 ? "+" : "")}{songOffset})", _ => marker != null ? $"Active: Auto ({MusicConstants.GetNoteName(marker.KeyOffset)} {(marker.KeyOffset >= 0 ? "+" : "")}{marker.KeyOffset}) from Marker at {FormatTimestampWithMs(marker.Time)}" @@ -433,7 +454,13 @@ protected override void OnActivate() _main.QueueView.PropertyChanged += HandleQueuePropertyChanged; _main.PlaybackEngine.NotePlayed += HandleNotePlayed; + // Immediately anchor the playhead to the actual playback position so the + // piano roll renders at the correct time from the very first frame. + // Without this, CurrentTime stays stale from the last deactivation. + CurrentTime = _main.PlaybackEngine.AccuratePlaybackTime; + ReloadSongData(); + NotifyOfPropertyChange(nameof(IsPlaying)); if (Controls.IsPlaying) { @@ -528,6 +555,7 @@ private void HandleControlsPropertyChanged(object? sender, PropertyChangedEventA { if (Controls.IsPlaying) StartSmoothClock(); else StopSmoothClock(); + NotifyOfPropertyChange(nameof(IsPlaying)); } } @@ -561,19 +589,7 @@ private void OnSmoothRenderTick(object? sender, EventArgs e) return; } - var playback = _main.PlaybackEngine.Playback; - if (playback is { IsRunning: true }) - { - try - { - var metric = playback.GetCurrentTime(); - CurrentTime = (TimeSpan)metric; - } - catch - { - // Playback might be resetting - } - } + CurrentTime = _main.PlaybackEngine.AccuratePlaybackTime; } private void HandleCurrentTimeTick(object? sender, PlaybackCurrentTimeChangedEventArgs e) @@ -597,7 +613,10 @@ private void HandleCurrentTimeTick(object? sender, PlaybackCurrentTimeChangedEve private void HandleSongPositionChanged(object? sender, EventArgs e) { - CurrentTime = Controls.CurrentTime; + if (!_isRenderingHooked) + { + CurrentTime = Controls.CurrentTime; + } } public void ReloadSongData() @@ -622,6 +641,8 @@ public void ReloadSongData() } } SelectedMarker = null; + _lastActiveMarker = null; + _lastActiveKey = int.MinValue; _lastActiveKeyDisplay = string.Empty; _lastActiveDisplayModeText = string.Empty; UpdateActiveKeyDisplay(); @@ -691,11 +712,43 @@ public void ScrubTo(TimeSpan time) CurrentTime = time; } + public async Task SetOffsetAsync() + { + await AddOffsetAtPlayheadAsync(); + } + public async Task AddOffsetAtPlayheadAsync() { if (CurrentFile?.Song is null) return; + var offset = SelectedKeyOption?.Value ?? SongSettings.KeyOffset; + var noteName = MusicConstants.GetNoteName(offset); + + // If a marker is currently selected, update its offset directly + if (SelectedMarker != null) + { + SelectedMarker.KeyOffset = offset; + SelectedMarker.Label = $"{noteName} ({(offset >= 0 ? "+" : "")}{offset})"; + + var duplicate = KeyChangeMarkers.FirstOrDefault(m => m != SelectedMarker && Math.Abs(m.TimeMs - SelectedMarker.TimeMs) < 300); + if (duplicate != null) + { + KeyChangeMarkers.Remove(duplicate); + } + + var sorted = KeyChangeMarkers.OrderBy(m => m.TimeMs).ToList(); + KeyChangeMarkers.Clear(); + foreach (var m in sorted) KeyChangeMarkers.Add(m); + + await SaveMarkersToSongAsync(); + UpdateActiveKeyDisplayIfNeeded(); + NotifyOfPropertyChange(nameof(SelectedMarker)); + + SnackbarService.Success("Key Offset Updated", $"Updated marker at {SelectedMarker.TimeString} to {SelectedMarker.Label}"); + return; + } + var ms = (long)CurrentTime.TotalMilliseconds; // Snap to nearest note start within 150ms if available @@ -722,9 +775,6 @@ public async Task AddOffsetAtPlayheadAsync() } } - var offset = SelectedKeyOption?.Value ?? SongSettings.KeyOffset; - var noteName = MusicConstants.GetNoteName(offset); - // Remove any existing marker close to this time (within 300ms) var existing = KeyChangeMarkers.FirstOrDefault(m => Math.Abs(m.TimeMs - ms) < 300); if (existing != null) @@ -740,14 +790,15 @@ public async Task AddOffsetAtPlayheadAsync() }; KeyChangeMarkers.Add(newMarker); - var sorted = KeyChangeMarkers.OrderBy(m => m.TimeMs).ToList(); + var sortedList = KeyChangeMarkers.OrderBy(m => m.TimeMs).ToList(); KeyChangeMarkers.Clear(); - foreach (var m in sorted) KeyChangeMarkers.Add(m); + foreach (var m in sortedList) KeyChangeMarkers.Add(m); SelectedMarker = newMarker; await SaveMarkersToSongAsync(); + UpdateActiveKeyDisplayIfNeeded(); - SnackbarService.Success("Key Offset Added", $"Set {newMarker.Label} at {newMarker.TimeString}"); + SnackbarService.Success("Key Offset Set", $"Set {newMarker.Label} at {newMarker.TimeString}"); } public async Task AutoDetectKeyChangesAsync() @@ -784,15 +835,41 @@ public async Task AutoDetectKeyChangesAsync() public async Task DeleteSelectedMarkerAsync() { - if (SelectedMarker is null || CurrentFile?.Song is null) + if (SelectedMarker is null) return; - var removed = SelectedMarker; - KeyChangeMarkers.Remove(removed); - SelectedMarker = null; + await DeleteMarkerAsync(SelectedMarker); + } + public async Task DeleteMarkerAsync(KeyChangeMarker marker) + { + if (marker is null || CurrentFile?.Song is null) + return; + + if (SelectedMarker == marker) + SelectedMarker = null; + + KeyChangeMarkers.Remove(marker); await SaveMarkersToSongAsync(); - SnackbarService.Info("Marker Removed", $"Removed key offset marker at {removed.TimeString}"); + UpdateActiveKeyDisplayIfNeeded(); + SnackbarService.Info("Marker Removed", $"Removed key offset marker at {marker.TimeString}"); + } + + public Task EditMarkerAsync(KeyChangeMarker marker) + { + if (marker is null || CurrentFile?.Song is null) + return Task.CompletedTask; + + if (SelectedMarker == marker) + { + SelectedMarker = null; + } + else + { + SelectedMarker = marker; + ScrubTo(marker.Time); + } + return Task.CompletedTask; } public void NavigatePrevMarker() @@ -843,6 +920,7 @@ private async Task SaveMarkersToSongAsync() NotifyOfPropertyChange(nameof(ActiveKeyAtPlayheadDisplay)); NotifyPitchUpdateRequired(); + SongSettings.UpdateAdjustmentState(); } catch (Exception ex) { From 6fd11b28dd04467fc3b6b424b459073309c43faf Mon Sep 17 00:00:00 2001 From: Jed556 Date: Sun, 11 Oct 2026 13:56:48 +0800 Subject: [PATCH 4/5] Fix workflow to use the correct ref --- .github/workflows/dotnet.yml | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 95075c2..befaee2 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -12,6 +12,9 @@ on: commit: description: 'Commit-ish to tag' required: false + source_ref: + description: 'Branch, tag, or commit to build (defaults to the selected Actions ref)' + required: false permissions: contents: write @@ -29,6 +32,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 + ref: ${{ github.ref }} - name: Resolve version and build ref id: release @@ -37,14 +41,12 @@ jobs: if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION="${{ github.event.inputs.version }}" INPUT_COMMIT="${{ github.event.inputs.commit }}" + INPUT_SOURCE_REF="${{ github.event.inputs.source_ref }}" if [ -n "$INPUT_COMMIT" ]; then BUILD_REF="$INPUT_COMMIT" - elif git rev-parse -q --verify "refs/tags/$VERSION^{commit}" >/dev/null; then - BUILD_REF="refs/tags/$VERSION" else - echo "Error: provide 'commit' when version tag '$VERSION' does not exist yet." >&2 - exit 1 + BUILD_REF="${INPUT_SOURCE_REF:-$GITHUB_REF}" fi else VERSION="${GITHUB_REF#refs/tags/}" @@ -99,10 +101,10 @@ jobs: if (Test-Path .\install-fd) { Get-ChildItem .\install-fd\*.pdb -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue } 7z a AutoMidiPlayer_${{ needs.prepare.outputs.version }}_win-x64-net-install.zip .\install-fd\* -mx9 - - name: Upload net-install artifact - uses: actions/upload-artifact@v7.0.1 + - name: Upload net-install package + uses: actions/upload-artifact@v7.0.2 with: - name: net-install-zip + name: net-install-package path: AutoMidiPlayer_${{ needs.prepare.outputs.version }}_win-x64-net-install.zip if-no-files-found: error retention-days: 1 @@ -145,10 +147,10 @@ jobs: 7z a $portableArchive "$stagedExe" -mx9 Remove-Item "$stagedExe" -Force - - name: Upload portable artifact - uses: actions/upload-artifact@v7.0.1 + - name: Upload portable package + uses: actions/upload-artifact@v7.0.2 with: - name: portable-zip + name: portable-package path: AutoMidiPlayer_${{ needs.prepare.outputs.version }}_win-x64-portable.zip if-no-files-found: error retention-days: 1 @@ -162,16 +164,16 @@ jobs: - build_portable steps: - - name: Download net-install artifact - uses: actions/download-artifact@v8.0.1 + - name: Download net-install package + uses: actions/download-artifact@v8.0.2 with: - name: net-install-zip + name: net-install-package path: artifacts/net-install - - name: Download portable artifact - uses: actions/download-artifact@v8.0.1 + - name: Download portable package + uses: actions/download-artifact@v8.0.2 with: - name: portable-zip + name: portable-package path: artifacts/portable - name: Verify assets and stage release files From 297a8d30db8697d7eb9d8b90ec97d5179319a9e1 Mon Sep 17 00:00:00 2001 From: Jed556 Date: Sun, 11 Oct 2026 15:30:59 +0800 Subject: [PATCH 5/5] Acknowledge QA & testing contributions Special thanks to Lunaferlone for testing the app. Co-authored-by: Lunaferlone <89746016+Lunaferlone@users.noreply.github.com>