Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/}"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions AutoMidiPlayer.Data/Entities/KeyChangeMarker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using System.Text.Json.Serialization;

namespace AutoMidiPlayer.Data.Entities;

/// <summary>
/// Represents a timed key offset change (modulation point) within a song.
/// </summary>
public class KeyChangeMarker
{
/// <summary>
/// Timestamp in milliseconds from the start of the song.
/// </summary>
public long TimeMs { get; set; }

/// <summary>
/// The relative key offset for this section (relative to Song.BaseKey).
/// </summary>
public int KeyOffset { get; set; }

/// <summary>
/// Optional section-specific transpose mode override.
/// If null, inherits the song's global Transpose mode.
/// </summary>
public Transpose? Transpose { get; set; }

/// <summary>
/// Optional section title or label (e.g., "Verse 1", "Chorus (+1)", "Key Change").
/// </summary>
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})";
}
80 changes: 80 additions & 0 deletions AutoMidiPlayer.Data/Entities/Song.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -77,6 +80,83 @@ public Song(string path, int key)
/// </summary>
public uint? ChordDetectionMilliseconds { get; set; }

private string? _keyChanges;
private volatile List<KeyChangeMarker>? _cachedKeyChangeMarkers;

/// <summary>
/// JSON-serialized list of timed key modulation markers.
/// </summary>
public string? KeyChanges
{
get => _keyChanges;
set
{
_keyChanges = value;
_cachedKeyChangeMarkers = null;
}
}

[NotMapped]
public List<KeyChangeMarker> KeyChangeMarkers
{
get
{
if (_cachedKeyChangeMarkers != null) return _cachedKeyChangeMarkers;
if (string.IsNullOrWhiteSpace(KeyChanges)) return _cachedKeyChangeMarkers = new();
try
{
var list = JsonSerializer.Deserialize<List<KeyChangeMarker>>(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);
}

/// <summary>
/// Computes SHA-256 hash of a file's content.
/// </summary>
Expand Down
37 changes: 29 additions & 8 deletions AutoMidiPlayer.Data/Midi/MidiTrack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>? _noteNumbers; // Cached note numbers for fast lookup
private Dictionary<int, List<(long StartUs, long EndUs)>>? _noteTimingsUs;
private const int GlowDurationMs = 150; // How long the glow stays on
Expand Down Expand Up @@ -97,8 +111,10 @@ public MidiTrack(IEventAggregator events, TrackChunk track, int index, Melanchal

Track = track;
Index = index;
TrackName = track.Events.OfType<SequenceTrackNameEvent>().FirstOrDefault()?.Text;
InstrumentName = track.Events.OfType<InstrumentNameEvent>().FirstOrDefault()?.Text;
var rawTrackName = track.Events.OfType<SequenceTrackNameEvent>().FirstOrDefault()?.Text;
TrackName = string.IsNullOrWhiteSpace(rawTrackName) ? null : rawTrackName.Trim();
var rawInstrumentName = track.Events.OfType<InstrumentNameEvent>().FirstOrDefault()?.Text;
InstrumentName = string.IsNullOrWhiteSpace(rawInstrumentName) ? null : rawInstrumentName.Trim();

if (string.IsNullOrWhiteSpace(InstrumentName))
{
Expand Down Expand Up @@ -275,8 +291,12 @@ public bool IsChecked
get => _isChecked;
set
{
_isChecked = value;
_events.Publish(this);
if (_isChecked != value)
{
_isChecked = value;
_events.Publish(this);
OnPropertyChanged();
}
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion AutoMidiPlayer.WPF/AutoMidiPlayer.WPF.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<UseWPF>true</UseWPF>
<StartupObject>AutoMidiPlayer.WPF.App</StartupObject>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>7.13.7</Version>
<Version>7.14.1</Version>
<ApplicationIcon>Resources\logo.ico</ApplicationIcon>
<Nullable>enable</Nullable>
<RepositoryUrl>https://github.com/Jed556/AutoMidiPlayer</RepositoryUrl>
Expand Down
3 changes: 2 additions & 1 deletion AutoMidiPlayer.WPF/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<UserControl x:Class="AutoMidiPlayer.WPF.Controls.MarkerActionPopup"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:AutoMidiPlayer.WPF.Controls"
x:Name="Root"
Width="84">

<UserControl.RenderTransformOrigin>
<Point X="0.5" Y="1.0" />
</UserControl.RenderTransformOrigin>
<UserControl.RenderTransform>
<TransformGroup>
<ScaleTransform x:Name="EntranceScale" ScaleX="1.0" ScaleY="1.0"/>
<TranslateTransform x:Name="EntranceTranslate" Y="0"/>
</TransformGroup>
</UserControl.RenderTransform>

<!-- Transparent outer container to bridge the mouse hover path towards the marker without triggering MouseLeave -->
<Border x:Name="OuterBorder" Width="84" HorizontalAlignment="Center" Padding="0,0,0,6" Background="Transparent">
<Border Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
BorderBrush="{DynamicResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8"
Width="84"
HorizontalAlignment="Center"
Padding="8,8,8,8">
<StackPanel HorizontalAlignment="Center">
<!-- Marker Key Title -->
<TextBlock x:Name="MarkerTitleText"
Text="C (+0)"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Center"
MaxWidth="68"
TextTrimming="CharacterEllipsis" />

<!-- Timestamp moved below title -->
<TextBlock x:Name="MarkerTimeText"
Text="00:00.000"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Opacity="0.65"
HorizontalAlignment="Center"
Margin="0,1,0,6" />

<!-- Action Buttons: Icon-only Delete on Left, Edit on Right -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<controls:Button x:Name="DeleteButton"
Variant="Ghost"
ColorMode="Danger"
Icon="Delete16"
IconFilled="True"
IconFontSize="13"
Width="26"
Height="26"
Padding="0"
Margin="0,0,4,0"
Click="OnDeleteClick"
ToolTip="Delete marker" />

<controls:Button x:Name="EditButton"
Variant="Ghost"
Icon="Edit16"
IconFilled="True"
IconFontSize="13"
Width="26"
Height="26"
Padding="0"
Margin="0,0,0,0"
Click="OnEditClick"
ToolTip="Edit marker" />
</StackPanel>
</StackPanel>
</Border>
</Border>
</UserControl>
Loading
Loading