diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 7005c95..d2984e0 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
@@ -38,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"
else
- # workflow_dispatch runs on the branch/tag selected in the Actions UI.
- # Use that ref by default instead of silently switching to a tag that
- # may point at a different branch.
- BUILD_REF="$GITHUB_REF"
+ BUILD_REF="${INPUT_SOURCE_REF:-$GITHUB_REF}"
fi
else
VERSION="${GITHUB_REF#refs/tags/}"
@@ -103,7 +104,7 @@ jobs:
- name: Upload net-install artifact
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
@@ -149,7 +150,7 @@ jobs:
- name: Upload portable artifact
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
@@ -166,13 +167,13 @@ jobs:
- name: Download net-install artifact
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.2
with:
- name: portable-zip
+ name: portable-package
path: artifacts/portable
- name: Verify assets and stage release files
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..b80ddcc 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,83 @@ public Song(string path, int key)
///
public uint? ChordDetectionMilliseconds { get; set; }
+ private string? _keyChanges;
+ private volatile 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
+ {
+ 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;
+ }
+ }
+
+ public KeyChangeMarker? GetKeyMarkerAtTime(TimeSpan time, long lookaheadMs = 0)
+ {
+ var markers = KeyChangeMarkers;
+ 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)
+ {
+ 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..cf135bb 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
@@ -97,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))
{
@@ -275,8 +291,12 @@ public bool IsChecked
get => _isChecked;
set
{
- _isChecked = value;
- _events.Publish(this);
+ if (_isChecked != value)
+ {
+ _isChecked = value;
+ _events.Publish(this);
+ OnPropertyChanged();
+ }
}
}
@@ -332,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)
@@ -347,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 34293c6..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.13.7
+ 7.14.1
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/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
new file mode 100644
index 0000000..a658001
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs
new file mode 100644
index 0000000..06185be
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/HorizontalPianoRoll.xaml.cs
@@ -0,0 +1,603 @@
+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? 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),
+ 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 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),
+ 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 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),
+ 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) =>
+ {
+ var roll = (HorizontalPianoRoll)d;
+ if (roll.Canvas != null)
+ {
+ roll.Canvas.CurrentTime = (TimeSpan)e.NewValue;
+ roll.UpdateMarkerPopupPositionDuringPlayback();
+ }
+ }));
+
+ 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 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),
+ 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();
+ _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;
+ 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.InstrumentId = InstrumentId;
+ Canvas.PitchRevision = PitchRevision;
+ Canvas.CurrentTime = CurrentTime;
+ Canvas.InvalidateVisual();
+ }
+ if (Canvas?.HasNotes != true)
+ {
+ Canvas?.RebuildNoteIndex();
+ }
+ Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, UpdateScrollBounds);
+ };
+ }
+
+ public bool IsOverflowing => Canvas?.IsOverflowing ?? false;
+ public PianoRollCanvas RollCanvas => Canvas;
+ public int PitchCount => Canvas?.PitchCount ?? 49;
+
+ public void RefreshNotes()
+ {
+ Canvas.RebuildNoteIndex();
+ 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 (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 = Math.Clamp(Canvas.VerticalOffset, 0, VerticalScrollBar.Maximum);
+ }
+ 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;
+ }
+ }
+ }
+
+ 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
new file mode 100644
index 0000000..3aa95f3
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs
new file mode 100644
index 0000000..1ef9003
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/InteractivePianoRollControl.xaml.cs
@@ -0,0 +1,662 @@
+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 event EventHandler? MarkerDeleteRequested;
+ public event EventHandler? MarkerEditRequested;
+
+ 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._hasFittedKeyHeightForCurrentFile = false;
+ ctrl.RefreshNotesAndTimeline();
+ ctrl.UpdateTimelineViewport();
+ ctrl.FitKeyHeightToViewport(force: true);
+ }
+ }
+
+ 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 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),
+ 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);
+ set => SetValue(MidiFileProperty, value);
+ }
+
+ public static readonly DependencyProperty CurrentTimeProperty =
+ DependencyProperty.Register(
+ nameof(CurrentTime),
+ typeof(TimeSpan),
+ typeof(InteractivePianoRollControl),
+ 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
+ {
+ 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, 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
+ {
+ get => (double)GetValue(KeyHeightProperty);
+ set => SetValue(KeyHeightProperty, Math.Clamp(value, 6.0, 50.0));
+ }
+
+ public InteractivePianoRollControl()
+ {
+ InitializeComponent();
+ Loaded += (_, _) =>
+ {
+ var file = MidiFile;
+ file?.EnsureMidiLoaded();
+ 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();
+ if (fileChanged || PianoRoll?.Canvas?.HasNotes != true)
+ {
+ RefreshNotesAndTimeline();
+ FitKeyHeightToViewport(force: true);
+ }
+ };
+ SizeChanged += (_, _) => UpdateTimelineViewport();
+ PianoRoll.SizeChanged += (_, _) =>
+ {
+ UpdateTimelineViewport();
+ if (_pendingFitOnLayout && PianoRoll.Canvas?.ActualHeight > 0)
+ {
+ FitKeyHeightToViewport(force: true);
+ }
+ };
+ if (PianoRoll.Canvas != null)
+ {
+ PianoRoll.Canvas.SizeChanged += (_, _) =>
+ {
+ if (_pendingFitOnLayout && PianoRoll.Canvas.ActualHeight > 0)
+ {
+ FitKeyHeightToViewport(force: true);
+ }
+ };
+ }
+ 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();
+ }
+
+ public void UpdateNotePitches()
+ {
+ PianoRoll?.Canvas?.UpdateNotePitches();
+ Timeline?.Canvas?.UpdateNotePitches();
+ }
+
+ 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 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);
+ }
+
+ private void OnPianoRollMarkerClicked(object? sender, KeyChangeMarker marker)
+ {
+ SelectedMarker = 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)
+ {
+ 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()
+ {
+ FitKeyHeightToViewport(force: true);
+ }
+
+ private void OnTimelineKeyHeightResetRequested(object? sender, EventArgs e)
+ {
+ FitKeyHeightToViewport(force: true);
+ }
+
+ private bool _pendingFitOnLayout;
+ private bool _hasFittedKeyHeightForCurrentFile;
+
+ public void FitKeyHeightToViewport(bool force = false)
+ {
+ if (MidiFile == null || PianoRoll?.Canvas == null)
+ {
+ _pendingFitOnLayout = false;
+ return;
+ }
+
+ if (!force && _hasFittedKeyHeightForCurrentFile)
+ {
+ 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;
+ _hasFittedKeyHeightForCurrentFile = true;
+ }
+
+ 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..2533550
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs
new file mode 100644
index 0000000..068bd11
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/MidiTimelineScrubber.xaml.cs
@@ -0,0 +1,704 @@
+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? 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),
+ 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),
+ 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 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),
+ 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) =>
+ {
+ var scrubber = (MidiTimelineScrubber)d;
+ if (!scrubber._isDraggingScrubber)
+ {
+ scrubber.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, 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
+ {
+ 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();
+ _markerHideTimer.Tick += OnMarkerHideTimerTick;
+ ScrubberArea.LostMouseCapture += OnScrubberLostMouseCapture;
+ ZoomText.Text = "100%";
+ UpdateKeyHeightText(KeyHeight);
+ Loaded += (_, _) =>
+ {
+ UpdateSyncButtonState(IsSyncEnabled);
+ UpdateKeyHeightText(KeyHeight);
+ if (Canvas != null && Canvas.MidiFile != MidiFile)
+ {
+ Canvas.MidiFile = MidiFile;
+ }
+ if (Canvas != null)
+ {
+ Canvas.InstrumentId = InstrumentId;
+ Canvas.PitchRevision = PitchRevision;
+ Canvas.CurrentTime = CurrentTime;
+ }
+ Canvas?.RebuildMinimap();
+ };
+ }
+
+ public void RefreshMinimap()
+ {
+ Canvas.RebuildMinimap();
+ }
+
+ private void OnScrubberLostMouseCapture(object sender, MouseEventArgs e)
+ {
+ if (_isDraggingScrubber)
+ {
+ _isDraggingScrubber = false;
+ }
+ }
+
+ private void OnScrubberMouseDown(object sender, MouseButtonEventArgs e)
+ {
+ e.Handled = true;
+ HideMarkerPopupImmediate();
+ 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 (drag preview until mouse up)
+ SelectedMarker = null;
+ _isDraggingScrubber = true;
+ ScrubberArea.CaptureMouse();
+ UpdateScrubberPreview(pos.X);
+ }
+
+ private void OnScrubberMouseMove(object sender, MouseEventArgs e)
+ {
+ e.Handled = true;
+ var pos = e.GetPosition(Canvas);
+ if (_isDraggingScrubber && ScrubberArea.IsMouseCaptured)
+ {
+ HideMarkerPopupImmediate();
+ UpdateScrubberPreview(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;
+
+ 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 (_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 (_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)
+ {
+ _isDraggingScrubber = false;
+ ScrubberArea.ReleaseMouseCapture();
+
+ var pos = e.GetPosition(Canvas);
+ SeekToPosition(pos.X);
+
+ if (Canvas != null)
+ {
+ 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)
+ {
+ 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 > 0 ? Canvas.TotalDurationMs : (long)Duration.TotalMilliseconds;
+ var targetMs = ratio * totalMs;
+ var time = TimeSpan.FromMilliseconds(targetMs);
+ if (Canvas != null)
+ {
+ Canvas.CurrentTime = time;
+ }
+ ScrubRequested?.Invoke(this, time);
+ }
+
+ 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..4c58cfe
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/PianoRollCanvas.cs
@@ -0,0 +1,1813 @@
+using System;
+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;
+
+namespace AutoMidiPlayer.WPF.Controls.PianoRoll;
+
+public class PianoRollCanvas : FrameworkElement
+{
+ public readonly record struct RollNote(int RawNoteNumber, int NoteNumber, long StartMs, long LengthMs, int TrackIndex);
+
+ 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 SolidColorBrush[] DisabledTrackBrushes;
+ private static readonly SolidColorBrush WhiteKeyBrush;
+ private static readonly SolidColorBrush BlackKeyBrush;
+ private static readonly SolidColorBrush BlackKeyExtensionBrush;
+ private static readonly SolidColorBrush WhiteKeyTextBrush;
+ private static readonly SolidColorBrush BlackKeyTextBrush;
+ 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 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;
+ 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.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));
+ WhiteKeyTextBrush = CreateFrozenBrush(Color.FromArgb(190, 20, 20, 20));
+ BlackKeyTextBrush = CreateFrozenBrush(Color.FromArgb(190, 215, 215, 220));
+
+ 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);
+
+ 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[] _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 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;
+ 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 DrawingGroup? _cachedKeyboard;
+ private double _lastKeyboardPitchHeight = -1;
+ private int _lastKeyboardMinPitch = -1;
+ private int _lastKeyboardMaxPitch = -1;
+ private double _lastKeyboardWidth = -1;
+
+ 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();
+ 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()
+ {
+ _trackRects = new List[TrackBrushes.Length];
+ for (var i = 0; i < TrackBrushes.Length; i++)
+ {
+ _trackRects[i] = new List(1024);
+ }
+
+ EnsureThemeResources();
+ Loaded += (_, _) =>
+ {
+ AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged;
+ UpdateTrackSubscriptions();
+ EnsureThemeResources();
+ InvalidateVisual();
+ };
+ Unloaded += (_, _) =>
+ {
+ AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged;
+ UnsubscribeTrackEvents();
+ };
+ }
+
+ private void OnThemeResourcesChanged()
+ {
+ Dispatcher.BeginInvoke(() =>
+ {
+ EnsureThemeResources();
+ InvalidateVisual();
+ });
+ }
+
+ private void EnsureThemeResources()
+ {
+ var accent = AutoMidiPlayer.WPF.Core.AccentColorHelper.GetAccentColor();
+ 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();
+ }
+
+ 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 =
+ 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, 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
+ {
+ 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.InvalidateNoteBitmap();
+ 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 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;
+
+ 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 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 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),
+ typeof(IEnumerable),
+ typeof(PianoRollCanvas),
+ 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();
+ }
+ 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
+ {
+ 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 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
+ {
+ 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;
+ InvalidateNoteBitmap();
+ 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;
+ InvalidateNoteBitmap();
+ 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, 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, note.NoteNumber, startMs, Math.Max(20, lengthMs), (int)note.Channel));
+ }
+ }
+
+ 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();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogException(ex, "Failed to build piano roll note index.");
+ _notes = Array.Empty();
+ _maxNoteLengthMs = 0;
+ _trackCount = 1;
+ }
+
+ _cachedLanes = null;
+ _cachedKeyboard = null;
+ InvalidateNoteBitmap();
+ 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();
+ }
+
+ 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();
+ 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 label = (p >= 0 && p < 128) ? MidiPitchLabels[p] : $"{NoteNames[pitchClass]}{(p / 12) - 1}";
+
+ 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);
+ EnsureThemeResources();
+ _lastRenderTimestamp = System.Diagnostics.Stopwatch.GetTimestamp();
+
+ 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(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;
+ }
+ _lanesTransform.Y = -verticalOffset;
+ dc.PushTransform(_lanesTransform);
+ dc.DrawDrawing(_cachedLanes);
+ 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);
+
+ // 3. Direct hardware-accelerated zero-allocation note rendering and active-key detection
+ if (_notes.Length > 0)
+ {
+ var visibleEndMs = effectiveViewTimeMs + (long)(rollWidth / pixelsPerMs) + 500;
+ var startIndex = FindFirstPossibleNoteIndex(effectiveViewTimeMs);
+ var activeStartIndex = FindFirstPossibleNoteIndex(currentTimeMs);
+
+ // Detect active keys at playhead
+ for (var i = activeStartIndex; i < _notes.Length; i++)
+ {
+ var note = _notes[i];
+ if (note.StartMs > currentTimeMs)
+ break;
+
+ if (currentTimeMs <= note.StartMs + note.LengthMs)
+ {
+ if (!IsTrackDisabled(note.TrackIndex) && (uint)note.NoteNumber < (uint)_activeKeys.Length)
+ _activeKeys[note.NoteNumber] = true;
+ }
+ }
+
+ 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)
+ {
+ // 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();
+ }
+ }
+ }
+
+ // 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));
+ }
+ }
+ }
+
+ // 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;
+ }
+
+ _keyboardTransform.Y = -verticalOffset;
+ dc.PushTransform(_keyboardTransform);
+ dc.DrawDrawing(_cachedKeyboard);
+ 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 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));
+
+ 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));
+ _playheadTransform.X = playheadX;
+ _playheadTransform.Y = 0;
+ dc.PushTransform(_playheadTransform);
+ dc.DrawGeometry(_playheadMarkerBrush, null, PlayheadMarkerGeometry);
+ dc.Pop();
+ }
+ }
+ }
+
+ private void InvalidateNoteBitmap()
+ {
+ }
+
+ 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);
+ MarkerHoverChanged?.Invoke(this, null);
+ 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;
+ HoverPositionChanged?.Invoke(this, (pos.X, clickedMs));
+
+ InvalidateVisual();
+ e.Handled = true;
+ }
+ }
+
+ 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;
+
+ if (Math.Abs(pos.X - _dragStartPos.X) > 3.0)
+ {
+ _hasDragged = true;
+ }
+
+ if (IsSyncEnabled)
+ {
+ if (pixelsPerMs > 0 && Math.Abs(deltaX) >= 1.0)
+ {
+ _dragAnchorMouseX = pos.X;
+ }
+ }
+ 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);
+ 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();
+ return;
+ }
+
+ if (pos.X >= KeyboardWidth && pos.X <= ActualWidth)
+ {
+ _hoverX = pos.X;
+ 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
+ : (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);
+
+ 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;
+ _lastReportedHoverMs = null;
+ if (Cursor != Cursors.Arrow) Cursor = Cursors.Arrow;
+ HoverTimeChanged?.Invoke(this, null);
+ HoverPositionChanged?.Invoke(this, (null, 0));
+ }
+ }
+ }
+
+ protected override void OnMouseLeftButtonUp(MouseButtonEventArgs e)
+ {
+ base.OnMouseLeftButtonUp(e);
+ if (_isDragging)
+ {
+ _isDragging = false;
+ ReleaseMouseCapture();
+
+ if (!_hasDragged || IsSyncEnabled)
+ {
+ var pos = e.GetPosition(this);
+ if (pos.X >= KeyboardWidth && pos.X <= ActualWidth)
+ {
+ var pixelsPerMs = 0.15 * ZoomLevel;
+ 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);
+ 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;
+ _lastReportedHoverMs = null;
+ if (Cursor != Cursors.Arrow) Cursor = Cursors.Arrow;
+ HoverTimeChanged?.Invoke(this, null);
+ HoverPositionChanged?.Invoke(this, (null, 0));
+ }
+ InvalidateVisual();
+ e.Handled = true;
+ }
+ }
+
+ 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;
+ _lastReportedHoverMs = null;
+ if (Cursor != Cursors.Arrow) Cursor = Cursors.Arrow;
+ HoverTimeChanged?.Invoke(this, null);
+ HoverPositionChanged?.Invoke(this, (null, 0));
+ }
+ }
+ }
+
+ protected override void OnLostMouseCapture(MouseEventArgs e)
+ {
+ base.OnLostMouseCapture(e);
+ _isDragging = false;
+ }
+
+ #endregion
+}
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/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
new file mode 100644
index 0000000..4cc0440
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Controls/PianoRoll/TimelineCanvas.cs
@@ -0,0 +1,882 @@
+using System;
+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;
+using MidiFile = AutoMidiPlayer.Data.Midi.MidiFile;
+
+namespace AutoMidiPlayer.WPF.Controls.PianoRoll;
+
+public class TimelineCanvas : FrameworkElement
+{
+ 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 MarkerPinBrush;
+ private static readonly Pen MarkerPen;
+ private static readonly Typeface RulerTypeface;
+ private static readonly Typeface MarkerTypeface;
+ private static readonly StreamGeometry PlayheadHandleGeometry;
+
+ 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));
+ }
+
+ MarkerPinBrush = CreateFrozenBrush(Color.FromRgb(255, 183, 3)); // Amber
+ MarkerPen = new Pen(MarkerPinBrush, 1.5);
+ MarkerPen.Freeze();
+
+ 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);
+
+ 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 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;
+ private long _totalDurationMs = 1;
+ private int _trackCount = 1;
+ 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()
+ {
+ EnsureThemeResources();
+ Loaded += (_, _) =>
+ {
+ AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged += OnThemeResourcesChanged;
+ UpdateTrackSubscriptions();
+ EnsureThemeResources();
+ InvalidateVisual();
+ };
+ Unloaded += (_, _) =>
+ {
+ AutoMidiPlayer.WPF.Services.SystemThemeService.ThemeResourcesChanged -= OnThemeResourcesChanged;
+ UnsubscribeTrackEvents();
+ };
+ }
+
+ private void OnThemeResourcesChanged()
+ {
+ 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 =
+ 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),
+ 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 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),
+ 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)
+ {
+ 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();
+ }
+ }
+
+ 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, 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, note.NoteNumber, startMs, lenMs, (int)note.Channel));
+ }
+ }
+
+ var notesArray = list.ToArray();
+ Array.Sort(notesArray, static (a, b) => a.StartMs.CompareTo(b.StartMs));
+ _notes = notesArray;
+
+ 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)
+ {
+ _totalDurationMs = Math.Max(_totalDurationMs, Math.Max(1L, lastNote));
+ }
+ UpdateNotePitchesInternal();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogException(ex, "Failed to build timeline minimap notes.");
+ _notes = Array.Empty();
+ _trackCount = 1;
+ }
+
+ 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)
+ {
+ 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);
+ EnsureThemeResources();
+
+ 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 highlight window with crisp accent border
+ dc.DrawRectangle(_viewportFillBrush, _viewportBorderPen, boxRect);
+
+ // Right tactile grip line
+ if (boxW > 14)
+ {
+ dc.DrawRectangle(_viewportGripBrush, null, new Rect(xStart + boxW - 3, 4, 2, height - 8));
+ }
+ }
+
+ // 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));
+ }
+
+ // 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
+ _playheadTransform.X = playheadX;
+ dc.PushTransform(_playheadTransform);
+ 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.DrawRectangle(TrackBackgroundBrush, null, new Rect(0, 0, width, height));
+
+ // 2. Minimap Notes
+ if (_notes.Length > 0)
+ {
+ var pitchSpan = Math.Max(12, _maxPitch - _minPitch);
+ var noteAreaH = Math.Max(10, height - 16);
+ 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 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);
+ }
+ }
+ }
+
+ // 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.DrawRectangle(brush, pen, badgeRect);
+ dc.DrawText(formatted, new Point(badgeRect.X + 4, badgeRect.Y + 1));
+ }
+ }
+ }
+
+ group.Freeze();
+ return group;
+ }
+}
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/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/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 a525cd3..433d914 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,13 @@ public void OnSongPositionChanged()
{
var isRunning = pb.IsRunning;
pb.Stop();
- pb.MoveToTime(new MetricTimeSpan(_songPosition));
+ pb.MoveToTime(new MetricTimeSpan(time));
Engine.ResetChordPadsTriggered();
+ Engine.NotifySeek(time);
if (Settings.UseSpeakers && isRunning)
pb.Start();
}
catch (ObjectDisposedException) { }
-
- _ignoreSliderChange = false;
}
public void OnSongTick(object? sender, PlaybackCurrentTimeChangedEventArgs e)
@@ -402,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 968d137..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,11 +57,66 @@ 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();
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();
@@ -298,12 +355,21 @@ 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();
+ _activePhysicalNotes.Clear();
foreach (var pedal in AllPedals) pedal.Clear();
_loggedSongContextForNotes = false;
playback.Finished += (_, _) =>
@@ -311,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();
@@ -328,13 +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();
- _scheduledEventTicks = TimeConverter.ConvertFrom(
- playback.GetCurrentTime(),
- playback.TempoMap);
+ _activePhysicalNotes.Clear();
+ 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;
@@ -390,6 +468,7 @@ public Task InitializePlayback()
playback.Stopped += (_, _) =>
{
+ _smoothStopwatch.Stop();
_timeWatcher.Stop();
ReleaseSustainIfActive();
SilenceSpeakers();
@@ -413,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);
@@ -423,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).
@@ -443,6 +553,9 @@ private void ApplyEffectivePlaybackSpeed()
}
Playback.Speed = speed;
+ _smoothBaseTime = AccuratePlaybackTime;
+ _smoothSpeed = speed;
+ _smoothStopwatch.Restart();
}
#endregion
@@ -451,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))
{
@@ -640,6 +756,7 @@ private void ReleaseSustainIfActive()
KeyboardPlayer.NoteUp(activeNote.Note, activeNote.Layout, activeNote.Instrument);
}
_activeGameNotes.Clear();
+ _activePhysicalNotes.Clear();
}
private void SilenceSpeakers()
@@ -676,7 +793,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 +834,7 @@ private void PlayNote(NoteEvent noteEvent)
return;
if (isNoteOn)
- NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, Controls.CurrentTime.Ticks / 10));
+ RaiseNotePlayed(sourceNote, eventTimeUs);
if (ShouldLogPlayedNotes)
LogNoteInputOutput("speakers", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey);
@@ -703,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)
{
@@ -714,7 +855,7 @@ private void PlayNote(NoteEvent noteEvent)
return;
if (isNoteOn)
- NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, Controls.CurrentTime.Ticks / 10));
+ RaiseNotePlayed(sourceNote, eventTimeUs);
if (ShouldLogPlayedNotes)
LogNoteInputOutput("auto-listen", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey);
@@ -732,7 +873,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 +899,7 @@ private void PlayNote(NoteEvent noteEvent)
if (!hasMappedKey)
return;
- NotePlayed?.Invoke(this, new NotePlayedEventArgs(sourceNote, Controls.CurrentTime.Ticks / 10));
+ RaiseNotePlayed(sourceNote, eventTimeUs);
if (ShouldLogPlayedNotes)
LogNoteInputOutput("game", noteEvent, sourceNote, noteForKeyboard, hasMappedKey, mappedKey);
@@ -786,7 +927,8 @@ private bool TryPlayChordPad(
int sourceNote,
string layout,
string instrument,
- int noteForKeyboard)
+ int noteForKeyboard,
+ long eventTimeUs)
{
var eventKey = new ChordPadEventKey(
_scheduledEventTicks,
@@ -817,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, Controls.CurrentTime.Ticks / 10));
+ RaiseNotePlayed(sourceNote, eventTimeUs);
if (detectedChordPad.WasTriggered)
return true;
@@ -864,8 +1006,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 +1080,56 @@ 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 } && _scheduledEventTicks >= 0)
+ {
+ return _scheduledEventMetricTime;
+ }
+
+ if (playback is { IsRunning: true })
+ {
+ try
+ {
+ return (TimeSpan)playback.GetCurrentTime();
+ }
+ catch
+ {
+ // Fall back if playback is resetting or transitioning
+ }
+ }
+
+ 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, 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);
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;
}
@@ -1124,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",
@@ -1185,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();
@@ -1299,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 acd489d..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();
}
@@ -303,10 +389,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()
@@ -339,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();
}
///
@@ -428,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/MainWindow/MainWindow.xaml b/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml
index c30acf5..2625178 100644
--- a/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml
+++ b/AutoMidiPlayer.WPF/Views/MainWindow/MainWindow.xaml
@@ -583,8 +583,12 @@
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/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
new file mode 100644
index 0000000..abd77db
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml
@@ -0,0 +1,351 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml.cs
new file mode 100644
index 0000000..d4fb645
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Views/Studio/StudioSidebarControl.xaml.cs
@@ -0,0 +1,219 @@
+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 StudioSidebarControl : UserControl
+{
+ private string _currentTab = "Tracks";
+ private StudioViewModel? ViewModel => 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
new file mode 100644
index 0000000..7706940
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml
@@ -0,0 +1,385 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs
new file mode 100644
index 0000000..cbf80d9
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Views/Studio/StudioView.xaml.cs
@@ -0,0 +1,382 @@
+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;
+ 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();
+ }
+
+ private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
+ {
+ if (e.OldValue is INotifyPropertyChanged oldVm)
+ {
+ 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);
+ UpdateDrawerSwitcherIndicator(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))
+ {
+ 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)
+ {
+ 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);
+ }
+
+ 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
new file mode 100644
index 0000000..b56f044
--- /dev/null
+++ b/AutoMidiPlayer.WPF/Views/Studio/StudioViewModel.cs
@@ -0,0 +1,939 @@
+using System;
+using System.Collections.Generic;
+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.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;
+
+public class StudioViewModel : Screen,
+ IHandle,
+ IHandle,
+ 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 string _lastActiveDisplayModeText = 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;
+
+ 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
+
+ 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; }
+
+ 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;
+ 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));
+ NotifyOfPropertyChange(nameof(OffsetButtonText));
+ NotifyOfPropertyChange(nameof(OffsetButtonIcon));
+ NotifyOfPropertyChange(nameof(OffsetButtonTooltip));
+ if (value != null)
+ {
+ _selectedKeyOption = KeyOptions.FirstOrDefault(k => k.Value == value.KeyOffset);
+ NotifyOfPropertyChange(nameof(SelectedKeyOption));
+ }
+ }
+ }
+ }
+
+ 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
+ {
+ get => _selectedKeyOption;
+ set => SetAndNotify(ref _selectedKeyOption, value);
+ }
+
+ public TimeSpan CurrentTime
+ {
+ get => _currentTime;
+ set
+ {
+ if (SetAndNotify(ref _currentTime, value))
+ {
+ UpdateActiveKeyDisplayIfNeeded();
+ }
+ }
+ }
+
+ private void UpdateActiveKeyDisplay()
+ {
+ var display = ActiveKeyAtPlayheadDisplay;
+ 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));
+ }
+ }
+
+ 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 ?? SongSettings.KeyOffset;
+
+ 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 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)
+ {
+ 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);
+
+ 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)",
+
+ PianoRollDisplayMode.Transpose =>
+ $"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)}"
+ : $"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
+ {
+ if (CurrentFile?.Song is null)
+ return "Key: C (+0)";
+
+ 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 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}) [{fallbackSource}]";
+ }
+ }
+
+ 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;
+
+ // 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)
+ {
+ 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();
+ }
+
+ 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))
+ {
+ if (Controls.IsPlaying) StartSmoothClock();
+ else StopSmoothClock();
+ NotifyOfPropertyChange(nameof(IsPlaying));
+ }
+ }
+
+ 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;
+ }
+
+ CurrentTime = _main.PlaybackEngine.AccuratePlaybackTime;
+ }
+
+ 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)
+ {
+ if (!_isRenderingHooked)
+ {
+ CurrentTime = Controls.CurrentTime;
+ }
+ }
+
+ public void ReloadSongData()
+ {
+ if (Application.Current?.Dispatcher?.CheckAccess() == false)
+ {
+ Application.Current.Dispatcher.BeginInvoke(ReloadSongData);
+ return;
+ }
+
+ 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;
+ _lastActiveMarker = null;
+ _lastActiveKey = int.MinValue;
+ _lastActiveKeyDisplay = string.Empty;
+ _lastActiveDisplayModeText = string.Empty;
+ UpdateActiveKeyDisplay();
+ NotifyPitchUpdateRequired();
+ SongDataReloaded?.Invoke(this, EventArgs.Empty);
+ }
+
+ #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.Seek(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
+ 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
+ }
+ }
+
+ // 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 sortedList = KeyChangeMarkers.OrderBy(m => m.TimeMs).ToList();
+ KeyChangeMarkers.Clear();
+ foreach (var m in sortedList) KeyChangeMarkers.Add(m);
+
+ SelectedMarker = newMarker;
+ await SaveMarkersToSongAsync();
+ UpdateActiveKeyDisplayIfNeeded();
+
+ SnackbarService.Success("Key Offset Set", $"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("Scan Keys", "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("Scan Keys Complete", $"Detected and added {detected.Count} key changes.");
+ }
+
+ public async Task DeleteSelectedMarkerAsync()
+ {
+ if (SelectedMarker is null)
+ return;
+
+ 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();
+ 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()
+ {
+ 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));
+ NotifyPitchUpdateRequired();
+ SongSettings.UpdateAdjustmentState();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogException(ex, "Failed to persist key change markers.");
+ }
+ }
+
+ #endregion
+}
+
+public class NoteDisplayModeOption
+{
+ public PianoRollDisplayMode Mode { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+}
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);
}