diff --git a/.gitignore b/.gitignore
index 13be635..353d9bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ output.osu
.idea
.vscode
.dotnet-home/
+/MapWizard.sln.DotSettings.user
diff --git a/MapWizard.CLI/MapWizard.CLI.csproj b/MapWizard.CLI/MapWizard.CLI.csproj
index 54575e3..19ad54c 100644
--- a/MapWizard.CLI/MapWizard.CLI.csproj
+++ b/MapWizard.CLI/MapWizard.CLI.csproj
@@ -5,7 +5,7 @@
-
+
diff --git a/MapWizard.CLI/ParserCompliancy/Decoding.cs b/MapWizard.CLI/ParserCompliancy/Decoding.cs
index ca57f94..ac6a549 100644
--- a/MapWizard.CLI/ParserCompliancy/Decoding.cs
+++ b/MapWizard.CLI/ParserCompliancy/Decoding.cs
@@ -29,9 +29,10 @@ public static void DecodeAllMapsFrom(string folderPath)
ConsoleUtils.ProgressBar.DrawProgressBar(osuFiles.Length, parsedBeatmaps.Count);
parsedBeatmaps.Add(Beatmap.Decode(new FileInfo(osuFile)));
}
- catch (Exception e)
+ catch (Exception ex)
{
- parsingErrors.Add((osuFile, e));
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ parsingErrors.Add((osuFile, ex));
}
}
@@ -50,4 +51,4 @@ public static void DecodeAllMapsFrom(string folderPath)
Console.WriteLine("Errors file written");
}
}
-}
\ No newline at end of file
+}
diff --git a/MapWizard.CLI/ParserCompliancy/Encoding.cs b/MapWizard.CLI/ParserCompliancy/Encoding.cs
index 8a397da..8825f81 100644
--- a/MapWizard.CLI/ParserCompliancy/Encoding.cs
+++ b/MapWizard.CLI/ParserCompliancy/Encoding.cs
@@ -62,6 +62,7 @@ public static void EncodeAllMapsFrom(string folderPath)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
Console.WriteLine($"Errors when processing {osuFile}: {ex}");
}
}
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/applause.mp3 b/MapWizard.Desktop/Assets/Skins/Legacy/applause.mp3
new file mode 100644
index 0000000..5c151e8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/applause.mp3 differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/approachcircle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/approachcircle@2x.png
new file mode 100644
index 0000000..db2f4a5
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/approachcircle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/combobreak.mp3 b/MapWizard.Desktop/Assets/Skins/Legacy/combobreak.mp3
new file mode 100644
index 0000000..64415cb
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/combobreak.mp3 differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/comboburst@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/comboburst@2x.png
new file mode 100644
index 0000000..94f0518
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/comboburst@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/count.wav b/MapWizard.Desktop/Assets/Skins/Legacy/count.wav
new file mode 100644
index 0000000..2d4ee02
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/count.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/count1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/count1@2x.png
new file mode 100644
index 0000000..e4a7b2e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/count1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/count2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/count2@2x.png
new file mode 100644
index 0000000..c765626
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/count2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/count3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/count3@2x.png
new file mode 100644
index 0000000..2c7f1f6
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/count3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/cursor-smoke@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/cursor-smoke@2x.png
new file mode 100644
index 0000000..4297b86
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/cursor-smoke@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/cursor@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/cursor@2x.png
new file mode 100644
index 0000000..fd5ba53
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/cursor@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/cursormiddle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/cursormiddle@2x.png
new file mode 100644
index 0000000..8fa6340
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/cursormiddle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/cursortrail@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/cursortrail@2x.png
new file mode 100644
index 0000000..8260fb7
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/cursortrail@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-0@2x.png
new file mode 100644
index 0000000..3f7b606
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-1@2x.png
new file mode 100644
index 0000000..1901149
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-2@2x.png
new file mode 100644
index 0000000..24cbde7
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-3@2x.png
new file mode 100644
index 0000000..5513451
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-4@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-4@2x.png
new file mode 100644
index 0000000..d7099a4
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-4@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-5@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-5@2x.png
new file mode 100644
index 0000000..cf46054
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-5@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-6@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-6@2x.png
new file mode 100644
index 0000000..7b54bb0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-6@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-7@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-7@2x.png
new file mode 100644
index 0000000..49cba61
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-7@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-8@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-8@2x.png
new file mode 100644
index 0000000..4220967
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-8@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/default-9@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/default-9@2x.png
new file mode 100644
index 0000000..5cc332d
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/default-9@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitclap.wav b/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitclap.wav
new file mode 100644
index 0000000..893b497
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitclap.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitfinish.wav b/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitfinish.wav
new file mode 100644
index 0000000..b2a4b37
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitfinish.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitnormal.wav b/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitnormal.wav
new file mode 100644
index 0000000..6adbb8c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitnormal.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitwhistle.wav
new file mode 100644
index 0000000..939b092
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/drum-hitwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/drum-sliderslide.wav b/MapWizard.Desktop/Assets/Skins/Legacy/drum-sliderslide.wav
new file mode 100644
index 0000000..40e6cc0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/drum-sliderslide.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/drum-slidertick.wav b/MapWizard.Desktop/Assets/Skins/Legacy/drum-slidertick.wav
new file mode 100644
index 0000000..79f345c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/drum-slidertick.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/drum-sliderwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/drum-sliderwhistle.wav
new file mode 100644
index 0000000..3328301
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/drum-sliderwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/failsound.mp3 b/MapWizard.Desktop/Assets/Skins/Legacy/failsound.mp3
new file mode 100644
index 0000000..c96f828
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/failsound.mp3 differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/followpoint@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/followpoint@2x.png
new file mode 100644
index 0000000..9e64df5
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/followpoint@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-apple-overlay@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-apple-overlay@2x.png
new file mode 100644
index 0000000..f9487d2
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-apple-overlay@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-apple@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-apple@2x.png
new file mode 100644
index 0000000..8cea27e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-apple@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-bananas-overlay@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-bananas-overlay@2x.png
new file mode 100644
index 0000000..28ecd3f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-bananas-overlay@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-bananas@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-bananas@2x.png
new file mode 100644
index 0000000..a247874
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-bananas@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-fail@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-fail@2x.png
new file mode 100644
index 0000000..365716a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-fail@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-idle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-idle@2x.png
new file mode 100644
index 0000000..85043bd
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-idle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-kiai@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-kiai@2x.png
new file mode 100644
index 0000000..a3891d5
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-catcher-kiai@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-drop@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-drop@2x.png
new file mode 100644
index 0000000..6dac307
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-drop@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-grapes-overlay@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-grapes-overlay@2x.png
new file mode 100644
index 0000000..79bb124
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-grapes-overlay@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-grapes@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-grapes@2x.png
new file mode 100644
index 0000000..ed1bf2f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-grapes@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-orange-overlay@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-orange-overlay@2x.png
new file mode 100644
index 0000000..a124eee
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-orange-overlay@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-orange@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-orange@2x.png
new file mode 100644
index 0000000..f7f3f5a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-orange@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-pear-overlay@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-pear-overlay@2x.png
new file mode 100644
index 0000000..1f1a9be
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-pear-overlay@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/fruit-pear@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-pear@2x.png
new file mode 100644
index 0000000..a375686
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/fruit-pear@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/go@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/go@2x.png
new file mode 100644
index 0000000..cb57d13
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/go@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hit0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hit0@2x.png
new file mode 100644
index 0000000..bdb2bcb
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hit0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hit100@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hit100@2x.png
new file mode 100644
index 0000000..7db8eb3
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hit100@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hit100k@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hit100k@2x.png
new file mode 100644
index 0000000..206840e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hit100k@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hit300@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hit300@2x.png
new file mode 100644
index 0000000..2c7c078
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hit300@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hit300g@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hit300g@2x.png
new file mode 100644
index 0000000..1ce746e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hit300g@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hit300k@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hit300k@2x.png
new file mode 100644
index 0000000..b0db9c0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hit300k@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hit50@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hit50@2x.png
new file mode 100644
index 0000000..94c09d2
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hit50@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hitcircle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hitcircle@2x.png
new file mode 100644
index 0000000..6674616
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hitcircle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hitcircleoverlay@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hitcircleoverlay@2x.png
new file mode 100644
index 0000000..1f98c16
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hitcircleoverlay@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/hitcircleselect@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/hitcircleselect@2x.png
new file mode 100644
index 0000000..87db85e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/hitcircleselect@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/inputoverlay-background@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/inputoverlay-background@2x.png
new file mode 100644
index 0000000..fb82a7b
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/inputoverlay-background@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/inputoverlay-key@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/inputoverlay-key@2x.png
new file mode 100644
index 0000000..3466884
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/inputoverlay-key@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/lighting@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/lighting@2x.png
new file mode 100644
index 0000000..db11864
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/lighting@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-0@2x.png
new file mode 100644
index 0000000..5712c4f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-1@2x.png
new file mode 100644
index 0000000..436fbd8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-2@2x.png
new file mode 100644
index 0000000..5712c4f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-3@2x.png
new file mode 100644
index 0000000..436fbd8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-4@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-4@2x.png
new file mode 100644
index 0000000..5712c4f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-4@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-5@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-5@2x.png
new file mode 100644
index 0000000..436fbd8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/lightingL-5@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/lightingN@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/lightingN@2x.png
new file mode 100644
index 0000000..3125d6d
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/lightingN@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit0@2x.png
new file mode 100644
index 0000000..2e7b9bc
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit100@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit100@2x.png
new file mode 100644
index 0000000..27ca7f8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit100@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit200@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit200@2x.png
new file mode 100644
index 0000000..24ad926
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit200@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300@2x.png
new file mode 100644
index 0000000..098561f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300g-0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300g-0@2x.png
new file mode 100644
index 0000000..7e6501d
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300g-0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300g-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300g-1@2x.png
new file mode 100644
index 0000000..f17b2b1
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit300g-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit50@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit50@2x.png
new file mode 100644
index 0000000..1afec2f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-hit50@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-key1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-key1@2x.png
new file mode 100644
index 0000000..aa681f6
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-key1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-key1D@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-key1D@2x.png
new file mode 100644
index 0000000..6494188
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-key1D@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-key2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-key2@2x.png
new file mode 100644
index 0000000..d949bcf
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-key2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-key2D@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-key2D@2x.png
new file mode 100644
index 0000000..2a64f1f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-key2D@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-keyS@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-keyS@2x.png
new file mode 100644
index 0000000..5fce1e9
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-keyS@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-keySD@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-keySD@2x.png
new file mode 100644
index 0000000..8e90c2f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-keySD@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1@2x.png
new file mode 100644
index 0000000..f95008c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1H@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1H@2x.png
new file mode 100644
index 0000000..43b689e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1H@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-0@2x.png
new file mode 100644
index 0000000..419ef8f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-1@2x.png
new file mode 100644
index 0000000..79c602f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-2@2x.png
new file mode 100644
index 0000000..201d940
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-3@2x.png
new file mode 100644
index 0000000..772864b
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-4@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-4@2x.png
new file mode 100644
index 0000000..201d940
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-4@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-5@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-5@2x.png
new file mode 100644
index 0000000..79c602f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note1L-5@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2@2x.png
new file mode 100644
index 0000000..e6da7a1
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2H@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2H@2x.png
new file mode 100644
index 0000000..e6da7a1
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2H@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-0@2x.png
new file mode 100644
index 0000000..40b801a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-1@2x.png
new file mode 100644
index 0000000..b56ba6c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-2@2x.png
new file mode 100644
index 0000000..a2f40b8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-3@2x.png
new file mode 100644
index 0000000..6f61ebc
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-4@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-4@2x.png
new file mode 100644
index 0000000..a2f40b8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-4@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-5@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-5@2x.png
new file mode 100644
index 0000000..b56ba6c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-note2L-5@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteS@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteS@2x.png
new file mode 100644
index 0000000..c9bc23e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteS@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSH@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSH@2x.png
new file mode 100644
index 0000000..c9bc23e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSH@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-0@2x.png
new file mode 100644
index 0000000..9714fa2
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-1@2x.png
new file mode 100644
index 0000000..6a4bd95
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-2@2x.png
new file mode 100644
index 0000000..c5b7f3a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-3@2x.png
new file mode 100644
index 0000000..5f2d4f9
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-4@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-4@2x.png
new file mode 100644
index 0000000..c5b7f3a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-4@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-5@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-5@2x.png
new file mode 100644
index 0000000..6a4bd95
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-noteSL-5@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-hint@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-hint@2x.png
new file mode 100644
index 0000000..79c3497
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-hint@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-left@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-left@2x.png
new file mode 100644
index 0000000..c27b193
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-left@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-light@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-light@2x.png
new file mode 100644
index 0000000..7051ad5
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-light@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-right@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-right@2x.png
new file mode 100644
index 0000000..9fe96c8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/mania-stage-right@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-clap.wav b/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-clap.wav
new file mode 100644
index 0000000..d8b0b12
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-clap.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-finish.wav b/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-finish.wav
new file mode 100644
index 0000000..bb55318
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-finish.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-hat.wav b/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-hat.wav
new file mode 100644
index 0000000..9faea33
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-hat.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-kick.wav b/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-kick.wav
new file mode 100644
index 0000000..3eaaa3f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/nightcore-kick.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitclap.wav b/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitclap.wav
new file mode 100644
index 0000000..77063f6
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitclap.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitfinish.wav b/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitfinish.wav
new file mode 100644
index 0000000..bed7efc
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitfinish.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitnormal.wav b/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitnormal.wav
new file mode 100644
index 0000000..a62be76
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitnormal.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitwhistle.wav
new file mode 100644
index 0000000..7e6604e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/normal-hitwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/normal-sliderslide.wav b/MapWizard.Desktop/Assets/Skins/Legacy/normal-sliderslide.wav
new file mode 100644
index 0000000..98e3b13
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/normal-sliderslide.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/normal-slidertick.wav b/MapWizard.Desktop/Assets/Skins/Legacy/normal-slidertick.wav
new file mode 100644
index 0000000..c457b96
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/normal-slidertick.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/normal-sliderwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/normal-sliderwhistle.wav
new file mode 100644
index 0000000..3328301
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/normal-sliderwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/particle100@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/particle100@2x.png
new file mode 100644
index 0000000..02e5a98
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/particle100@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/particle300@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/particle300@2x.png
new file mode 100644
index 0000000..ced9ce6
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/particle300@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/particle50@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/particle50@2x.png
new file mode 100644
index 0000000..3223fc1
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/particle50@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear0.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear0.png
new file mode 100644
index 0000000..5140558
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear0.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear1.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear1.png
new file mode 100644
index 0000000..7761376
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear1.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear2.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear2.png
new file mode 100644
index 0000000..fcc5ce8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear2.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear3.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear3.png
new file mode 100644
index 0000000..88a7a4e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear3.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear4.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear4.png
new file mode 100644
index 0000000..d245b95
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear4.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear5.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear5.png
new file mode 100644
index 0000000..91ff7b9
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear5.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear6.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear6.png
new file mode 100644
index 0000000..c3cc21d
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear6.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear7.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear7.png
new file mode 100644
index 0000000..91ff7b9
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear7.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear8.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear8.png
new file mode 100644
index 0000000..d245b95
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonclear8.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail0.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail0.png
new file mode 100644
index 0000000..932ce74
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail0.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail1.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail1.png
new file mode 100644
index 0000000..1b45bf8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail1.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail2.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail2.png
new file mode 100644
index 0000000..e79d252
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonfail2.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonidle0.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonidle0.png
new file mode 100644
index 0000000..8b191c0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonidle0.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonidle1.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonidle1.png
new file mode 100644
index 0000000..7becea6
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonidle1.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonkiai0.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonkiai0.png
new file mode 100644
index 0000000..6f9e194
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonkiai0.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/pippidonkiai1.png b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonkiai1.png
new file mode 100644
index 0000000..3673a57
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/pippidonkiai1.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-A-small@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-A-small@2x.png
new file mode 100644
index 0000000..b7030af
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-A-small@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-A@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-A@2x.png
new file mode 100644
index 0000000..e067345
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-A@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-B-small@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-B-small@2x.png
new file mode 100644
index 0000000..ff37d42
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-B-small@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-B@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-B@2x.png
new file mode 100644
index 0000000..18297ce
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-B@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-C-small@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-C-small@2x.png
new file mode 100644
index 0000000..3392339
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-C-small@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-C@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-C@2x.png
new file mode 100644
index 0000000..37ad0ff
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-C@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-D-small@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-D-small@2x.png
new file mode 100644
index 0000000..ce7a051
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-D-small@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-D@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-D@2x.png
new file mode 100644
index 0000000..d282a74
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-D@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-S-small@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-S-small@2x.png
new file mode 100644
index 0000000..4e12c05
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-S-small@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-S@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-S@2x.png
new file mode 100644
index 0000000..a5e2f95
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-S@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-SH-small@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-SH-small@2x.png
new file mode 100644
index 0000000..5359615
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-SH-small@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-SH@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-SH@2x.png
new file mode 100644
index 0000000..fe11007
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-SH@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-X-small@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-X-small@2x.png
new file mode 100644
index 0000000..5a348c2
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-X-small@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-X@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-X@2x.png
new file mode 100644
index 0000000..781a045
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-X@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-XH-small@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-XH-small@2x.png
new file mode 100644
index 0000000..2d1d7d0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-XH-small@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ranking-XH@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-XH@2x.png
new file mode 100644
index 0000000..6d4f3c7
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ranking-XH@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/ready@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/ready@2x.png
new file mode 100644
index 0000000..2c8d003
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/ready@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/reversearrow@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/reversearrow@2x.png
new file mode 100644
index 0000000..c402d7f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/reversearrow@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-0@2x.png
new file mode 100644
index 0000000..cea176c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-1@2x.png
new file mode 100644
index 0000000..9162b4a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-2@2x.png
new file mode 100644
index 0000000..4e0031c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-3@2x.png
new file mode 100644
index 0000000..874e242
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-4@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-4@2x.png
new file mode 100644
index 0000000..19ab5c5
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-4@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-5@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-5@2x.png
new file mode 100644
index 0000000..e2365db
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-5@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-6@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-6@2x.png
new file mode 100644
index 0000000..0af4bbd
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-6@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-7@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-7@2x.png
new file mode 100644
index 0000000..9bb3224
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-7@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-8@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-8@2x.png
new file mode 100644
index 0000000..4e4fdab
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-8@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-9@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-9@2x.png
new file mode 100644
index 0000000..6cdd088
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-9@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-comma@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-comma@2x.png
new file mode 100644
index 0000000..23685e3
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-comma@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-dot@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-dot@2x.png
new file mode 100644
index 0000000..85308ff
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-dot@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-percent@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-percent@2x.png
new file mode 100644
index 0000000..47b2245
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-percent@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-pp@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-pp@2x.png
new file mode 100644
index 0000000..ba1f04c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-pp@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/score-x@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/score-x@2x.png
new file mode 100644
index 0000000..9930160
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/score-x@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-bg@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-bg@2x.png
new file mode 100644
index 0000000..e938037
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-bg@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-colour@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-colour@2x.png
new file mode 100644
index 0000000..6cc2065
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-colour@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-marker@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-marker@2x.png
new file mode 100644
index 0000000..148aaef
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scorebar-marker@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreboard-explosion-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreboard-explosion-1@2x.png
new file mode 100644
index 0000000..051c3bf
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreboard-explosion-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreboard-explosion-2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreboard-explosion-2@2x.png
new file mode 100644
index 0000000..af80901
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreboard-explosion-2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-0@2x.png
new file mode 100644
index 0000000..a3ad04f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-1@2x.png
new file mode 100644
index 0000000..3eaaf87
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-2@2x.png
new file mode 100644
index 0000000..ee4bab2
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-3@2x.png
new file mode 100644
index 0000000..de691e1
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-4@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-4@2x.png
new file mode 100644
index 0000000..3e6224b
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-4@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-5@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-5@2x.png
new file mode 100644
index 0000000..97f50d1
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-5@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-6@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-6@2x.png
new file mode 100644
index 0000000..a9cefa6
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-6@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-7@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-7@2x.png
new file mode 100644
index 0000000..ac1cfbc
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-7@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-8@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-8@2x.png
new file mode 100644
index 0000000..d774ada
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-8@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-9@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-9@2x.png
new file mode 100644
index 0000000..c2c217c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-9@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-comma@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-comma@2x.png
new file mode 100644
index 0000000..1c08756
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-comma@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-dot@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-dot@2x.png
new file mode 100644
index 0000000..5aa7d1e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-dot@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-percent@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-percent@2x.png
new file mode 100644
index 0000000..e48b1b7
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-percent@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-x@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-x@2x.png
new file mode 100644
index 0000000..fbe3a40
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/scoreentry-x@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sectionfail.mp3 b/MapWizard.Desktop/Assets/Skins/Legacy/sectionfail.mp3
new file mode 100644
index 0000000..c10a02e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sectionfail.mp3 differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sectionpass.mp3 b/MapWizard.Desktop/Assets/Skins/Legacy/sectionpass.mp3
new file mode 100644
index 0000000..396f701
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sectionpass.mp3 differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb-nd@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb-nd@2x.png
new file mode 100644
index 0000000..626fd91
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb-nd@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb-spec@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb-spec@2x.png
new file mode 100644
index 0000000..76fd9ab
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb-spec@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb0@2x.png
new file mode 100644
index 0000000..0a24a72
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb1@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb1@2x.png
new file mode 100644
index 0000000..e99f076
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb1@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb2@2x.png
new file mode 100644
index 0000000..cd36a0a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb3@2x.png
new file mode 100644
index 0000000..f494bd3
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb4@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb4@2x.png
new file mode 100644
index 0000000..a5b1988
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb4@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb5@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb5@2x.png
new file mode 100644
index 0000000..4bb01f0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb5@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb6@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb6@2x.png
new file mode 100644
index 0000000..859e0aa
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb6@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb7@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb7@2x.png
new file mode 100644
index 0000000..90efda0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb7@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb8@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb8@2x.png
new file mode 100644
index 0000000..fcdf4ed
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb8@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderb9@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb9@2x.png
new file mode 100644
index 0000000..c990cf0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderb9@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderendmiss@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderendmiss@2x.png
new file mode 100644
index 0000000..cbda936
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderendmiss@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderfollowcircle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderfollowcircle@2x.png
new file mode 100644
index 0000000..42fb8b4
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderfollowcircle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/sliderscorepoint@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/sliderscorepoint@2x.png
new file mode 100644
index 0000000..bcfdfc5
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/sliderscorepoint@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/slidertickmiss@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/slidertickmiss@2x.png
new file mode 100644
index 0000000..a45cc1d
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/slidertickmiss@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitclap.wav b/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitclap.wav
new file mode 100644
index 0000000..f3b56a0
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitclap.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitfinish.wav b/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitfinish.wav
new file mode 100644
index 0000000..2956501
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitfinish.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitnormal.wav b/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitnormal.wav
new file mode 100644
index 0000000..dfe98b6
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitnormal.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitwhistle.wav
new file mode 100644
index 0000000..9ec7c0d
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/soft-hitwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/soft-sliderslide.wav b/MapWizard.Desktop/Assets/Skins/Legacy/soft-sliderslide.wav
new file mode 100644
index 0000000..2b14f5f
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/soft-sliderslide.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/soft-slidertick.wav b/MapWizard.Desktop/Assets/Skins/Legacy/soft-slidertick.wav
new file mode 100644
index 0000000..d01981b
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/soft-slidertick.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/soft-sliderwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/soft-sliderwhistle.wav
new file mode 100644
index 0000000..032431e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/soft-sliderwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-approachcircle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-approachcircle@2x.png
new file mode 100644
index 0000000..ebfa3b3
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-approachcircle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-bottom@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-bottom@2x.png
new file mode 100644
index 0000000..63f4109
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-bottom@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-circle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-circle@2x.png
new file mode 100644
index 0000000..e3025f4
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-circle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-clear@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-clear@2x.png
new file mode 100644
index 0000000..22663a9
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-clear@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-glow@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-glow@2x.png
new file mode 100644
index 0000000..a7206b5
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-glow@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-middle2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-middle2@2x.png
new file mode 100644
index 0000000..69c7096
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-middle2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-middle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-middle@2x.png
new file mode 100644
index 0000000..4ffcf42
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-middle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-rpm@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-rpm@2x.png
new file mode 100644
index 0000000..55ddab5
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-rpm@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-spin@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-spin@2x.png
new file mode 100644
index 0000000..8e57158
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-spin@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-top@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-top@2x.png
new file mode 100644
index 0000000..aa0eb76
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-top@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinner-warning@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-warning@2x.png
new file mode 100644
index 0000000..f014a0b
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinner-warning@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinnerbonus.wav b/MapWizard.Desktop/Assets/Skins/Legacy/spinnerbonus.wav
new file mode 100644
index 0000000..5e583e7
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinnerbonus.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/spinnerspin.wav b/MapWizard.Desktop/Assets/Skins/Legacy/spinnerspin.wav
new file mode 100644
index 0000000..bba1938
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/spinnerspin.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/star2@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/star2@2x.png
new file mode 100644
index 0000000..1e8af54
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/star2@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/star3@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/star3@2x.png
new file mode 100644
index 0000000..f2bb751
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/star3@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/star@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/star@2x.png
new file mode 100644
index 0000000..b9f49a8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/star@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-left@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-left@2x.png
new file mode 100644
index 0000000..d3576dc
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-left@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-right-glow@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-right-glow@2x.png
new file mode 100644
index 0000000..0f54c32
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-right-glow@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-right@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-right@2x.png
new file mode 100644
index 0000000..549a7e1
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-bar-right@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-barline@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-barline@2x.png
new file mode 100644
index 0000000..3e96245
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-barline@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitclap.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitclap.wav
new file mode 100644
index 0000000..893b497
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitclap.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitfinish.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitfinish.wav
new file mode 100644
index 0000000..b2a4b37
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitfinish.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitnormal.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitnormal.wav
new file mode 100644
index 0000000..6adbb8c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitnormal.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitwhistle.wav
new file mode 100644
index 0000000..939b092
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-hitwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-inner@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-inner@2x.png
new file mode 100644
index 0000000..30f9759
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-inner@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-outer@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-outer@2x.png
new file mode 100644
index 0000000..0faf011
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-drum-outer@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-glow@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-glow@2x.png
new file mode 100644
index 0000000..2c00e08
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-glow@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit0@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit0@2x.png
new file mode 100644
index 0000000..70c81ef
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit0@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit100@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit100@2x.png
new file mode 100644
index 0000000..6db4cf8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit100@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit100k@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit100k@2x.png
new file mode 100644
index 0000000..82000d2
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit100k@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300@2x.png
new file mode 100644
index 0000000..f992815
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300g@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300g@2x.png
new file mode 100644
index 0000000..b5f425a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300g@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300k@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300k@2x.png
new file mode 100644
index 0000000..5d1f8f9
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-hit300k@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitclap.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitclap.wav
new file mode 100644
index 0000000..f79d065
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitclap.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitfinish.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitfinish.wav
new file mode 100644
index 0000000..667c14e
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitfinish.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitnormal.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitnormal.wav
new file mode 100644
index 0000000..43b2cbe
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitnormal.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitwhistle.wav
new file mode 100644
index 0000000..1da3962
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-normal-hitwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-roll-end@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-roll-end@2x.png
new file mode 100644
index 0000000..ac57267
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-roll-end@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-roll-middle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-roll-middle@2x.png
new file mode 100644
index 0000000..7a91b2d
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-roll-middle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-slider-fail@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-slider-fail@2x.png
new file mode 100644
index 0000000..2d9974a
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-slider-fail@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-slider@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-slider@2x.png
new file mode 100644
index 0000000..07b2f16
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-slider@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitclap.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitclap.wav
new file mode 100644
index 0000000..e7a9310
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitclap.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitfinish.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitfinish.wav
new file mode 100644
index 0000000..dd359b4
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitfinish.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitnormal.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitnormal.wav
new file mode 100644
index 0000000..1bbc495
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitnormal.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitwhistle.wav b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitwhistle.wav
new file mode 100644
index 0000000..1b5c45d
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taiko-soft-hitwhistle.wav differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taikobigcircle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taikobigcircle@2x.png
new file mode 100644
index 0000000..16157c8
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taikobigcircle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taikobigcircleoverlay@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taikobigcircleoverlay@2x.png
new file mode 100644
index 0000000..48da96c
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taikobigcircleoverlay@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taikohitcircle@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taikohitcircle@2x.png
new file mode 100644
index 0000000..44c2322
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taikohitcircle@2x.png differ
diff --git a/MapWizard.Desktop/Assets/Skins/Legacy/taikohitcircleoverlay@2x.png b/MapWizard.Desktop/Assets/Skins/Legacy/taikohitcircleoverlay@2x.png
new file mode 100644
index 0000000..ae1d0c6
Binary files /dev/null and b/MapWizard.Desktop/Assets/Skins/Legacy/taikohitcircleoverlay@2x.png differ
diff --git a/MapWizard.Desktop/Converters/ColorToHexConverter.cs b/MapWizard.Desktop/Converters/ColorToHexConverter.cs
index 7f8035f..445d872 100644
--- a/MapWizard.Desktop/Converters/ColorToHexConverter.cs
+++ b/MapWizard.Desktop/Converters/ColorToHexConverter.cs
@@ -30,8 +30,9 @@ public object ConvertBack(object? value, Type targetType, object? parameter, Cul
{
return ColorTranslator.FromHtml(hex); // Convert HTML color string to Color
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return Color.Black; // Default if parsing fails
}
}
diff --git a/MapWizard.Desktop/Converters/MapPathSummaryConverter.cs b/MapWizard.Desktop/Converters/MapPathSummaryConverter.cs
index be6c7da..467fa46 100644
--- a/MapWizard.Desktop/Converters/MapPathSummaryConverter.cs
+++ b/MapWizard.Desktop/Converters/MapPathSummaryConverter.cs
@@ -35,8 +35,9 @@ public object Convert(object? value, Type targetType, object? parameter, Culture
{
fileName = Path.GetFileNameWithoutExtension(rawPath);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
fileName = rawPath;
}
@@ -82,8 +83,9 @@ private static bool IsValidOsuMapPath(string path)
return File.Exists(Path.GetFullPath(path));
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return false;
}
}
diff --git a/MapWizard.Desktop/DependencyInjection/ServiceCollectionExtensions.cs b/MapWizard.Desktop/DependencyInjection/ServiceCollectionExtensions.cs
index 96c41f6..2f455b7 100644
--- a/MapWizard.Desktop/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/MapWizard.Desktop/DependencyInjection/ServiceCollectionExtensions.cs
@@ -1,11 +1,23 @@
using System;
using Avalonia.Controls;
using MapWizard.Desktop.Services;
+using MapWizard.Desktop.Services.ComboColourService;
+using MapWizard.Desktop.Services.HitsoundService;
+using MapWizard.Desktop.Services.MapCleanerService;
+using MapWizard.Desktop.Services.MemoryService;
+using MapWizard.Desktop.Services.MetadataService;
+using MapWizard.Desktop.Services.Playback;
using MapWizard.Desktop.ViewModels;
using MapWizard.Desktop.Views;
using Microsoft.Extensions.DependencyInjection;
using SukiUI.Dialogs;
using SukiUI.Toasts;
+using ComboColourStudioView = MapWizard.Desktop.Views.ComboColourStudio.ComboColourStudioView;
+using HitSoundVisualizerView = MapWizard.Desktop.Views.HitSoundVisualizer.HitSoundVisualizerView;
+using MapCleanerView = MapWizard.Desktop.Views.MapCleaner.MapCleanerView;
+using MetadataManagerView = MapWizard.Desktop.Views.MetadataManager.MetadataManagerView;
+using SettingsView = MapWizard.Desktop.Views.Settings.SettingsView;
+using WelcomePageView = MapWizard.Desktop.Views.WelcomePage.WelcomePageView;
namespace MapWizard.Desktop.DependencyInjection;
@@ -15,6 +27,7 @@ public static void AddCommonServices(this IServiceCollection collection)
{
// ViewModels
collection.AddTransient();
+ collection.AddTransient();
collection.AddTransient();
collection.AddTransient();
collection.AddTransient();
@@ -24,6 +37,7 @@ public static void AddCommonServices(this IServiceCollection collection)
// Views
collection.AddTransient();
+ collection.AddTransient();
collection.AddTransient();
collection.AddTransient();
collection.AddTransient();
@@ -42,6 +56,7 @@ public static void AddCommonServices(this IServiceCollection collection)
collection.AddScoped();
collection.AddScoped();
collection.AddScoped();
+ collection.AddSingleton();
collection.AddSingleton();
collection.AddScoped();
collection.AddSingleton();
diff --git a/MapWizard.Desktop/Enums/NavigationPage.cs b/MapWizard.Desktop/Enums/NavigationPage.cs
index 4c46076..f63223b 100644
--- a/MapWizard.Desktop/Enums/NavigationPage.cs
+++ b/MapWizard.Desktop/Enums/NavigationPage.cs
@@ -4,6 +4,7 @@ public enum NavigationPage
{
Welcome,
HitSoundCopier,
+ HitSoundVisualizer,
MetadataManager,
ComboColourStudio,
MapCleaner,
diff --git a/MapWizard.Desktop/Enums/ResultStatus.cs b/MapWizard.Desktop/Enums/ResultStatus.cs
index d156946..7f797f7 100644
--- a/MapWizard.Desktop/Enums/ResultStatus.cs
+++ b/MapWizard.Desktop/Enums/ResultStatus.cs
@@ -1,3 +1,5 @@
+namespace MapWizard.Desktop.Enums;
+
public enum ResultStatus
{
Success,
diff --git a/MapWizard.Desktop/MapWizard.Desktop.csproj b/MapWizard.Desktop/MapWizard.Desktop.csproj
index a9ed3af..0902999 100644
--- a/MapWizard.Desktop/MapWizard.Desktop.csproj
+++ b/MapWizard.Desktop/MapWizard.Desktop.csproj
@@ -20,8 +20,10 @@
-
-
+
+
+
+
@@ -38,9 +40,20 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineContextRequest.cs b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineContextRequest.cs
new file mode 100644
index 0000000..f0ae9fe
--- /dev/null
+++ b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineContextRequest.cs
@@ -0,0 +1,9 @@
+namespace MapWizard.Desktop.Models.HitSoundVisualizer;
+
+public class HitSoundTimelineContextRequest
+{
+ public int TimeMs { get; set; }
+ public int PointId { get; set; } = -1;
+ public int? SampleChangeTimeMs { get; set; }
+ public bool IsSampleRow { get; set; }
+}
diff --git a/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineRowAddRequest.cs b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineRowAddRequest.cs
new file mode 100644
index 0000000..7d93e6d
--- /dev/null
+++ b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineRowAddRequest.cs
@@ -0,0 +1,7 @@
+namespace MapWizard.Desktop.Models.HitSoundVisualizer;
+
+public class HitSoundTimelineRowAddRequest
+{
+ public int TimeMs { get; set; }
+ public int RowIndex { get; set; }
+}
diff --git a/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineZoomRequest.cs b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineZoomRequest.cs
new file mode 100644
index 0000000..612e0cb
--- /dev/null
+++ b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundTimelineZoomRequest.cs
@@ -0,0 +1,7 @@
+namespace MapWizard.Desktop.Models.HitSoundVisualizer;
+
+public class HitSoundTimelineZoomRequest
+{
+ public int AnchorTimeMs { get; set; }
+ public double ZoomFactor { get; set; } = 1d;
+}
diff --git a/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerDocument.cs b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerDocument.cs
new file mode 100644
index 0000000..3016490
--- /dev/null
+++ b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerDocument.cs
@@ -0,0 +1,20 @@
+using System.Collections.Generic;
+using MapWizard.Tools.HitSounds.Timeline;
+
+namespace MapWizard.Desktop.Models.HitSoundVisualizer;
+
+public class HitSoundVisualizerDocument
+{
+ public string BeatmapPath { get; set; } = string.Empty;
+ public string MapsetDirectoryPath { get; set; } = string.Empty;
+ public string AudioFilePath { get; set; } = string.Empty;
+ public string DisplayTitle { get; set; } = string.Empty;
+ public double EndTimeMs { get; set; }
+
+ // Export-compatible working structure used by the hitsound copier.
+ public HitSoundTimeline Timeline { get; set; } = new();
+
+ public IReadOnlyList Points { get; set; } = [];
+ public IReadOnlyList SampleChanges { get; set; } = [];
+ public IReadOnlyList SnapTicks { get; set; } = [];
+}
diff --git a/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerPoint.cs b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerPoint.cs
new file mode 100644
index 0000000..98695b8
--- /dev/null
+++ b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerPoint.cs
@@ -0,0 +1,15 @@
+using BeatmapParser.Enums;
+
+namespace MapWizard.Desktop.Models.HitSoundVisualizer;
+
+public class HitSoundVisualizerPoint
+{
+ public int Id { get; set; }
+ public int TimeMs { get; set; }
+ public SampleSet SampleSet { get; set; } = SampleSet.Normal;
+ public int? SampleIndexOverride { get; set; }
+ public int? SampleVolumeOverridePercent { get; set; }
+ public bool IsAutoSampleSet { get; set; }
+ public HitSound HitSound { get; set; } = HitSound.Normal;
+ public bool IsDraggable { get; set; }
+}
diff --git a/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerSampleChange.cs b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerSampleChange.cs
new file mode 100644
index 0000000..2abe1d3
--- /dev/null
+++ b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerSampleChange.cs
@@ -0,0 +1,11 @@
+using BeatmapParser.Enums;
+
+namespace MapWizard.Desktop.Models.HitSoundVisualizer;
+
+public class HitSoundVisualizerSampleChange
+{
+ public int TimeMs { get; set; }
+ public SampleSet SampleSet { get; set; } = SampleSet.Normal;
+ public int Index { get; set; }
+ public int Volume { get; set; }
+}
diff --git a/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerSnapTick.cs b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerSnapTick.cs
new file mode 100644
index 0000000..c9c71d4
--- /dev/null
+++ b/MapWizard.Desktop/Models/HitSoundVisualizer/HitSoundVisualizerSnapTick.cs
@@ -0,0 +1,11 @@
+namespace MapWizard.Desktop.Models.HitSoundVisualizer;
+
+public class HitSoundVisualizerSnapTick
+{
+ public int TimeMs { get; set; }
+ public double ExactTimeMs { get; set; }
+ public string Label { get; set; } = string.Empty;
+ public int Strength { get; set; }
+ public int Denominator { get; set; }
+ public bool IsMeasureLine { get; set; }
+}
diff --git a/MapWizard.Desktop/Models/MapsetDifficultyCard.cs b/MapWizard.Desktop/Models/MapsetDifficultyCard.cs
index 96eb536..376e376 100644
--- a/MapWizard.Desktop/Models/MapsetDifficultyCard.cs
+++ b/MapWizard.Desktop/Models/MapsetDifficultyCard.cs
@@ -6,10 +6,29 @@ public partial class MapsetDifficultyCard(string path) : ObservableObject
{
[ObservableProperty] private bool _isSelected;
- public SelectedMap Beatmap { get; } = new()
+ public string Path { get; } = path;
+ public string DifficultyLabel { get; } = ResolveDifficultyLabel(path);
+
+ private static string ResolveDifficultyLabel(string beatmapPath)
{
- Path = path
- };
+ if (string.IsNullOrWhiteSpace(beatmapPath))
+ {
+ return "Unknown Difficulty";
+ }
+
+ var fileName = System.IO.Path.GetFileNameWithoutExtension(beatmapPath);
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return "Unknown Difficulty";
+ }
+
+ var openBracketIndex = fileName.LastIndexOf('[');
+ var closeBracketIndex = fileName.LastIndexOf(']');
+ if (openBracketIndex >= 0 && closeBracketIndex > openBracketIndex)
+ {
+ return fileName.Substring(openBracketIndex, closeBracketIndex - openBracketIndex + 1);
+ }
- public string Path => Beatmap.Path;
+ return fileName;
+ }
}
diff --git a/MapWizard.Desktop/Models/Result.cs b/MapWizard.Desktop/Models/Result.cs
index e6e2060..5759ad9 100644
--- a/MapWizard.Desktop/Models/Result.cs
+++ b/MapWizard.Desktop/Models/Result.cs
@@ -1,3 +1,5 @@
+using MapWizard.Desktop.Enums;
+
namespace MapWizard.Desktop.Models;
public class Result
diff --git a/MapWizard.Desktop/Models/SelectedMap.cs b/MapWizard.Desktop/Models/SelectedMap.cs
index c507c6c..3b34689 100644
--- a/MapWizard.Desktop/Models/SelectedMap.cs
+++ b/MapWizard.Desktop/Models/SelectedMap.cs
@@ -92,8 +92,9 @@ private void LoadBeatmapCardData()
var resolvedBackgroundPath = MapsetAssetPathUtils.ResolveRelativePathFromBeatmap(fullPath, backgroundRelativePath);
LoadBackgroundImage(resolvedBackgroundPath);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ApplyFallbackCardData(normalizedPath);
}
}
@@ -128,8 +129,9 @@ private void LoadBackgroundImage(string? backgroundPath)
var fullPath = System.IO.Path.GetFullPath(backgroundPath);
BackgroundImage = File.Exists(fullPath) ? new Bitmap(fullPath) : null;
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
BackgroundImage = null;
}
}
diff --git a/MapWizard.Desktop/Models/Settings/AudioOutputDeviceOption.cs b/MapWizard.Desktop/Models/Settings/AudioOutputDeviceOption.cs
new file mode 100644
index 0000000..c21b752
--- /dev/null
+++ b/MapWizard.Desktop/Models/Settings/AudioOutputDeviceOption.cs
@@ -0,0 +1,22 @@
+namespace MapWizard.Desktop.Models.Settings;
+
+public sealed class AudioOutputDeviceOption
+{
+ public AudioOutputDeviceOption(string id, string displayName, bool isDefault, bool isEnabled)
+ {
+ Id = id;
+ DisplayName = displayName;
+ IsDefault = isDefault;
+ IsEnabled = isEnabled;
+ }
+
+ public string Id { get; }
+
+ public string DisplayName { get; }
+
+ public bool IsDefault { get; }
+
+ public bool IsEnabled { get; }
+
+ public override string ToString() => DisplayName;
+}
diff --git a/MapWizard.Desktop/Models/Settings/MainSettings.cs b/MapWizard.Desktop/Models/Settings/MainSettings.cs
index ad05437..2186f9d 100644
--- a/MapWizard.Desktop/Models/Settings/MainSettings.cs
+++ b/MapWizard.Desktop/Models/Settings/MainSettings.cs
@@ -10,4 +10,16 @@ public class MainSettings
[Setting("General", "UpdateStream")]
public UpdateStream UpdateStream { get; set; } = UpdateStream.Release;
+
+ [Setting("General", "EnableHitSoundVisualizer")]
+ public bool EnableHitSoundVisualizer { get; set; } = false;
+
+ [Setting("Audio", "PreviewSongVolumePercent")]
+ public int AudioPreviewSongVolumePercent { get; set; } = 80;
+
+ [Setting("Audio", "PreviewHitSoundVolumePercent")]
+ public int AudioPreviewHitSoundVolumePercent { get; set; } = 100;
+
+ [Setting("Audio", "OutputDeviceId")]
+ public string AudioOutputDeviceId { get; set; } = "default";
}
diff --git a/MapWizard.Desktop/Native/Bass/BASS-LICENSE.txt b/MapWizard.Desktop/Native/Bass/BASS-LICENSE.txt
new file mode 100644
index 0000000..4daf19d
--- /dev/null
+++ b/MapWizard.Desktop/Native/Bass/BASS-LICENSE.txt
@@ -0,0 +1,2542 @@
+BASS 2.4
+Copyright (c) 1999-2025 Un4seen Developments Ltd. All rights reserved.
+
+
+BASS is an audio library for use in software on several platforms. Its purpose
+is to provide developers with powerful and efficient sample, stream, MOD music,
+and recording functions. All in a compact DLL that won't bloat your software.
+
+Main Features
+-------------
+* Samples
+ supports WAV/AIFF/MP3/MP2/MP1/OGG and custom generated samples
+
+* Sample streams
+ stream any sample data in 8/16/32 bit, with both "push" and "pull" systems
+
+* File streams
+ MP3/MP2/MP1/OGG/WAV/AIFF file streaming
+
+* Internet file streaming
+ stream files from the internet, including Shout/Icecast
+
+* User file streaming
+ stream files from anywhere using any delivery method
+
+* OS codecs
+ ACM, Media Foundation and CoreAudio codec support for additional audio formats
+
+* Multi-channel streaming
+ support for more than plain stereo, including multi-channel OGG/WAV/AIFF files
+
+* MOD music
+ uses the same engine as XMPlay = best accuracy, speed, and quality
+
+* MO3 music
+ MP3/OGG compressed MOD music
+
+* Add-on system
+ support for more formats is available via add-ons (aka plugins)
+
+* Multiple outputs
+ simultaneously use multiple soundcards, and move channels between them
+
+* Recording
+ flexible recording system, with support for multiple devices
+
+* Decode without playback
+ streams and MOD musics can be outputted in any way you want
+
+* Speaker assignment
+ assign streams and MOD musics to specific speakers
+
+* High precision synchronization
+ synchronize events in your software to the streams and MOD musics
+
+* Effects
+ chorus/compressor/distortion/echo/flanger/gargle/parameq/reverb
+
+* User-defined DSP functions
+ custom effects may be applied to individual streams or the final output mix
+
+* 32 bit floating-point decoding and processing
+ floating-point stream/music decoding, DSP/FX, and recording
+
+* 3D sound
+ play samples/streams/musics in any 3D position
+
+
+Requirements
+============
+Win32 version
+-------------
+Windows 98 or newer is required with an SSE-supporting CPU. BASS can use WASAPI
+or DirectSound for playback and recording. WASAPI is the default on Windows
+Vista and newer, while DirectSound is on older Windows versions. 32-bit and
+64-bit Windows are supported. ARM64 support is available separately at the BASS
+website.
+
+macOS version
+-------------
+macOS 10.6 or above is required. The arm64 / x86_64 / i386 architectures are
+supported in a single universal library.
+
+Linux version
+-------------
+BASS requires ALSA for playback and recording, but decoding and silent playback
+on the "No sound" device is still possible without it. The x86 / x86_64 /
+armhf / aarch64 architectures are supported. The x86 build requires SSE, while
+the armhf build requires ARMv6+VFP2 or above.
+
+Android version
+---------------
+Android 2.3 or above is required. BASS can use AAudio, OpenSL ES, or AudioTrack
+for playback, and AAudio or OpenSL ES for recording. AAudio is the default on
+Android 8.1 and above, while OpenSL ES is on older Android versions. The
+armeabi-v7a / arm64-v8a / x86 / x86_64 architectures are supported.
+
+iOS version
+-----------
+iOS 8 or above is required. armv7 / armv7s / arm64 "device" builds and
+i386 / x86_64 / arm64 "simulator" builds are supported in a single universal
+framework.
+
+
+Using BASS
+==========
+See the documentation for descriptions of all the BASS functions. Also check
+the included example source-codes for some examples of how to use BASS in your
+own programs.
+
+There is no guarantee that all future BASS versions will be compatible with all
+previous versions, so your program should use BASS_GetVersion to check the
+version that is loaded. This also means that you should put the BASS module in
+the same directory as your executable (not just somewhere in the path), to
+avoid the possibility of a wrong version being loaded.
+
+If you are updating your software from a previous BASS version, then you should
+check the "History" section (below), to see if any of the functions that you
+are using have been affected by a change. Also see the "Upgrading" section of
+the documentation.
+
+NOTE: To view the documentation outside of Windows, you will need a CHM file
+ viewer. The documentation can also be viewed online at the BASS website.
+
+Win32 version
+-------------
+C/C++, Delphi, and Visual Basic APIs and example projects are provided. To
+use BASS with another language you will first have to convert the header
+file. Or, as a last resort, you could use LoadLibrary and GetProcAddress.
+
+The downside is that you have to manually import each function that you
+use, with the GetProcAddress function. But it has been made a lot simpler
+to import BASS this way by the use of the BASSDEF #define. Here's a small
+example:
+
+#define BASSDEF(f) (WINAPI *f) // define the functions as pointers
+#include "bass.h"
+...
+HINSTANCE bass=LoadLibrary("BASS.DLL"); // load BASS
+BASS_Init=GetProcAddress(bass,"BASS_Init"); // get BASS_Init
+BASS_Init(-1,44100,0,hWnd,NULL); // call BASS_Init
+
+To use BASS with Borland C++ Builder, you will first have to create a Borland
+C++ Builder import library for it. This is done by using the IMPLIB tool that
+comes with Borland C++ Builder. Simply execute this:
+
+ IMPLIB BASSBCB.LIB BASS.DLL
+
+... and then use BASSBCB.LIB in your projects to import BASS.
+
+To use BASS with LCC-Win32, you will first have to create a compatible import
+library for it. This is done by using the PEDUMP and BUILDLIB tools that come
+with LCC-Win32. Run these 2 commands:
+
+ PEDUMP /EXP BASS.LIB > BASSLCC.EXP
+ BUILDLIB BASSLCC.EXP BASSLCC.LIB
+
+... and then use BASSLCC.LIB in your projects to import BASS.
+
+For the BASS functions that return strings (char*), VB users should use the
+VBStrFromAnsiPtr function to convert the returned pointer into a VB string.
+
+macOS version
+-------------
+LIBBASS.DYLIB is a universal binary with support for the x86_64 / arm64 / i386
+architectures. If not all of those architectures are needed, the included
+makefile can be used to extract the wanted one(s). Type "make x86_64" or
+"make arm64" or "make i386" to extract a particular architecture. "make 64bit"
+can be used to extract the x86_64 and arm64 architectures (exclude i386), and
+"make intel" to extract the x86_64 and i386 architectures (exclude arm64). Any
+BASS add-ons placed in the same directory will also be processed.
+
+NOTE: You will need to exclude the i386 architecture (run "make 64bit") for
+ submitting an app to Apple's App Store.
+
+Since BASS 2.4.17, the BASS/add-on libraries use the "@rpath" install path,
+while "@loader_path was used by older versions. If upgrading from an old
+version, you may need to change your copy/embed destination to "Frameworks"
+rather than "Executables", unless your "Runtime Search Paths" setting includes
+"@executable_path" or "@loader_path". The included makefile ("make rpath") can
+be used to update older BASS/add-on versions to use "@rpath".
+
+See the example Xcode project.
+
+Linux version
+-------------
+The LIBBASS.SO file needs to be in the OS's library search path for it to
+be found; simply placing it in the same directory as the executable won't
+necessarily work as it does on other platforms. The BASS libraries should
+also not be placed in the standard library directories to avoid version
+conflicts. To meet those competing requirements, a wrapper script could be
+used to set the "LD_LIBRARY_PATH" environment variable before loading the
+executable. Another option is to set the "rpath" in the executable to tell
+it where to look for libraries, which is what the example makefiles do.
+
+When building for multiple architectures, it is possible to simply try linking
+with all LIBBASS.SO versions, and the linker will use the appropriate one and
+ignore the others.
+
+Android version
+---------------
+The BASS library includes support for use in Java and native code. The
+BASS.JAVA file mirrors BASS.H as much as possible, but there are some
+unavoidable differences, mainly in parameter/return types.
+
+When using ProGuard to obfuscate your Java code, the BASS classes should be
+left alone (see PROGUARD-BASS.PRO).
+
+See the example Android Studio project.
+
+iOS version
+-----------
+The BASS XCFramework should be added to XCode's "Frameworks, Libraries, and
+Embedded Content" section. If you're using an old Xcode version without
+XCFramework support then you can instead directly use the "device" or
+"simulator" framework from within the XCFramework. BASS does not include
+bitcode, so the "Enable Bitcode" option should be set to "No".
+
+Swift
+-----
+To use BASS with Swift, you will need a bridging header referenced in your
+project's "Objective-C Bridging Header" setting, which includes "#import"
+lines for BASS.H and any add-on headers. See Apple's documentation for more
+information on bridging headers.
+
+
+Included files
+==============
+Win32 version
+-------------
+BASS.TXT This file
+BASS.DLL 32-bit BASS module
+BASS.CHM BASS documentation
+X64\
+ BASS.DLL 64-bit BASS module
+C\
+ BASS.H BASS C/C++ header file
+ BASS.LIB 32-bit BASS import library
+ BASS.SLN Visual Studio solution for examples
+ MAKEFILE Makefile for examples
+ MAKEFILE.IN Makefile helper macros
+ X64\
+ BASS.LIB 64-bit BASS import library
+ 3DTEST\ 3D example
+ ...
+ BASSTEST\ Simple playback example
+ ...
+ CONTEST\ Console example
+ ...
+ CUSTLOOP\ Custom looping example
+ ...
+ DEVLIST\ Device list example
+ ...
+ DSPTEST\ DSP example
+ ...
+ FXTEST\ Effects example
+ ...
+ LIVEFX\ Full-duplex example
+ ...
+ LIVESPEC\ Live version of spectrum analyser example
+ ...
+ MODTEST\ MOD music example
+ ...
+ MULTI\ Multiple device example
+ ...
+ NETRADIO\ Internet streaming example
+ ...
+ PLUGINS\ Plugins example
+ ...
+ RECTEST\ Recording example
+ ...
+ SPEAKERS\ Multi-speaker example
+ ...
+ SPECTRUM\ Spectrum analyser example
+ ...
+ SYNTH\ Low latency custom streaming example
+ ...
+ WRITEWAV\ WAVE writer example
+ ...
+ BIN\ Precompiled examples
+ 3DTEST.EXE
+ BASSTEST.EXE
+ CONTEST.EXE
+ CUSTLOOP.EXE
+ DEVLIST.EXE
+ DSPTEST.EXE
+ FXTEST.EXE
+ LIVEFX.EXE
+ LIVESPEC.EXE
+ MODTEST.EXE
+ MULTI.EXE
+ NETRADIO.EXE
+ RECTEST.EXE
+ SPEAKERS.EXE
+ SPECTRUM.EXE
+ SYNTH.EXE
+ WRITEWAV.EXE
+DELPHI\
+ BASS.PAS BASS Delphi/Pascal unit
+ 3DTEST\ 3D example
+ ...
+ BASSTEST\ Simple playback example
+ ...
+ CONTEST\ Console example
+ ...
+ CUSTLOOP\ Custom looping example
+ ...
+ DSPTEST\ DSP example
+ ...
+ FXTEST\ Effects example
+ ...
+ LIVEFX\ Full-duplex example
+ ...
+ LIVESPEC\ Live version of spectrum analyser example
+ ...
+ MULTI\ Multiple device example
+ ...
+ NETRADIO\ Internet streaming example
+ ...
+ PLUGINS\ Plugins example
+ ...
+ RECTEST\ Recording example
+ ...
+ SAMPLEVIS\ Visualisation example
+ ...
+ SPEAKERS\ Multi-speaker example
+ ...
+ SPECTRUM\ Spectrum analyser example
+ ...
+ STREAMTEST\ User stream example
+ ...
+ WRITEWAV\ WAVE writer example
+ ...
+VB\
+ BASS.BAS BASS Visual Basic module
+ 3DTEST\ 3D example
+ ...
+ BASSTEST\ Simple playback example
+ ...
+ CUSTLOOP\ Custom looping example
+ ...
+ DEVLIST\ Device list example
+ ...
+ DSPTEST\ DSP example
+ ...
+ FXTEST\ Effects example
+ ...
+ LIVEFX\ Full-duplex example
+ ...
+ LIVESPEC\ Live version of spectrum analyser example
+ ...
+ MODTEST\ MOD music example
+ ...
+ MULTI\ Multiple device example
+ ...
+ NETRADIO\ Internet streaming example
+ ...
+ PLUGINS\ Plugins example
+ ...
+ RECTEST\ Recording example
+ ...
+ SPEAKERS\ Multi-speaker example
+ ...
+ SPECTRUM\ Spectrum analyser example
+ ...
+ SYNTH\ Low latency custom streaming example
+ ...
+ WRITEWAV\ WAVE writer example
+ ...
+ MEMORY\ Playing from memory example by Jobnik
+ ...
+
+NOTE: You may need to "Unblock" the BASS.CHM file in its "Properties" to view
+ it. The documentation can also be viewed online at the BASS website.
+
+NOTE: To run the example EXEs, first you will need to copy BASS.DLL into the
+ same directory as them.
+
+NOTE: The Delphi and VB examples may not always be up to date with the C
+ examples, so the latter should be preferred as references if possible.
+
+macOS version
+-------------
+BASS.TXT This file
+LIBBASS.DYLIB BASS module
+BASS.CHM BASS documentation
+MAKEFILE Makefile for specific architectures
+C\
+ BASS.H BASS C/C++/Objective-C header file
+ BASS.XCODEPROJ Xcode project for examples
+ CONTEST\ Console example
+ ...
+ DEVLIST\ Device list example
+ ...
+ DSPTEST\ DSP example
+ ...
+ FXTEST\ Effects example
+ ...
+ LIVESPEC\ Live version of spectrum analyser example
+ ...
+ MULTI\ Multiple device example
+ ...
+ NETRADIO\ Internet streaming example
+ ...
+ PLUGINS\ Plugins example
+ ...
+ RECTEST\ Recording example
+ ...
+ SPEAKERS\ Multi-speaker example
+ ...
+ SPECTRUM\ Spectrum analyser example
+ ...
+ SYNTH\ Low latency custom streaming example
+ ...
+ WRITEWAV\ WAVE writer example
+ ...
+DELPHI\
+ BASS.PAS BASS Delphi/Pascal unit
+ DEMO\
+ ...
+
+Linux version
+-------------
+BASS.TXT This file
+BASS.CHM BASS documentation
+LIBS\ BASS modules
+ ...
+C\
+ BASS.H BASS C/C++ header file
+ MAKEFILE Makefile for examples
+ MAKEFILE.IN Makefile helper macros
+ 3DTEST\ 3D example
+ ...
+ BASSTEST\ Simple playback example
+ ...
+ CONTEST\ Console example
+ ...
+ CUSTLOOP\ Custom looping example
+ ...
+ DEVLIST\ Device list example
+ ...
+ DSPTEST\ DSP example
+ ...
+ FXTEST\ Effects example
+ ...
+ LIVESPEC\ Live version of spectrum analyser example
+ ...
+ MODTEST\ MOD music example
+ ...
+ MULTI\ Multiple device example
+ ...
+ NETRADIO\ Internet streaming example
+ ...
+ PLUGINS\ Plugins example
+ ...
+ RECTEST\ Recording example
+ ...
+ SPEAKERS\ Multi-speaker example
+ ...
+ SPECTRUM\ Spectrum analyser example
+ ...
+ SYNTH\ Low latency custom streaming example
+ ...
+ WRITEWAV\ WAVE writer example
+ ...
+DELPHI\
+ BASS.PAS BASS Delphi/Pascal unit
+
+Android version
+---------------
+BASS.TXT This file
+BASS.CHM BASS documentation
+LIBS\ BASS modules
+ ...
+C\
+ BASS.H BASS C/C++ header file
+JAVA\COM\UN4SEEN\BASS\
+ BASS.JAVA BASS Java class
+EXAMPLES\ Java examples
+ BUILD.GRADLE Android Studio build script for examples
+ SETTINGS.GRADLE
+ PROGUARD-BASS.PRO
+ DSPTEST\ DSP example
+ ...
+ FXTEST\ Effects example
+ ...
+ LIVESPEC\ Live version of spectrum analyser example
+ ...
+ NETRADIO\ Internet streaming example
+ ...
+ PLUGINS\ Plugins example
+ ...
+ RECTEST\ Recording example
+ ...
+ SPECTRUM\ Spectrum analyser example
+ ...
+ BASSLIB\ BASS library wrapper shared by examples
+ ...
+
+iOS version
+-----------
+BASS.TXT This file
+BASS.XCFRAMEWORK BASS framework
+BASS.CHM BASS documentation
+BASS.H BASS C/C++/Objective-C header file
+
+
+Latest Version
+==============
+The latest version of BASS can always be found at the BASS website:
+
+ www.un4seen.com
+
+
+Licence
+=======
+BASS is free for non-commercial use. If you are a non-commercial entity
+(eg. an individual) and you are not making any money from your product
+(through sales/advertising/etc), then you can use BASS in it for free.
+If you wish to use BASS in commercial products, then please also see the
+next section.
+
+TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, BASS IS PROVIDED
+"AS IS", WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY
+AND/OR FITNESS FOR A PARTICULAR PURPOSE. THE AUTHORS SHALL NOT BE HELD
+LIABLE FOR ANY DAMAGE THAT MAY RESULT FROM THE USE OF BASS. YOU USE
+BASS ENTIRELY AT YOUR OWN RISK.
+
+Usage of BASS indicates that you agree to the above conditions.
+
+All trademarks and other registered names contained in the BASS
+package are the property of their respective owners.
+
+Commercial licensing
+--------------------
+BASS is available for use in your commercial products. The licence types
+available are as follows:
+
+SHAREWARE: Allows the usage of BASS in an unlimited number of your shareware
+("try before you buy") products, which must sell for no more than 40 EUR each.
+Non-shareware products are also permitted, but the price limit is 10 EUR in
+that case. With subscription models, the limit applies to the annual
+subscription fee. The limit can be raised by purchasing duplicate licences (for
+the same platform), eg. 2 licences doubles it. If you are an individual (not a
+corporation) making and selling your own software, this is the licence for you.
+
+SINGLE COMMERCIAL: Allows the usage of BASS in one commercial product.
+
+UNLIMITED COMMERCIAL: Allows the usage of BASS in an unlimited number of your
+commercial products. This licence is on a per-site basis, eg. if you are
+creating products with BASS at 2 sites/locations, then 2 licences are required.
+
+Please note the products must be end-user products, eg. not components used by
+other products.
+
+These licences only cover your own software, not the publishing of other's
+software. If you publish other's software, its developers (or the software
+itself) will need to be licensed to use BASS.
+
+These licences are on a per-platform basis, with reductions available when
+licensing for multiple platforms. In all cases there are no royalties to pay,
+and you can use future BASS updates without further cost.
+
+These licences do not allow reselling/sublicensing of BASS. For example, if a
+product is a development system, the users of said product are not licensed to
+use BASS in their productions; they will need their own licences.
+
+If the standard licences do not meet your requirements, or if you have any
+questions, please get in touch (email: bass@un4seen.com).
+
+Visit the BASS website for the latest pricing:
+
+ www.un4seen.com
+
+
+Credits
+=======
+MP3 decoding is based on minimp3, https://github.com/lieff/minimp3
+
+Ogg Vorbis decoding is based on libogg/vorbis,
+Copyright (c) 2002-2020 Xiph.org Foundation
+
+API/Sample contributors
+-----------------------
+Delphi: Titus Miloi, Rogier Timmermans, Alessandro Cappellozza,
+ Jesse Naranjo, Chris Troesken, Evgeny Melnikov
+Visual Basic: Adam Hoult, Hendrik Knaepen, Arthur Aminov,
+ Peter Hebels, Juan Pablo Franchi
+
+
+History
+=======
+These are the major (and not so major) changes in each release, along with the
+functions/options that are introduced/affected by them. There are other little
+tweaks and fixes made along the way too.
+
+2.4.18.3 - 15/12/2025
+---------------------
+* Support for MP3/MP2/MP1 data in a single track MP4 container
+ BASS_StreamCreateFile/User/URL
+* Specific error code for seeking failures that leave a stream unable to continue
+ BASS_ERROR_ENDED (BASS_ChannelSetPosition error code)
+* Tweaks for internet/buffered streaming with Media Foundation codecs
+ BASS_StreamCreateURL
+ BASS_StreamCreateFileUser
+ BASS_ChannelSetPosition
+* Fix for missing iTunes MP4 tags bug in 2.4.18
+ BASS_TAG_MP4 (BASS_ChannelGetTags type)
+* Fix for burst of old sound when restarting a stopped device on Windows
+ BASS_Start
+
+2.4.18 - 7/10/2025
+------------------
+* Downmixed multi-channel playback
+ BASS_CONFIG_DOWNMIX (BASS_SetConfig option)
+ BASS_ATTRIB_DOWNMIX (BASS_ChannelSetAttribute option)
+* Multi-channel DX8 effect support
+ BASS_ChannelSetFX
+* DSP/FX on individual mono/stereo channels within multi-channel streams
+ BASS_ChannelSetDSPEx
+ BASS_DSP_MONO_N/BASS_DSP_STEREO_N (BASS_ChannelSetDSPEx/FX flags)
+* DSP/FX bypassing
+ BASS_DSP_BYPASS (BASS_ChannelSetDSPEx/FX flag)
+ BASS_FXSetBypass
+* Floating-point DSP functions
+ BASS_DSP_FLOAT (BASS_ChannelSetDSPEx flag)
+* Read-only DSP functions
+ BASS_DSP_READONLY (BASS_ChannelSetDSPEx flag)
+* Freed/removed DSP function notifications
+ BASS_DSP_FREECALL (BASS_ChannelSetDSPEx flag)
+* DSP/FX freeing without needing a channel handle
+ BASS_FXFree
+ FXTEST example updated
+* DSP/FX processing granularity support on decoding channels
+ BASS_ATTRIB_GRANULE (BASS_ChannelSetAttribute option)
+* DSP callback position retrieval
+ BASS_POS_DSP (BASS_ChannelGetPosition mode)
+* Channels can be assigned to unavailable speakers
+ BASS_SPEAKER_xxx (BASS_MusicLoad/BASS_StreamCreate/File/URL flags)
+* Speaker flags can be added/removed after channel creation and set to unavailable ones
+ BASS_ChannelFlags
+* Multi-speaker output enabling on Android
+ BASS_DEVICE_SPEAKERS (BASS_Init flag)
+* Multi-speaker output on "No Sound" device
+ BASS_DEVICE_SPEAKERS (BASS_Init flag)
+* Configurable "No sound" device update delay limit
+ BASS_CONFIG_NOSOUND_MAXDELAY (BASS_SetConfig option)
+* Device list notifications on Windows/macOS
+ BASS_CONFIG_DEV_NOTIFY (BASS_SetConfigPtr option)
+ DEVICENOTIFYPROC
+* User handling of all local file reading
+ BASS_CONFIG_FILEOPENPROCS (BASS_SetConfigPtr option)
+ FILEOPENPROC
+ BASS_FILEOPENPROCS structure
+* Monotonic byte position (unaffected by seeking) retrieval and syncing
+ BASS_POS_RAW (BASS_ChannelGetPosition mode)
+ BASS_SYNC_POS_RAW (BASS_ChannelSetSync type)
+* Custom end position is rounded-up to nearest sample
+ BASS_POS_END (BASS_ChannelGetPosition mode)
+* Retrieval of custom end and loop position settings
+ BASS_POS_END (BASS_ChannelGetPosition mode)
+ BASS_POS_LOOP (BASS_ChannelGetPosition mode)
+* Integer attribute values
+ BASS_ChannelSetAttributeEx
+ BASS_ChannelGetAttributeEx
+ BASS_ATTRIBTYPE_FLOAT
+ BASS_ATTRIBTYPE_INT
+* Unlimited user-defined attribute size
+ BASS_ATTRIB_USER (BASS_ChannelSetAttributeEx option)
+* DSP volume priority attribute retrieval
+ BASS_ATTRIB_VOLDSP_PRIORITY (BASS_ChannelGetAttribute/Ex option)
+* Setting an attribute cancels sliding of it
+ BASS_ChannelSetAttribute/Ex
+ BASS_ChannelSlideAttribute
+* RECORDPROC option for doing nothing with the data
+ RECORDPROC_TRUE (BASS_RecordStart option)
+ LIVESPEC example updated
+* Constant added for recording without a RECORDPROC
+ RECORDPROC_NONE (BASS_RecordStart option)
+* Minimum recording period reduced to 1ms
+ BASS_RecordStart
+* A recording channel is no longer auto-freed when it fails
+ BASS_RecordStart
+ BASS_SYNC_DEV_FAIL (BASS_ChannelSetSync type)
+* ID3v2 tag block retrieval with its length
+ BASS_TAG_ID3V2_BINARY (BASS_ChannelGetTags type)
+ BASS_TAG_ID3V2_2_BINARY (BASS_ChannelGetTags type)
+ TAG_BINARY structure
+ BASS_TAG_ID3V2 deprecated
+ BASS_TAG_ID3V2_2 deprecated
+* MP4 cover art retrieval
+ BASS_TAG_MP4_COVERART (BASS_ChannelGetTags type)
+* MOD music filename retrieval
+ BASS_CHANNELINFO filename member
+* Internet stream creation cancelling
+ BASS_StreamCancel
+ NETRADIO example updated
+* File reading "mem" option changed to a multi-option "filetype"
+ BASS_FILE_NAME
+ BASS_FILE_MEM
+ BASS_StreamCreateFile
+ BASS_SampleLoad
+ BASS_MusicLoad
+* Option to use a copy when streaming a file from memory
+ BASS_FILE_MEMCOPY (BASS_StreamCreateFile option)
+* Streaming from file handles/descriptors
+ BASS_FILE_HANDLE
+ BASS_StreamCreateFile
+ BASS_SampleLoad
+ BASS_MusicLoad
+* ParcelFileDescriptors are duplicated so they can be reused on Android
+ BASS_StreamCreateFile
+ BASS_SampleLoad
+ BASS_MusicLoad
+* Thread-specific playback buffer option
+ BASS_CONFIG_THREAD (BASS_SetConfig flag)
+ BASS_CONFIG_BUFFER (BASS_SetConfig option)
+* Thread-specific asynchronous file reading buffer option
+ BASS_CONFIG_THREAD (BASS_SetConfig flag)
+ BASS_CONFIG_ASYNCFILE_BUFFER (BASS_SetConfig option)
+* Retrieval of asynchronous file reading buffer size
+ BASS_FILEPOS_ASYNCSIZE (BASS_StreamGetFilePosition mode)
+* Channel reference counting for delayed/asynchronous freeing
+ BASS_ChannelRef
+ BASS_ChannelFree
+ BASS_StreamFree
+ BASS_MusicFree
+ BASS_TAG_INCREF (BASS_ChannelGetTags flag)
+* Channels can be freed (asynchronously) within their callback functions
+ BASS_ChannelFree
+ BASS_StreamFree
+ BASS_MusicFree
+* Freeing a locked channel disables other calls on it while waiting for unlock
+ BASS_ChannelLock
+ BASS_ChannelFree
+ BASS_StreamFree
+ BASS_MusicFree
+* Improved MP3 seeking accuracy without prescanning
+ BASS_ChannelSetPosition
+* Improved performance of playback/recording data retrieval
+ BASS_ChannelGetData
+ BASS_ChannelGetLevel/Ex
+* 16 point SRC is used by default when NEON is available
+ BASS_CONFIG_SRC (BASS_SetConfig option)
+* Option to play audio from videos now applies to all formats (may require add-on updates)
+ BASS_CONFIG_MF_VIDEO renamed to BASS_CONFIG_VIDEO (BASS_SetConfig option)
+* Option to disable all Android Media codecs except AAC/ADTS
+ BASS_CONFIG_AM_DISABLE (BASS_SetConfig option)
+* Apple voice processing for echo cancellation on macOS/iOS
+ BASS_DEVICE_APPLEVOICE (BASS_Init flag)
+* Stereo output limiting on macOS
+ BASS_DEVICE_STEREO (BASS_Init flag)
+* Initialization flag for OpenSL ES output on Android
+ BASS_DEVICE_OPENSLES (BASS_Init flag)
+* AAudio recording on Android (OpenSL ES is fallback/optional)
+ BASS_RECORD_OPENSLES (BASS_RecordStart flag)
+* Native recording format retrieval on Android (8.1 and above)
+ BASS_RecordGetInfo
+ RECTEST example updated
+* Supported recording formats info removed on old Windows (as on other platforms)
+ BASS_RECORDINFO formats member
+* Unused structure members removed (changed to "reserved")
+ BASS_INFO structure
+ BASS_SAMPLE structure
+* buffer parameter must be 0 when using BASS_DATA_AVAILABLE flag
+ BASS_ChannelGetData
+* Support for custom "Host" request header on Linux/Android/UWP (already on other platforms)
+ BASS_StreamCreateURL
+* libssl.so.3 added to OpenSSL library search list on Linux
+ BASS_StreamCreateURL
+ BASS_CONFIG_LIBSSL (BASS_SetConfigPtr option)
+* 16KB page support on Android
+* Fix for bug in Doppler effect from listener velocity
+ BASS_Set3DPosition
+* Channel order fix for 7/8 channel OGG files
+ BASS_StreamCreateFile/User/URL
+* Fix for Media Foundation FLAC decoder seeking freeze
+ BASS_ChannelSetPosition
+
+2.4.17 - 12/10/2022
+-------------------
+* "Default" recording device on Windows/macOS
+ BASS_RecordInit
+ BASS_RecordGetDeviceInfo
+ BASS_CONFIG_REC_DEFAULT (BASS_SetConfig option)
+* Recording continues after device format changes on Windows/macOS
+ BASS_RecordStart
+ BASS_SYNC_DEV_FORMAT (BASS_ChannelSetSync type)
+* DSP volume control
+ BASS_ATTRIB_VOLDSP (BASS_ChannelSetAttribute option)
+ BASS_ATTRIB_VOLDSP_PRIORITY (BASS_ChannelSetAttribute option)
+ RECTEST example updated
+* Auto-reconnecting when a download stops before the end
+ BASS_StreamCreateURL
+* Internet streams use HTTP/1.1 instead of HTTP/1.0
+ BASS_StreamCreateURL
+* Download callback changing after stream creation
+ BASS_ATTRIB_DOWNLOADPROC (BASS_ChannelSetAttributeEx option)
+ NETRADIO example updated
+* Thread-specific internet/buffered stream download buffer option
+ BASS_CONFIG_THREAD (BASS_SetConfig flag)
+ BASS_CONFIG_NET_BUFFER (BASS_SetConfig option)
+ BASS_StreamCreateURL/FileUser
+* Internet stream creation never waits for pre-buffering
+ BASS_CONFIG_NET_PREBUF_WAIT disabled
+ BASS_StreamCreateURL/FileUser
+* Stalled stream resumption buffer level defaults to prebuffer level
+ BASS_ATTRIB_NET_RESUME (BASS_ChannelSetAttribute option)
+* New simplified channel starting/playing function
+ BASS_ChannelStart
+* Channel remapping fix for various multi-channel source/output combinations
+ BASS_ChannelStart/Play
+* Support for using common add-on name instead of platform-specific filename in plugin loading
+ BASS_PluginLoad
+ NETRADIO example updated
+* Library filename/path retrieval
+ BASS_CONFIG_FILENAME (BASS_GetConfigPtr option)
+ PLUGINS and WRITEWAV examples updated
+* Device info is still available if reinitialization fails
+ BASS_GetInfo
+ BASS_DEVICE_REINIT (BASS_Init flag)
+* Playback ramping-in/out can be disabled separately
+ BASS_ATTRIB_NORAMP (BASS_ChannelSetAttribute option)
+* Default playback ramping setting
+ BASS_CONFIG_NORAMP (BASS_SetConfig option)
+* Error code for when access is denied
+ BASS_ERROR_DENIED
+ BASS_StreamCreateURL
+ BASS_RecordStart
+* REAR2 speaker flags renamed/duplicated to SIDE
+ BASS_SPEAKER_SIDE/SIDELEFT/SIDERIGHT (BASS_MusicLoad/BASS_StreamCreate/File/URL flags)
+* Switch to dynamic library/framework (rather than static) on iOS
+ BASS_PluginLoad
+* Recording routing audio session options on iOS
+ BASS_IOS_SESSION_AIRPLAY/BTHFP/BTA2DP (BASS_CONFIG_IOS_SESSION flags)
+* Audio session deactivation when nothing is playing/recording on iOS
+ BASS_IOS_SESSION_DEACTIVATE (BASS_CONFIG_IOS_SESSION flag)
+* Interrupted status retrieval on iOS
+ BASS_IsStarted
+* Floating-point recording support below Android 5 (already supported above)
+ BASS_RecordStart
+* Internet stream support for Linux systems without /etc/services file
+ BASS_StreamCreateURL
+* Fix for IPv6 addresses in URLs on Windows/Linux/Android
+ BASS_StreamCreateURL
+* Fix for getting volume changes made externally on Linux
+ BASS_GetVolume
+ BASS_RecordGetInput
+* Media Foundation Matroska support disabled due to a leak in it (use BASSWEBM instead)
+* macOS version now uses "@rpath" instead of "@loader_path" install path
+* ARM builds are now included (instead of separate) on Linux
+* Non-FPU ARM architecture support dropped (Windows CE and "softfp" Linux)
+* SSE is required on x86 platforms (Windows 95 dropped)
+
+2.4.16 - 24/6/2021
+------------------
+* DirectSound output mix is now generated by BASS (as with WASAPI output and
+ on other platforms), losing support for DirectSound-specific features but
+ gaining support for all other BASS features
+ BASS_DEVICE_DSOUND (BASS_Init flag)
+ BASS_SAMPLE_FX/SOFTWARE/VAM *ignored*
+* Length trimming
+ BASS_POS_END (BASS_ChannelSetPosition mode)
+ BASS_SYNC_END (BASS_ChannelSetSync type)
+ CUSTLOOP and WRITEWAV examples updated
+* Custom loop start positions
+ BASS_POS_LOOP (BASS_ChannelSetPosition mode)
+ CUSTLOOP example updated
+* Length extending
+ BASS_ATTRIB_TAIL (BASS_ChannelSetAttribute option)
+ BASS_SYNC_END (BASS_ChannelSetSync type)
+* Device reinitialization while retaining BASS channels and 3D settings
+ BASS_DEVICE_REINIT (BASS_Init flag)
+* Retrieval of a device's activity status
+ BASS_IsStarted
+* BASS_DEVICE_3D flag is no longer needed to enable 3D support
+ BASS_Init
+* The "Default" device is now enabled by default on Windows/macOS
+ BASS_CONFIG_DEV_DEFAULT (BASS_SetConfig option)
+* Default communications device indicator on Windows
+ BASS_DEVICE_DEFAULTCOM (BASS_DEVICEINFO flag)
+* Sample channel "newonly" option changed to a flag
+ BASS_SAMCHAN_NEW (BASS_SampleGetChannel flag)
+* Sample playback streams
+ BASS_SAMCHAN_STREAM (BASS_SampleGetChannel flag)
+ BASS_CTYPE_STREAM_SAMPLE
+* Speaker assignment support for samples
+ BASS_SampleGetChannel
+ BASS_ChannelFlags
+* Overriding of default flags during sample channel creation
+ BASS_SampleGetChannel
+* Same sample channel handle when max=1 is now optional
+ BASS_CONFIG_SAMPLE_ONEHANDLE (BASS_SetConfig option)
+* 3D max distance muting can now be toggled
+ BASS_SAMPLE_MUTEMAX (BASS_ChannelFlags flag)
+* Plugin disabling without affecting existing streams
+ BASS_PluginEnable
+* WASAPI recording on Windows (DirectSound is fallback/optional)
+ BASS_CONFIG_REC_WASAPI (BASS_SetConfig option)
+ BASS_RecordInit
+* Minimum recording buffer reduced to 10ms
+ BASS_CONFIG_REC_BUFFER (BASS_SetConfig option)
+* Recording data/level retrieval without removing from buffer when not using a RECORDPROC
+ BASS_DATA_NOREMOVE (BASS_ChannelGetData flag)
+ BASS_LEVEL_NOREMOVE (BASS_ChannelGetLevelEx flag)
+* Support for level measurements longer than 1s from decoding channels
+ BASS_ChannelGetLevelEx
+* Configurable restricted download rate
+ BASS_CONFIG_NET_RESTRATE (BASS_SetConfig option)
+* Thread-specific internet stream timeout options
+ BASS_CONFIG_THREAD (BASS_SetConfig flag)
+ BASS_CONFIG_NET_TIMEOUT (BASS_SetConfig option)
+ BASS_CONFIG_NET_READTIMEOUT (BASS_SetConfig option)
+ BASS_StreamCreateURL
+* Configured "User-Agent" header can be overridden in custom request headers
+ BASS_StreamCreateURL
+* Multiple IPs will be tried when available on Linux/Android (already on other platforms)
+ BASS_StreamCreateURL
+* BASS_STREAM_BLOCK applied automatically on files larger than 2GB
+ BASS_StreamCreateURL/FileUser
+* Redirected URL retrieval
+ BASS_TAG_LOCATION (BASS_ChannelGetTags type)
+* Shoutcast metadata is now optional
+ BASS_CONFIG_NET_META (BASS_SetConfig option)
+ BASS_TAG_META (BASS_ChannelGetTags type)
+* Support for a 2nd ID3v2 tag block
+ BASS_TAG_ID3V2_2 (BASS_ChannelGetTags type)
+* OpenMPT channel name retrieval
+ BASS_TAG_MUSIC_CHAN (BASS_ChannelGetTags type)
+* Built-in support for MP4 tags on all platforms
+ BASS_TAG_MP4 (BASS_ChannelGetTags type)
+* Delayed OGG file scanning when prescanning is disabled/impossible
+ BASS_ChannelGetLength
+ BASS_ChannelSetPosition
+ BASS_CONFIG_OGG_PRESCAN (BASS_SetConfig option)
+* Faster seeking in large OGG files
+ BASS_ChannelSetPosition
+* Unified freeing function for all channel types
+ BASS_ChannelFree
+* User-defined attribute
+ BASS_ATTRIB_USER (BASS_ChannelSetAttributeEx option)
+* Push stream buffer size limiting
+ BASS_ATTRIB_PUSH_LIMIT (BASS_ChannelSetAttribute option)
+* Retrieval of a stream's available file data amount
+ BASS_FILEPOS_AVAILABLE (BASS_StreamGetFilePosition mode)
+* Error code for unsupported URL protocols
+ BASS_ERROR_PROTOCOL (BASS_StreamCreateURL error code)
+* BASS_ERROR_ALREADY error changed to success in some functions
+ BASS_ChannelPause
+ BASS_ChannelSetDevice
+ BASS_ChannelSetLink
+* Support for custom file streaming with Android Media codecs
+ BASS_StreamCreateFileUser
+* Switch to using native APIs for Android Media codecs (Android 5 or above required now)
+ BASS_StreamCreateFile/User/URL
+* BASS_TAG_AM_MIME value changed to fix conflict on Android
+ BASS_TAG_AM_MIME (BASS_ChannelGetTags type)
+* Improved support for single active input recording devices on Linux
+ BASS_RecordGetInputName
+ BASS_RecordGetInput
+ BASS_RecordSetInput
+* Automatic resumption of recordings after an interruption on iOS
+* Fix for Media Foundation AAC streaming with Windows 10 20H1
+ BASS_StreamCreateURL
+ BASS_StreamCreateFileUser
+* Fix for freeze when freeing a stream after BASS_ChannelSetLink failed with BASS_ERROR_ALREADY on it
+ BASS_ChannelSetLink
+* macOS examples rewritten in Objective-C/Cocoa
+* "MP3-FREE" version removed (MP3 patents expired)
+* armv5 (armeabi) architecture support dropped on Android
+
+2.4.15 - 17/12/2019
+-------------------
+* Channel processing granularity option
+ BASS_ATTRIB_GRANULE (BASS_ChannelSetAttribute option)
+* Asynchronous mixtime sync callbacks
+ BASS_SYNC_THREAD (BASS_ChannelSetSync flag)
+* Output device update period can be set in samples
+ BASS_CONFIG_DEV_PERIOD (BASS_SetConfig option)
+* Continuous output when nothing is playing is now optional on macOS/iOS too
+ BASS_CONFIG_DEV_NONSTOP (BASS_SetConfig option)
+* AAudio output support on Android
+ BASS_Init
+ BASS_CONFIG_ANDROID_AAUDIO (BASS_SetConfig option)
+* Support for audio session IDs on Android
+ BASS_CONFIG_ANDROID_SESSIONID (BASS_SetConfig option)
+* ParcelFileDescriptor support on Android
+ BASS_StreamCreateFile
+ BASS_SampleLoad
+ BASS_MusicLoad
+* FloatValue Java class added to receive floating-point values on Android 10
+ BASS_Get3DFactors
+ BASS_RecordGetInput
+ BASS_ChannelGetAttribute
+ BASS_ChannelGet3DAttributes
+* Audio session configuration option on iOS
+ BASS_CONFIG_IOS_SESSION (BASS_SetConfig option)
+* OGG channel type constant renamed/duplicated to VORBIS
+ BASS_CTYPE_STREAM_VORBIS
+* Error code for recognised file formats without audio
+ BASS_ERROR_NOTAUDIO (BASS_StreamCreateFile/User/URL error code)
+* Error code for unstreamable MP4 files due to atom order
+ BASS_ERROR_UNSTREAMABLE (BASS_StreamCreateFileUser/URL error code)
+* Fix for AAC streaming with BASS_STREAM_BLOCK on macOS 10.15 and iOS 13
+ BASS_StreamCreateURL/FileUser
+* Fix for playback rate changing bug when using DirectSound
+ BASS_ATTRIB_FREQ (BASS_ChannelSetAttribute option)
+* armv6 architecture support dropped on iOS
+
+2.4.14 - 16/1/2019
+------------------
+* Support for playback on the "No sound" device
+ BASS_Init
+ BASS_StreamCreateFile/User/URL
+ BASS_SampleLoad/Create
+ BASS_ChannelPlay
+* Improved sample rate conversion quality, including 64 point option
+ BASS_ATTRIB_SRC (BASS_ChannelSetAttribute option)
+ BASS_CONFIG_SRC/_SAMPLE (BASS_SetConfig options)
+* Loopback (output device) recording on Windows Vista and newer
+ BASS_RecordInit
+ BASS_RecordGetDeviceInfo
+ BASS_DEVICE_LOOPBACK (BASS_DEVICEINFO flag)
+ DEVLIST and RECTEST examples updated
+* Native format recording option
+ BASS_RecordStart
+* Access to the 3D output mix for applying DSP/FX
+ STREAMPROC_DEVICE_3D (BASS_StreamCreate option)
+* Retrieval of a device's started/stopped status
+ BASS_IsStarted
+* Sync for when a device stops unexpectedly
+ BASS_SYNC_DEV_FAIL (BASS_ChannelSetSync type)
+* New channel playback state for a paused device
+ BASS_ACTIVE_PAUSED_DEVICE (BASS_ChannelIsActive)
+* Sync for when a device's sample format changes
+ BASS_SYNC_DEV_FORMAT (BASS_ChannelSetSync type)
+* Adjustable output device update period
+ BASS_CONFIG_DEV_PERIOD (BASS_SetConfig option)
+* Nyquist FFT option
+ BASS_DATA_FFT_NYQUIST (BASS_ChannelGetData flag)
+* Plugin-specific flags are ignored in standard stream creation functions
+ BASS_StreamCreateFile/User/URL
+* Pre-buffering during stream creation is now disabled by default
+ BASS_CONFIG_NET_PREBUF_WAIT (BASS_SetConfig option)
+ BASS_StreamCreateURL/FileUser
+* Existing internet streams are unaffected by read timeout config changes
+ BASS_CONFIG_NET_READTIMEOUT (BASS_SetConfig option)
+* Retrieval of the number of existing handles
+ BASS_CONFIG_HANDLES (BASS_GetConfig option)
+* Windows mixer settings are retained by default and optionally not
+ BASS_CONFIG_WASAPI_PERSIST (BASS_SetConfig option)
+* Configurable OpenSSL library filename on Linux/Android
+ BASS_CONFIG_LIBSSL (BASS_SetConfigPtr option)
+* Files can be deleted/renamed while streaming on Windows (already on other platforms)
+ BASS_StreamCreateFile
+* Fix for bug in playback buffering disabling option
+ BASS_ATTRIB_NOBUFFER (BASS_ChannelSetAttribute option)
+* Fix to support long logarithmic curves in volume effects
+ BASS_FX_VOLUME (BASS_ChannelSetFX type)
+
+2.4.13.8 - 6/2/2018
+-------------------
+* Indication of a file's original sample format being floating-point
+ BASS_ORIGRES_FLOAT (BASS_CHANNELINFO/SAMPLE flag)
+* Some extra sample data is retained to account for device latency in data retrieval
+ BASS_ChannelGetData
+* FFT window is shortened to match amount of available data when less than FFT size
+ BASS_ChannelGetData
+* FFT level adjusted to be approx the same regardless of amount of available data
+ BASS_ChannelGetData
+* Fix for crash when decoding channels are dissociated from a device
+ BASS_NODEVICE (BASS_ChannelSetDevice option)
+
+2.4.13 - 19/12/2017
+-------------------
+* WASAPI output on Windows (DirectSound is fallback/optional)
+ BASS_DEVICE_DSOUND (BASS_Init flag)
+ BASS_CONFIG_DEV_BUFFER/NONSTOP (BASS_SetConfig options)
+ BASS_CONFIG_SRC/_SAMPLE (BASS_SetConfig options)
+ BASS_ATTRIB_SRC/NORAMP (BASS_ChannelSetAttribute options)
+* Default output device following support extended to Windows Vista and macOS
+ BASS_CONFIG_DEV_DEFAULT (BASS_SetConfig option)
+* Seamless playback buffer length changing
+ BASS_ATTRIB_BUFFER (BASS_ChannelSetAttribute option)
+ BASS_ATTRIB_NOBUFFER deprecated
+ SYNTH example updated
+* Access to the final output mix for applying DSP/FX
+ STREAMPROC_DEVICE (BASS_StreamCreate option)
+ BASS_CTYPE_STREAM_DEVICE
+ FXTEST example updated
+* Dissociating of decoding channels from a device
+ BASS_NODEVICE (BASS_ChannelSetDevice option)
+* Volume attribute levels above 1.0 for amplification
+ BASS_ATTRIB_VOL (BASS_ChannelSetAttribute option)
+* Logarithmic attribute slides
+ BASS_SLIDE_LOG (BASS_ChannelSlideAttribute flag)
+* Volume & panning attribute affected level retrieval
+ BASS_LEVEL_VOLPAN (BASS_ChannelGetLevelEx flag)
+* Volume level adjustment effect
+ BASS_FX_VOLUME (BASS_ChannelSetFX type)
+ BASS_FX_VOLUME_PARAM structure
+* Asynchronous pre-buffering
+ BASS_CONFIG_NET_PREBUF_WAIT (BASS_SetConfig option)
+ BASS_StreamCreateURL/FileUser
+ CONTEST and NETRADIO examples updated
+* Decoding channels can be dissociated from a device
+ BASS_ChannelSetDevice
+* Resetting of user file streams/decoders
+ BASS_POS_RESET (BASS_ChannelSetPosition flag)
+* Relative seeking
+ BASS_POS_RELATIVE (BASS_ChannelSetPosition flag)
+* RIFF cue points chunk retrieval
+ BASS_TAG_RIFF_CUE (BASS_ChannelGetTags type)
+ TAG_CUE structure
+* RIFF sample chunk retrieval
+ BASS_TAG_RIFF_SMPL (BASS_ChannelGetTags type)
+ TAG_SMPL structure
+* Support for Category/Description/Grouping/Keywords/Lyrics MP4 tags
+ BASS_TAG_MP4 (BASS_ChannelGetTags type)
+* Separate channel type for dummy streams
+ BASS_CTYPE_STREAM_DUMMY
+* Stalled internet stream buffering progress retrieval
+ BASS_FILEPOS_BUFFERING (BASS_StreamGetFilePosition mode)
+ CONTEST and NETRADIO examples updated
+* Playlist recursion depth config option
+ BASS_CONFIG_NET_PLAYLIST_DEPTH (BASS_SetConfig options)
+* Push file system read functions can now return 0 without ending the file
+ STREAMFILE_BUFFERPUSH (BASS_StreamCreateFileUser system)
+ FILEREADPROC
+* 16 point SRC is used by default when SSE is available
+ BASS_CONFIG_SRC (BASS_SetConfig option)
+* Mono downmixing for all file formats in sample loading
+ BASS_SAMPLE_MONO (BASS_SampleLoad flag)
+* Mono OGG stream downmixing
+ BASS_SAMPLE_MONO (BASS_StreamCreateFile/User/URL flag)
+* Media Foundation availability detection
+ BASS_CONFIG_MF_DISABLE (BASS_GetConfig option)
+ PLUGINS example updated
+* AudioTrack output on Android
+ BASS_DEVICE_AUDIOTRACK (BASS_Init flag)
+* Support for Android Media codecs
+ BASS_StreamCreateFile/User/URL
+ BASS_TAG_AM_MIME/NAME (BASS_ChannelGetTags type)
+ BASS_CONFIG_AM_DISABLE (BASS_SetConfig option)
+* Panning fix for MOD files created with ScreamTracker 3
+ BASS_MusicLoad
+* Fix for compressor and distotion DX8 effects with floating-point data on Windows
+ BASS_FX_DX8_COMPRESSOR/DISTORTION (BASS_ChannelSetFX types)
+* Fix for plugin freeing issue on 64-bit platforms (HPLUGIN is not library address any more)
+ BASS_PluginLoad
+ BASS_PluginFree
+
+2.4.12 - 10/3/2016
+------------------
+* DSP/FX chain order modification
+ BASS_FXSetPriority
+* Average bitrate retrieval
+ BASS_ATTRIB_BITRATE (BASS_ChannelGetAttribute option)
+* Ramping-out when stopping/pausing playback
+ BASS_ChannelStop
+ BASS_ChannelPause
+* Disabling of playback ramping
+ BASS_ATTRIB_NORAMP (BASS_ChannelSetAttribute option)
+* Pre-buffering when playback buffering is disabled
+ BASS_ChannelUpdate
+ BASS_ATTRIB_NOBUFFER (BASS_ChannelSetAttribute option)
+* Net agent and proxy config options now make a copy of the provided string
+ BASS_CONFIG_NET_AGENT/PROXY (BASS_SetConfigPtr options)
+ NETRADIO example updated
+* Unicode net agent and proxy config options on Windows
+ BASS_CONFIG_NET_AGENT/PROXY (BASS_SetConfigPtr options)
+* 32768 sample FFT
+ BASS_DATA_FFT32768 (BASS_ChannelGetData flag)
+* Support for iTunes gapless info in MP3 files
+ BASS_StreamCreateFile/User
+* 529 sample delay removed from start of MP3 files without delay info
+ BASS_StreamCreateFile/User/URL
+* Support for numerical MP4 genre (gnre) tags
+ BASS_TAG_MP4 (BASS_ChannelGetTags type)
+* Support for BASSWMA's tag option when using Media Foundation WMA codec
+ BASS_TAG_WMA (BASS_ChannelGetTags type)
+* OpenMPT author tag retrieval
+ BASS_TAG_MUSIC_AUTH (BASS_ChannelGetTags type)
+* FastTracker2 panning for XM files
+ BASS_MUSIC_FT2PAN (BASS_MusicLoad flag)
+* Support for longer device names on Windows
+ BASS_DEVICEINFO name member
+* Checking for floating-point support on platforms that might not have it
+ BASS_CONFIG_FLOAT (BASS_GetConfig option)
+* Attribute size retrieval fix
+ BASS_ChannelGetAttributeEx
+* Fix for missing LC_VERSION_MIN issue with Xcode 7 on OSX, ppc architecture (PowerPC) support dropped
+* LIST example renamed to DEVLIST
+
+2.4.11 - 22/12/2014
+-------------------
+* Support for custom HTTP request headers
+ BASS_StreamCreateURL
+* HTTPS support on Linux/Android
+ BASS_StreamCreateURL
+* Redirection HTTP headers provided during connection
+ BASS_STREAM_STATUS (BASS_StreamCreateURL flag)
+ DOWNLOADPROC
+* Adjustable stalled internet stream resumption buffer level
+ BASS_ATTRIB_NET_RESUME (BASS_ChannelSetAttribute option)
+* Extended level retrieval inc. multi-channel and RMS options
+ BASS_ChannelGetLevelEx
+* Inexact/faster seeking
+ BASS_POS_INEXACT (BASS_ChannelSetPosition flag)
+* Post-stream creation scanning (seek table building)
+ BASS_POS_SCAN (BASS_ChannelSetPosition flag)
+* Extended attribute support
+ BASS_ChannelSetAttributeEx
+ BASS_ChannelGetAttributeEx
+* Scanned info retrieval and setting
+ BASS_ATTRIB_SCANINFO (BASS_ChannelGet/SetAttributeEx option)
+* Built-in ADPCM WAVE file support on Linux/Android/WinCE
+ BASS_StreamCreateFile/User/URL
+ BASS_SampleLoad
+* Active MOD channel count retrieval
+ BASS_ATTRIB_MUSIC_ACTIVE (BASS_ChannelGetAttribute option)
+* Fractional sample rates
+ BASS_ATTRIB_FREQ (BASS_ChannelSetAttribute option)
+* Device type information
+ BASS_DEVICE_TYPE_xxx (BASS_DEVICEINFO flags)
+* Unicode support for recording input names
+ BASS_CONFIG_UNICODE (BASS_SetConfig option)
+ BASS_RecordGetInputName
+* File size retrieval
+ BASS_FILEPOS_SIZE (BASS_StreamGetFilePosition mode)
+* Asynchronous file reading buffer level retrieval
+ BASS_FILEPOS_ASYNCBUF (BASS_StreamGetFilePosition mode)
+* MPEG data search limit raised to 1MB
+ BASS_CONFIG_VERIFY (BASS_SetConfig option)
+* Separate MPEG data search limit for internet/buffered streams
+ BASS_CONFIG_VERIFY_NET (BASS_SetConfig option)
+* Support for iTunes gapless info with Media Foundation and CoreAudio codecs
+ BASS_StreamCreateFile/User
+* Airplay receiver enumeration and selection on OSX
+ BASS_DEVICES_AIRPLAY (BASS_GetDeviceInfo flag)
+ BASS_CONFIG_AIRPLAY (BASS_SetConfig option)
+* Media Foundation playback of audio from videos is now optional
+ BASS_CONFIG_MF_VIDEO (BASS_SetConfig option)
+* Optional floating-point or fixed-point data retrieval on Android/WinCE
+ BASS_DATA_FLOAT/FIXED (BASS_ChannelGetData flags)
+* Continuous output when nothing is playing on Linux/Android/WinCE
+ BASS_CONFIG_DEV_NONSTOP (BASS_SetConfig option)
+* Fix for decoding channels being ended when stopping the output device
+ BASS_Stop
+* Fix for Media Foundation internet/buffered streaming on Windows 8
+ BASS_StreamCreateURL
+ BASS_StreamCreateFileUser
+* Improved compatibility with recent PulseAudio versions on Linux
+* LIST and MODTEST examples added
+* MASM API and examples removed (available as separate download)
+
+2.4.10 - 16/2/2013
+------------------
+* Asynchronous file reading/buffering
+ BASS_ASYNCFILE (BASS_StreamCreateFile/User flag)
+ BASS_CONFIG_ASYNCFILE_BUFFER (BASS_SetConfig option)
+* Support for chained OGG files (multiple logical bitstreams)
+ BASS_StreamCreateFile/User
+ BASS_CONFIG_OGG_PRESCAN (BASS_SetConfig option)
+ BASS_POS_OGG (BASS_ChannelSetPosition/ChannelGetLength mode)
+* OGG logical bitstream syncs are no longer automatically "mixtime"
+ BASS_SYNC_OGG_CHANGE (BASS_ChannelSetSync type)
+* Support for Soundtracker MOD files
+ BASS_MusicLoad
+* Complex FFT data
+ BASS_DATA_FFT_COMPLEX (BASS_ChannelGetData flag)
+* Volume levels above 1.0 for decoding channels
+ BASS_ATTRIB_VOL (BASS_ChannelSetAttribute option)
+* Recording device channel count retrieval on Windows/OSX/iOS
+ BASS_RECORDINFO formats member
+* Minimum recording period reduced to 5ms
+ BASS_RecordStart
+* Recording device period adjustment on OSX
+ BASS_RecordStart
+* Device UID retrieval on OSX
+ BASS_DEVICEINFO driver member
+* The desktop window handle is used when none is provided
+ BASS_Init
+
+2.4.9 - 3/5/2012
+----------------
+* Improved and adjustable sample rate conversion quality
+ BASS_ATTRIB_SRC (BASS_ChannelSetAttribute option)
+ BASS_CONFIG_SRC/_SAMPLE (BASS_SetConfig options)
+* Support for buffered file & internet streams with CoreAudio codecs on OSX/iOS
+ BASS_StreamCreateURL
+ BASS_StreamCreateUser
+* Data and level retrieval from sample channels
+ BASS_ChannelGetData
+ BASS_ChannelGetLevel
+* Oldest sample channel will be overridden when there are multiple candidates
+ BASS_SampleGetChannel
+* Use of the device's output rate with MODs
+ BASS_MusicLoad
+* Optional device output rate setting on OSX/iOS
+ BASS_DEVICE_FREQ (BASS_Init flag)
+* UTF-8 device information on Windows
+ BASS_CONFIG_UNICODE (BASS_SetConfig option)
+* Speaker assignment with panning/balance control on Windows Vista/7
+ BASS_CONFIG_VISTA_SPEAKERS (BASS_SetConfig option)
+ BASS_ATTRIB_PAN (BASS_ChannelGet/Set/SlideAttribute option)
+* Push stream queue level can be checked after using BASS_STREAMPROC_END
+ BASS_StreamPutData
+* Support for ALSA subdevices on Linux
+ BASS_Init
+ BASS_GetDeviceInfo
+ BASS_RecordInit
+ BASS_RecordGetDeviceInfo
+* Device list fix on 64-bit OSX
+ BASS_GetDeviceInfo
+ BASS_RecordGetDeviceInfo
+* BASS_UNICODE support fix on Linux
+ BASS_PluginLoad
+* Internet streaming tweaks/fixes on Windows
+* SYNTH example updated to use device output rate
+* RECTEST example updated (C version) for better Windows Vista/7 support
+* Documentation updated with platform-specific info for Android
+
+2.4.8 - 29/8/2011
+-----------------
+* Support for Media Foundation codecs on Windows 7 and updated Vista
+ BASS_StreamCreateFile/User/URL
+ BASS_TAG_MF (BASS_ChannelGetTags type)
+ PLUGINS example updated
+* Built-in support for MP4 tags on Win32/OSX/iOS (for Media Foundation & CoreAudio AAC/ALAC codecs)
+ BASS_TAG_MP4 (BASS_ChannelGetTags type)
+* WAVE format chunk retrieval
+ BASS_TAG_WAVEFORMAT (BASS_ChannelGetTags type)
+* Adjustable sample channel limit
+ BASS_SampleSetInfo
+* Per-channel CPU usage monitoring
+ BASS_ATTRIB_CPU (BASS_ChannelGetAttribute option)
+* Pre-allocation of push stream queue space
+ BASS_StreamPutData
+* Support for AIFF files with the data chunk before the format info chunk
+ BASS_StreamCreateFile/User/URL
+* Ramping-in disabled for all but the XM format (and MODs in FT2 mode) in "normal" ramping mode
+ BASS_MUSIC_RAMP (BASS_MusicLoad flag)
+* RIFF "INFO" tag reading fix
+ BASS_TAG_RIFF_INFO (BASS_ChannelGetTags type)
+* OSX 10.7 compatibility fix
+* x86_64 architecture support on OSX
+* Delphi dynamic loading unit removed (now available as separate download)
+
+2.4.7 - 30/11/2010
+------------------
+* Decode-to-position seeking
+ BASS_POS_DECODETO (BASS_ChannelSetPosition flag)
+* Support for RIFF "DISP" text tags
+ BASS_TAG_RIFF_DISP (BASS_ChannelGetTags type)
+* Support for APEv1 tags
+ BASS_TAG_APE (BASS_ChannelGetTags type)
+ BASS_TAG_APE_BINARY (BASS_ChannelGetTags type)
+* 16384 sample FFT
+ BASS_DATA_FFT16384 (BASS_ChannelGetData flag)
+* Support for AIFF files larger than 4GB
+ BASS_StreamCreateFile/User/URL
+* Acceptance of self-signed and invalid certificates
+ BASS_StreamCreateURL
+* Internet stream reading timeout
+ BASS_CONFIG_NET_READTIMEOUT (BASS_SetConfig option)
+* Support for removing a DSP within its DSPPROC
+ BASS_ChannelRemoveDSP
+* Buffer bypassing for lower latency output (not available on Windows)
+ BASS_ATTRIB_NOBUFFER (BASS_ChannelSetAttribute option)
+ SYNTH example added (OSX)
+* A "Default" device that follows default device changes on Windows 7
+ BASS_CONFIG_DEV_DEFAULT (BASS_SetConfig option)
+* Xcode examples project added for OSX
+* Documentation updated with platform-specific info for iOS/Linux/WinCE
+
+2.4.6 - 17/6/2010
+-----------------
+* Support for APEv2 tags, including binary
+ BASS_TAG_APE (BASS_ChannelGetTags type)
+ BASS_TAG_APE_BINARY (BASS_ChannelGetTags type)
+* Unicode/IDN URL support
+ BASS_StreamCreateURL
+* Decoding position retrieval
+ BASS_POS_DECODE (BASS_ChannelGetPosition flag)
+* Support for OGG 6/7.1 channel configs
+ BASS_StreamCreateFile/User/URL
+* Syncing upon attribute changes
+ BASS_SYNC_ATTRIB (BASS_ChannelSetSync type)
+* Support for setting syncs within mixtime SYNCPROCs
+ BASS_ChannelSetSync
+* Support for setting additional DSP/FX within DSPPROCs
+ BASS_ChannelSetDSP/FX
+* DC bias removal in FFT processing
+ BASS_DATA_FFT_REMOVEDC (BASS_ChannelGetData flag)
+* Fix for incorrect (2x) return value when getting FFT data from a 16-bit channel
+ BASS_ChannelGetData
+* Fix for incorrect (STOPPED instead of PAUSED) channel status following BASS_Pause
+ BASS_ChannelIsActive
+
+2.4.5 - 18/12/2009
+------------------
+* Support for little-endian AIFF files
+ BASS_StreamCreateFile/User/URL
+ BASS_SampleLoad
+* Support for 64-bit floating-point WAVE/AIFF files
+ BASS_StreamCreateFile/User/URL
+ BASS_SampleLoad
+* Input volume retrieval failure results in a reading of -1 instead of 1
+ BASS_RecordGetInput
+ RECTEST example updated
+* Input volume support on OSX
+ BASS_RecordSetInput
+ BASS_RecordGetInput
+ RECTEST example updated
+* Fix for deferred input settings on Vista
+ BASS_RecordSetInput
+* Windows MP3 codec given preference over other installed MP3 codecs (MP3-FREE version)
+ BASS_StreamCreateFile/User/URL
+ BASS_SampleLoad
+
+2.4.4 - 13/10/2009
+------------------
+* RIFF/BWF "radio traffic" tag retrieval
+ BASS_TAG_RIFF_CART (BASS_ChannelGetTags type)
+ TAG_CART structure
+* Support for ID3v2 tags in RIFF/WAVE/AIFF files ("ID3 " chunk)
+ BASS_TAG_ID3V2 (BASS_ChannelGetTags type)
+* Pushed fractional samples are refused rather than discarded
+ BASS_StreamPutData
+* DX8 effect emulation on OSX
+ BASS_FX_DX8_CHORUS/DISTORTION/ECHO/FLANGER/PARAMEQ/REVERB (BASS_ChannelSetFX types)
+ FXTEST example added
+* UTF-16 support on OSX
+ BASS_UNICODE (BASS_StreamCreateFile/SampleLoad/MusicLoad/PluginLoad flag)
+
+2.4.3 - 8/5/2009
+----------------
+* MOD order list retrieval
+ BASS_TAG_MUSIC_ORDERS (BASS_ChannelGetTags type)
+* Support for ID3v2 tags in RIFF/WAVE files ("id3 " chunk)
+ BASS_TAG_ID3V2 (BASS_ChannelGetTags type)
+* Improved position reporting precision on Vista
+ BASS_ChannelGetPosition
+* Length retrieval when streaming in blocks (BASS_STREAM_BLOCK)
+ BASS_ChannelGetLength
+* Support for CoreAudio codecs on OSX
+ BASS_StreamCreateFile/User
+ BASS_SampleLoad
+ BASS_TAG_CA_CODEC (BASS_ChannelGetTags type)
+ TAG_CA_CODEC structure
+* 3D algorithm option support on OSX
+ BASS_CONFIG_3DALGORITHM (BASS_SetConfig option)
+
+2.4.2 - 18/9/2008
+-----------------
+* RF64 support
+ BASS_StreamCreateFile/User
+* RIFF/BWF "Broadcast Audio Extension" tag retrieval
+ BASS_TAG_RIFF_BEXT (BASS_ChannelGetTags type)
+ TAG_BEXT structure
+* ID3v1 tag structure
+ TAG_ID3 structure
+* Multiple simultaneous recordings per device on Vista & OSX (as on XP)
+ BASS_RecordStart
+* DX8 effect parameter defaults updated/corrected in documentation
+
+2.4 - 2/4/2008
+--------------
+* "Push" streaming
+ STREAMPROC_PUSH (BASS_StreamCreate option)
+ BASS_StreamPutData
+ LIVEFX and MULTI examples updated
+* "Push" buffered file streaming
+ STREAMFILE_BUFFERPUSH (BASS_StreamCreateFileUser system)
+ BASS_StreamPutFileData
+* STREAMFILEPROC replaced by table of callbacks for each file operation
+ BASS_FILEPROCS (FILECLOSEPROC/FILELENPROC/FILEREADPROC/FILESEEKPROC)
+ STREAMFILEPROC *removed*
+* 64-bit file positioning
+ BASS_SampleLoad
+ BASS_MusicLoad
+ BASS_StreamCreateFile
+ BASS_StreamGetFilePosition
+* File buffer level retrieval
+ BASS_FILEPOS_BUFFER (BASS_StreamGetFilePosition mode)
+* Sinc interpolated MOD music mixing
+ BASS_MUSIC_SINCINTER (BASS_MusicLoad flag)
+* MO3 2.4 support
+ BASS_MusicLoad
+* MOD orders positioning incorporated into channel functions
+ BASS_ChannelGetLength
+ BASS_ChannelSetPosition
+ BASS_ChannelGetPosition
+ BASS_MusicGetOrderPosition *removed*
+ BASS_MusicGetOrders *removed*
+* Channel attribute functions consolidated
+ BASS_ChannelSetAttribute
+ BASS_ChannelGetAttribute
+ BASS_ChannelSlideAttribute
+ BASS_ChannelIsSliding
+ BASS_ChannelSetAttributes *removed*
+ BASS_ChannelGetAttributes *removed*
+ BASS_ChannelSlideAttributes *removed*
+ BASS_ChannelSetEAXMix *removed*
+ BASS_ChannelGetEAXMix *removed*
+ BASS_MusicSetAttribute *removed*
+ BASS_MusicGetAttribute *removed*
+* Floating-point volume and panning
+ BASS_SetVolume
+ BASS_GetVolume
+ BASS_RecordSetInput
+ BASS_RecordGetInput
+ BASS_ATTRIB_PAN/VOL (BASS_ChannelGet/Set/SlideAttribute options)
+ BASS_ATTRIB_MUSIC_VOL_CHAN/INST (BASS_ChannelGet/Set/SlideAttribute options)
+ BASS_SAMPLE volume/pan/outvol members
+ BASS_CONFIG_MAXVOL *removed*
+ BASSTEST and RECTEST examples updated
+* Output device volume control on Vista (as on other OS)
+ BASS_SetVolume
+ BASS_GetVolume
+* Multiple update threads
+ BASS_CONFIG_UPDATETHREADS (BASS_SetConfig option)
+ BASSTEST example updated
+* Global volume range increased to 10000
+ BASS_CONFIG_GVOL_SAMPLE/STREAM/MUSIC (BASS_SetConfig options)
+ BASSTEST example updated
+* Setting and retrieving of a sample's data
+ BASS_SampleSetData
+ BASS_SampleGetData
+ BASS_SampleCreate
+ BASS_SampleCreateDone *removed*
+* Channel flag setting mask
+ BASS_ChannelFlags
+ BASS_ChannelSetFlags *removed*
+ SPEAKERS example updated
+* 256 sample FFT
+ BASS_DATA_FFT256 (BASS_ChannelGetData flag)
+* Channel locking to prevent access by other threads
+ BASS_ChannelLock
+* Manual channel buffer updating
+ BASS_ChannelUpdate
+ BASS_ChannelPreBuf *removed*
+* Configurable manual update length
+ BASS_Update
+* Extended device information retrieval and detection of new/removed devices
+ BASS_GetDeviceInfo
+ BASS_RecordGetDeviceInfo
+ BASS_DEVICEINFO structure
+ BASS_GetDeviceDescription *removed*
+ BASS_RecordGetDeviceDescription *removed*
+ BASS_INFO driver member *removed*
+ BASS_RECORDINFO driver member *removed*
+ MULTI example updated
+* Default device change tracking on Windows (as on OSX)
+ BASS_Init
+ BASS_RecordInit
+* Speaker detection from Windows control panel
+ BASS_DEVICE_CPSPEAKERS (BASS_Init flag)
+* Channel automatically stopped & resumed for DX8 effects
+ BASS_ChannelSetFX
+ BASS_ChannelRemoveFX
+* "double" precision position conversion
+ BASS_ChannelBytes2Seconds
+ BASS_ChannelSeconds2Bytes
+* Separate config functions for pointers
+ BASS_SetConfigPtr
+ BASS_GetConfigPtr
+ BASS_CONFIG_NET_AGENT/PROXY (BASS_SetConfigPtr options)
+* Configurable file format verification length
+ BASS_CONFIG_VERIFY (BASS_SetConfig option)
+* Stream filename retrieval
+ BASS_CHANNELINFO file member
+* Channel sample retrieval
+ BASS_CHANNELINFO sample member
+* META syncs no longer receive metadata in the "data" parameter
+ BASS_SYNC_META (BASS_ChannelSetSync type)
+* Separate sync for OGG logical bitstream changes (instead of BASS_SYNC_META)
+ BASS_SYNC_OGG_CHANGE (BASS_ChannelSetSync type)
+ NETRADIO example updated (C version)
+* Message syncing removed (use PostMessage instead)
+ BASS_SYNC_MESSAGE (BASS_ChannelSetSync flag) *removed*
+* Data retrieval from stopped/paused channels
+ BASS_ChannelGetData
+* Callback "user" parameters changed to pointers
+ BASS_StreamCreate / STREAMPROC
+ BASS_StreamCreateFileUser
+ BASS_StreamCreateURL / DOWNLOADPROC
+ BASS_RecordStart / RECORDPROC
+ BASS_ChannelSetDSP / DSPPROC
+ BASS_ChannelSetSync / SYNCPROC
+
+2.3.0.3 - 30/7/2007
+-------------------
+* FX state resetting
+ BASS_FXReset
+* PLS/M3U playlist URL processing
+ BASS_CONFIG_NET_PLAYLIST (BASS_SetConfig option)
+ NETRADIO example updated
+* Internet stream connection status retrieval
+ BASS_FILEPOS_CONNECTED (BASS_StreamGetFilePosition mode)
+* Lyrics3v2 tags
+ BASS_TAG_LYRICS3 (BASS_ChannelGetTags type)
+* IT virtual channel configuration
+ BASS_CONFIG_MUSIC_VIRTUAL (BASS_SetConfig option)
+* Accurate speaker detection on Vista
+ BASS_INFO speakers member
+* Device output/input rate retrieval on Vista
+ BASS_INFO freq member
+ BASS_RECORDINFO freq member
+* Syncing upon position changes
+ BASS_SYNC_SETPOS (BASS_ChannelSetSync type)
+* Improved stall handling
+ BASS_SYNC_STALL
+* Invalid decoding channel flags produce an error instead of being ignored
+ BASS_StreamCreate/File/User/URL
+ BASS_MusicLoad
+
+2.3.0.2 - 22/1/2007
+-------------------
+* Retrieval of a sample's existing channel handles
+ BASS_SampleGetChannels
+* 8192 sample FFT
+ BASS_DATA_FFT8192 (BASS_ChannelGetData flag)
+* Adjustable recording buffer
+ BASS_CONFIG_REC_BUFFER (BASS_SetConfig option)
+* Stopping decoding channels before the end
+ BASS_ChannelStop
+* Sample channels created paused to prevent overriding before playback
+ BASS_SampleGetChannel
+* Separate "MP3-FREE" version using the OS's MP3 decoder
+ BASS_CONFIG_MP3_CODEC *removed*
+
+2.3.0.1 - 12/6/2006
+-------------------
+* Ability to move a channel to another device
+ BASS_ChannelSetDevice
+ MULTI example updated
+* Support for ID3v2.4 tags at end of file
+ BASS_TAG_ID3V2 (BASS_ChannelGetTags type)
+
+2.3 - 21/5/2006
+---------------
+* MOD message/instrument/sample text retrieval, merged with stream tag retrieval function
+ BASS_ChannelGetTags
+ BASS_TAG_MUSIC_NAME/MESSAGE/INST/SAMPLE (BASS_ChannelGetTags types)
+ BASS_MusicGetName *removed*
+ BASS_StreamGetTags *removed*
+* Plugin information retrieval
+ BASS_PluginGetInfo
+ BASS_PLUGININFO structure
+ BASS_PLUGINFORM structure
+ BASS_CHANNELINFO plugin member
+ PLUGINS example updated
+* RIFF/WAVE "INFO" tag retrieval
+ BASS_TAG_RIFF_INFO (BASS_StreamGetTags type)
+* More specific WAVE format information
+ BASS_CTYPE_STREAM_WAV_PCM/FLOAT (channel types)
+ BASS_CTYPE_STREAM_WAV (channel type flag)
+* Proxy server configuration
+ BASS_CONFIG_NET_PROXY (BASS_SetConfig option)
+ BASS_CONFIG_NET_NOPROXY *removed*
+ NETRADIO example updated
+* Passive FTP mode
+ BASS_CONFIG_NET_PASSIVE (BASS_SetConfig option)
+* Agent changes take immediate effect
+ BASS_CONFIG_NET_AGENT (BASS_SetConfig option)
+* Minimum time gap between creating new sample channels
+ BASS_SAMPLE mingap member
+ BASS_SampleGetChannel
+* Support for Unicode plugin filenames
+ BASS_PluginLoad
+* Device output/input rate retrieval (MacOSX only)
+ BASS_INFO freq member
+ BASS_RECORDINFO freq member
+* Extended version info (minor revision)
+ BASS_GetVersion
+* Unsupported codec error code
+ BASS_ERROR_CODEC
+* Optional use of the Windows MP3 codec
+ BASS_CONFIG_MP3_CODEC (BASS_SetConfig option)
+* 3D support for MacOSX
+ BASS_Set3DFactors
+ BASS_Get3DFactors
+ BASS_Set3DPosition
+ BASS_Get3DPosition
+ BASS_Apply3D
+ BASS_ChannelSet3DAttributes
+ BASS_ChannelGet3DAttributes
+ BASS_ChannelSet3DPosition
+ BASS_ChannelGet3DPosition
+ 3DTEST example added (Win32 example also updated)
+* VB version of SYNTH example added
+
+2.2 - 2/10/2005
+---------------
+* Add-on plugin system
+ BASS_PluginLoad
+ BASS_PluginFree
+ BASS_StreamCreateFile/User/URL
+ BASS_SampleLoad
+ PLUGINS example added
+* MOD position & syncing in bytes
+ BASS_ChannelSetPosition
+ BASS_ChannelGetPosition
+ BASS_MusicGetOrderPosition added for orders position
+ BASS_SYNC_MUSICPOS added for orders syncing
+ MAKEMUSICPOS macro/define
+ CUSTLOOP example updated
+* Stream/MOD "length" functions combined (also works with samples), new MOD orders length function
+ BASS_ChannelGetLength
+ BASS_MusicGetOrders
+ BASS_StreamGetLength *removed*
+ BASS_MusicGetLength *removed*
+* Support for AIFF files
+ BASS_StreamCreateFile/User/URL
+ BASS_SampleLoad
+* Support for 24 and 32-bit (integer) WAV files
+ BASS_StreamCreateFile/User/URL
+ BASS_SampleLoad
+* WAV files are no longer converted to the "device" resolution
+ BASS_StreamCreateFile/User/URL
+* Recording master control
+ BASS_RecordGetInput
+ BASS_RecordSetInput
+* Adjustable pre-buffering
+ BASS_ChannelPreBuf
+* Floating-point data retrieval
+ BASS_DATA_FLOAT (BASS_ChannelGetData flag)
+* Support for floating-point samples
+ BASS_SampleLoad
+ BASS_SampleCreate
+* Multi-channel samples
+ BASS_SampleLoad/Create
+ BASS_SAMPLE chans member
+* Sample lengths given in bytes
+ BASS_SampleCreate
+ BASS_SAMPLE length member
+* MOD music 8-bit resolution option
+ BASS_MusicLoad
+* OGG vendor tag retrieval
+ BASS_TAG_VENDOR (BASS_StreamGetTags type)
+* Configurable "User-Agent" header for internet streams
+ BASS_CONFIG_NET_AGENT (BASS_SetConfig option)
+* Shoutcast metadata is now requested automatically
+ BASS_STREAM_META flag *removed*
+* Download callbacks receive all data from start of file/stream (including any non-audio data)
+ DOWNLOADPROC
+* Length when streaming in blocks is unavailable (BASS_ERROR_NOTAVAIL, not just 0)
+ BASS_ChannelGetLength
+* Support for growing custom file streams
+ BASS_FILE_LEN (STREAMFILEPROC action)
+* Query file action removed
+ BASS_FILE_QUERY *removed*
+* Recording channel syncing
+ BASS_ChannelSetSync
+* Info structure "size" members removed
+ BASS_INFO structure
+ BASS_RECORDINFO structure
+* Little bit of flag reshuffling
+ BASS_MP3_SETPOS renamed to BASS_STREAM_PRESCAN
+ BASS_MUSIC_CALCLEN value changed and renamed to BASS_MUSIC_PRESCAN
+ BASS_MUSIC_POSRESET value changed
+* Add-on API enhancements
+* MacOSX port introduced
+
+2.1 - 28/11/2004
+----------------
+* Improved "mixtime" sync system, allowing custom looping
+ SYNCPROC
+ CUSTLOOP example added
+* FX can now be in the DSP chain, so can be prioritized and applied in floating-point
+ BASS_ChannelSetFX
+ BASS_CONFIG_FLOATDSP (BASS_SetConfig option)
+* Ability to set channel flags (eg. looping) independent of playback
+ BASS_ChannelSetFlags
+ SPEAKERS example updated
+* Stream/MOD "play" and channel "resume" functions combined
+ BASS_ChannelPlay
+ BASS_StreamPlay *removed*
+ BASS_MusicPlay *removed*
+ BASS_MusicPlayEx *removed*
+ BASS_ChannelResume *removed*
+* Stream/MOD pre-buffering functions combined
+ BASS_ChannelPreBuf
+ BASS_StreamPreBuf *removed*
+ BASS_MusicPreBuf *removed*
+* MOD attribute functions combined, with added BPM/speed/globalvolume options
+ BASS_MusicSetAttribute
+ BASS_MusicGetAttribute
+ BASS_MUSIC_ATTRIB_AMPLIFY
+ BASS_MUSIC_ATTRIB_PANSEP
+ BASS_MUSIC_ATTRIB_PSCALER
+ BASS_MUSIC_ATTRIB_BPM
+ BASS_MUSIC_ATTRIB_SPEED
+ BASS_MUSIC_ATTRIB_VOL_GLOBAL
+ BASS_MUSIC_ATTRIB_VOL_CHAN
+ BASS_MUSIC_ATTRIB_VOL_INST
+ BASS_MusicSetAmplify *removed*
+ BASS_MusicSetPanSep *removed*
+ BASS_MusicSetPositionScaler *removed*
+ BASS_MusicSetVolume *removed*
+ BASS_MusicGetVolume *removed*
+* Flag to reset bpm/etc as well as notes when seeking in MOD musics
+ BASS_MUSIC_POSRESETEX (BASS_MusicLoad & BASS_ChannelSetFlags flag)
+* More flexible and concise sample channel creation system
+ BASS_SampleGetChannel
+ BASS_SamplePlay *removed*
+ BASS_SamplePlayEx *removed*
+ BASS_SamplePlay3D *removed*
+ BASS_SamplePlay3DEx *removed*
+* Support for up to 30 speakers
+ BASS_SPEAKER_N macro/define
+* More precise level measurement
+ BASS_ChannelGetLevel
+* Level can now be retrieved from decoding channels
+ BASS_ChannelGetLevel
+* Retrieval of a sample/channel's original sample resolution
+ BASS_SAMPLE origres member
+ BASS_CHANNELINFO origres member
+* Support for streaming WAV files in "blocks"
+ BASS_StreamCreateURL
+ BASS_StreamCreateFileUser
+* Status info (HTTP/ICY tags) available during connection to server
+ BASS_STREAM_STATUS (BASS_StreamCreateURL flag)
+ DOWNLOADPROC
+ NETRADIO example updated (Delphi version also added)
+* Adjustable internet stream pre-buffering
+ BASS_CONFIG_NET_PREBUF (BASS_SetConfig option)
+* Option to bypass proxy server
+ BASS_CONFIG_NET_NOPROXY (BASS_SetConfig option)
+* Option whether to allow channels to be played after BASS_Pause
+ BASS_CONFIG_PAUSE_NOPLAY (BASS_SetConfig option)
+* Recording channel count now a separate parameter
+ BASS_RecordStart (chans parameter)
+* Synchronizer for when a channel is freed
+ BASS_SYNC_FREE (BASS_ChannelSetSync type)
+* Data start file position retrieval
+ BASS_FILEPOS_START (BASS_StreamGetFilePosition mode)
+* Performance improvements
+ MP2 decoding ~20% faster
+ MP3/MP1 decoding & FFT processing all up to 10% faster
+ OGG decoding ~3% faster
+* C/C++ examples reorganised, with makefiles & VC++ projects
+* Add-on API enhancements
+* More DLL shrinkage :)
+
+2.0 - 31/10/2003
+----------------
+* Multiple output device support
+ BASS_Init (device number changes)
+ BASS_SetDevice
+ BASS_GetDevice
+ BASS_ChannelGetDevice
+ MULTI example updated (VB version also added)
+* Multiple recording device support
+ BASS_RecordSetDevice
+ BASS_RecordGetDevice
+ BASS_RecordStart
+ BASS_ChannelGetDevice
+ HRECORD handle
+ RECORDPROC (handle parameter)
+* Recording with DSP/FX
+ BASS_ChannelSetDSP
+ BASS_ChannelSetFX
+* Recording position retrieval
+ BASS_ChannelGetPosition
+* Start recording paused
+ BASS_RECORD_PAUSE (BASS_RecordStart flag)
+* Multi-channel streams, inc. support for multichannel OGG & WAV files
+ BASS_StreamCreate
+ BASS_StreamCreateFile/User/URL
+* FFT for individual channels, inc. multi-channel streams
+ BASS_DATA_FFT_INDIVIDUAL (BASS_ChannelGetData flag)
+ BASS_DATA_FFT512S/1024S/2048S/4096S *removed*
+* DSP prioritizing
+ BASS_ChannelSetDSP
+ DSPTEST example updated
+* Seeking in internet streamed files
+ BASS_ChannelSetPosition
+* Enhanced custom file stream systems
+ BASS_StreamCreateFileUser
+ BASS_FILE_SEEK (STREAMFILEPROC action)
+ BASS_STREAM_FILEPROC flag *removed*
+* Enhanced custom stream system with automatic stalling/resuming
+ STREAMPROC
+ BASS_STREAMPROC_END (STREAMPROC flag)
+* Synchronizer for stalled/resumed playback
+ BASS_SYNC_STALL (BASS_ChannelSetSync type)
+* Synchronizer for completed download
+ BASS_SYNC_DOWNLOAD (BASS_ChannelSetSync type)
+* End sync support for custom streams
+ BASS_SYNC_END (BASS_ChannelSetSync type)
+* Synchronizer support for decoding channels
+ BASS_ChannelSetSync
+* Unified configuration function
+ BASS_SetConfig
+ BASS_GetConfig
+ BASS_SetBufferLength *removed*
+ BASS_SetNetConfig *removed*
+ BASS_SetGlobalVolumes *removed*
+ BASS_GetGlobalVolumes *removed*
+ BASS_SetLogCurves *removed*
+ BASS_Set3DAlgorithm *removed*
+ BASS_DEVICE_FLOATDSP flag *removed*
+* Internet stream saving to disk replaced by more flexible callback
+ BASS_StreamCreateURL
+ DOWNLOADPROC
+ VB NETRADIO example updated
+* Buffer length retrieval when "streaming in blocks"
+ BASS_FILEPOS_END (BASS_StreamGetFilePosition mode)
+* Individual sample rate setting for MOD musics
+ BASS_MusicLoad
+* Channel type and default sample rate retrieval
+ BASS_ChannelGetInfo (replaces BASS_ChannelGetFlags)
+ BASS_CHANNELINFO
+* MOD music flag retrieval
+ BASS_CHANNELINFO flags member
+* Adjustable instrument volumes in MOD musics
+ BASS_MusicSetVolume (replaces BASS_MusicSetChannelVol)
+ BASS_MusicGetVolume (replaces BASS_MusicGetChannelVol)
+* Automatically free a MOD music when it stops or ends
+ BASS_MUSIC_AUTOFREE (BASS_MusicLoad flag)
+* Class GUID added to initialization parameters
+ BASS_Init
+ BASS_SetCLSID *removed*
+* Update period adjustable at any time
+ BASS_CONFIG_UPDATEPERIOD (BASS_SetConfig option)
+ BASS_DEVICE_NOTHREAD flag *removed*
+* Customizable maximum volume setting
+ BASS_CONFIG_MAXVOL (BASS_SetConfig option)
+ BASS_DEVICE_VOL1000 flag *removed*
+* Device volume is now always left as it is during init/freeing
+ BASS_DEVICE_LEAVEVOL flag *removed*
+* Device driver name retrieval
+ BASS_INFO driver member
+ BASS_RECORDINFO driver member
+* Error codes are local to the current thread
+ BASS_ErrorGetCode
+* Performance improvements
+ MP2 decoding 15-20% faster
+ MP3 decoding ~5% faster
+* Built-in CD functions removed (replaced in BASSCD)
+ BASS_CDDoor *removed*
+ BASS_CDFree *removed*
+ BASS_CDGetID *removed*
+ BASS_CDGetTrackLength *removed*
+ BASS_CDGetTracks *removed*
+ BASS_CDInDrive *removed*
+ BASS_CDInit *removed*
+ BASS_CDPlay *removed*
+* Force channels to use software mixing
+ BASS_SAMPLE_SOFTWARE (BASS_StreamCreate/File/User/URL & BASS_MusicLoad flag)
+* Support for high-pass filter and forward/reverse (S9E/F) IT/MPT effects
+* BASS_MUSIC flags rearranged to mirror BASS_SAMPLE/STREAM counterparts
+* Output automatically started during initialization
+* BASS_ChannelGetData once again accepts any "length" param
+* All function comments have been removed from the API headers to avoid
+ outdated/incorrect information - the documentation should be used.
+* TMT Pascal API removed from main distribution - now available on the website
+* A few more 'K' knocked off the DLL size :)
+
+1.8a - 18/6/2003
+----------------
+* Tweaks 'n' fixes, including...
+ Fixed seeking bug on 32-bit OGG streams
+ Fixed seeking on a decoding channel after it has reached the end
+ Low FPU precision (eg. when using Direct3D) issue addressed
+ Improved speakers (BASS_INFO) detection
+ BASS_ChannelSeconds2Bytes return value is rounded down to nearest sample
+ BASS_ChannelGetData "length" param must equal a whole number of samples
+ Slide syncs are triggered by "-2" volume slides on "autofree" streams
+* Support for Unicode filenames
+ BASS_UNICODE (BASS_SampleLoad/BASS_StreamCreateFile/BASS_MusicLoad flag)
+* 4096 sample FFT
+ BASS_DATA_FFT4096/S (BASS_ChannelGetData flags)
+* Another 'K' knocked off the DLL size
+
+1.8 - 9/3/2003
+--------------
+* 32-bit floating-point channels
+ BASS_SAMPLE_FLOAT (BASS_StreamCreate/File/URL flag)
+ BASS_MUSIC_FLOAT (BASS_MusicLoad flag)
+ BASS_SAMPLE_FLOAT (BASS_RecordStart flag)
+ BASS_DEVICE_FLOATDSP (BASS_Init flag)
+ DSPTEST example updated
+* Support for 32-bit floating-point (type 3) WAV files
+ BASS_StreamCreateFile/URL
+ BASS_SampleLoad
+* Channel speaker assignment
+ BASS_SPEAKER_FRONT (BASS_MusicLoad/BASS_StreamCreate/File/URL flag)
+ BASS_SPEAKER_REAR "
+ BASS_SPEAKER_CENLFE "
+ BASS_SPEAKER_REAR2 "
+ BASS_SPEAKER_FRONTLEFT "
+ BASS_SPEAKER_FRONTRIGHT "
+ BASS_SPEAKER_REARLEFT "
+ BASS_SPEAKER_REARRIGHT "
+ BASS_SPEAKER_CENTER "
+ BASS_SPEAKER_LFE "
+ BASS_SPEAKER_REAR2LEFT "
+ BASS_SPEAKER_REAR2RIGHT "
+ BASS_INFO speakers member
+ BASS_DEVICE_SPEAKERS (BASS_Init flag)
+ 4SPEAKER example replaced by SPEAKERS example
+* Recording input type retrieval
+ BASS_INPUT_TYPE_xxx (BASS_RecordGetInput)
+ RECTEST example updated
+* Non-interpolated MOD mixing
+ BASS_MUSIC_NONINTER (BASS_MusicLoad/PlayEx flag)
+* Performance improvements
+ FFT up to 100% faster!
+ MP3(MPEG2/2.5) decoding up to 60% faster
+ MMX mixers 5-10% faster
+ MP3(MPEG1)/MP2/MP1/OGG decoding all ~5% faster
+* Optional disabling of FFT windowing
+ BASS_DATA_FFT_NOWINDOW (BASS_ChannelGetData flag)
+* BASS_ERROR_FILEFORM - error code to distinguish between file and sample format
+ BASS_MusicLoad
+ BASS_SampleLoad
+ BASS_StreamCreate/File/URL
+* BASS_StreamGetFilePosition mode flags added
+ BASS_FILEPOS_DECODE/DOWNLOAD/END
+* DirectSound 9 detection
+ BASS_INFO dsver member
+* Initialization flags retrieval
+ BASS_INFO initflags member
+* Half-rate MP3 playback option removed
+ BASS_MP3_HALFRATE flag *removed*
+* New internal "plugin" system - BASSWMA is further integrated as a result
+* Improved documentation - integrated with BASSWMA, search option added
+* VB version of DSPTEST example added
+* Delphi RECORDTEST example added
+* Guess what... reduced DLL size again :)
+
+1.7 - 27/10/2002
+----------------
+* New alternative DX8 (DMO) effects implementation
+ BASS_StreamCreate/File/URL
+ BASS_MusicLoad
+ BASS_ChannelSetFX
+ SYNTH example updated
+* User file streaming
+ BASS_STREAM_FILEPROC (BASS_StreamCreate flag)
+ STREAMFILEPROC
+* DSP & FX support for decoding channels
+ BASS_ChannelSetDSP
+ BASS_ChannelSetFX
+* Support for DX8 (DMO) effects in IT/XM/MO3 files
+ BASS_MusicLoad
+* Support for chained OGG streaming (and syncing)
+ BASS_StreamCreateURL
+ BASS_SYNC_META (BASS_ChannelSetSync type)
+* Attribute (volume/frequency/pan) sliding
+ BASS_ChannelSlideAttributes
+ BASS_ChannelIsSliding
+ BASS_SYNC_SLIDE (BASS_ChannelSetSync type)
+* Recording without a callback function
+ BASS_RecordStart
+ LIVEFX example added
+* Query a channel's buffered data
+ BASS_DATA_AVAILABLE (BASS_ChannelGetData flag)
+* Discard data from the recording buffer
+ BASS_ChannelGetData
+* Adjustable internet stream config (timeout/buffer lengths)
+ BASS_SetNetConfig
+* Recommended minimum buffer length
+ BASS_INFO minbuf member
+* MOD music flags adjustment without changing playback position
+ BASS_MusicPlayEx
+ PLAYER (MASM) example updated
+* More functions are now useable in MOD music "mixtime" syncs
+ SYNCPROC
+ BASS_ChannelSetPosition
+ BASS_MusicPlayEx
+ BASS_MusicSetAmplify
+ BASS_MusicSetPanSep
+* Maximum buffer length increased to 5 seconds
+ BASS_SetBufferLength
+* Support for extended filter range in IT files
+ BASS_MusicLoad
+* Speedier MOD music file verification
+ BASS_MusicLoad
+* Delphi 3DTEST example fixed
+* Magically reduced DLL size again :)
+
+1.6a - 25/8/2002
+----------------
+* OGG support updated to 1.0
+* Stereo FFT
+ BASS_DATA_FFT512S/1024S/2048S (BASS_ChannelGetData flags)
+* Support for "Invert Loop" (EFx) MOD effect
+* Reduced DLL size
+* New Delphi examples
+ WRITEWAV - WAVE writer example
+ SAMPLEVIS - Visualisation example
+
+1.6 - 13/6/2002
+---------------
+* 64-bit stream lengths and positions
+ BASS_StreamGetLength
+ BASS_ChannelBytes2Seconds
+ BASS_ChannelSeconds2Bytes
+ BASS_ChannelGetPosition
+ BASS_ChannelSetPosition
+ BASS_ChannelSetSync
+* Recording input selection
+ BASS_RECORDINFO inputs/singlein members
+ BASS_RecordGetInputName
+ BASS_RecordGetInput
+ BASS_RecordSetInput
+* Adjustable recording update period
+ BASS_RecordStart
+* Load OGG files as samples
+ BASS_SampleLoad
+* CD drive door opening & closing
+ BASS_CDDoor
+* CDDB2 ID retrieval
+ BASS_CDID_CDDB2 (BASS_CDGetID flag)
+* Streaming beyond initial file length
+ BASS_StreamCreateFile
+* Recording position bytes<->seconds translation
+ BASS_ChannelBytes2Seconds
+ BASS_ChannelSeconds2Bytes
+* Improved multi-threaded support (play from any thread)
+ BASS_MusicPlay/Ex
+ BASS_SamplePlay/3D/Ex
+ BASS_StreamPlay
+ BASS_DEVICE_NOSYNC flag *removed*
+* Paused channel status
+ BASS_ACTIVE_PAUSED (BASS_ChannelIsActive)
+* Integrated WMA stream freeing
+ BASS_StreamFree
+ BASS_Free
+* Pin-point accurate OGG seeking without BASS_MP3_SETPOS flag
+* Win2k DS buffer bug fix
+
+1.5a - 14/4/2002
+----------------
+* NT4 fix (also enables "nosound" device without DX installed)
+ BASS_ERROR_DX error code
+* MOD music loading without the samples
+ BASS_MUSIC_NOSAMPLE (BASS_MusicLoad flag)
+* Custom decoding channels
+ BASS_STREAM_DECODE (BASS_StreamCreate flag)
+* 5 second HTTP connection timeout
+ BASS_ERROR_TIMEOUT (BASS_StreamCreateURL error code)
+
+1.5 - 31/3/2002
+---------------
+* Improved performance
+ MMX mixers lot faster (over 50% faster in some cases!)
+ OGG decoding 15-20% faster
+ MP3 decoding 5-10% faster
+* Recording
+ BASS_RecordGetDeviceDescription
+ BASS_RecordInit
+ BASS_RecordFree
+ BASS_RecordGetInfo
+ BASS_RecordStart
+* OGG support built-in (OGG/VORBIS DLLs not required)
+ BASS_DEVICE_OGG flag *removed*
+* MOD music seeking in seconds
+ BASS_MusicPlayEx
+ BASS_ChannelSetPosition
+* Shoutcast metadata retrieval
+ BASS_STREAM_META (BASS_StreamCreateURL flag)
+ BASS_TAG_META (BASS_StreamGetTags type)
+ BASS_SYNC_META (BASS_ChannelSetSync type)
+* 1000 volume levels
+ BASS_DEVICE_VOL1000 (BASS_Init/CDInit flag)
+* CDDB ID retrieval
+ BASS_CDID_CDDB (BASS_CDGetID flag)
+* Leave the CD volume as it is during init/closing
+ BASS_DEVICE_LEAVEVOL (BASS_CDInit flag)
+* FFT enabled on decoding channels
+ BASS_ChannelGetData
+* Left level duplicated on right for mono channels
+ BASS_ChannelGetLevel
+* Improved MPEG length estimation without BASS_MP3_SETPOS flag
+ BASS_StreamGetLength
+* Support for Modplug/ADPCM compressed files
+ BASS_MusicLoad
+* Device description function parameter change
+ BASS_GetDeviceDescription
+* MASM API
+
+1.4 - 30/1/2002
+---------------
+* Channel decoding without playback
+ BASS_MUSIC_DECODE (BASS_MusicLoad flag)
+ BASS_STREAM_DECODE (BASS_StreamCreateFile/URL flag)
+ BASS_ChannelGetData
+* Windows message sync callbacks
+ BASS_SYNC_MESSAGE (BASS_ChannelSetSync flag)
+* Adjustable channel volumes in MOD musics
+ BASS_MusicSetChannelVol
+ BASS_MusicGetChannelVol
+* Customizable DirectSound initialization object
+ BASS_SetCLSID
+* Retrieve HMUSIC/HSTREAM/HCHANNEL IDirectSoundBuffer interfaces
+ BASS_GetDSoundObject
+* A3D functions removed (use BASS_SetCLSID/BASS_GetDSoundObject to access A3D)
+ BASS_DEVICE_A3D (BASS_Init flag)
+ BASS_SetA3DResManager
+ BASS_GetA3DResManager
+ BASS_SetA3DHFAbsorbtion
+ BASS_GetA3DHFAbsorbtion
+* Callback functions now work in VB6
+ DSPPROC
+ STREAMPROC
+ SYNCPROC
+* Improved PCM WAVE streaming performance
+ BASS_StreamCreateFile
+ BASS_StreamCreateURL
+* OGG modules updated to RC3
+* Stereo sample support in MO3 format
+* MO3 encoder now distributed separately from BASS
+
+1.3 - 17/11/2001
+----------------
+* Manual buffer updating
+ BASS_DEVICE_NOTHREAD (BASS_Init flag)
+ BASS_Update
+* Adjustable buffer update period (allows small buffer sizes)
+ BASS_Init
+* Output device latency retrieval
+ BASS_INFO latency member
+* MPEG/OGG seeking without BASS_MP3_SETPOS flag
+ BASS_ChannelSetPosition
+* Internet file streaming from offsets
+ BASS_StreamCreateURL
+* File stream tag/comment retrieval (ID3/ID3v2/OGG/HTTP/ICY tags)
+ BASS_StreamGetTags
+* Byte<->time position translation
+ BASS_ChannelBytes2Seconds
+ BASS_ChannelSeconds2Bytes
+* UMX (Unreal/Tournament music package) format support
+ BASS_MusicLoad
+* S3M/IT sync fx changed to S2x (S0x conflicted with S00)
+ BASS_SYNC_MUSICFX
+* Stereo sample support in IT/XM/S3M formats
+* MO3: OGG compression supported
+
+1.2 - 25/9/2001
+---------------
+* OGG (Ogg Vorbis) stream support
+ BASS_DEVICE_OGG (BASS_Init flag)
+ BASS_StreamCreateFile
+ BASS_StreamCreateURL
+* Channel linking (start/stop/pause/resume channels together)
+ BASS_ChannelSetLink
+ BASS_ChannelRemoveLink
+* MOD music playback length calculation
+ BASS_MUSIC_CALCLEN (BASS_MusicLoad flag)
+ BASS_MusicGetLength
+* Pre-buffering
+ BASS_MusicPreBuf
+ BASS_StreamPreBuf
+* Samples with single simultaneous playbacks have same HSAMPLE/HCHANNEL handle
+ BASS_SamplePlay/Ex
+* Stopping a custom stream flushes its buffer contents
+ BASS_ChannelStop
+
+1.1a - 31/8/2001
+----------------
+* NT4 bug fixed
+* XM Wxx effect syncing
+ BASS_SYNC_MUSICFX
+* MP3/2/1 rewinding without BASS_MP3_SETPOS
+ BASS_ChannelSetPosition
+
+1.1 - 11/8/2001
+---------------
+* DX8 (DMO) effects
+ BASS_SAMPLE_FX (BASS_StreamCreate/File/URL flag)
+ BASS_MUSIC_FX (BASS_MusicLoad flag)
+ BASS_ChannelSetFX
+ BASS_ChannelRemoveFX
+ BASS_FXSetParameters
+ BASS_FXGetParameters
+ BASS_FXCHORUS structure
+ BASS_FXCOMPRESSOR structure
+ BASS_FXDISTORTION structure
+ BASS_FXECHO structure
+ BASS_FXFLANGER structure
+ BASS_FXGARGLE structure
+ BASS_FXI3DL2REVERB structure
+ BASS_FXPARAMEQ structure
+ BASS_FXREVERB structure
+* Internet file streaming in blocks (inc. Shoutcast/Icecast stream support)
+ BASS_STREAM_BLOCK (BASS_StreamCreateURL flag)
+* 512/1024/2048 sample FFT
+ BASS_DATA_FFT512/1024/2048 (BASS_ChannelGetData flags)
+* CD identification
+ BASS_CDGetID
+* Improved DX version detection
+ BASS_INFO dsver member
+
+1.0 - 20/6/2001
+---------------
+* Load MP3/MP2/MP1 files as samples
+ BASS_SampleLoad
+* Internet file streaming from FTP servers
+ BASS_StreamCreateURL
+* Save a local copy of internet file streams
+ BASS_StreamCreateURL
+* Sample accurate file stream seeking
+ BASS_ChannelSetPosition
+ BASS_StreamGetBlockLength *removed*
+* Stream position synchronizer
+ BASS_SYNC_POS
+* Increased synchronizer precision
+* Improved MPEG file detection and error detection
+* Stop MOD musics on a backwards jump effect
+ BASS_MUSIC_STOPBACK (BASS_MusicLoad/PlayEx flag)
+* Leave the volume as it is during closing (as well as initialization)
+ BASS_DEVICE_LEAVEVOL (BASS_Init flag)
+* Optional automatic use of foreground window handle during initialization
+ BASS_Init
+* Reduced DLL size
+* VB API fixes
+
+0.9 - 18/4/2001
+---------------
+* Internet file streaming
+ BASS_StreamCreateURL
+* MP1 & MP2 (MPEG layer 1 & 2) support
+ BASS_StreamCreateFile/URL
+* MPEG 2.5 support (12000/11025/8000hz sample rates)
+ BASS_StreamCreateFile/URL
+* Decoding/download/end file stream position retrieval
+ BASS_StreamGetFilePosition
+* XMPlay surround sound for MOD musics
+ BASS_MUSIC_SURROUND (BASS_MusicLoad/PlayEx flag)
+ BASS_MUSIC_SURROUND2 (BASS_MusicLoad/PlayEx flag)
+* Restrict the download rate of internet file streams
+ BASS_STREAM_RESTRATE (BASS_StreamCreateURL flag)
+* Check if an internet file stream is stalled
+ BASS_ChannelIsActive
+* Automatically free a stream when it stops or ends
+ BASS_STREAM_AUTOFREE (BASS_StreamCreate/File/URL flag)
+* Leave the volume as it is during initialization
+ BASS_DEVICE_LEAVEVOL (BASS_Init flag)
+* Number of CD tracks retrieval
+ BASS_CDGetTracks
+* CD track length retrieval
+ BASS_CDGetTrackLength
+* Exact stream length set after whole file is streamed
+ BASS_StreamGetLength
+* TMT Pascal API and samples
+* Dynamic-loading Delphi API
+
+0.8a - 28/2/2000
+----------------
+* Updated Delphi API and samples
+
+0.8 - 24/1/2000
+---------------
+* Improved MP3 performance on P2/K6 and above CPUs
+* User DSP functions on streams and MOD musics
+ BASS_ChannelSetDSP
+ BASS_ChannelRemoveDSP
+* DX7 voice allocation & management
+ BASS_SAMPLE_VAM (BASS_SampleLoad/Create flag)
+ BASS_VAM_xxx flags
+ BASS_SAMPLE vam/priority members
+* DX7 software 3D algorithm selection
+ BASS_Set3DAlgorithm
+* DirectSound interface retrieval
+ BASS_GetDSoundObject
+* Log/linear volume & panning curves
+ BASS_SetLogCurves
+* User data passed to callback functions
+ STREAMPROC - BASS_StreamCreate
+ SYNCPROC - BASS_ChannelSetSync
+* New synchronizer
+ BASS_SYNC_MUSICFX
+* New synchronizer flag
+ BASS_SYNC_MIXTIME
+* Disable synchronizers option - saves a little CPU time
+ BASS_DEVICE_NOSYNC (BASS_Init flag)
+* Hi-res floating-point CPU usage monitoring
+ BASS_GetCPU
+* Wait for playback to start when playing a CD
+ BASS_CDPlay
+* DirectSound (dsound.dll) version retrieval
+ BASS_INFO dsver member
+* Removed volume sliding functions (they were fairly pointless)
+ BASS_SlideVolume
+ BASS_IsSliding
+* MO3: read/write encoder settings
+* MO3: remove inst/samp/message texts now optional
+* MO3: LAME encoder settings
+
+0.7 - 3/10/1999
+---------------
+* MO3 (MP3 compressed MODs)
+* A3D functions
+ BASS_DEVICE_A3D (BASS_Init flag)
+ BASS_INFO a3d member
+ BASS_SetA3DResManager
+ BASS_GetA3DResManager
+ BASS_SetA3DHFAbsorbtion
+ BASS_GetA3DHFAbsorbtion
+* Music/stream immediate sample data retrieval
+ BASS_ChannelGetData
+* File stream (WAV/MP3) length retrieval
+ BASS_StreamGetLength
+ BASS_StreamGetBlockLength
+* File stream seeking
+ BASS_ChannelSetPosition
+* Mono MP3 option (lower CPU usage)
+ BASS_StreamCreateFile
+* Music length retrieval
+ BASS_MusicGetLength
+* Music name retrieval
+ BASS_MusicGetName
+* Stop notes when moving MOD music position
+ BASS_MUSIC_POSRESET (BASS_MusicLoad/BASS_MusicPlayEx flag)
+* BASS_ERROR_FREQ - invalid sample rate error code
+ BASS_SampleCreate
+ BASS_SamplePlayEx
+ BASS_SamplePlay3DEx
+ BASS_StreamCreate
+ BASS_ChannelSetAttributes
+* Delphi and VB APIs
+
+0.6a - 26/7/1999
+----------------
+* Half rate MP3 option (lower CPU usage)
+ BASS_MP3_HALFRATE
+* Loading/streaming from file offsets
+ BASS_MusicLoad
+ BASS_SampleLoad
+ BASS_StreamCreateFile
+* Global music/sample/stream volume levels
+ BASS_SetGlobalVolumes
+ BASS_GetGlobalVolumes
+* Other new function
+ BASS_SampleStop
+* New synchronizer
+ BASS_SYNC_END
+* New sample overrider
+ BASS_SAMPLE_OVER_DIST
+* LoadLibrary/GetProcAddress instructions and example
+
+0.5 - 4/7/1999
+--------------
+* Documentation!
+* File streaming (MP3 and WAV)
+ BASS_StreamCreateFile
+* Custom generated samples
+ BASS_SampleCreate
+ BASS_SampleCreateDone
+* Other new function
+ BASS_MusicSetPositionScaler
+* Renamed function
+ BASS_ChannelClearSync -> BASS_ChannelRemoveSync
+* Alterations made to
+ BASS_ChannelGetPosition
+ BASS_SampleLoad
+ BASS_StreamPlay
+
+0.4 - 30/3/1999
+---------------
+* Compressed WAV samples support (using audio CODECs)
+* Updated CD volume handling - now works with SB Live
+* More linear channel volume/pan scales (were slightly off before)
+* "No sound" device option
+* 3D sound functions
+ BASS_Set3DFactors
+ BASS_Get3DFactors
+ BASS_Set3DPosition
+ BASS_Get3DPosition
+ BASS_Apply3D
+ BASS_SamplePlay3D
+ BASS_SamplePlay3DEx
+ BASS_ChannelSet3DAttributes
+ BASS_ChannelGet3DAttributes
+ BASS_ChannelSet3DPosition
+ BASS_ChannelGet3DPosition
+* EAX functions
+ BASS_SetEAXParameters
+ BASS_GetEAXParameters
+ BASS_ChannelSetEAXMix
+ BASS_ChannelGetEAXMix
+* Other new functions
+ BASS_GetDeviceDescription
+ BASS_SetBufferLen
+ BASS_ChannelGetFlags
+ BASS_ChannelPause
+ BASS_ChannelResume
+ BASS_ChannelSetPosition
+* Replaced function
+ BASS_CDResume -> BASS_ChannelResume
+* Alterations made to
+ BASS_Init
+ BASS_CDInit
+ BASS_SampleLoad
+ BASS_StreamPlay
+ BASS_INFO structure
+ BASS_SAMPLE structure
+ BASS_DEVICE_xxx flags
+ BASS_SAMPLE_xxx flags
+
+0.3 - 8/3/1999
+--------------
+* Synchronization functions
+ BASS_ChannelSetSync
+ BASS_ChannelClearSync
+* Other new functions
+ BASS_GetVersion
+ BASS_ChannelGetPosition
+ BASS_ChannelGetLevel
+ BASS_ChannelGetAttributes
+ BASS_ChannelSetAttributes
+* Replaced functions
+ BASS_MusicStop -> BASS_ChannelStop
+ BASS_MusicSetVolume -> BASS_ChannelSetAttributes
+ BASS_CDStop -> BASS_ChannelStop
+ BASS_CDSetVolume -> BASS_ChannelSetAttributes
+ BASS_CDGetVolume -> BASS_ChannelGetAttributes
+ BASS_ChannelUpdate -> BASS_ChannelSetAttributes
+* Alterations made to
+ BASS_MusicPlayEx
+ BASS_StreamPlay
+ BASS_INFO structure
+
+0.2 - 28/2/1999
+---------------
+* First public release
+
+
+Bug reports, Suggestions, Comments, Enquiries, etc
+==================================================
+If you have any of the aforementioned please visit the BASS forum at the
+website. If you can't find an answer there, you can also email:
+
+ bass@un4seen.com
+
diff --git a/MapWizard.Desktop/Native/Bass/linux-x64/libbass.so b/MapWizard.Desktop/Native/Bass/linux-x64/libbass.so
new file mode 100644
index 0000000..0f70544
Binary files /dev/null and b/MapWizard.Desktop/Native/Bass/linux-x64/libbass.so differ
diff --git a/MapWizard.Desktop/Native/Bass/osx/libbass.dylib b/MapWizard.Desktop/Native/Bass/osx/libbass.dylib
new file mode 100644
index 0000000..c8197de
Binary files /dev/null and b/MapWizard.Desktop/Native/Bass/osx/libbass.dylib differ
diff --git a/MapWizard.Desktop/Native/Bass/win-x64/bass.dll b/MapWizard.Desktop/Native/Bass/win-x64/bass.dll
new file mode 100644
index 0000000..d58ff25
Binary files /dev/null and b/MapWizard.Desktop/Native/Bass/win-x64/bass.dll differ
diff --git a/MapWizard.Desktop/Services/ComboColourProjectStore.cs b/MapWizard.Desktop/Services/ComboColourService/ComboColourProjectStore.cs
similarity index 96%
rename from MapWizard.Desktop/Services/ComboColourProjectStore.cs
rename to MapWizard.Desktop/Services/ComboColourService/ComboColourProjectStore.cs
index 99c5fb7..7ec71bd 100644
--- a/MapWizard.Desktop/Services/ComboColourProjectStore.cs
+++ b/MapWizard.Desktop/Services/ComboColourService/ComboColourProjectStore.cs
@@ -4,10 +4,9 @@
using System.IO;
using System.Linq;
using System.Text.Json;
-using BeatmapParser.Colours;
using MapWizard.Tools.ComboColourStudio;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.ComboColourService;
public class ComboColourProjectStore : IComboColourProjectStore
{
@@ -140,7 +139,7 @@ private static ComboColourProject FromPersistedProject(PersistedProject persiste
var comboColours = persistedProject.ComboColours
.Take(8)
.Select((colour, index) =>
- new ComboColour((uint)(index + 1), Color.FromArgb(255, colour.R, colour.G, colour.B)))
+ new BeatmapParser.Colours.ComboColour((uint)(index + 1), Color.FromArgb(255, colour.R, colour.G, colour.B)))
.ToList();
if (comboColours.Count == 0)
@@ -204,8 +203,9 @@ private static string NormalizePath(string path)
{
normalized = Path.GetFullPath(path);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// Keep original value if path normalization fails.
}
diff --git a/MapWizard.Desktop/Services/ComboColourStudioService.cs b/MapWizard.Desktop/Services/ComboColourService/ComboColourStudioService.cs
similarity index 94%
rename from MapWizard.Desktop/Services/ComboColourStudioService.cs
rename to MapWizard.Desktop/Services/ComboColourService/ComboColourStudioService.cs
index bd08634..3866547 100644
--- a/MapWizard.Desktop/Services/ComboColourStudioService.cs
+++ b/MapWizard.Desktop/Services/ComboColourService/ComboColourStudioService.cs
@@ -2,7 +2,7 @@
using System.Drawing;
using MapWizard.Tools.ComboColourStudio;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.ComboColourService;
public class ComboColourStudioService : IComboColourStudioService
{
diff --git a/MapWizard.Desktop/Services/IComboColourProjectStore.cs b/MapWizard.Desktop/Services/ComboColourService/IComboColourProjectStore.cs
similarity index 81%
rename from MapWizard.Desktop/Services/IComboColourProjectStore.cs
rename to MapWizard.Desktop/Services/ComboColourService/IComboColourProjectStore.cs
index c4dfc55..6bf7302 100644
--- a/MapWizard.Desktop/Services/IComboColourProjectStore.cs
+++ b/MapWizard.Desktop/Services/ComboColourService/IComboColourProjectStore.cs
@@ -1,6 +1,6 @@
using MapWizard.Tools.ComboColourStudio;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.ComboColourService;
public interface IComboColourProjectStore
{
diff --git a/MapWizard.Desktop/Services/IComboColourStudioService.cs b/MapWizard.Desktop/Services/ComboColourService/IComboColourStudioService.cs
similarity index 90%
rename from MapWizard.Desktop/Services/IComboColourStudioService.cs
rename to MapWizard.Desktop/Services/ComboColourService/IComboColourStudioService.cs
index 27661e5..a98a39f 100644
--- a/MapWizard.Desktop/Services/IComboColourStudioService.cs
+++ b/MapWizard.Desktop/Services/ComboColourService/IComboColourStudioService.cs
@@ -2,7 +2,7 @@
using System.Drawing;
using MapWizard.Tools.ComboColourStudio;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.ComboColourService;
public interface IComboColourStudioService
{
diff --git a/MapWizard.Desktop/Services/HitSoundService.cs b/MapWizard.Desktop/Services/HitSoundService.cs
deleted file mode 100644
index 8b08ceb..0000000
--- a/MapWizard.Desktop/Services/HitSoundService.cs
+++ /dev/null
@@ -1,136 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using BeatmapParser;
-using BeatmapParser.TimingPoints;
-using MapWizard.Tools.HitSounds.Copier;
-
-namespace MapWizard.Desktop.Services;
-
-public class HitSoundService : IHitSoundService
-{
- private const double RedlineTimeToleranceMs = 0.5d;
- private const double BeatLengthToleranceMs = 0.001d;
-
- public HitSoundTimingCompatibilityReport AnalyzeTimingCompatibility(string sourcePath, string[] targetPaths)
- {
- var sourceFile = Beatmap.Decode(File.ReadAllText(sourcePath));
- var results = new List(targetPaths.Length);
-
- foreach (var targetPath in targetPaths)
- {
- var targetFile = Beatmap.Decode(File.ReadAllText(targetPath));
- results.Add(CompareTiming(sourceFile, targetFile, targetPath));
- }
-
- return new HitSoundTimingCompatibilityReport
- {
- Targets = results
- };
- }
-
- public bool CopyHitsoundsAsync(string sourcePath, string[] targetPaths, HitSoundCopierOptions options)
- {
- try
- {
- HitSoundCopier.CopyFromBeatmapToTarget(sourcePath: sourcePath, targetPath: targetPaths, options: options);
- }
- catch (Exception ex)
- {
- Console.WriteLine(ex.Message);
- return false;
- }
-
- return true;
- }
-
- private static HitSoundTimingCompatibilityTargetResult CompareTiming(Beatmap source, Beatmap target, string targetPath)
- {
- var sourceRedlines = GetRedlines(source);
- var targetRedlines = GetRedlines(target);
-
- if (sourceRedlines.Count == 0 || targetRedlines.Count == 0)
- {
- return new HitSoundTimingCompatibilityTargetResult
- {
- TargetPath = targetPath,
- Kind = HitSoundTimingCompatibilityKind.Compatible,
- Details = "One of the beatmaps has no redlines to compare."
- };
- }
-
- if (sourceRedlines.Count != targetRedlines.Count)
- {
- return new HitSoundTimingCompatibilityTargetResult
- {
- TargetPath = targetPath,
- Kind = HitSoundTimingCompatibilityKind.TimingMismatch,
- Details = $"Different redline count ({sourceRedlines.Count} vs {targetRedlines.Count})."
- };
- }
-
- double? offsetMs = null;
-
- for (var i = 0; i < sourceRedlines.Count; i++)
- {
- var sourceRedline = sourceRedlines[i];
- var targetRedline = targetRedlines[i];
-
- if (Math.Abs(sourceRedline.BeatLength - targetRedline.BeatLength) > BeatLengthToleranceMs)
- {
- return new HitSoundTimingCompatibilityTargetResult
- {
- TargetPath = targetPath,
- Kind = HitSoundTimingCompatibilityKind.TimingMismatch,
- Details = $"Different BPM/redline beat length at redline #{i + 1}."
- };
- }
-
- var currentOffsetMs = targetRedline.Time.TotalMilliseconds - sourceRedline.Time.TotalMilliseconds;
- if (offsetMs is null)
- {
- offsetMs = currentOffsetMs;
- continue;
- }
-
- if (Math.Abs(currentOffsetMs - offsetMs.Value) > RedlineTimeToleranceMs)
- {
- return new HitSoundTimingCompatibilityTargetResult
- {
- TargetPath = targetPath,
- Kind = HitSoundTimingCompatibilityKind.TimingMismatch,
- Details = "Redline positions are not aligned by a constant offset."
- };
- }
- }
-
- var resolvedOffsetMs = offsetMs ?? 0d;
- if (Math.Abs(resolvedOffsetMs) <= RedlineTimeToleranceMs)
- {
- return new HitSoundTimingCompatibilityTargetResult
- {
- TargetPath = targetPath,
- Kind = HitSoundTimingCompatibilityKind.Compatible
- };
- }
-
- return new HitSoundTimingCompatibilityTargetResult
- {
- TargetPath = targetPath,
- Kind = HitSoundTimingCompatibilityKind.OffsetOnlyMismatch,
- OffsetMs = resolvedOffsetMs,
- SuggestedLeniencyMs = Math.Max(1, (int)Math.Ceiling(Math.Abs(resolvedOffsetMs))),
- Details = $"Constant offset detected ({resolvedOffsetMs:+0.###;-0.###;0} ms)."
- };
- }
-
- private static IReadOnlyList GetRedlines(Beatmap beatmap)
- {
- return beatmap.TimingPoints?.TimingPointList
- .OfType()
- .OrderBy(x => x.Time.TotalMilliseconds)
- .ToList()
- ?? [];
- }
-}
diff --git a/MapWizard.Desktop/Services/HitsoundService/HitSoundService.cs b/MapWizard.Desktop/Services/HitsoundService/HitSoundService.cs
new file mode 100644
index 0000000..cc1b7e8
--- /dev/null
+++ b/MapWizard.Desktop/Services/HitsoundService/HitSoundService.cs
@@ -0,0 +1,515 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using BeatmapParser;
+using BeatmapParser.Enums;
+using BeatmapParser.TimingPoints;
+using MapWizard.Desktop.Models.HitSoundVisualizer;
+using MapWizard.Tools.HelperExtensions;
+using MapWizard.Tools.HitSounds.Copier;
+using MapWizard.Tools.HitSounds.Extensions;
+using MapWizard.Tools.HitSounds.Timeline;
+using MapWizard.Tools.MapCleaner.Snapping;
+
+namespace MapWizard.Desktop.Services.HitsoundService;
+
+public class HitSoundService : IHitSoundService
+{
+ private const double RedlineTimeToleranceMs = 0.5d;
+ private const double BeatLengthToleranceMs = 0.001d;
+ // Guards against invalid/near-zero intervals when generating snap ticks.
+ private const double SnapTickStepToleranceMs = 0.00001d;
+ // Inclusive segment-end tolerance to absorb floating-point drift at boundaries.
+ private const double SnapTickSegmentEndToleranceMs = 0.0001d;
+ private static readonly IReadOnlyList VisualizerDivisors =
+ Enumerable.Range(1, 16).Select(x => new SnapDivisor(1, x)).ToList();
+
+ public HitSoundTimingCompatibilityReport AnalyzeTimingCompatibility(string sourcePath, string[] targetPaths)
+ {
+ var sourceFile = Beatmap.Decode(File.ReadAllText(sourcePath));
+ var results = new List(targetPaths.Length);
+
+ foreach (var targetPath in targetPaths)
+ {
+ var targetFile = Beatmap.Decode(File.ReadAllText(targetPath));
+ results.Add(CompareTiming(sourceFile, targetFile, targetPath));
+ }
+
+ return new HitSoundTimingCompatibilityReport
+ {
+ Targets = results
+ };
+ }
+
+ public bool CopyHitsounds(string sourcePath, string[] targetPaths, HitSoundCopierOptions options)
+ {
+ try
+ {
+ HitSoundCopier.CopyFromBeatmapToTarget(sourcePath: sourcePath, targetPath: targetPaths, options: options);
+ }
+ catch (Exception ex)
+ {
+ MapWizardLogger.LogException(ex);
+ Console.WriteLine(ex.Message);
+ return false;
+ }
+
+ return true;
+ }
+
+ public HitSoundVisualizerDocument LoadHitsoundVisualizerDocument(string beatmapPath)
+ {
+ var beatmap = Beatmap.Decode(File.ReadAllText(beatmapPath));
+ var timeline = beatmap.BuildTimeline();
+ var points = BuildVisualizerPoints(timeline);
+ var sampleChanges = BuildVisualizerSampleChanges(timeline);
+
+ var endTimeMs = ResolveEndTimeMs(beatmap, points, sampleChanges);
+ var snapTicks = BuildSnapTicks(beatmap, endTimeMs);
+
+ var artist = string.IsNullOrWhiteSpace(beatmap.MetadataSection.ArtistUnicode)
+ ? beatmap.MetadataSection.Artist
+ : beatmap.MetadataSection.ArtistUnicode;
+ var title = string.IsNullOrWhiteSpace(beatmap.MetadataSection.TitleUnicode)
+ ? beatmap.MetadataSection.Title
+ : beatmap.MetadataSection.TitleUnicode;
+ var mapsetDirectory = Path.GetDirectoryName(beatmapPath) ?? string.Empty;
+ var audioFileName = beatmap.GeneralSection.AudioFilename;
+ var audioFilePath = string.IsNullOrWhiteSpace(audioFileName) || string.IsNullOrWhiteSpace(mapsetDirectory)
+ ? string.Empty
+ : Path.Combine(mapsetDirectory, audioFileName);
+
+ return new HitSoundVisualizerDocument
+ {
+ BeatmapPath = beatmapPath,
+ MapsetDirectoryPath = mapsetDirectory,
+ AudioFilePath = File.Exists(audioFilePath) ? audioFilePath : string.Empty,
+ DisplayTitle = $"{artist} - {title} [{beatmap.MetadataSection.Version}]",
+ EndTimeMs = endTimeMs,
+ Timeline = timeline,
+ Points = points,
+ SampleChanges = sampleChanges,
+ SnapTicks = snapTicks
+ };
+ }
+
+ public IReadOnlyList BuildHitsoundVisualizerSnapTicks(string beatmapPath, double endTimeMs)
+ {
+ var beatmap = Beatmap.Decode(File.ReadAllText(beatmapPath));
+ return BuildSnapTicks(beatmap, Math.Max(1000, endTimeMs));
+ }
+
+ private static HitSoundTimingCompatibilityTargetResult CompareTiming(Beatmap source, Beatmap target, string targetPath)
+ {
+ var sourceRedlines = GetRedlines(source);
+ var targetRedlines = GetRedlines(target);
+
+ if (sourceRedlines.Count == 0 || targetRedlines.Count == 0)
+ {
+ return new HitSoundTimingCompatibilityTargetResult
+ {
+ TargetPath = targetPath,
+ Kind = HitSoundTimingCompatibilityKind.Compatible,
+ Details = "One of the beatmaps has no redlines to compare."
+ };
+ }
+
+ if (sourceRedlines.Count != targetRedlines.Count)
+ {
+ return new HitSoundTimingCompatibilityTargetResult
+ {
+ TargetPath = targetPath,
+ Kind = HitSoundTimingCompatibilityKind.TimingMismatch,
+ Details = $"Different redline count ({sourceRedlines.Count} vs {targetRedlines.Count})."
+ };
+ }
+
+ double? offsetMs = null;
+
+ for (var i = 0; i < sourceRedlines.Count; i++)
+ {
+ var sourceRedline = sourceRedlines[i];
+ var targetRedline = targetRedlines[i];
+
+ if (Math.Abs(sourceRedline.BeatLength - targetRedline.BeatLength) > BeatLengthToleranceMs)
+ {
+ return new HitSoundTimingCompatibilityTargetResult
+ {
+ TargetPath = targetPath,
+ Kind = HitSoundTimingCompatibilityKind.TimingMismatch,
+ Details = $"Different BPM/redline beat length at redline #{i + 1}."
+ };
+ }
+
+ var currentOffsetMs = targetRedline.Time.TotalMilliseconds - sourceRedline.Time.TotalMilliseconds;
+ if (offsetMs is null)
+ {
+ offsetMs = currentOffsetMs;
+ continue;
+ }
+
+ if (Math.Abs(currentOffsetMs - offsetMs.Value) > RedlineTimeToleranceMs)
+ {
+ return new HitSoundTimingCompatibilityTargetResult
+ {
+ TargetPath = targetPath,
+ Kind = HitSoundTimingCompatibilityKind.TimingMismatch,
+ Details = "Redline positions are not aligned by a constant offset."
+ };
+ }
+ }
+
+ var resolvedOffsetMs = offsetMs ?? 0d;
+ if (Math.Abs(resolvedOffsetMs) <= RedlineTimeToleranceMs)
+ {
+ return new HitSoundTimingCompatibilityTargetResult
+ {
+ TargetPath = targetPath,
+ Kind = HitSoundTimingCompatibilityKind.Compatible
+ };
+ }
+
+ return new HitSoundTimingCompatibilityTargetResult
+ {
+ TargetPath = targetPath,
+ Kind = HitSoundTimingCompatibilityKind.OffsetOnlyMismatch,
+ OffsetMs = resolvedOffsetMs,
+ SuggestedLeniencyMs = Math.Max(1, (int)Math.Ceiling(Math.Abs(resolvedOffsetMs))),
+ Details = $"Constant offset detected ({resolvedOffsetMs:+0.###;-0.###;0} ms)."
+ };
+ }
+
+ private static IReadOnlyList GetRedlines(Beatmap beatmap)
+ {
+ return beatmap.TimingPoints?.TimingPointList
+ .OfType()
+ .OrderBy(x => x.Time.TotalMilliseconds)
+ .ToList()
+ ?? [];
+ }
+
+ private static List BuildVisualizerPoints(HitSoundTimeline timeline)
+ {
+ var result = new List();
+ var nextId = 1;
+
+ AddPointsFromTimeline(timeline.NonDraggableSoundTimeline, false, timeline.SampleSetTimeline, result, ref nextId);
+
+ return result
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => HitSoundSortOrder(x.HitSound))
+ .ThenBy(x => SampleSetSortOrder(x.SampleSet))
+ .ToList();
+ }
+
+ private static List BuildVisualizerSampleChanges(HitSoundTimeline timeline)
+ {
+ var sorted = timeline.SampleSetTimeline.HitSamples
+ .OrderBy(x => x.Time)
+ .Select(x => new HitSoundVisualizerSampleChange
+ {
+ TimeMs = StableSnapEngine.StableRound(x.Time),
+ SampleSet = NormalizeSampleSet(x.Sample, SampleSet.Normal),
+ Index = NormalizeSampleIndex(x.Index),
+ Volume = NormalizeSampleVolume(x.Volume)
+ })
+ .ToList();
+
+ if (sorted.Count <= 1)
+ {
+ return sorted;
+ }
+
+ var normalized = new List(sorted.Count);
+ foreach (var change in sorted)
+ {
+ if (normalized.Count == 0)
+ {
+ normalized.Add(change);
+ continue;
+ }
+
+ var last = normalized[^1];
+
+ // Multiple timing points can collapse to the same rounded millisecond; keep the latest effective value.
+ if (last.TimeMs == change.TimeMs)
+ {
+ normalized[^1] = change;
+ continue;
+ }
+
+ // Skip redundant entries that don't change the effective sample timeline state.
+ if (HasSameSampleState(last, change))
+ {
+ continue;
+ }
+
+ normalized.Add(change);
+ }
+
+ return normalized;
+ }
+
+ private static bool HasSameSampleState(HitSoundVisualizerSampleChange a, HitSoundVisualizerSampleChange b)
+ {
+ return a.SampleSet == b.SampleSet &&
+ a.Index == b.Index &&
+ a.Volume == b.Volume;
+ }
+
+ private static void AddPointsFromTimeline(
+ SoundTimeline soundTimeline,
+ bool isDraggable,
+ SampleSetTimeline sampleSetTimeline,
+ ICollection target,
+ ref int nextId)
+ {
+ foreach (var soundEvent in soundTimeline.SoundEvents)
+ {
+ var sourceTimeMs = soundEvent.Time.TotalMilliseconds;
+ var timeMs = StableSnapEngine.StableRound(soundEvent.Time.TotalMilliseconds);
+ var effectiveSampleChange = sampleSetTimeline.GetCurrentSampleAtTime(sourceTimeMs, leniency: 0);
+ var timelineSample = NormalizeSampleSet(effectiveSampleChange?.Sample ?? SampleSet.Normal, SampleSet.Normal);
+ var resolvedNormalSample = NormalizeSampleSet(soundEvent.NormalSample, timelineSample);
+ var normalUsesAutoSampleSet = !IsExplicitSampleSet(soundEvent.NormalSample);
+
+ // Normalize parser flags into atomic sounds.
+ // This avoids treating combined flags as unknown "normal" lanes, which can duplicate hitnormal playback.
+ var hitSounds = ExpandAtomicHitSounds(soundEvent.HitSounds);
+
+ foreach (var hitSound in hitSounds)
+ {
+ var rawSample = hitSound == HitSound.Normal
+ ? soundEvent.NormalSample
+ : soundEvent.AdditionSample;
+ var usesAutoSampleSet = hitSound == HitSound.Normal
+ ? normalUsesAutoSampleSet
+ : !IsExplicitSampleSet(soundEvent.AdditionSample);
+ var resolvedSample = hitSound == HitSound.Normal
+ ? resolvedNormalSample
+ : NormalizeSampleSet(rawSample, resolvedNormalSample);
+
+ target.Add(new HitSoundVisualizerPoint
+ {
+ Id = nextId++,
+ TimeMs = timeMs,
+ HitSound = hitSound,
+ SampleSet = resolvedSample,
+ SampleIndexOverride = soundEvent.SampleIndexOverride,
+ SampleVolumeOverridePercent = soundEvent.SampleVolumeOverride,
+ IsAutoSampleSet = usesAutoSampleSet,
+ IsDraggable = isDraggable
+ });
+ }
+ }
+ }
+
+ private static double ResolveEndTimeMs(
+ Beatmap beatmap,
+ IReadOnlyCollection points,
+ IReadOnlyCollection sampleChanges)
+ {
+ var maxPoint = points.Count == 0 ? 0 : points.Max(x => x.TimeMs);
+ var maxSample = sampleChanges.Count == 0 ? 0 : sampleChanges.Max(x => x.TimeMs);
+ var maxTiming = beatmap.TimingPoints?.TimingPointList.Count > 0
+ ? beatmap.TimingPoints.TimingPointList.Max(x => x.Time.TotalMilliseconds)
+ : 0;
+
+ var resolved = Math.Max(maxPoint, Math.Max(maxSample, maxTiming));
+ return Math.Max(1000, resolved + 500);
+ }
+
+ private static IReadOnlyList BuildSnapTicks(Beatmap beatmap, double endTimeMs)
+ {
+ if (beatmap.TimingPoints == null)
+ {
+ return [];
+ }
+
+ var redlines = beatmap.TimingPoints.TimingPointList
+ .OfType()
+ .OrderBy(x => x.Time.TotalMilliseconds)
+ .ToList();
+
+ if (redlines.Count == 0)
+ {
+ return [];
+ }
+
+ var ticksByTimeAndDenominator = new Dictionary<(int TimeMs, int Denominator), HitSoundVisualizerSnapTick>();
+
+ for (var redlineIndex = 0; redlineIndex < redlines.Count; redlineIndex++)
+ {
+ var redline = redlines[redlineIndex];
+ // Keep the original redline phase (including negative offsets) so snap lines align correctly.
+ var startMs = redline.Time.TotalMilliseconds;
+ var nextRedlineMs = redlineIndex < redlines.Count - 1
+ ? redlines[redlineIndex + 1].Time.TotalMilliseconds
+ : endTimeMs;
+ var segmentEndMs = Math.Min(endTimeMs, nextRedlineMs);
+ var beatLength = Math.Abs(redline.BeatLength);
+ var beatsPerMeasure = redline.TimeSignature <= 0 ? 4 : redline.TimeSignature;
+
+ if (beatLength <= SnapTickStepToleranceMs || segmentEndMs < startMs)
+ {
+ continue;
+ }
+
+ foreach (var divisor in VisualizerDivisors)
+ {
+ var step = beatLength * divisor.Numerator / divisor.Denominator;
+ if (step <= SnapTickStepToleranceMs)
+ {
+ continue;
+ }
+
+ var maxStepIndex = (int)Math.Floor((segmentEndMs - startMs + SnapTickSegmentEndToleranceMs) / step);
+ for (var stepIndex = 0; stepIndex <= maxStepIndex; stepIndex++)
+ {
+ var tickTime = startMs + (stepIndex * step);
+ if (tickTime > segmentEndMs + SnapTickSegmentEndToleranceMs)
+ {
+ // Safety guard against floating-point accumulation on boundary indices.
+ break;
+ }
+
+ var rounded = StableSnapEngine.StableRound(tickTime);
+ if (rounded < 0 || rounded > endTimeMs + 1)
+ {
+ continue;
+ }
+
+ var newTick = new HitSoundVisualizerSnapTick
+ {
+ TimeMs = rounded,
+ ExactTimeMs = tickTime,
+ Label = divisor.ToString(),
+ Strength = TickStrength(divisor),
+ Denominator = divisor.Denominator,
+ IsMeasureLine = divisor.Denominator == 1 && stepIndex % beatsPerMeasure == 0
+ };
+
+ var key = (rounded, divisor.Denominator);
+ if (!ticksByTimeAndDenominator.TryGetValue(key, out var current))
+ {
+ ticksByTimeAndDenominator[key] = newTick;
+ continue;
+ }
+
+ current.IsMeasureLine |= newTick.IsMeasureLine;
+ }
+ }
+ }
+
+ return ticksByTimeAndDenominator.Values
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => x.Denominator)
+ .ToList();
+ }
+
+ private static int TickStrength(SnapDivisor divisor)
+ {
+ return divisor.Denominator switch
+ {
+ 1 => 4,
+ <= 4 => 3,
+ <= 8 => 2,
+ _ => 1
+ };
+ }
+
+ private static IReadOnlyList ExpandAtomicHitSounds(IEnumerable? sourceHitSounds)
+ {
+ var combinedFlags = 0;
+ if (sourceHitSounds is not null)
+ {
+ foreach (var hitSound in sourceHitSounds)
+ {
+ combinedFlags |= (int)hitSound;
+ }
+ }
+
+ // Hitnormal is implicit in osu! hittables; represent it explicitly in the visualizer.
+ var resolved = new List { HitSound.Normal };
+
+ if (ContainsHitSoundFlag(combinedFlags, HitSound.Whistle))
+ {
+ resolved.Add(HitSound.Whistle);
+ }
+
+ if (ContainsHitSoundFlag(combinedFlags, HitSound.Finish))
+ {
+ resolved.Add(HitSound.Finish);
+ }
+
+ if (ContainsHitSoundFlag(combinedFlags, HitSound.Clap))
+ {
+ resolved.Add(HitSound.Clap);
+ }
+
+ return resolved;
+ }
+
+ private static bool ContainsHitSoundFlag(int combinedFlags, HitSound flag)
+ {
+ var numericFlag = (int)flag;
+ if (numericFlag == 0)
+ {
+ return combinedFlags == 0;
+ }
+
+ return (combinedFlags & numericFlag) == numericFlag;
+ }
+
+ private static int HitSoundSortOrder(HitSound hitSound)
+ {
+ return hitSound switch
+ {
+ HitSound.Normal => 0,
+ HitSound.Whistle => 1,
+ HitSound.Finish => 2,
+ HitSound.Clap => 3,
+ _ => 9
+ };
+ }
+
+ private static int SampleSetSortOrder(SampleSet sampleSet)
+ {
+ return sampleSet switch
+ {
+ SampleSet.Normal => 0,
+ SampleSet.Soft => 1,
+ SampleSet.Drum => 2,
+ _ => 9
+ };
+ }
+
+ private static SampleSet NormalizeSampleSet(SampleSet rawSampleSet, SampleSet fallback)
+ {
+ if (IsExplicitSampleSet(rawSampleSet))
+ {
+ return rawSampleSet;
+ }
+
+ return IsExplicitSampleSet(fallback)
+ ? fallback
+ : SampleSet.Normal;
+ }
+
+ private static bool IsExplicitSampleSet(SampleSet sampleSet)
+ {
+ return sampleSet is SampleSet.Normal or SampleSet.Soft or SampleSet.Drum;
+ }
+
+ private static int NormalizeSampleIndex(int rawIndex)
+ {
+ return rawIndex > 0 ? rawIndex : 1;
+ }
+
+ private static int NormalizeSampleVolume(double rawVolume)
+ {
+ var rounded = (int)Math.Round(rawVolume);
+ return rounded <= 0 ? 100 : Math.Clamp(rounded, 1, 100);
+ }
+}
diff --git a/MapWizard.Desktop/Services/HitSoundTimingCompatibilityReport.cs b/MapWizard.Desktop/Services/HitsoundService/HitSoundTimingCompatibilityReport.cs
similarity index 96%
rename from MapWizard.Desktop/Services/HitSoundTimingCompatibilityReport.cs
rename to MapWizard.Desktop/Services/HitsoundService/HitSoundTimingCompatibilityReport.cs
index ee6b50b..30b8757 100644
--- a/MapWizard.Desktop/Services/HitSoundTimingCompatibilityReport.cs
+++ b/MapWizard.Desktop/Services/HitsoundService/HitSoundTimingCompatibilityReport.cs
@@ -1,8 +1,7 @@
-using System;
using System.Collections.Generic;
using System.Linq;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.HitsoundService;
public enum HitSoundTimingCompatibilityKind
{
diff --git a/MapWizard.Desktop/Services/HitsoundService/IHitSoundService.cs b/MapWizard.Desktop/Services/HitsoundService/IHitSoundService.cs
new file mode 100644
index 0000000..bcc5c86
--- /dev/null
+++ b/MapWizard.Desktop/Services/HitsoundService/IHitSoundService.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using MapWizard.Desktop.Models.HitSoundVisualizer;
+using MapWizard.Tools.HitSounds.Copier;
+
+namespace MapWizard.Desktop.Services.HitsoundService;
+
+public interface IHitSoundService
+{
+ public HitSoundTimingCompatibilityReport AnalyzeTimingCompatibility(string sourcePath, string[] targetPaths);
+ public bool CopyHitsounds(string sourcePath, string[] targetPaths, HitSoundCopierOptions options);
+ public HitSoundVisualizerDocument LoadHitsoundVisualizerDocument(string beatmapPath);
+ public IReadOnlyList BuildHitsoundVisualizerSnapTicks(string beatmapPath, double endTimeMs);
+}
diff --git a/MapWizard.Desktop/Services/IHitSoundService.cs b/MapWizard.Desktop/Services/IHitSoundService.cs
deleted file mode 100644
index e43d5c2..0000000
--- a/MapWizard.Desktop/Services/IHitSoundService.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using MapWizard.Tools.HitSounds.Copier;
-
-namespace MapWizard.Desktop.Services;
-
-public interface IHitSoundService
-{
- public HitSoundTimingCompatibilityReport AnalyzeTimingCompatibility(string sourcePath, string[] targetPaths);
- public bool CopyHitsoundsAsync(string sourcePath, string[] targetPath, HitSoundCopierOptions options);
-}
diff --git a/MapWizard.Desktop/Services/ISongLibraryService.cs b/MapWizard.Desktop/Services/ISongLibraryService.cs
index 784286f..f13edba 100644
--- a/MapWizard.Desktop/Services/ISongLibraryService.cs
+++ b/MapWizard.Desktop/Services/ISongLibraryService.cs
@@ -9,6 +9,20 @@ public interface ISongLibraryService
{
bool IsValidSongsPath(string? songsPath);
string? TryDetectSongsPath();
+
+ ///
+ /// Invalidates cached song-library scan and mapset metadata entries.
+ ///
+ ///
+ /// Optional songs root path to target invalidation. When or whitespace,
+ /// all cached entries are invalidated. When provided, invalidation is applied only if it matches
+ /// the currently cached songs path.
+ ///
+ ///
+ /// Safe to call concurrently with and .
+ ///
+ void InvalidateCache(string? songsPath = null);
+
Task> GetMapsetDirectoriesAsync(string songsPath, CancellationToken cancellationToken = default);
Task LoadMapsetAsync(string mapsetDirectoryPath, CancellationToken cancellationToken = default);
}
diff --git a/MapWizard.Desktop/Services/IMapCleanerService.cs b/MapWizard.Desktop/Services/MapCleanerService/IMapCleanerService.cs
similarity index 82%
rename from MapWizard.Desktop/Services/IMapCleanerService.cs
rename to MapWizard.Desktop/Services/MapCleanerService/IMapCleanerService.cs
index 8d85e4c..0fb82a3 100644
--- a/MapWizard.Desktop/Services/IMapCleanerService.cs
+++ b/MapWizard.Desktop/Services/MapCleanerService/IMapCleanerService.cs
@@ -1,6 +1,6 @@
using MapWizard.Tools.MapCleaner;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.MapCleanerService;
public interface IMapCleanerService
{
diff --git a/MapWizard.Desktop/Services/MapCleanerService.cs b/MapWizard.Desktop/Services/MapCleanerService/MapCleanerService.cs
similarity index 84%
rename from MapWizard.Desktop/Services/MapCleanerService.cs
rename to MapWizard.Desktop/Services/MapCleanerService/MapCleanerService.cs
index 729ded3..c2bd0b4 100644
--- a/MapWizard.Desktop/Services/MapCleanerService.cs
+++ b/MapWizard.Desktop/Services/MapCleanerService/MapCleanerService.cs
@@ -3,7 +3,7 @@
using BeatmapParser;
using MapWizard.Tools.MapCleaner;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.MapCleanerService;
public class MapCleanerService : IMapCleanerService
{
@@ -24,6 +24,7 @@ public bool TryAnalyzeMap(string beatmapPath, out MapCleanerAnalysis analysis)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
Console.WriteLine(ex.Message);
return false;
}
@@ -40,6 +41,7 @@ public bool CleanMaps(string[] targetPaths, MapCleanerOptions options, out MapCl
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
Console.WriteLine(ex.Message);
return false;
}
diff --git a/MapWizard.Desktop/Services/FallbackClientIpc.cs b/MapWizard.Desktop/Services/MemoryService/FallbackClientIpc.cs
similarity index 95%
rename from MapWizard.Desktop/Services/FallbackClientIpc.cs
rename to MapWizard.Desktop/Services/MemoryService/FallbackClientIpc.cs
index a1f4adf..886cbb3 100644
--- a/MapWizard.Desktop/Services/FallbackClientIpc.cs
+++ b/MapWizard.Desktop/Services/MemoryService/FallbackClientIpc.cs
@@ -7,7 +7,7 @@
using System.Runtime.Versioning;
using System.Threading;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.MemoryService;
internal static class FallbackClientIpc
{
@@ -46,10 +46,11 @@ public static bool TryReadBeatmapPath(out string? beatmapPath, out string? error
return true;
}
- catch (Exception exception)
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
InvalidateConnection();
- error = exception.Message;
+ error = ex.Message;
return false;
}
}
@@ -67,10 +68,11 @@ public static bool TryReadEditorTime(out int timestamp, out string? error)
timestamp = reader.ReadInt32();
return true;
}
- catch (Exception exception)
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
InvalidateConnection();
- error = exception.Message;
+ error = ex.Message;
return false;
}
}
diff --git a/MapWizard.Desktop/Services/IOsuMemoryReaderService.cs b/MapWizard.Desktop/Services/MemoryService/IOsuMemoryReaderService.cs
similarity index 74%
rename from MapWizard.Desktop/Services/IOsuMemoryReaderService.cs
rename to MapWizard.Desktop/Services/MemoryService/IOsuMemoryReaderService.cs
index eaeaefc..d5b23ec 100644
--- a/MapWizard.Desktop/Services/IOsuMemoryReaderService.cs
+++ b/MapWizard.Desktop/Services/MemoryService/IOsuMemoryReaderService.cs
@@ -1,6 +1,6 @@
using MapWizard.Desktop.Models;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.MemoryService;
public interface IOsuMemoryReaderService
{
diff --git a/MapWizard.Desktop/Services/OsuMemoryReaderService.cs b/MapWizard.Desktop/Services/MemoryService/OsuMemoryReaderService.cs
similarity index 97%
rename from MapWizard.Desktop/Services/OsuMemoryReaderService.cs
rename to MapWizard.Desktop/Services/MemoryService/OsuMemoryReaderService.cs
index b0e6e6a..4f919ba 100644
--- a/MapWizard.Desktop/Services/OsuMemoryReaderService.cs
+++ b/MapWizard.Desktop/Services/MemoryService/OsuMemoryReaderService.cs
@@ -2,13 +2,14 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.Versioning;
+using MapWizard.Desktop.Enums;
using MapWizard.Desktop.Models;
using MapWizard.Desktop.Utils;
using OsuMemoryDataProvider;
using OsuMemoryDataProvider.OsuMemoryModels.Direct;
using OsuWineMemReader;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.MemoryService;
public class OsuMemoryReaderService(ISettingsService settingsService, ISongLibraryService songLibraryService)
: IOsuMemoryReaderService
@@ -103,13 +104,14 @@ private static Result GetBeatmapLinuxFromMemory()
ErrorMessage = null
};
}
- catch (Exception exception)
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return new Result()
{
Value = null,
Status = ResultStatus.Error,
- ErrorMessage = exception.Message
+ ErrorMessage = ex.Message
};
}
}
diff --git a/MapWizard.Desktop/Services/IMetadataManagerService.cs b/MapWizard.Desktop/Services/MetadataService/IMetadataManagerService.cs
similarity index 81%
rename from MapWizard.Desktop/Services/IMetadataManagerService.cs
rename to MapWizard.Desktop/Services/MetadataService/IMetadataManagerService.cs
index 7bed23d..ddb8bf0 100644
--- a/MapWizard.Desktop/Services/IMetadataManagerService.cs
+++ b/MapWizard.Desktop/Services/MetadataService/IMetadataManagerService.cs
@@ -1,7 +1,7 @@
using MapWizard.Desktop.Models;
using MapWizard.Tools.MetadataManager;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.MetadataService;
public interface IMetadataManagerService
{
diff --git a/MapWizard.Desktop/Services/MetadataManagerService.cs b/MapWizard.Desktop/Services/MetadataService/MetadataManagerService.cs
similarity index 96%
rename from MapWizard.Desktop/Services/MetadataManagerService.cs
rename to MapWizard.Desktop/Services/MetadataService/MetadataManagerService.cs
index 52f3229..973743d 100644
--- a/MapWizard.Desktop/Services/MetadataManagerService.cs
+++ b/MapWizard.Desktop/Services/MetadataService/MetadataManagerService.cs
@@ -4,7 +4,7 @@
using MapWizard.Desktop.Models;
using MapWizard.Tools.MetadataManager;
-namespace MapWizard.Desktop.Services;
+namespace MapWizard.Desktop.Services.MetadataService;
public class MetadataManagerService : IMetadataManagerService
{
diff --git a/MapWizard.Desktop/Services/Playback/IAudioPlaybackService.cs b/MapWizard.Desktop/Services/Playback/IAudioPlaybackService.cs
new file mode 100644
index 0000000..a7ad81a
--- /dev/null
+++ b/MapWizard.Desktop/Services/Playback/IAudioPlaybackService.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using MapWizard.Desktop.Models.Settings;
+
+namespace MapWizard.Desktop.Services.Playback;
+
+public interface IAudioPlaybackService
+{
+ bool LoadSong(string filePath);
+ bool PlaySong(int startTimeMs);
+ void PauseSong();
+ void StopSong();
+ int GetSongPositionMs();
+ int GetLoadedSongDurationMs();
+ bool IsSongPlaying { get; }
+ string GetTimingTelemetryStatus();
+ string GetSongDebugStatus();
+
+ void SetSongVolume(float volume);
+ void SetHitsoundVolume(float volume);
+ bool PlayHitsound(string filePath, float volumeMultiplier = 1f, string playbackBusKey = "");
+
+ IReadOnlyList GetAudioOutputDevices();
+ string GetSelectedAudioOutputDeviceId();
+ bool SetSelectedAudioOutputDevice(string deviceId);
+}
diff --git a/MapWizard.Desktop/Services/Playback/ManagedBassPlaybackService.cs b/MapWizard.Desktop/Services/Playback/ManagedBassPlaybackService.cs
new file mode 100644
index 0000000..3bf09fa
--- /dev/null
+++ b/MapWizard.Desktop/Services/Playback/ManagedBassPlaybackService.cs
@@ -0,0 +1,719 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using ManagedBass;
+using MapWizard.Desktop.Models.Settings;
+
+namespace MapWizard.Desktop.Services.Playback;
+
+public sealed class ManagedBassPlaybackService : IAudioPlaybackService, IDisposable
+{
+ private const string DefaultAudioOutputDeviceId = "default";
+ private const int OutputSampleRateHz = 44100;
+ private const int PlaybackBufferLengthMs = 150;
+ private const int UpdatePeriodMs = 15;
+ private const int HitsoundSampleMaxVoices = 256;
+ private const int SongClockCompensationMs = -40;
+
+ private readonly Lock _sync = new();
+ private readonly Dictionary _hitsoundSampleCache = new(StringComparer.OrdinalIgnoreCase);
+
+ // Prevent the delegate from being garbage-collected while BASS holds a reference to it.
+ private static readonly SyncProcedure HitsoundStreamEndSync = OnHitsoundStreamEnd;
+
+ private static void OnHitsoundStreamEnd(int handle, int channel, int data, IntPtr user)
+ {
+ // Free the stream channel created via SampleChannelStream once playback finishes.
+ _ = Bass.StreamFree(channel);
+ }
+
+ private bool _initialized;
+ private int _songStreamHandle;
+ private string _songPath = string.Empty;
+ private long _songLengthBytes;
+ private int _songDurationMs;
+ private float _songVolume = 1f;
+ private float _hitsoundVolume = 1f;
+ private int _lastSeekRequestMs = -1;
+ private int _lastSeekObservedMs = -1;
+ private int _lastSeekErrorMs;
+ private Errors _lastBassError = Errors.OK;
+ private string _selectedAudioOutputDeviceId;
+
+ public ManagedBassPlaybackService(ISettingsService settingsService)
+ {
+ try
+ {
+ var configuredDeviceId = settingsService.GetMainSettings().AudioOutputDeviceId;
+ _selectedAudioOutputDeviceId = NormalizeAudioOutputDeviceId(configuredDeviceId);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ _selectedAudioOutputDeviceId = DefaultAudioOutputDeviceId;
+ }
+ }
+
+ public bool IsSongPlaying
+ {
+ get
+ {
+ lock (_sync)
+ {
+ if (_songStreamHandle == 0)
+ {
+ return false;
+ }
+
+ var state = Bass.ChannelIsActive(_songStreamHandle);
+ return state == PlaybackState.Playing || state == PlaybackState.Stalled;
+ }
+ }
+ }
+
+ public bool LoadSong(string filePath)
+ {
+ if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
+ {
+ lock (_sync)
+ {
+ ClearLoadedSongState();
+ }
+
+ return false;
+ }
+
+ lock (_sync)
+ {
+ if (!EnsureInitialized())
+ {
+ ClearLoadedSongState();
+ return false;
+ }
+
+ var fullPath = Path.GetFullPath(filePath);
+ if (_songStreamHandle != 0 && string.Equals(_songPath, fullPath, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ FreeSongStream();
+
+ var streamHandle = Bass.CreateStream(fullPath, 0, 0, BassFlags.Prescan);
+ if (streamHandle == 0)
+ {
+ _lastBassError = Bass.LastError;
+ ClearLoadedSongState();
+ return false;
+ }
+
+ _songStreamHandle = streamHandle;
+ _songPath = fullPath;
+ ResetLoadedSongRuntimeState();
+ ApplySongVolume();
+ CacheSongLength();
+ return true;
+ }
+ }
+
+ public bool PlaySong(int startTimeMs)
+ {
+ lock (_sync)
+ {
+ if (_songStreamHandle == 0 || !EnsureInitialized())
+ {
+ return false;
+ }
+
+ _ = Bass.ChannelStop(_songStreamHandle);
+
+ var targetMs = Math.Max(0, startTimeMs);
+ var targetPositionBytes = MillisecondsToStreamBytes(_songStreamHandle, targetMs);
+ if (_songLengthBytes > 0)
+ {
+ targetPositionBytes = Math.Clamp(targetPositionBytes, 0L, Math.Max(0L, _songLengthBytes - 1));
+ }
+
+ _ = Bass.ChannelSetPosition(_songStreamHandle, targetPositionBytes);
+ if (!Bass.ChannelPlay(_songStreamHandle))
+ {
+ _lastBassError = Bass.LastError;
+ UpdateLastSeekDebug(targetMs);
+ return false;
+ }
+
+ UpdateLastSeekDebug(targetMs);
+ return true;
+ }
+ }
+
+ public void PauseSong()
+ {
+ lock (_sync)
+ {
+ if (_songStreamHandle != 0)
+ {
+ _ = Bass.ChannelPause(_songStreamHandle);
+ }
+ }
+ }
+
+ public void StopSong()
+ {
+ lock (_sync)
+ {
+ if (_songStreamHandle == 0)
+ {
+ return;
+ }
+
+ _ = Bass.ChannelStop(_songStreamHandle);
+ _ = Bass.ChannelSetPosition(_songStreamHandle, 0);
+ }
+ }
+
+ public int GetSongPositionMs()
+ {
+ lock (_sync)
+ {
+ if (_songStreamHandle == 0)
+ {
+ return 0;
+ }
+
+ return GetObservedSongPositionMs(_songStreamHandle, applyCompensation: true);
+ }
+ }
+
+ public int GetLoadedSongDurationMs()
+ {
+ lock (_sync)
+ {
+ if (_songStreamHandle == 0)
+ {
+ return 0;
+ }
+
+ if (_songDurationMs > 0)
+ {
+ return _songDurationMs;
+ }
+
+ CacheSongLength();
+ return _songDurationMs;
+ }
+ }
+
+ public string GetTimingTelemetryStatus()
+ {
+ lock (_sync)
+ {
+ if (!_initialized)
+ {
+ return "Audio[ManagedBass]: idle";
+ }
+
+ return $"Audio[ManagedBass/event-driven] upd {Bass.UpdatePeriod}ms buf {Bass.PlaybackBufferLength}ms err {_lastBassError}";
+ }
+ }
+
+ public string GetSongDebugStatus()
+ {
+ lock (_sync)
+ {
+ if (_songStreamHandle == 0)
+ {
+ return $"AudioDbg: no song (ManagedBass err {_lastBassError})";
+ }
+
+ var ext = Path.GetExtension(_songPath);
+ var lengthBytes = _songLengthBytes > 0 ? _songLengthBytes : SafeChannelGetLengthBytes(_songStreamHandle);
+ var lengthMs = lengthBytes > 0 ? StreamBytesToMilliseconds(_songStreamHandle, lengthBytes) : 0;
+ var cursorBytes = SafeChannelGetPositionBytes(_songStreamHandle);
+ var rawCursorMs = StreamBytesToMilliseconds(_songStreamHandle, cursorBytes);
+ var cursorMs = ApplySongClockCompensation(rawCursorMs);
+ var state = Bass.ChannelIsActive(_songStreamHandle);
+ var seekState = _lastSeekRequestMs < 0
+ ? "seek:none"
+ : $"seek:req {_lastSeekRequestMs} obs {_lastSeekObservedMs} err {_lastSeekErrorMs}ms";
+
+ return $"AudioDbg | backend managedbass ext {ext} state {state} lenB {lengthBytes} lenMs {lengthMs} curB {cursorBytes} rawCurMs {rawCursorMs} curMs {cursorMs} comp {SongClockCompensationMs}ms {seekState} err {_lastBassError}";
+ }
+ }
+
+ public void SetSongVolume(float volume)
+ {
+ lock (_sync)
+ {
+ _songVolume = Clamp01(volume);
+ ApplySongVolume();
+ }
+ }
+
+ public void SetHitsoundVolume(float volume)
+ {
+ lock (_sync)
+ {
+ _hitsoundVolume = Clamp01(volume);
+ }
+ }
+
+ public bool PlayHitsound(string filePath, float volumeMultiplier = 1f, string playbackBusKey = "")
+ {
+ if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
+ {
+ return false;
+ }
+
+ lock (_sync)
+ {
+ if (!EnsureInitialized())
+ {
+ return false;
+ }
+
+ var sampleHandle = GetOrLoadHitsoundSample(filePath, playbackBusKey);
+ if (sampleHandle == 0)
+ {
+ return false;
+ }
+
+ // Use SampleChannelStream to create an independent stream channel for each playback.
+ // Unlike normal sample channels, stream channels are fully independent and mix additively
+ // without sharing a voice pool, so simultaneous hitsounds play at their intended volume.
+ var channelHandle = Bass.SampleGetChannel(sampleHandle, BassFlags.SampleChannelStream);
+ if (channelHandle == 0)
+ {
+ _lastBassError = Bass.LastError;
+ return false;
+ }
+
+ var effectiveVolume = Clamp01(_hitsoundVolume * Clamp01(volumeMultiplier));
+ _ = Bass.ChannelSetAttribute(channelHandle, ChannelAttribute.Volume, effectiveVolume);
+
+ // Register a sync to automatically free the stream channel when playback ends,
+ // preventing handle leaks since stream channels are not auto-freed.
+ Bass.ChannelSetSync(channelHandle, SyncFlags.End | SyncFlags.Onetime, 0,
+ HitsoundStreamEndSync);
+
+ if (!Bass.ChannelPlay(channelHandle, true))
+ {
+ _lastBassError = Bass.LastError;
+ _ = Bass.StreamFree(channelHandle);
+ return false;
+ }
+
+ return true;
+ }
+ }
+
+ public IReadOnlyList GetAudioOutputDevices()
+ {
+ lock (_sync)
+ {
+ return EnumerateAudioOutputDevices();
+ }
+ }
+
+ public string GetSelectedAudioOutputDeviceId()
+ {
+ lock (_sync)
+ {
+ return _selectedAudioOutputDeviceId;
+ }
+ }
+
+ public bool SetSelectedAudioOutputDevice(string deviceId)
+ {
+ var normalizedDeviceId = NormalizeAudioOutputDeviceId(deviceId);
+
+ lock (_sync)
+ {
+ if (string.Equals(_selectedAudioOutputDeviceId, normalizedDeviceId, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ var previousDeviceId = _selectedAudioOutputDeviceId;
+ _selectedAudioOutputDeviceId = normalizedDeviceId;
+
+ // Reinitialize backend on device switch. Existing playback/samples are intentionally dropped.
+ FreeSongStream();
+ FreeHitsoundSamples();
+
+ if (_initialized)
+ {
+ try
+ {
+ _ = Bass.Free();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore backend shutdown failures during device switch.
+ }
+ finally
+ {
+ _initialized = false;
+ }
+ }
+
+ if (EnsureInitialized())
+ {
+ return true;
+ }
+
+ _selectedAudioOutputDeviceId = previousDeviceId;
+ return EnsureInitialized() && false;
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_sync)
+ {
+ FreeSongStream();
+
+ foreach (var sampleHandle in _hitsoundSampleCache.Values)
+ {
+ _ = sampleHandle;
+ }
+
+ FreeHitsoundSamples();
+ ClearLoadedSongState();
+
+ if (_initialized)
+ {
+ try
+ {
+ _ = Bass.Free();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore backend free failures during shutdown.
+ }
+ finally
+ {
+ _initialized = false;
+ }
+ }
+ }
+ }
+
+ private bool EnsureInitialized()
+ {
+ if (_initialized)
+ {
+ return true;
+ }
+
+ try
+ {
+ // Keep latency low for editor preview use-cases without running our own update thread.
+ Bass.UpdatePeriod = UpdatePeriodMs;
+ Bass.PlaybackBufferLength = PlaybackBufferLengthMs;
+ Bass.OggPreScan = true;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Config is best-effort; continue with defaults if unavailable.
+ }
+
+ var targetDevice = ResolveBassDevice(_selectedAudioOutputDeviceId);
+ if (!Bass.Init(targetDevice, OutputSampleRateHz, DeviceInitFlags.Default, IntPtr.Zero, IntPtr.Zero))
+ {
+ _lastBassError = Bass.LastError;
+
+ if (targetDevice != Bass.DefaultDevice &&
+ Bass.Init(Bass.DefaultDevice, OutputSampleRateHz, DeviceInitFlags.Default, IntPtr.Zero,
+ IntPtr.Zero))
+ {
+ _selectedAudioOutputDeviceId = DefaultAudioOutputDeviceId;
+ _initialized = true;
+ _lastBassError = Errors.OK;
+ return true;
+ }
+
+ return false;
+
+ }
+
+ _initialized = true;
+ _lastBassError = Errors.OK;
+ return true;
+ }
+
+ private void FreeHitsoundSamples()
+ {
+ foreach (var sampleHandle in _hitsoundSampleCache.Values)
+ {
+ try
+ {
+ if (sampleHandle != 0)
+ {
+ _ = Bass.SampleFree(sampleHandle);
+ }
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore sample disposal failures during shutdown/device switch.
+ }
+ }
+
+ _hitsoundSampleCache.Clear();
+ }
+
+ private int GetOrLoadHitsoundSample(string filePath, string playbackBusKey)
+ {
+ var fullPath = Path.GetFullPath(filePath);
+ var cacheKey = BuildHitsoundSampleCacheKey(fullPath, playbackBusKey);
+ if (_hitsoundSampleCache.TryGetValue(cacheKey, out var existing))
+ {
+ return existing;
+ }
+
+ var sampleHandle = Bass.SampleLoad(
+ fullPath,
+ 0,
+ 0,
+ HitsoundSampleMaxVoices,
+ BassFlags.SampleOverrideLongestPlaying | BassFlags.Float);
+ if (sampleHandle == 0)
+ {
+ _lastBassError = Bass.LastError;
+ return 0;
+ }
+
+ _hitsoundSampleCache[cacheKey] = sampleHandle;
+ return sampleHandle;
+ }
+
+ private static string BuildHitsoundSampleCacheKey(string fullPath, string playbackBusKey)
+ {
+ var normalizedBus = string.IsNullOrWhiteSpace(playbackBusKey)
+ ? "default"
+ : playbackBusKey.Trim();
+ return normalizedBus + "|" + fullPath;
+ }
+
+ private void CacheSongLength()
+ {
+ if (_songStreamHandle == 0)
+ {
+ _songLengthBytes = 0;
+ _songDurationMs = 0;
+ return;
+ }
+
+ _songLengthBytes = SafeChannelGetLengthBytes(_songStreamHandle);
+ _songDurationMs = _songLengthBytes > 0 ? StreamBytesToMilliseconds(_songStreamHandle, _songLengthBytes) : 0;
+ }
+
+ private void ApplySongVolume()
+ {
+ if (_songStreamHandle == 0)
+ {
+ return;
+ }
+
+ _ = Bass.ChannelSetAttribute(_songStreamHandle, ChannelAttribute.Volume, _songVolume);
+ }
+
+ private void FreeSongStream()
+ {
+ if (_songStreamHandle != 0)
+ {
+ try
+ {
+ _ = Bass.StreamFree(_songStreamHandle);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore free failures during reload/shutdown.
+ }
+ }
+
+ _songStreamHandle = 0;
+ _songPath = string.Empty;
+ ResetLoadedSongRuntimeState();
+ }
+
+ private void ClearLoadedSongState()
+ {
+ _songPath = string.Empty;
+ _songStreamHandle = 0;
+ ResetLoadedSongRuntimeState();
+ }
+
+ private void ResetLoadedSongRuntimeState()
+ {
+ _songLengthBytes = 0;
+ _songDurationMs = 0;
+ ResetSongSeekDebugState();
+ }
+
+ private int GetObservedSongPositionMs(int streamHandle, bool applyCompensation)
+ {
+ var positionBytes = SafeChannelGetPositionBytes(streamHandle);
+ var rawPositionMs = StreamBytesToMilliseconds(streamHandle, positionBytes);
+ return applyCompensation ? ApplySongClockCompensation(rawPositionMs) : rawPositionMs;
+ }
+
+ private static long MillisecondsToStreamBytes(int streamHandle, int timeMs)
+ {
+ var seconds = Math.Max(0, timeMs) / 1000d;
+ var bytes = Bass.ChannelSeconds2Bytes(streamHandle, seconds);
+ return bytes < 0 ? 0 : bytes;
+ }
+
+ private static int StreamBytesToMilliseconds(int streamHandle, long positionBytes)
+ {
+ if (positionBytes <= 0)
+ {
+ return 0;
+ }
+
+ var seconds = Bass.ChannelBytes2Seconds(streamHandle, positionBytes);
+ if (seconds < 0)
+ {
+ return 0;
+ }
+
+ return (int)Math.Max(0, Math.Round(seconds * 1000d));
+ }
+
+ private static long SafeChannelGetLengthBytes(int streamHandle)
+ {
+ try
+ {
+ var length = Bass.ChannelGetLength(streamHandle);
+ return length < 0 ? 0 : length;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return 0;
+ }
+ }
+
+ private static long SafeChannelGetPositionBytes(int streamHandle)
+ {
+ try
+ {
+ var position = Bass.ChannelGetPosition(streamHandle);
+ return position < 0 ? 0 : position;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return 0;
+ }
+ }
+
+ private void UpdateLastSeekDebug(int targetTimeMs)
+ {
+ _lastSeekRequestMs = Math.Max(0, targetTimeMs);
+
+ if (_songStreamHandle == 0)
+ {
+ _lastSeekObservedMs = -1;
+ _lastSeekErrorMs = 0;
+ return;
+ }
+
+ var observedMs = GetObservedSongPositionMs(_songStreamHandle, applyCompensation: false);
+ _lastSeekObservedMs = observedMs;
+ _lastSeekErrorMs = observedMs - _lastSeekRequestMs;
+ }
+
+ private void ResetSongSeekDebugState()
+ {
+ _lastSeekRequestMs = -1;
+ _lastSeekObservedMs = -1;
+ _lastSeekErrorMs = 0;
+ }
+
+ private static float Clamp01(float value) => Math.Clamp(value, 0f, 1f);
+
+ private static string NormalizeAudioOutputDeviceId(string? deviceId)
+ {
+ if (string.IsNullOrWhiteSpace(deviceId))
+ {
+ return DefaultAudioOutputDeviceId;
+ }
+
+ var trimmed = deviceId.Trim();
+ return trimmed.StartsWith("device:", StringComparison.OrdinalIgnoreCase)
+ ? $"device:{trimmed["device:".Length..]}"
+ : DefaultAudioOutputDeviceId;
+ }
+
+ private static int ResolveBassDevice(string deviceId)
+ {
+ var normalized = NormalizeAudioOutputDeviceId(deviceId);
+ if (string.Equals(normalized, DefaultAudioOutputDeviceId, StringComparison.Ordinal))
+ {
+ return Bass.DefaultDevice;
+ }
+
+ if (normalized.StartsWith("device:", StringComparison.Ordinal) &&
+ int.TryParse(normalized["device:".Length..], out var deviceIndex))
+ {
+ return deviceIndex;
+ }
+
+ return Bass.DefaultDevice;
+ }
+
+ private static List EnumerateAudioOutputDevices()
+ {
+ var devices = new List
+ {
+ new(DefaultAudioOutputDeviceId, "System Default", isDefault: true, isEnabled: true)
+ };
+
+ try
+ {
+ var deviceCount = Math.Max(0, Bass.DeviceCount);
+ for (var i = 1; i < deviceCount; i++)
+ {
+ DeviceInfo info;
+ try
+ {
+ info = Bass.GetDeviceInfo(i);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ continue;
+ }
+
+ if (!info.IsEnabled || info.IsLoopback)
+ {
+ continue;
+ }
+
+ var name = string.IsNullOrWhiteSpace(info.Name) ? $"Device {i}" : info.Name.Trim();
+ var suffix = info.IsDefault ? " (Default)" : string.Empty;
+ devices.Add(new AudioOutputDeviceOption($"device:{i}", $"{name}{suffix}", info.IsDefault, info.IsEnabled));
+ }
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Return at least the synthetic default option on enumeration failures.
+ }
+
+ return devices;
+ }
+
+ private static int ApplySongClockCompensation(int rawPositionMs)
+ {
+ return Math.Max(0, rawPositionMs - SongClockCompensationMs);
+ }
+}
diff --git a/MapWizard.Desktop/Services/Playback/MiniAudioPlaybackService.cs b/MapWizard.Desktop/Services/Playback/MiniAudioPlaybackService.cs
new file mode 100644
index 0000000..9a58a0a
--- /dev/null
+++ b/MapWizard.Desktop/Services/Playback/MiniAudioPlaybackService.cs
@@ -0,0 +1,737 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using MapWizard.Desktop.Models.Settings;
+using MiniAudioEx.Core.StandardAPI;
+
+namespace MapWizard.Desktop.Services.Playback;
+
+public sealed class MiniAudioPlaybackService : IAudioPlaybackService, IDisposable
+{
+ private const uint SampleRate = 44100;
+ private const uint Channels = 2;
+ // Smaller period lowers output latency for editor-preview style transport.
+ private const uint PeriodSizeInFrames = 256;
+ // Drive MiniAudioEx updates off the UI thread at a fixed high rate for stable transport timing.
+ private const int AudioUpdateRateHz = 1000;
+ private const string AudioUpdateThreadName = "MapWizard.AudioUpdate";
+ private static readonly TimeSpan AudioUpdateShutdownWait = TimeSpan.FromMilliseconds(500);
+ private const int HitsoundSourceMaxVoices = 256;
+
+ private readonly object _sync = new();
+ private readonly Dictionary _clipCache = new(StringComparer.OrdinalIgnoreCase);
+
+ private bool _initialized;
+ private CancellationTokenSource? _updateLoopCts;
+ private Thread? _updateLoopThread;
+ private AudioSource? _songSource;
+ private readonly Dictionary _hitsoundSourcesByVolumePercent = [];
+ private float _hitsoundVolume = 1f;
+ private AudioClip? _songClip;
+ private string _songPath = string.Empty;
+ private ulong _songSourceLengthFrames;
+ private int _songDurationMs;
+ private int _lastSeekRequestMs = -1;
+ private int _lastSeekObservedMs = -1;
+ private int _lastSeekErrorMs;
+ private long _audioUpdateLastTickMs;
+ private int _audioUpdateLastIntervalMs;
+ private int _audioUpdateMaxIntervalMs;
+ private int _audioUpdateLastLoopMs;
+ private int _audioUpdateMaxLoopMs;
+
+ public bool IsSongPlaying
+ {
+ get
+ {
+ lock (_sync)
+ {
+ return _songSource?.IsPlaying == true;
+ }
+ }
+ }
+
+ public bool LoadSong(string filePath)
+ {
+ if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
+ {
+ lock (_sync)
+ {
+ ClearLoadedSongState();
+ }
+
+ return false;
+ }
+
+ lock (_sync)
+ {
+ EnsureInitialized();
+
+ if (string.Equals(_songPath, filePath, StringComparison.OrdinalIgnoreCase) && _songClip is not null)
+ {
+ return true;
+ }
+
+ _songClip = GetOrCreateClip(filePath, streamFromDisk: true);
+ _songPath = filePath;
+ ResetLoadedSongRuntimeState();
+ if (_songClip is null)
+ {
+ ResetLoadedSongRuntimeState();
+ }
+
+ return _songClip is not null;
+ }
+ }
+
+ public bool PlaySong(int startTimeMs)
+ {
+ lock (_sync)
+ {
+ if (_songClip is null)
+ {
+ return false;
+ }
+
+ EnsureInitialized();
+ if (_songSource is null)
+ {
+ return false;
+ }
+
+ _songSource.Stop();
+ _songSource.Cursor = 0;
+ _songSource.Play(_songClip);
+ AudioContext.Update();
+ var targetFrame = MillisecondsToFrames(startTimeMs, GetAudioFrameRateHz());
+ SetSongCursor(_songSource, targetFrame);
+
+ // Streaming clips may not expose a seekable length/cursor immediately on the same tick.
+ // Retry once after another update so transport seeks do not restart from 0.
+ if (startTimeMs > 0 && _songSource.Cursor == 0)
+ {
+ AudioContext.Update();
+ SetSongCursor(_songSource, targetFrame);
+ }
+
+ UpdateLastSeekDebug(_songSource, startTimeMs);
+
+ return true;
+ }
+ }
+
+ public void PauseSong()
+ {
+ lock (_sync)
+ {
+ _songSource?.Stop();
+ }
+ }
+
+ public void StopSong()
+ {
+ lock (_sync)
+ {
+ if (_songSource is null)
+ {
+ return;
+ }
+
+ _songSource.Stop();
+ _songSource.Cursor = 0;
+ }
+ }
+
+ public int GetSongPositionMs()
+ {
+ lock (_sync)
+ {
+ if (_songSource is null)
+ {
+ return 0;
+ }
+
+ return GetObservedSongPositionMs(_songSource);
+ }
+ }
+
+ public int GetLoadedSongDurationMs()
+ {
+ lock (_sync)
+ {
+ if (_songClip is null)
+ {
+ return 0;
+ }
+
+ try
+ {
+ EnsureInitialized();
+ if (_songSource is null)
+ {
+ return Math.Max(0, _songDurationMs);
+ }
+
+ ResetSongSourcePlaybackState(_songSource);
+ _songSource.Play(_songClip);
+
+ var lengthFrames = TryReadSongSourceLengthFrames(_songSource, attempts: 8);
+
+ if (lengthFrames > 0)
+ {
+ _songSourceLengthFrames = lengthFrames;
+ var durationMs = FramesToMilliseconds(lengthFrames, GetAudioFrameRateHz());
+ _songDurationMs = durationMs;
+ ResetSongSourcePlaybackState(_songSource);
+ return durationMs;
+ }
+
+ ResetSongSourcePlaybackState(_songSource);
+
+ // Fallback when source length is unavailable (can happen on some files/code paths).
+ _songDurationMs = 0;
+ _songSourceLengthFrames = 0;
+ return _songDurationMs;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return 0;
+ }
+ }
+ }
+
+ public string GetTimingTelemetryStatus()
+ {
+ var isInitialized = _initialized;
+ var isThreadAlive = _updateLoopThread?.IsAlive == true;
+ var lastIntervalMs = Volatile.Read(ref _audioUpdateLastIntervalMs);
+ var maxIntervalMs = Volatile.Read(ref _audioUpdateMaxIntervalMs);
+ var lastLoopMs = Volatile.Read(ref _audioUpdateLastLoopMs);
+ var maxLoopMs = Volatile.Read(ref _audioUpdateMaxLoopMs);
+
+ if (!isInitialized)
+ {
+ return $"Audio[{AudioUpdateThreadName}]: idle";
+ }
+
+ var threadState = isThreadAlive ? "alive" : "stopped";
+ return $"Audio[{AudioUpdateThreadName}/{AudioUpdateRateHz}Hz/{threadState}] dt {lastIntervalMs}ms (max {maxIntervalMs}ms) loop {lastLoopMs}ms (max {maxLoopMs}ms)";
+ }
+
+ public string GetSongDebugStatus()
+ {
+ lock (_sync)
+ {
+ if (_songClip is null)
+ {
+ return "AudioDbg: no song";
+ }
+
+ var outputRateHz = _initialized ? Math.Max(1, AudioContext.SampleRate) : (int)SampleRate;
+ var sourceLengthFrames = _songSourceLengthFrames;
+ if (sourceLengthFrames == 0 && _songSource is not null)
+ {
+ sourceLengthFrames = SafeReadSourceLength(_songSource);
+ }
+
+ var cursorFrames = _songSource is not null ? SafeReadSourceCursor(_songSource) : 0UL;
+
+ var rateHz = GetAudioFrameRateHz();
+ var cursorMs = FramesToMilliseconds(cursorFrames, rateHz);
+ var sourceDurationMs = sourceLengthFrames > 0 ? FramesToMilliseconds(sourceLengthFrames, rateHz) : 0;
+ var ext = Path.GetExtension(_songPath);
+ var seekState = _lastSeekRequestMs < 0
+ ? "seek:none"
+ : $"seek:req {_lastSeekRequestMs} obs {_lastSeekObservedMs} err {_lastSeekErrorMs}ms";
+
+ return $"AudioDbg | ext {ext} out {outputRateHz}Hz srcLenF {sourceLengthFrames} srcMs {sourceDurationMs} curF {cursorFrames} curMs {cursorMs} {seekState}";
+ }
+ }
+
+ public void SetSongVolume(float volume)
+ {
+ lock (_sync)
+ {
+ EnsureInitialized();
+ if (_songSource is not null)
+ {
+ _songSource.Volume = Clamp01(volume);
+ }
+ }
+ }
+
+ public void SetHitsoundVolume(float volume)
+ {
+ lock (_sync)
+ {
+ EnsureInitialized();
+
+ _hitsoundVolume = Clamp01(volume);
+ foreach (var entry in _hitsoundSourcesByVolumePercent)
+ {
+ entry.Value.Volume = _hitsoundVolume * (entry.Key / 100f);
+ }
+ }
+ }
+
+ public bool PlayHitsound(string filePath, float volumeMultiplier = 1f, string playbackBusKey = "")
+ {
+ if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
+ {
+ return false;
+ }
+
+ lock (_sync)
+ {
+ EnsureInitialized();
+
+ var effectiveVolumePercent = (int)Math.Round(Clamp01(volumeMultiplier) * 100f);
+ var source = GetOrCreateHitsoundSource(effectiveVolumePercent);
+ if (source is null)
+ {
+ return false;
+ }
+
+ var clip = GetOrCreateClip(filePath, streamFromDisk: false);
+ if (clip is null)
+ {
+ return false;
+ }
+
+ source.PlayOneShot(clip);
+ return true;
+ }
+ }
+
+ public IReadOnlyList GetAudioOutputDevices()
+ {
+ return
+ [
+ new AudioOutputDeviceOption("default", "System Default (MiniAudio)", isDefault: true, isEnabled: true)
+ ];
+ }
+
+ public string GetSelectedAudioOutputDeviceId()
+ {
+ return "default";
+ }
+
+ public bool SetSelectedAudioOutputDevice(string deviceId)
+ {
+ // MiniAudio implementation currently uses the default output device only.
+ return true;
+ }
+
+ public void Dispose()
+ {
+ CancellationTokenSource? updateLoopCts;
+ Thread? updateLoopThread;
+
+ lock (_sync)
+ {
+ updateLoopCts = _updateLoopCts;
+ updateLoopThread = _updateLoopThread;
+ _updateLoopCts = null;
+ _updateLoopThread = null;
+ }
+
+ if (updateLoopCts is not null)
+ {
+ try
+ {
+ updateLoopCts.Cancel();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore shutdown cancellation failures.
+ }
+
+ try
+ {
+ updateLoopThread?.Join(AudioUpdateShutdownWait);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore worker shutdown failures during app shutdown/disposal.
+ }
+ finally
+ {
+ updateLoopCts.Dispose();
+ }
+ }
+
+ lock (_sync)
+ {
+ _songSource?.Dispose();
+ _songSource = null;
+
+ foreach (var source in _hitsoundSourcesByVolumePercent.Values)
+ {
+ try
+ {
+ source.Dispose();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore hitsound source disposal failures on shutdown.
+ }
+ }
+
+ _hitsoundSourcesByVolumePercent.Clear();
+
+ foreach (var clip in _clipCache.Values)
+ {
+ try
+ {
+ clip.Dispose();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore clip disposal failures on shutdown.
+ }
+ }
+
+ _clipCache.Clear();
+ ClearLoadedSongState();
+
+ if (_initialized)
+ {
+ try
+ {
+ AudioContext.Deinitialize();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore deinit failures during app shutdown.
+ }
+ finally
+ {
+ _initialized = false;
+ }
+ }
+ }
+ }
+
+ private void EnsureInitialized()
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ AudioContext.Initialize(SampleRate, Channels, PeriodSizeInFrames, deviceInfo: null!);
+ _songSource = new AudioSource(maxSources: 1);
+ _hitsoundSourcesByVolumePercent.Clear();
+ _hitsoundSourcesByVolumePercent[100] = new AudioSource(maxSources: HitsoundSourceMaxVoices)
+ {
+ Volume = _hitsoundVolume
+ };
+ _initialized = true;
+ ResetAudioUpdateTelemetry();
+ _updateLoopCts = new CancellationTokenSource();
+ _updateLoopThread = new Thread(() => RunAudioUpdateLoop(_updateLoopCts.Token))
+ {
+ IsBackground = true,
+ Name = AudioUpdateThreadName
+ };
+ _updateLoopThread.Start();
+ }
+
+ private void RunAudioUpdateLoop(CancellationToken token)
+ {
+ var targetStepTicks = Math.Max(1L, Stopwatch.Frequency / Math.Max(1, AudioUpdateRateHz));
+ var clock = Stopwatch.StartNew();
+ var nextTick = clock.ElapsedTicks;
+
+ while (!token.IsCancellationRequested)
+ {
+ var loopStartTickMs = Environment.TickCount64;
+ var previousTickMs = Interlocked.Exchange(ref _audioUpdateLastTickMs, loopStartTickMs);
+ if (previousTickMs > 0)
+ {
+ var intervalMs = (int)Math.Clamp(loopStartTickMs - previousTickMs, 0, int.MaxValue);
+ Volatile.Write(ref _audioUpdateLastIntervalMs, intervalMs);
+ UpdateMax(ref _audioUpdateMaxIntervalMs, intervalMs);
+ }
+
+ try
+ {
+ lock (_sync)
+ {
+ if (!_initialized)
+ {
+ return;
+ }
+
+ AudioContext.Update();
+ }
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Keep the audio update loop resilient; a transient update failure should not crash the app.
+ }
+
+ nextTick += targetStepTicks;
+
+ while (!token.IsCancellationRequested)
+ {
+ var remainingTicks = nextTick - clock.ElapsedTicks;
+ if (remainingTicks <= 0)
+ {
+ break;
+ }
+
+ var remainingMs = (remainingTicks * 1000d) / Stopwatch.Frequency;
+ if (remainingMs >= 2d)
+ {
+ Thread.Sleep(Math.Max(0, (int)remainingMs - 1));
+ }
+ else
+ {
+ Thread.Yield();
+ }
+ }
+
+ // If the app was stalled, resync instead of trying to "catch up" thousands of iterations.
+ var driftTicks = clock.ElapsedTicks - nextTick;
+ if (driftTicks > targetStepTicks * 8)
+ {
+ nextTick = clock.ElapsedTicks;
+ }
+
+ var loopDurationMs = (int)Math.Clamp(Environment.TickCount64 - loopStartTickMs, 0, int.MaxValue);
+ Volatile.Write(ref _audioUpdateLastLoopMs, loopDurationMs);
+ UpdateMax(ref _audioUpdateMaxLoopMs, loopDurationMs);
+ }
+ }
+
+ private void ResetAudioUpdateTelemetry()
+ {
+ Interlocked.Exchange(ref _audioUpdateLastTickMs, 0);
+ Volatile.Write(ref _audioUpdateLastIntervalMs, 0);
+ Volatile.Write(ref _audioUpdateMaxIntervalMs, 0);
+ Volatile.Write(ref _audioUpdateLastLoopMs, 0);
+ Volatile.Write(ref _audioUpdateMaxLoopMs, 0);
+ }
+
+ private static void UpdateMax(ref int target, int candidate)
+ {
+ while (true)
+ {
+ var current = Volatile.Read(ref target);
+ if (candidate <= current)
+ {
+ return;
+ }
+
+ if (Interlocked.CompareExchange(ref target, candidate, current) == current)
+ {
+ return;
+ }
+ }
+ }
+
+ private AudioClip? GetOrCreateClip(string filePath, bool streamFromDisk)
+ {
+ var fullPath = Path.GetFullPath(filePath);
+ var cacheKey = $"{(streamFromDisk ? "stream" : "mem")}::{Path.GetFullPath(filePath)}";
+ if (_clipCache.TryGetValue(cacheKey, out var existing))
+ {
+ return existing;
+ }
+
+ try
+ {
+ var clip = new AudioClip(fullPath, streamFromDisk);
+ _clipCache[cacheKey] = clip;
+ return clip;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return null;
+ }
+ }
+
+ private static void SetSongCursor(AudioSource source, ulong targetFrame)
+ {
+ try
+ {
+ var length = source.Length;
+ if (length > 1)
+ {
+ source.Cursor = Math.Min(targetFrame, length - 2);
+ return;
+ }
+
+ if (length == 1)
+ {
+ source.Cursor = 0;
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Fall through and try direct cursor set when length isn't available yet.
+ }
+
+ try
+ {
+ source.Cursor = targetFrame;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore seek failures; caller will continue from current playback position.
+ }
+ }
+
+ private static ulong MillisecondsToFrames(int timeMs, int sampleRate)
+ {
+ var clampedMs = Math.Max(0, timeMs);
+ var frames = (clampedMs / 1000d) * Math.Max(1, sampleRate);
+ return (ulong)Math.Max(0, Math.Round(frames));
+ }
+
+ private static int FramesToMilliseconds(ulong frames, int sampleRate)
+ {
+ var sr = Math.Max(1, sampleRate);
+ var ms = (frames * 1000d) / sr;
+ return (int)Math.Max(0, Math.Round(ms));
+ }
+
+ private static float Clamp01(float value) => Math.Clamp(value, 0f, 1f);
+
+ private AudioSource? GetOrCreateHitsoundSource(int volumePercent)
+ {
+ var clampedPercent = Math.Clamp(volumePercent, 0, 100);
+ if (_hitsoundSourcesByVolumePercent.TryGetValue(clampedPercent, out var source))
+ {
+ return source;
+ }
+
+ try
+ {
+ source = new AudioSource(maxSources: HitsoundSourceMaxVoices)
+ {
+ Volume = _hitsoundVolume * (clampedPercent / 100f)
+ };
+ _hitsoundSourcesByVolumePercent[clampedPercent] = source;
+ return source;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return null;
+ }
+ }
+
+ private void ClearLoadedSongState()
+ {
+ _songClip = null;
+ _songPath = string.Empty;
+ ResetLoadedSongRuntimeState();
+ }
+
+ private void ResetLoadedSongRuntimeState()
+ {
+ _songSourceLengthFrames = 0;
+ _songDurationMs = 0;
+ ResetSongSeekDebugState();
+ }
+
+ private static void ResetSongSourcePlaybackState(AudioSource source)
+ {
+ source.Stop();
+ source.Cursor = 0;
+ }
+
+ private ulong TryReadSongSourceLengthFrames(AudioSource source, int attempts)
+ {
+ ulong lengthFrames = 0;
+ for (var i = 0; i < Math.Max(1, attempts); i++)
+ {
+ AudioContext.Update();
+ lengthFrames = SafeReadSourceLength(source);
+ if (lengthFrames > 0)
+ {
+ return lengthFrames;
+ }
+ }
+
+ return 0;
+ }
+
+ private int GetObservedSongPositionMs(AudioSource source)
+ {
+ return FramesToMilliseconds(SafeReadSourceCursor(source), GetAudioFrameRateHz());
+ }
+
+ private int GetAudioFrameRateHz()
+ {
+ return _initialized ? Math.Max(1, AudioContext.SampleRate) : (int)SampleRate;
+ }
+
+ private static ulong SafeReadSourceLength(AudioSource source)
+ {
+ try
+ {
+ return source.Length;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return 0;
+ }
+ }
+
+ private static ulong SafeReadSourceCursor(AudioSource source)
+ {
+ try
+ {
+ return source.Cursor;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return 0;
+ }
+ }
+
+ private void UpdateLastSeekDebug(AudioSource source, int targetTimeMs)
+ {
+ _lastSeekRequestMs = Math.Max(0, targetTimeMs);
+
+ try
+ {
+ AudioContext.Update();
+ var observedMs = GetObservedSongPositionMs(source);
+ _lastSeekObservedMs = observedMs;
+ _lastSeekErrorMs = observedMs - _lastSeekRequestMs;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ _lastSeekObservedMs = -1;
+ _lastSeekErrorMs = 0;
+ }
+ }
+
+ private void ResetSongSeekDebugState()
+ {
+ _lastSeekRequestMs = -1;
+ _lastSeekObservedMs = -1;
+ _lastSeekErrorMs = 0;
+ }
+
+}
diff --git a/MapWizard.Desktop/Services/Playback/PlaybackRunner.cs b/MapWizard.Desktop/Services/Playback/PlaybackRunner.cs
new file mode 100644
index 0000000..67be261
--- /dev/null
+++ b/MapWizard.Desktop/Services/Playback/PlaybackRunner.cs
@@ -0,0 +1,450 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MapWizard.Desktop.Models.HitSoundVisualizer;
+
+namespace MapWizard.Desktop.Services.Playback;
+
+internal enum PlaybackRunnerStartResult
+{
+ Started,
+ MissingSongAudio
+}
+
+internal sealed class PlaybackRunner(IAudioPlaybackService audioPlaybackService) : IDisposable
+{
+ private const int ClockSampleIntervalMs = 1;
+ private const int UiUpdateIntervalMs = 8;
+ private const int ClockWarmupWindowMs = 300;
+ private const int ClockWarmupJumpToleranceMs = 150;
+
+ private readonly Lock _sync = new();
+
+ private CancellationTokenSource? _runCts;
+ private int _runIdSequence;
+ private int _currentRunId;
+ private int _timelineEndMs;
+ private int _clockSampleTimeMs;
+ private int _isRunning;
+ private int _isPaused;
+
+ private long _clockLoopLastTickMs;
+ private int _clockLoopLastIntervalMs;
+ private int _clockLoopMaxIntervalMs;
+ private long _schedulerLoopLastTickMs;
+ private int _schedulerLoopLastIntervalMs;
+ private int _schedulerLoopMaxIntervalMs;
+ private int _schedulerLastLagMs;
+ private int _schedulerMaxLagMs;
+ private long _uiLoopLastTickMs;
+ private int _uiLoopLastIntervalMs;
+ private int _uiLoopMaxIntervalMs;
+
+ private bool IsRunning => Volatile.Read(ref _isRunning) != 0;
+ private bool IsPaused => Volatile.Read(ref _isPaused) != 0;
+
+ private int CurrentTimeMs
+ {
+ get
+ {
+ var timelineEndMs = Math.Max(0, Volatile.Read(ref _timelineEndMs));
+ return Math.Clamp(Volatile.Read(ref _clockSampleTimeMs), 0, timelineEndMs);
+ }
+ }
+
+ public PlaybackRunnerStartResult Start(
+ string songPath,
+ int startTimeMs,
+ double timelineEndMs,
+ IReadOnlyList points,
+ Action postToUi,
+ Action onCursorUpdatedOnUi,
+ Action onPlaybackCompletedOnUi,
+ Action onPlayPoint)
+ {
+ var clampedTimelineEndMs = Math.Max(0, (int)Math.Ceiling(timelineEndMs));
+ var clampedStartTimeMs = Math.Clamp(startTimeMs, 0, clampedTimelineEndMs);
+
+ StopInternal(resetPausedState: false, stopSongPlayback: true);
+
+ var songStarted = audioPlaybackService.LoadSong(songPath) && audioPlaybackService.PlaySong(clampedStartTimeMs);
+ if (!songStarted)
+ {
+ Volatile.Write(ref _isRunning, 0);
+ Volatile.Write(ref _isPaused, 0);
+ Volatile.Write(ref _timelineEndMs, clampedTimelineEndMs);
+ Volatile.Write(ref _clockSampleTimeMs, clampedStartTimeMs);
+ ResetTimingTelemetry();
+ return PlaybackRunnerStartResult.MissingSongAudio;
+ }
+
+ var playbackPoints = points
+ .Where(x => x.TimeMs >= clampedStartTimeMs)
+ .OrderBy(x => x.TimeMs)
+ .ToList();
+
+ Volatile.Write(ref _timelineEndMs, clampedTimelineEndMs);
+ Volatile.Write(ref _clockSampleTimeMs, clampedStartTimeMs);
+ ResetTimingTelemetry();
+ Volatile.Write(ref _isRunning, 1);
+ Volatile.Write(ref _isPaused, 0);
+
+ var cts = new CancellationTokenSource();
+ var runId = Interlocked.Increment(ref _runIdSequence);
+ Volatile.Write(ref _currentRunId, runId);
+ lock (_sync)
+ {
+ _runCts = cts;
+ }
+
+ _ = RunPlaybackAsync(
+ runId,
+ clampedStartTimeMs,
+ cts,
+ playbackPoints,
+ postToUi,
+ onCursorUpdatedOnUi,
+ onPlaybackCompletedOnUi,
+ onPlayPoint);
+
+ return PlaybackRunnerStartResult.Started;
+ }
+
+ public int Pause()
+ {
+ var currentTimeMs = GetCurrentTimeMs();
+ StopInternal(resetPausedState: false, stopSongPlayback: false);
+ Volatile.Write(ref _isPaused, 1);
+ return currentTimeMs;
+ }
+
+ public void Stop()
+ {
+ StopInternal(resetPausedState: true, stopSongPlayback: true);
+ }
+
+ public int GetCurrentTimeMs()
+ {
+ var timelineEndMs = Math.Max(0, Volatile.Read(ref _timelineEndMs));
+ if (!IsRunning)
+ {
+ return Math.Clamp(CurrentTimeMs, 0, timelineEndMs);
+ }
+
+ return Math.Clamp(audioPlaybackService.GetSongPositionMs(), 0, timelineEndMs);
+ }
+
+ public string GetTimingTelemetryStatus()
+ {
+ var state = IsRunning ? "running" : (IsPaused ? "paused" : "idle");
+ var clockLast = Volatile.Read(ref _clockLoopLastIntervalMs);
+ var clockMax = Volatile.Read(ref _clockLoopMaxIntervalMs);
+ var uiLast = Volatile.Read(ref _uiLoopLastIntervalMs);
+ var uiMax = Volatile.Read(ref _uiLoopMaxIntervalMs);
+ var schedLast = Volatile.Read(ref _schedulerLoopLastIntervalMs);
+ var schedMax = Volatile.Read(ref _schedulerLoopMaxIntervalMs);
+ var schedLagLast = Volatile.Read(ref _schedulerLastLagMs);
+ var schedLagMax = Volatile.Read(ref _schedulerMaxLagMs);
+
+ return $"PlaybackRunner[{state}] clk {clockLast}/{clockMax}ms | ui {uiLast}/{uiMax}ms | sched {schedLast}/{schedMax}ms lag {schedLagLast}/{schedLagMax}ms";
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ }
+
+ private async Task RunPlaybackAsync(
+ int runId,
+ int startTimeMs,
+ CancellationTokenSource cts,
+ IReadOnlyList playbackPoints,
+ Action postToUi,
+ Action onCursorUpdatedOnUi,
+ Action onPlaybackCompletedOnUi,
+ Action onPlayPoint)
+ {
+ using var clockTickSignal = new SemaphoreSlim(0, 1);
+ bool completedNaturally;
+ var clockLoopTask = RunClockLoopAsync(runId, startTimeMs, cts, clockTickSignal, postToUi, onCursorUpdatedOnUi);
+
+ try
+ {
+ completedNaturally = await RunSchedulerLoopAsync(runId, cts, clockTickSignal, playbackPoints, onPlayPoint);
+ }
+ finally
+ {
+ try
+ {
+ await cts.CancelAsync();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore cancellation races during restart/stop.
+ }
+
+ try
+ {
+ await clockLoopTask;
+ }
+ catch (OperationCanceledException ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Expected when stopping/restarting.
+ }
+ }
+
+ if (!completedNaturally || !IsCurrentRun(runId, cts))
+ {
+ return;
+ }
+
+ StopInternal(resetPausedState: true, stopSongPlayback: true);
+ var endTimeMs = Math.Max(0, Volatile.Read(ref _timelineEndMs));
+ postToUi(() =>
+ {
+ if (runId != Volatile.Read(ref _currentRunId))
+ {
+ return;
+ }
+
+ onPlaybackCompletedOnUi(endTimeMs);
+ });
+ }
+
+ private async Task RunClockLoopAsync(
+ int runId,
+ int startTimeMs,
+ CancellationTokenSource cts,
+ SemaphoreSlim clockTickSignal,
+ Action postToUi,
+ Action onCursorUpdatedOnUi)
+ {
+ var token = cts.Token;
+ var lastUiUpdateTicks = 0L;
+ var warmupStartTickMs = Environment.TickCount64;
+
+ try
+ {
+ while (!token.IsCancellationRequested)
+ {
+ if (!IsCurrentRun(runId, cts))
+ {
+ return;
+ }
+
+ RecordIntervalTelemetry(ref _clockLoopLastTickMs, ref _clockLoopLastIntervalMs, ref _clockLoopMaxIntervalMs);
+ var currentTimeMs = GetCurrentTimeMs();
+ var lastPublishedTimeMs = CurrentTimeMs;
+ var warmupElapsedMs = Environment.TickCount64 - warmupStartTickMs;
+ if (warmupElapsedMs < ClockWarmupWindowMs)
+ {
+ var isClearlyStaleWarmupReading =
+ Math.Abs(currentTimeMs - lastPublishedTimeMs) > ClockWarmupJumpToleranceMs &&
+ Math.Abs(currentTimeMs - startTimeMs) > ClockWarmupJumpToleranceMs;
+ if (isClearlyStaleWarmupReading)
+ {
+ currentTimeMs = lastPublishedTimeMs;
+ }
+ }
+ if (!IsCurrentRun(runId, cts))
+ {
+ return;
+ }
+
+ Volatile.Write(ref _clockSampleTimeMs, currentTimeMs);
+ SignalSingleTick(clockTickSignal);
+
+ var uiTick = Environment.TickCount64;
+ if (uiTick - lastUiUpdateTicks >= UiUpdateIntervalMs)
+ {
+ lastUiUpdateTicks = uiTick;
+ postToUi(() =>
+ {
+ if (!IsCurrentRun(runId, cts))
+ {
+ return;
+ }
+
+ RecordIntervalTelemetry(ref _uiLoopLastTickMs, ref _uiLoopLastIntervalMs, ref _uiLoopMaxIntervalMs);
+ onCursorUpdatedOnUi(currentTimeMs);
+ });
+ }
+
+ await Task.Delay(ClockSampleIntervalMs, token);
+ }
+ }
+ catch (OperationCanceledException ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Expected on stop/restart.
+ }
+ }
+
+ private async Task RunSchedulerLoopAsync(
+ int runId,
+ CancellationTokenSource cts,
+ SemaphoreSlim clockTickSignal,
+ IReadOnlyList playbackPoints,
+ Action onPlayPoint)
+ {
+ var token = cts.Token;
+ var nextPointIndex = 0;
+ var sawSongPlaying = false;
+
+ try
+ {
+ while (!token.IsCancellationRequested)
+ {
+ if (!IsCurrentRun(runId, cts))
+ {
+ return false;
+ }
+
+ RecordIntervalTelemetry(ref _schedulerLoopLastTickMs, ref _schedulerLoopLastIntervalMs, ref _schedulerLoopMaxIntervalMs);
+ if (audioPlaybackService.IsSongPlaying)
+ {
+ sawSongPlaying = true;
+ }
+
+ var currentTimeMs = CurrentTimeMs;
+ while (nextPointIndex < playbackPoints.Count && playbackPoints[nextPointIndex].TimeMs <= currentTimeMs)
+ {
+ if (!IsCurrentRun(runId, cts))
+ {
+ return false;
+ }
+
+ var point = playbackPoints[nextPointIndex++];
+ var lagMs = Math.Max(0, currentTimeMs - point.TimeMs);
+ Volatile.Write(ref _schedulerLastLagMs, lagMs);
+ UpdateMaxTelemetry(ref _schedulerMaxLagMs, lagMs);
+ onPlayPoint(point, token);
+ }
+
+ var songEndedNaturally = sawSongPlaying && !audioPlaybackService.IsSongPlaying;
+ if (songEndedNaturally)
+ {
+ break;
+ }
+
+ await clockTickSignal.WaitAsync(token);
+ }
+ }
+ catch (OperationCanceledException ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return false;
+ }
+
+ return !token.IsCancellationRequested;
+ }
+
+ private void StopInternal(bool resetPausedState, bool stopSongPlayback)
+ {
+ if (resetPausedState)
+ {
+ Volatile.Write(ref _isPaused, 0);
+ }
+
+ Volatile.Write(ref _isRunning, 0);
+
+ CancellationTokenSource? cts;
+ lock (_sync)
+ {
+ cts = _runCts;
+ _runCts = null;
+ }
+
+ if (cts is not null)
+ {
+ try
+ {
+ cts.Cancel();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore cancellation failures during stop/restart.
+ }
+ finally
+ {
+ cts.Dispose();
+ }
+ }
+
+ if (stopSongPlayback)
+ {
+ audioPlaybackService.StopSong();
+ }
+ else
+ {
+ audioPlaybackService.PauseSong();
+ }
+ }
+
+ private bool IsCurrentRun(int runId, CancellationTokenSource cts)
+ {
+ lock (_sync)
+ {
+ return runId == Volatile.Read(ref _currentRunId) && ReferenceEquals(_runCts, cts);
+ }
+ }
+
+ private void ResetTimingTelemetry()
+ {
+ Interlocked.Exchange(ref _clockLoopLastTickMs, 0);
+ Volatile.Write(ref _clockLoopLastIntervalMs, 0);
+ Volatile.Write(ref _clockLoopMaxIntervalMs, 0);
+ Interlocked.Exchange(ref _schedulerLoopLastTickMs, 0);
+ Volatile.Write(ref _schedulerLoopLastIntervalMs, 0);
+ Volatile.Write(ref _schedulerLoopMaxIntervalMs, 0);
+ Volatile.Write(ref _schedulerLastLagMs, 0);
+ Volatile.Write(ref _schedulerMaxLagMs, 0);
+ Interlocked.Exchange(ref _uiLoopLastTickMs, 0);
+ Volatile.Write(ref _uiLoopLastIntervalMs, 0);
+ Volatile.Write(ref _uiLoopMaxIntervalMs, 0);
+ }
+
+ private static void RecordIntervalTelemetry(ref long lastTickField, ref int lastIntervalField, ref int maxIntervalField)
+ {
+ var nowTickMs = Environment.TickCount64;
+ var previousTickMs = Interlocked.Exchange(ref lastTickField, nowTickMs);
+ if (previousTickMs <= 0)
+ {
+ return;
+ }
+
+ var intervalMs = (int)Math.Clamp(nowTickMs - previousTickMs, 0, int.MaxValue);
+ Volatile.Write(ref lastIntervalField, intervalMs);
+ UpdateMaxTelemetry(ref maxIntervalField, intervalMs);
+ }
+
+ private static void SignalSingleTick(SemaphoreSlim signal)
+ {
+ if (signal.CurrentCount == 0)
+ {
+ signal.Release();
+ }
+ }
+
+ private static void UpdateMaxTelemetry(ref int target, int candidate)
+ {
+ while (true)
+ {
+ var current = Volatile.Read(ref target);
+ if (candidate <= current)
+ {
+ return;
+ }
+
+ if (Interlocked.CompareExchange(ref target, candidate, current) == current)
+ {
+ return;
+ }
+ }
+ }
+}
diff --git a/MapWizard.Desktop/Services/SettingsService.cs b/MapWizard.Desktop/Services/SettingsService.cs
index 9d69c24..7e121eb 100644
--- a/MapWizard.Desktop/Services/SettingsService.cs
+++ b/MapWizard.Desktop/Services/SettingsService.cs
@@ -217,8 +217,9 @@ private static bool TryConvert(string rawValue, Type targetType, out object? val
value = Convert.ChangeType(rawValue, nonNullableType, CultureInfo.InvariantCulture);
return true;
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
value = null;
return false;
}
diff --git a/MapWizard.Desktop/Services/SongLibraryService.cs b/MapWizard.Desktop/Services/SongLibraryService.cs
index 250bba4..b53da23 100644
--- a/MapWizard.Desktop/Services/SongLibraryService.cs
+++ b/MapWizard.Desktop/Services/SongLibraryService.cs
@@ -39,6 +39,42 @@ public bool IsValidSongsPath(string? songsPath)
return IsValidSongsPath(detected) ? Path.GetFullPath(detected!) : null;
}
+ public void InvalidateCache(string? songsPath = null)
+ {
+ string? normalizedSongsPath = null;
+ if (!string.IsNullOrWhiteSpace(songsPath))
+ {
+ try
+ {
+ normalizedSongsPath = Path.GetFullPath(songsPath);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return;
+ }
+ }
+
+ _scanGate.Wait();
+ try
+ {
+ if (normalizedSongsPath is not null &&
+ !string.Equals(_cachedSongsPath, normalizedSongsPath, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ _cachedSongsPath = null;
+ _cachedMapsetDirectories = [];
+ _cachedAtUtc = DateTime.MinValue;
+ _mapsetCache.Clear();
+ }
+ finally
+ {
+ _scanGate.Release();
+ }
+ }
+
public async Task> GetMapsetDirectoriesAsync(
string songsPath,
CancellationToken cancellationToken = default)
@@ -136,8 +172,9 @@ private static List EnumerateMapsetDirectories(string songsPath)
{
directories = Directory.EnumerateDirectories(songsPath);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return [];
}
@@ -148,8 +185,9 @@ private static List EnumerateMapsetDirectories(string songsPath)
var lastWrite = Directory.GetLastWriteTimeUtc(mapsetDirectory);
mapsetDirectories.Add((mapsetDirectory, lastWrite));
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// ignored
}
}
@@ -167,8 +205,9 @@ private static List EnumerateMapsetDirectories(string songsPath)
{
osuFiles = Directory.EnumerateFiles(mapsetDirectory, "*.osu", SearchOption.TopDirectoryOnly).ToArray();
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return null;
}
@@ -198,8 +237,9 @@ private static List EnumerateMapsetDirectories(string songsPath)
{
beatmap = Beatmap.Decode(new FileInfo(osuFile));
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// ignored
}
@@ -413,8 +453,9 @@ private static IEnumerable EnumerateWindowsInstallPaths()
candidates.Add(ExtractExecutableDirectory(appKey.GetValue("DisplayIcon")?.ToString()));
candidates.Add(ExtractExecutableDirectory(appKey.GetValue("UninstallString")?.ToString()));
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// ignored
}
finally
@@ -423,8 +464,9 @@ private static IEnumerable EnumerateWindowsInstallPaths()
}
}
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// ignored
}
finally
@@ -447,8 +489,9 @@ private static IEnumerable EnumerateWindowsInstallPaths()
{
classRootValue = Registry.ClassesRoot.OpenSubKey(subKeyPath)?.GetValue(valueName)?.ToString();
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// ignored
}
@@ -467,8 +510,9 @@ private static IEnumerable EnumerateWindowsInstallPaths()
{
subKey = RegistryKey.OpenBaseKey(hive, view).OpenSubKey(subKeyPath);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// ignored
}
@@ -727,8 +771,9 @@ private static IEnumerable ResolveWineDriveRootCandidates(char driveLett
var targetPath = Path.GetFullPath(Path.Combine(driveInfo.Parent?.FullName ?? "/", driveInfo.LinkTarget));
candidates.Add(targetPath);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// ignored
}
}
@@ -785,8 +830,9 @@ private static IEnumerable EnumerateOsuConfigFiles(string osuInstallPath
{
return Directory.EnumerateFiles(osuInstallPath, "osu!.*.cfg", SearchOption.TopDirectoryOnly);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return [];
}
}
@@ -819,8 +865,9 @@ private static IEnumerable EnumerateOsuConfigFiles(string osuInstallPath
: Path.GetFullPath(Path.Combine(installPath, configuredPath));
}
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// ignored
}
@@ -882,8 +929,9 @@ private static IEnumerable EnumerateOsuConfigFiles(string osuInstallPath
using var reader = new StreamReader(filePath);
return reader.ReadLine();
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return null;
}
}
diff --git a/MapWizard.Desktop/Utils/BeatmapPathUtils.cs b/MapWizard.Desktop/Utils/BeatmapPathUtils.cs
index 214f415..f929c18 100644
--- a/MapWizard.Desktop/Utils/BeatmapPathUtils.cs
+++ b/MapWizard.Desktop/Utils/BeatmapPathUtils.cs
@@ -16,8 +16,9 @@ public static class BeatmapPathUtils
{
return Path.GetDirectoryName(Path.GetFullPath(beatmapPath));
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return null;
}
}
diff --git a/MapWizard.Desktop/Utils/BeatmapSelectionUtils.cs b/MapWizard.Desktop/Utils/BeatmapSelectionUtils.cs
index 6c2876e..a54c90a 100644
--- a/MapWizard.Desktop/Utils/BeatmapSelectionUtils.cs
+++ b/MapWizard.Desktop/Utils/BeatmapSelectionUtils.cs
@@ -5,8 +5,10 @@
using System.IO;
using System.Linq;
using Avalonia.Controls.Notifications;
+using MapWizard.Desktop.Enums;
using MapWizard.Desktop.Models;
using MapWizard.Desktop.Services;
+using MapWizard.Desktop.Services.MemoryService;
namespace MapWizard.Desktop.Utils;
@@ -144,8 +146,9 @@ public static string[] GetSiblingDifficultyPaths(string referenceBeatmapPath)
.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return [];
}
}
@@ -165,8 +168,9 @@ public static bool TryOpenBeatmapFolder(string beatmapPath, out string? errorMes
{
fullBeatmapPath = Path.GetFullPath(beatmapPath);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
errorMessage = "The origin beatmap path is invalid.";
return false;
}
@@ -187,9 +191,10 @@ public static bool TryOpenBeatmapFolder(string beatmapPath, out string? errorMes
});
return true;
}
- catch (Exception exception)
+ catch (Exception ex)
{
- errorMessage = exception.Message;
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ errorMessage = ex.Message;
return false;
}
}
diff --git a/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs b/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs
index 5665cc5..9e50b76 100644
--- a/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs
+++ b/MapWizard.Desktop/Utils/MapPickerDialogUtils.cs
@@ -62,8 +62,9 @@ void OnSelectionSubmitted(IReadOnlyList selectedPaths)
{
dialogLifetimeCts.Cancel();
}
- catch (ObjectDisposedException)
+ catch (ObjectDisposedException ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// The dialog completion path can dispose this CTS before the dismissed callback runs.
}
diff --git a/MapWizard.Desktop/Utils/MapsetAssetPathUtils.cs b/MapWizard.Desktop/Utils/MapsetAssetPathUtils.cs
index 4e7d8f7..3f54c2c 100644
--- a/MapWizard.Desktop/Utils/MapsetAssetPathUtils.cs
+++ b/MapWizard.Desktop/Utils/MapsetAssetPathUtils.cs
@@ -71,8 +71,9 @@ public static class MapsetAssetPathUtils
return File.Exists(current) ? current : null;
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return null;
}
}
@@ -97,8 +98,9 @@ public static class MapsetAssetPathUtils
return Directory.EnumerateFiles(mapsetDirectory, "*", SearchOption.AllDirectories)
.FirstOrDefault(file => string.Equals(Path.GetFileName(file), fileName, StringComparison.OrdinalIgnoreCase));
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return null;
}
}
diff --git a/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs b/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs
index 9d58daf..4d1b94e 100644
--- a/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs
+++ b/MapWizard.Desktop/ViewModels/ComboColourStudioViewModel.cs
@@ -21,6 +21,8 @@
using MapWizard.Desktop.Extensions;
using MapWizard.Desktop.Models;
using MapWizard.Desktop.Services;
+using MapWizard.Desktop.Services.ComboColourService;
+using MapWizard.Desktop.Services.MemoryService;
using MapWizard.Desktop.Utils;
using MapWizard.Tools.ComboColourStudio;
using SukiUI.Dialogs;
@@ -160,6 +162,26 @@ private void ToggleDestinationMap(string path)
HasMultiple = BeatmapPanelViewModelUtils.HasMultipleDestinationBeatmaps(DestinationBeatmaps);
}
+ [RelayCommand]
+ private async Task SelectOriginMap(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) ||
+ string.Equals(OriginBeatmap.Path, path, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ try
+ {
+ await SetOriginBeatmapPath(path);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
+ }
+ }
+
[RelayCommand]
private void AddColour()
{
@@ -236,8 +258,9 @@ private async Task AddColourPoint()
{
clipboardText = await clipboard.TryGetTextAsync();
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return null;
}
@@ -343,6 +366,7 @@ private void SaveProject()
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
}
}
@@ -364,6 +388,7 @@ private void ImportComboColoursFromOrigin()
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
}
}
@@ -385,6 +410,7 @@ private void ImportColourHaxFromOrigin()
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
}
}
@@ -434,6 +460,7 @@ private async Task PickImageAndGenerateColours(CancellationToken token)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
}
}
@@ -456,6 +483,7 @@ private async Task PickOriginFile(CancellationToken token)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
}
}
@@ -491,6 +519,7 @@ private async Task PickDestinationFile(CancellationToken token)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
}
}
@@ -614,6 +643,7 @@ private void ExportColourHax()
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
}
}
@@ -633,6 +663,7 @@ private void GenerateColoursFromImagePath(string imagePath)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Error, "Combo Colour Studio", ex.Message);
}
}
@@ -698,6 +729,7 @@ private async Task LoadOriginBeatmapContext()
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ClearOriginBeatmapMetadata();
HasLocalProjectSnapshot = false;
HasUnsavedChanges = false;
@@ -745,6 +777,7 @@ private bool SaveLocalProjectSnapshot(ComboColourProject project, bool showSucce
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Warning, "Combo Colour Studio", $"Failed to save local project snapshot: {ex.Message}");
return false;
}
@@ -764,6 +797,7 @@ private async Task TryRestoreSavedProject()
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ShowToast(NotificationType.Warning, "Combo Colour Studio", $"Failed to check saved projects: {ex.Message}");
return SavedProjectRestoreState.NotFound;
}
@@ -839,8 +873,9 @@ private void LoadBackgroundImage()
HeaderBackgroundImage = new Bitmap(backgroundPath);
}
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
BackgroundImagePath = string.Empty;
HeaderBackgroundImage?.Dispose();
HeaderBackgroundImage = null;
diff --git a/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs b/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs
index bb171fd..3f5db62 100644
--- a/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs
+++ b/MapWizard.Desktop/ViewModels/HitSoundCopierViewModel.cs
@@ -12,6 +12,8 @@
using MapWizard.Desktop.Extensions;
using MapWizard.Desktop.Models;
using MapWizard.Desktop.Services;
+using MapWizard.Desktop.Services.HitsoundService;
+using MapWizard.Desktop.Services.MemoryService;
using MapWizard.Desktop.Utils;
using MapWizard.Tools.HitSounds.Copier;
using SukiUI.Dialogs;
@@ -68,6 +70,18 @@ private void ToggleDestinationMap(string path)
HasMultiple = BeatmapPanelViewModelUtils.HasMultipleDestinationBeatmaps(DestinationBeatmaps);
}
+ [RelayCommand]
+ private void SelectOriginMap(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) ||
+ string.Equals(OriginBeatmap.Path, path, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ SetOriginBeatmapPath(path);
+ }
+
[RelayCommand]
private async Task PickOriginFile(CancellationToken token)
{
@@ -86,6 +100,7 @@ private async Task PickOriginFile(CancellationToken token)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
toastManager.ShowToast(NotificationType.Error, "HitSound Copier", ex.Message);
}
}
@@ -108,6 +123,7 @@ private async Task PickDestinationFile(CancellationToken token)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
toastManager.ShowToast(NotificationType.Error, "HitSound Copier", ex.Message);
}
}
@@ -244,7 +260,7 @@ private void SetDestinationBeatmaps(IReadOnlyCollection beatmapPaths)
private async Task CopyHitSounds()
{
var type = NotificationType.Error;
- var message = string.Empty;
+ string message;
try
{
@@ -307,7 +323,7 @@ private async Task CopyHitSounds()
Leniency = suggestedLeniency;
}
- if (hitSoundService.CopyHitsoundsAsync(OriginBeatmap.Path, destinationPaths, options))
+ if (hitSoundService.CopyHitsounds(OriginBeatmap.Path, destinationPaths, options))
{
type = NotificationType.Success;
message = $"HitSounds applied successfully to {destinationPaths.Length} beatmap(s)!";
@@ -320,6 +336,7 @@ private async Task CopyHitSounds()
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
message = ex.Message;
}
diff --git a/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs
new file mode 100644
index 0000000..4c7cadb
--- /dev/null
+++ b/MapWizard.Desktop/ViewModels/HitSoundVisualizerViewModel.cs
@@ -0,0 +1,3363 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Controls.Notifications;
+using Avalonia.Threading;
+using BeatmapParser.Enums;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Material.Icons;
+using MapWizard.Desktop.Extensions;
+using MapWizard.Desktop.Models;
+using MapWizard.Desktop.Models.HitSoundVisualizer;
+using MapWizard.Desktop.Services;
+using MapWizard.Desktop.Services.HitsoundService;
+using MapWizard.Desktop.Services.MemoryService;
+using MapWizard.Desktop.Services.Playback;
+using MapWizard.Desktop.Utils;
+using MapWizard.Tools.HitSounds.Event;
+using MapWizard.Tools.HitSounds.Timeline;
+using SukiUI.Dialogs;
+using SukiUI.Toasts;
+
+namespace MapWizard.Desktop.ViewModels;
+
+public partial class HitSoundVisualizerViewModel(
+ IFilesService filesService,
+ IHitSoundService hitSoundService,
+ IAudioPlaybackService audioPlaybackService,
+ IOsuMemoryReaderService osuMemoryReaderService,
+ ISettingsService settingsService,
+ ISongLibraryService songLibraryService,
+ ISukiDialogManager dialogManager,
+ ISukiToastManager toastManager) : ViewModelBase
+{
+ public event Action? FocusPlaybackRequested;
+
+ private const string PointClipboardFormatId = "MapWizard.HitSoundVisualizer.Points/v1";
+ private const int PlaybackDebugInfoUpdateIntervalMs = 250;
+ private const int CursorColumnToleranceMs = 2;
+ private const int HitsoundDebugActiveWindowMs = 200;
+ private readonly string[] _sampleExtensions = [".wav", ".ogg", ".mp3"];
+ private readonly ConcurrentDictionary _resolvedPlaybackSamplePathCache =
+ new(StringComparer.OrdinalIgnoreCase);
+ private int _nextPointId = 1;
+ private string _loadedMapsetDirectoryPath = string.Empty;
+ private string _loadedAudioFilePath = string.Empty;
+ private string _legacySkinDirectoryPath = string.Empty;
+ private HitSoundTimeline _workingTimeline = new();
+ private bool _suppressHeaderBankQuickApply;
+ private PlaybackRunner? _playbackRunner;
+ private long _playbackDebugStatusLastUiTickMs;
+ private int[] _sortedPointTimeCache = [];
+ private readonly Stack _undoStack = new();
+ private readonly Stack _redoStack = new();
+ private bool _isRestoringHistory;
+ private int _lastNonZeroSongVolumePercent = Math.Max(1, LoadPersistedSongVolumePercent(settingsService));
+ private int _lastNonZeroHitSoundVolumePercent = Math.Max(1, LoadPersistedHitSoundVolumePercent(settingsService));
+ private readonly object _hitsoundDebugSync = new();
+ private readonly Queue _hitsoundDebugRecentSuccessTickMs = new();
+ private long _hitsoundDebugAttemptCount;
+ private long _hitsoundDebugSuccessCount;
+ private long _hitsoundDebugFailureCount;
+ private int _hitsoundDebugLastPointTimeMs;
+ private int _hitsoundDebugLastResolvedIndex = 1;
+ private int _hitsoundDebugLastResolvedVolume = 100;
+ private bool _hitsoundDebugLastUsedPointIndexOverride;
+ private bool _hitsoundDebugLastUsedPointVolumeOverride;
+ private string _hitsoundDebugLastSampleName = "-";
+
+ [ObservableProperty] private SelectedMap _originBeatmap = new();
+ // Kept for BeatmapSelectionPanel binding compatibility (destination section is hidden in this view).
+ [ObservableProperty] private bool _hasMultiple;
+
+ [NotifyPropertyChangedFor(nameof(AdditionalBeatmaps))]
+ [ObservableProperty]
+ private ObservableCollection _destinationBeatmaps = [];
+
+ public ObservableCollection AdditionalBeatmaps
+ {
+ get => BeatmapPanelViewModelUtils.GetAdditionalBeatmaps(DestinationBeatmaps);
+ set => DestinationBeatmaps = BeatmapPanelViewModelUtils.MergeWithAdditionalBeatmaps(DestinationBeatmaps, value);
+ }
+
+ [ObservableProperty] private string _preferredDirectory = string.Empty;
+ [ObservableProperty] private string _loadedMapTitle = string.Empty;
+ [ObservableProperty] private bool _hasLoadedMap;
+ [ObservableProperty] private bool _hasLoadedSongAudio;
+ [ObservableProperty] private double _timelineEndMs = 1000;
+ [ObservableProperty] private double _viewStartMs;
+ [ObservableProperty] private double _viewWindowMs = 8000;
+ [ObservableProperty] private int _cursorTimeMs;
+ [ObservableProperty] private int _selectedPointId = -1;
+ [ObservableProperty] private string _selectedSampleSetName = "Normal";
+ [ObservableProperty] private string _selectedHitSoundName = "Hitnormal";
+ [ObservableProperty] private bool _newPointIsSliderBody;
+ [ObservableProperty] private int _contextSamplePointTimeMs;
+ [ObservableProperty] private string _contextSampleSetName = "Normal";
+ [ObservableProperty] private int _contextSampleIndex = 1;
+ [ObservableProperty] private int _contextSampleVolume = 100;
+ [ObservableProperty] private bool _contextSamplePointExists;
+ [ObservableProperty] private string _contextSamplePointSummary = "Right-click the timeline to edit a sample point.";
+ [ObservableProperty] private bool _isSampleRowContextActive;
+ [ObservableProperty] private string _headerNormalBankName = "Auto";
+ [ObservableProperty] private string _headerAdditionBankName = "Auto";
+ [ObservableProperty] private string _hsSelectorNormalBankFilterName = "Any";
+ [ObservableProperty] private string _hsSelectorAdditionBankFilterName = "Any";
+ [ObservableProperty] private bool _hsSelectorIncludeHitNormal = true;
+ [ObservableProperty] private bool _hsSelectorIncludeWhistle = true;
+ [ObservableProperty] private bool _hsSelectorIncludeFinish = true;
+ [ObservableProperty] private bool _hsSelectorIncludeClap = true;
+ [ObservableProperty] private string _timelineStats = "No beatmap loaded.";
+ [ObservableProperty] private string _playbackStatus = "Idle";
+ [ObservableProperty] private string _timingDebugStatus = "Timing: playback idle";
+ [ObservableProperty] private string _audioDebugStatus = "AudioDbg: no song";
+ [ObservableProperty] private string _hitsoundDebugStatus = "HSDbg: idle";
+ [ObservableProperty] private string _selectedPointSummary = "No point selected.";
+ [ObservableProperty] private bool _isPlaybackRunning;
+ [ObservableProperty] private bool _isPlaybackPaused;
+ [ObservableProperty] private bool _followPlaybackCursor = true;
+ [ObservableProperty] private bool _isTimelinePeekActive;
+ [ObservableProperty] private int _playbackSeekMs;
+ [ObservableProperty] private bool _isPreparingPlayback;
+ [ObservableProperty] private int _songVolumePercent = LoadPersistedSongVolumePercent(settingsService);
+ [ObservableProperty] private int _hitSoundVolumePercent = LoadPersistedHitSoundVolumePercent(settingsService);
+ [ObservableProperty] private int _selectedSnapDivisorDenominator = 4;
+ [ObservableProperty] private ObservableCollection _selectedPointIds = [];
+
+ [ObservableProperty] private ObservableCollection _points = [];
+ [ObservableProperty] private ObservableCollection _sampleChanges = [];
+ [ObservableProperty] private ObservableCollection _snapTicks = [];
+ [ObservableProperty] private ObservableCollection _timelineRowLabels =
+ [
+ "Sample changes",
+ "hitnormal",
+ "hitwhistle",
+ "hitfinish",
+ "hitclap"
+ ];
+
+ public IReadOnlyList PointSampleSetNames { get; } = ["Auto", "Normal", "Soft", "Drum"];
+ public IReadOnlyList SampleSetNames { get; } = ["Normal", "Soft", "Drum"];
+ public IReadOnlyList HeaderBankNames { get; } = ["Auto", "Normal", "Soft", "Drum"];
+ public IReadOnlyList SelectorBankFilterNames { get; } = ["Any", "Auto", "Normal", "Soft", "Drum"];
+ public IReadOnlyList HitSoundNames { get; } = ["Hitnormal", "Whistle", "Finish", "Clap"];
+ public IReadOnlyList SnapDivisorOptions { get; } = Enumerable.Range(1, 16).ToList();
+
+ public double ViewEndMs => Math.Min(TimelineEndMs, ViewStartMs + Math.Max(100, ViewWindowMs));
+ public int VisiblePointCount => CountVisiblePointsCached(ViewStartMs, ViewEndMs);
+ public bool HasSelectedPoint => SelectedPointId >= 0;
+ public int SelectedPointCount => SelectedPointIds.Count;
+ public string CursorTimeText => FormatTimeLabel(CursorTimeMs);
+ public string TimelineEndText => FormatTimeLabel((int)Math.Round(TimelineEndMs));
+ public string PlaybackButtonText => IsPlaybackRunning ? "Pause" : (IsPlaybackPaused ? "Resume" : "Play");
+ public MaterialIconKind PlaybackButtonIconKind => IsPlaybackRunning
+ ? MaterialIconKind.Pause
+ : MaterialIconKind.Play;
+ public string AudioSourceStatus => !HasLoadedSongAudio || string.IsNullOrWhiteSpace(_loadedAudioFilePath)
+ ? "Song audio unavailable (playback disabled)."
+ : $"Song: {Path.GetFileName(_loadedAudioFilePath)}";
+ public bool ShowPlaybackTimeline => HasLoadedMap && HasLoadedSongAudio;
+ public bool ShowPlaybackTimelineSection => ShowPlaybackTimeline || IsPreparingPlayback;
+ public bool ShowDebugInfoCard => Debugger.IsAttached;
+ public string LegacySkinStatus => string.IsNullOrWhiteSpace(_legacySkinDirectoryPath)
+ ? "Legacy fallback skin: not found (place osu-resources legacy skin locally)."
+ : $"Legacy fallback skin: {Path.GetFileName(_legacySkinDirectoryPath)}";
+ public string SongVolumeText => $"{Math.Clamp(SongVolumePercent, 0, 100)}%";
+ public string HitSoundVolumeText => $"{Math.Clamp(HitSoundVolumePercent, 0, 100)}%";
+ public MaterialIconKind SongVolumeIconKind => SongVolumePercent <= 0 ? MaterialIconKind.MusicNoteOff : MaterialIconKind.MusicNote;
+ public MaterialIconKind EffectsVolumeIconKind => HitSoundVolumePercent <= 0 ? MaterialIconKind.VolumeOff : MaterialIconKind.VolumeHigh;
+ public string SelectedSnapDivisorText => $"1/{SelectedSnapDivisorDenominator}";
+ public string ContextSamplePointTimeText => FormatTimeLabel(ContextSamplePointTimeMs);
+ public bool ShowSamplePointContextPopup => IsSampleRowContextActive;
+ public bool ShowHitsoundContextPopup => !IsSampleRowContextActive;
+ public bool CanEditHeaderBanks => HasLoadedMap;
+ public bool HasAnyHsSelectorAudioTypeEnabled =>
+ HsSelectorIncludeHitNormal || HsSelectorIncludeWhistle || HsSelectorIncludeFinish || HsSelectorIncludeClap;
+ public bool IsHitNormalSelected
+ {
+ get => string.Equals(SelectedHitSoundName, "Hitnormal", StringComparison.OrdinalIgnoreCase);
+ set
+ {
+ if (value)
+ {
+ SelectedHitSoundName = "Hitnormal";
+ }
+ }
+ }
+ public bool IsHitWhistleSelected
+ {
+ get => string.Equals(SelectedHitSoundName, "Whistle", StringComparison.OrdinalIgnoreCase);
+ set
+ {
+ if (value)
+ {
+ SelectedHitSoundName = "Whistle";
+ }
+ }
+ }
+ public bool IsHitFinishSelected
+ {
+ get => string.Equals(SelectedHitSoundName, "Finish", StringComparison.OrdinalIgnoreCase);
+ set
+ {
+ if (value)
+ {
+ SelectedHitSoundName = "Finish";
+ }
+ }
+ }
+ public bool IsHitClapSelected
+ {
+ get => string.Equals(SelectedHitSoundName, "Clap", StringComparison.OrdinalIgnoreCase);
+ set
+ {
+ if (value)
+ {
+ SelectedHitSoundName = "Clap";
+ }
+ }
+ }
+
+ private PlaybackRunner PlaybackRunner => _playbackRunner ??= new(audioPlaybackService);
+
+ partial void OnViewStartMsChanged(double value)
+ {
+ NormalizeViewWindow();
+ OnPropertyChanged(nameof(ViewEndMs));
+ OnPropertyChanged(nameof(VisiblePointCount));
+ UpdateTimelineStats();
+ }
+
+ partial void OnViewWindowMsChanged(double value)
+ {
+ NormalizeViewWindow();
+ OnPropertyChanged(nameof(ViewEndMs));
+ OnPropertyChanged(nameof(VisiblePointCount));
+ UpdateTimelineStats();
+ }
+
+ partial void OnTimelineEndMsChanged(double value)
+ {
+ NormalizeViewWindow();
+ OnPropertyChanged(nameof(ViewEndMs));
+ OnPropertyChanged(nameof(VisiblePointCount));
+ OnPropertyChanged(nameof(TimelineEndText));
+ UpdateTimelineStats();
+ }
+
+ partial void OnPointsChanged(ObservableCollection value)
+ {
+ RebuildPointTimeCache(value);
+ OnPropertyChanged(nameof(VisiblePointCount));
+ }
+
+ partial void OnCursorTimeMsChanged(int value)
+ {
+ OnPropertyChanged(nameof(CursorTimeText));
+ PlaybackSeekMs = value;
+ if (!IsPlaybackRunning && !HasSelectedPoint && !IsSampleRowContextActive)
+ {
+ RefreshHeaderBankQuickState();
+ }
+ }
+
+ partial void OnSelectedPointIdChanged(int value)
+ {
+ OnPropertyChanged(nameof(HasSelectedPoint));
+ OnPropertyChanged(nameof(CanEditHeaderBanks));
+ }
+
+ partial void OnSelectedPointIdsChanged(ObservableCollection value)
+ {
+ OnPropertyChanged(nameof(SelectedPointCount));
+ OnPropertyChanged(nameof(HasSelectedPoint));
+ OnPropertyChanged(nameof(CanEditHeaderBanks));
+ }
+
+ partial void OnHasLoadedMapChanged(bool value)
+ {
+ OnPropertyChanged(nameof(CanEditHeaderBanks));
+ OnPropertyChanged(nameof(ShowPlaybackTimeline));
+ OnPropertyChanged(nameof(ShowPlaybackTimelineSection));
+ }
+
+ partial void OnHasLoadedSongAudioChanged(bool value)
+ {
+ OnPropertyChanged(nameof(AudioSourceStatus));
+ OnPropertyChanged(nameof(ShowPlaybackTimeline));
+ OnPropertyChanged(nameof(ShowPlaybackTimelineSection));
+ }
+
+ partial void OnIsPlaybackPausedChanged(bool value)
+ {
+ OnPropertyChanged(nameof(PlaybackButtonText));
+ OnPropertyChanged(nameof(PlaybackButtonIconKind));
+ RefreshTimingDebugStatus(force: true);
+ }
+
+ partial void OnIsPlaybackRunningChanged(bool value)
+ {
+ OnPropertyChanged(nameof(PlaybackButtonText));
+ OnPropertyChanged(nameof(PlaybackButtonIconKind));
+ RefreshTimingDebugStatus(force: true);
+ }
+
+ partial void OnSongVolumePercentChanged(int value)
+ {
+ SongVolumePercent = Math.Clamp(value, 0, 100);
+ if (SongVolumePercent > 0)
+ {
+ _lastNonZeroSongVolumePercent = SongVolumePercent;
+ }
+
+ audioPlaybackService.SetSongVolume(SongVolumePercent / 100f);
+ PersistVisualizerVolumeSettings();
+ OnPropertyChanged(nameof(SongVolumeText));
+ OnPropertyChanged(nameof(SongVolumeIconKind));
+ }
+
+ partial void OnHitSoundVolumePercentChanged(int value)
+ {
+ HitSoundVolumePercent = Math.Clamp(value, 0, 100);
+ if (HitSoundVolumePercent > 0)
+ {
+ _lastNonZeroHitSoundVolumePercent = HitSoundVolumePercent;
+ }
+
+ audioPlaybackService.SetHitsoundVolume(HitSoundVolumePercent / 100f);
+ PersistVisualizerVolumeSettings();
+ OnPropertyChanged(nameof(HitSoundVolumeText));
+ OnPropertyChanged(nameof(EffectsVolumeIconKind));
+ }
+
+ partial void OnIsPreparingPlaybackChanged(bool value)
+ {
+ OnPropertyChanged(nameof(ShowPlaybackTimelineSection));
+ }
+
+ partial void OnSelectedSnapDivisorDenominatorChanged(int value)
+ {
+ SelectedSnapDivisorDenominator = Math.Clamp(value, 1, 16);
+ OnPropertyChanged(nameof(SelectedSnapDivisorText));
+ }
+
+ partial void OnSelectedHitSoundNameChanged(string value)
+ {
+ OnPropertyChanged(nameof(IsHitNormalSelected));
+ OnPropertyChanged(nameof(IsHitWhistleSelected));
+ OnPropertyChanged(nameof(IsHitFinishSelected));
+ OnPropertyChanged(nameof(IsHitClapSelected));
+ }
+
+ partial void OnHsSelectorIncludeHitNormalChanged(bool value) => OnPropertyChanged(nameof(HasAnyHsSelectorAudioTypeEnabled));
+ partial void OnHsSelectorIncludeWhistleChanged(bool value) => OnPropertyChanged(nameof(HasAnyHsSelectorAudioTypeEnabled));
+ partial void OnHsSelectorIncludeFinishChanged(bool value) => OnPropertyChanged(nameof(HasAnyHsSelectorAudioTypeEnabled));
+ partial void OnHsSelectorIncludeClapChanged(bool value) => OnPropertyChanged(nameof(HasAnyHsSelectorAudioTypeEnabled));
+
+ partial void OnContextSamplePointTimeMsChanged(int value)
+ {
+ var clamped = Math.Max(0, value);
+ if (clamped != value)
+ {
+ ContextSamplePointTimeMs = clamped;
+ return;
+ }
+
+ ContextSamplePointExists = SampleChanges.Any(x => x.TimeMs == clamped);
+ UpdateContextSamplePointSummary();
+ OnPropertyChanged(nameof(ContextSamplePointTimeText));
+ }
+
+ partial void OnContextSampleIndexChanged(int value)
+ {
+ ContextSampleIndex = Math.Clamp(value, 1, 99);
+ }
+
+ partial void OnContextSampleVolumeChanged(int value)
+ {
+ ContextSampleVolume = Math.Clamp(value, 0, 100);
+ }
+
+ partial void OnIsSampleRowContextActiveChanged(bool value)
+ {
+ OnPropertyChanged(nameof(ShowSamplePointContextPopup));
+ OnPropertyChanged(nameof(ShowHitsoundContextPopup));
+ OnPropertyChanged(nameof(CanEditHeaderBanks));
+ RefreshHeaderBankQuickState();
+ }
+
+ partial void OnHeaderNormalBankNameChanged(string value)
+ {
+ ApplyHeaderBankQuickChange(isNormalBank: true, value);
+ }
+
+ partial void OnHeaderAdditionBankNameChanged(string value)
+ {
+ ApplyHeaderBankQuickChange(isNormalBank: false, value);
+ }
+
+ [RelayCommand]
+ private void SetSnapDivisor(int denominator)
+ {
+ SelectedSnapDivisorDenominator = Math.Clamp(denominator, 1, 16);
+ }
+
+ [RelayCommand]
+ private async Task PickOriginFile(CancellationToken token)
+ {
+ try
+ {
+ var selectedPaths = await ShowSongSelectDialogAsync(false, token);
+ if (token.IsCancellationRequested || selectedPaths is null || selectedPaths.Count == 0)
+ {
+ return;
+ }
+
+ SetOriginBeatmapPath(selectedPaths[0]);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", ex.Message);
+ }
+ }
+
+ [RelayCommand]
+ private void RemoveMap(string _)
+ {
+ }
+
+ [RelayCommand]
+ private void SelectOriginMap(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) ||
+ string.Equals(OriginBeatmap.Path, path, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ SetOriginBeatmapPath(path);
+ }
+
+ [RelayCommand]
+ private Task PickDestinationFile(CancellationToken _)
+ {
+ return Task.CompletedTask;
+ }
+
+ [RelayCommand]
+ private void AddDestinationFromMemory()
+ {
+ }
+
+ [RelayCommand]
+ private void OpenOriginFolder()
+ {
+ if (BeatmapSelectionUtils.TryOpenBeatmapFolder(OriginBeatmap.Path, out var errorMessage))
+ {
+ return;
+ }
+
+ toastManager.ShowToast(
+ NotificationType.Warning,
+ "Hitsound Visualizer",
+ string.IsNullOrWhiteSpace(errorMessage)
+ ? "Unable to open the origin beatmap folder."
+ : errorMessage);
+ }
+
+ [RelayCommand]
+ private void SetOriginFromMemory()
+ {
+ var currentBeatmap = GetBeatmapFromMemory();
+ if (currentBeatmap is null)
+ {
+ return;
+ }
+
+ SetOriginBeatmapPath(currentBeatmap);
+ }
+
+ [RelayCommand]
+ private Task LoadTimeline()
+ {
+ return LoadTimelineCore(preservePlaybackPosition: false);
+ }
+
+ private async Task LoadTimelineCore(bool preservePlaybackPosition)
+ {
+ if (string.IsNullOrWhiteSpace(OriginBeatmap.Path))
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Please select a beatmap first.");
+ return;
+ }
+
+ var preservedPlaybackTimeMs = preservePlaybackPosition
+ ? CapturePlaybackPositionMsForReload()
+ : 0;
+
+ IsPreparingPlayback = true;
+ PlaybackStatus = "Loading...";
+ HasLoadedMap = false;
+ HasLoadedSongAudio = false;
+ await Task.Yield();
+
+ try
+ {
+ RefreshPersistedPlaybackVolumes();
+ StopPlaybackCore(resetPausedState: true);
+ var document = hitSoundService.LoadHitsoundVisualizerDocument(OriginBeatmap.Path);
+
+ _loadedMapsetDirectoryPath = document.MapsetDirectoryPath;
+ _loadedAudioFilePath = document.AudioFilePath;
+ _legacySkinDirectoryPath = ResolveLegacySkinDirectory();
+ _resolvedPlaybackSamplePathCache.Clear();
+ _workingTimeline = document.Timeline;
+ _workingTimeline.DraggableSoundTimeline = new SoundTimeline();
+ _nextPointId = Math.Max(1, document.Points.Any() ? document.Points.Max(x => x.Id) + 1 : 1);
+
+ Points = new ObservableCollection(
+ document.Points
+ .Where(x => !x.IsDraggable)
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => (int)x.HitSound));
+ SampleChanges = new ObservableCollection(document.SampleChanges.OrderBy(x => x.TimeMs));
+
+ var songLoaded = audioPlaybackService.LoadSong(_loadedAudioFilePath);
+ var loadedSongDurationMs = songLoaded ? audioPlaybackService.GetLoadedSongDurationMs() : 0;
+
+ LoadedMapTitle = document.DisplayTitle;
+ TimelineEndMs = Math.Max(1000, Math.Max(document.EndTimeMs, loadedSongDurationMs));
+ SnapTicks = new ObservableCollection(
+ hitSoundService.BuildHitsoundVisualizerSnapTicks(OriginBeatmap.Path, TimelineEndMs));
+ ViewWindowMs = Math.Min(12000, TimelineEndMs);
+ var restoredPlaybackTimeMs = preservePlaybackPosition
+ ? Math.Clamp(preservedPlaybackTimeMs, 0, (int)Math.Ceiling(TimelineEndMs))
+ : 0;
+ var playbackWindowMs = Math.Max(100, ViewWindowMs);
+ ViewStartMs = preservePlaybackPosition
+ ? Math.Clamp(restoredPlaybackTimeMs - (playbackWindowMs * 0.35d), 0, Math.Max(0, TimelineEndMs - playbackWindowMs))
+ : 0;
+ CursorTimeMs = restoredPlaybackTimeMs;
+ ContextSamplePointTimeMs = restoredPlaybackTimeMs;
+ PlaybackSeekMs = restoredPlaybackTimeMs;
+ SelectedPointId = -1;
+ SelectedPointIds = [];
+ ContextSampleSetName = "Normal";
+ ContextSampleIndex = 1;
+ ContextSampleVolume = 100;
+ ContextSamplePointExists = false;
+ IsSampleRowContextActive = false;
+ HeaderNormalBankName = "Auto";
+ HeaderAdditionBankName = "Auto";
+ UpdateContextSamplePointSummary();
+ HasLoadedSongAudio = songLoaded;
+ HasLoadedMap = true;
+ _undoStack.Clear();
+ _redoStack.Clear();
+
+ audioPlaybackService.SetSongVolume(SongVolumePercent / 100f);
+ audioPlaybackService.SetHitsoundVolume(HitSoundVolumePercent / 100f);
+
+ UpdateTimelineStats();
+ UpdateSelectedPointSummary();
+ OnPropertyChanged(nameof(LegacySkinStatus));
+ OnPropertyChanged(nameof(TimelineEndText));
+ PlaybackStatus = "Loaded";
+ FocusPlaybackRequested?.Invoke();
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", ex.Message);
+ PlaybackStatus = "Idle";
+ }
+ finally
+ {
+ IsPreparingPlayback = false;
+ }
+ }
+
+ public void RefreshPersistedPlaybackVolumes()
+ {
+ SongVolumePercent = LoadPersistedSongVolumePercent(settingsService);
+ HitSoundVolumePercent = LoadPersistedHitSoundVolumePercent(settingsService);
+ }
+
+ [RelayCommand]
+ private void SelectTimelinePoint(int pointId)
+ {
+ IsSampleRowContextActive = false;
+ ApplySelection([pointId], primaryPointId: pointId);
+ var point = Points.FirstOrDefault(x => x.Id == pointId);
+ if (point is null)
+ {
+ UpdateSelectedPointSummary();
+ return;
+ }
+
+ CursorTimeMs = point.TimeMs;
+ SyncEditorFromPoint(point);
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ }
+
+ [RelayCommand]
+ private void SelectTimelinePoints(IReadOnlyList? pointIds)
+ {
+ IsSampleRowContextActive = false;
+ var ids = pointIds?.Distinct().ToList() ?? [];
+ if (ids.Count == 0)
+ {
+ ApplySelection([], primaryPointId: -1);
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ return;
+ }
+
+ var primaryId = ids[0];
+ var primaryPoint = Points.FirstOrDefault(x => x.Id == primaryId);
+ ApplySelection(ids, primaryPointId: primaryId);
+ if (primaryPoint is not null)
+ {
+ CursorTimeMs = primaryPoint.TimeMs;
+ }
+
+ UpdateSelectedPointSummary();
+ if (primaryPoint is not null)
+ {
+ SyncEditorFromPoint(primaryPoint);
+ }
+ RefreshHeaderBankQuickState();
+ }
+
+ [RelayCommand]
+ private void SelectAllPoints()
+ {
+ if (!HasLoadedMap || Points.Count == 0)
+ {
+ return;
+ }
+
+ IsSampleRowContextActive = false;
+ var ids = Points
+ .OrderBy(point => point.TimeMs)
+ .ThenBy(point => HitSoundSortOrder(point.HitSound))
+ .ThenBy(point => SampleSetSortOrder(point.SampleSet))
+ .Select(point => point.Id)
+ .Distinct()
+ .ToArray();
+
+ if (ids.Length == 0)
+ {
+ return;
+ }
+
+ var primaryPoint = Points.FirstOrDefault(point => point.Id == SelectedPointId)
+ ?? Points.FirstOrDefault(point => point.Id == ids[0]);
+ var primaryId = primaryPoint?.Id ?? ids[0];
+
+ ApplySelection(ids, primaryId);
+ if (primaryPoint is not null)
+ {
+ CursorTimeMs = primaryPoint.TimeMs;
+ SyncEditorFromPoint(primaryPoint);
+ }
+
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ }
+
+ [RelayCommand]
+ private void AddTimelinePointsToSelection(IReadOnlyList? pointIds)
+ {
+ IsSampleRowContextActive = false;
+ var idsToAdd = pointIds?.Where(id => id > 0).Distinct().ToList() ?? [];
+ if (idsToAdd.Count == 0)
+ {
+ return;
+ }
+
+ var merged = SelectedPointIds
+ .Concat(idsToAdd)
+ .Where(id => id > 0)
+ .Distinct()
+ .ToList();
+
+ var primaryId = SelectedPointId > 0 ? SelectedPointId : idsToAdd[0];
+ ApplySelection(merged, primaryPointId: primaryId);
+
+ var primaryPoint = Points.FirstOrDefault(x => x.Id == primaryId) ?? Points.FirstOrDefault(x => x.Id == idsToAdd[0]);
+ if (primaryPoint is not null)
+ {
+ CursorTimeMs = primaryPoint.TimeMs;
+ SyncEditorFromPoint(primaryPoint);
+ }
+
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ }
+
+ [RelayCommand]
+ private void ToggleTimelinePointSelection(int pointId)
+ {
+ IsSampleRowContextActive = false;
+ if (pointId <= 0)
+ {
+ return;
+ }
+
+ var point = Points.FirstOrDefault(x => x.Id == pointId);
+ if (point is null)
+ {
+ return;
+ }
+
+ var current = SelectedPointIds.Where(id => id > 0).Distinct().ToList();
+ if (current.Contains(pointId))
+ {
+ var remaining = current.Where(id => id != pointId).ToList();
+ var nextPrimary = pointId == SelectedPointId ? remaining.FirstOrDefault() : SelectedPointId;
+ ApplySelection(remaining, primaryPointId: nextPrimary);
+
+ var nextPoint = Points.FirstOrDefault(x => x.Id == SelectedPointId);
+ if (nextPoint is not null)
+ {
+ SyncEditorFromPoint(nextPoint);
+ }
+
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ return;
+ }
+
+ current.Add(pointId);
+ ApplySelection(current, primaryPointId: pointId);
+ CursorTimeMs = point.TimeMs;
+ SyncEditorFromPoint(point);
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ }
+
+ [RelayCommand]
+ private void PrepareTimelineContext(HitSoundTimelineContextRequest? request)
+ {
+ if (!HasLoadedMap || request is null)
+ {
+ return;
+ }
+
+ var clampedTime = Math.Clamp(request.TimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ CursorTimeMs = clampedTime;
+
+ if (request.IsSampleRow)
+ {
+ IsSampleRowContextActive = true;
+ ApplySelection([], primaryPointId: -1);
+ UpdateSelectedPointSummary();
+ }
+ else
+ {
+ IsSampleRowContextActive = false;
+ }
+
+ if (!request.IsSampleRow && request.PointId > 0)
+ {
+ var point = Points.FirstOrDefault(x => x.Id == request.PointId);
+ if (point is not null)
+ {
+ SyncEditorFromPoint(point);
+ }
+ }
+
+ var samplePointTime = Math.Clamp(request.SampleChangeTimeMs ?? clampedTime, 0, (int)Math.Ceiling(TimelineEndMs));
+ SyncContextSamplePointEditor(samplePointTime);
+ RefreshHeaderBankQuickState();
+ }
+
+ [RelayCommand]
+ private void SeekTime(int timeMs)
+ {
+ var clamped = Math.Clamp(timeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ CursorTimeMs = clamped;
+ ContextSamplePointTimeMs = clamped;
+
+ if (IsPlaybackRunning)
+ {
+ RestartPlaybackAt(clamped);
+ }
+ }
+
+ [RelayCommand]
+ private void CenterViewOnCursor()
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ var halfWindow = Math.Max(100, ViewWindowMs) / 2d;
+ ViewStartMs = Math.Clamp(CursorTimeMs - halfWindow, 0, Math.Max(0, TimelineEndMs - Math.Max(100, ViewWindowMs)));
+ }
+
+ [RelayCommand]
+ private void ZoomToSelection()
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ ViewWindowMs = Math.Clamp(ViewWindowMs / 2d, 500, Math.Max(500, TimelineEndMs));
+ CenterViewOnCursor();
+ }
+
+ [RelayCommand]
+ private void ZoomOut()
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ ViewWindowMs = Math.Clamp(ViewWindowMs * 2d, 500, Math.Max(500, TimelineEndMs));
+ CenterViewOnCursor();
+ }
+
+ [RelayCommand]
+ private void PanTimeline(double deltaMs)
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ ViewStartMs = Math.Clamp(ViewStartMs + deltaMs, 0, Math.Max(0, TimelineEndMs - Math.Max(100, ViewWindowMs)));
+ }
+
+ [RelayCommand]
+ private void ZoomTimeline(HitSoundTimelineZoomRequest? request)
+ {
+ if (!HasLoadedMap || request is null || request.ZoomFactor <= 0)
+ {
+ return;
+ }
+
+ var oldWindow = Math.Max(100, ViewWindowMs);
+ var newWindow = Math.Clamp(oldWindow * request.ZoomFactor, 200, Math.Max(200, TimelineEndMs));
+ var anchorTime = Math.Clamp(request.AnchorTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ var anchorRatio = oldWindow <= 0 ? 0 : (anchorTime - ViewStartMs) / oldWindow;
+
+ ViewWindowMs = newWindow;
+ ViewStartMs = Math.Clamp(anchorTime - (anchorRatio * newWindow), 0, Math.Max(0, TimelineEndMs - newWindow));
+ }
+
+ [RelayCommand]
+ private void AddPoint()
+ {
+ if (!HasLoadedMap)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Load a beatmap first.");
+ return;
+ }
+
+ var timeMs = Math.Clamp(CursorTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ var hitSound = ParseHitSound(SelectedHitSoundName);
+ var usesAutoSampleSet = IsAutoSampleSetDisplay(SelectedSampleSetName);
+ var sampleSet = usesAutoSampleSet
+ ? ResolveAutoSampleSetForPointAtTime(hitSound, timeMs)
+ : ParseSampleSet(SelectedSampleSetName);
+
+ var point = new HitSoundVisualizerPoint
+ {
+ Id = _nextPointId++,
+ TimeMs = timeMs,
+ SampleSet = sampleSet,
+ HitSound = hitSound,
+ IsAutoSampleSet = usesAutoSampleSet,
+ IsDraggable = false
+ };
+
+ if (HasPointConflict(point, out var conflictMessage))
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", conflictMessage);
+ return;
+ }
+
+ var updated = Points.ToList();
+ updated.Add(point);
+ ApplyUpdatedPoints(updated, selectPointId: point.Id);
+ }
+
+ [RelayCommand]
+ private void AddTimelinePointOnSnapLine(HitSoundTimelineRowAddRequest? request)
+ {
+ if (!HasLoadedMap || request is null)
+ {
+ return;
+ }
+
+ if (!TryGetHitSoundFromRowIndex(request.RowIndex, out var hitSound))
+ {
+ return;
+ }
+
+ var timeMs = Math.Clamp(request.TimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ var normalBank = ResolveHeaderBankAtTime(isNormalBank: true, timeMs);
+ var additionBank = ResolveHeaderBankAtTime(isNormalBank: false, timeMs, normalBank);
+ var sampleSet = hitSound == HitSound.Normal ? normalBank : additionBank;
+
+ var point = new HitSoundVisualizerPoint
+ {
+ Id = _nextPointId++,
+ TimeMs = timeMs,
+ SampleSet = sampleSet,
+ HitSound = hitSound,
+ IsDraggable = false
+ };
+
+ if (HasPointConflict(point, out var conflictMessage))
+ {
+ _nextPointId = Math.Max(1, _nextPointId - 1);
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", conflictMessage);
+ return;
+ }
+
+ IsSampleRowContextActive = false;
+ CursorTimeMs = timeMs;
+
+ var updated = Points.ToList();
+ updated.Add(point);
+ ApplyUpdatedPoints(updated, selectPointId: point.Id);
+ }
+
+ [RelayCommand]
+ private void SelectHitsoundsByFilter()
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ if (!HasAnyHsSelectorAudioTypeEnabled)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Select at least one hitsound type in HS Selector.");
+ return;
+ }
+
+ var matched = Points
+ .Where(MatchesHsSelectorFilter)
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => HitSoundSortOrder(x.HitSound))
+ .ThenBy(x => SampleSetSortOrder(x.SampleSet))
+ .ToList();
+
+ if (matched.Count == 0)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "No hitsound points matched the current HS selector filters.");
+ return;
+ }
+
+ IsSampleRowContextActive = false;
+ var ids = matched.Select(x => x.Id).ToArray();
+ var primary = matched[0];
+ ApplySelection(ids, primary.Id);
+ CursorTimeMs = primary.TimeMs;
+ SyncEditorFromPoint(primary);
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ }
+
+ public bool TryBuildPointClipboardPayload(out string payload)
+ {
+ payload = string.Empty;
+
+ var selectedIds = SelectedPointIds.Where(id => id > 0).Distinct().ToHashSet();
+ if (selectedIds.Count == 0 && SelectedPointId > 0)
+ {
+ selectedIds.Add(SelectedPointId);
+ }
+
+ if (selectedIds.Count == 0)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Select at least one hitsound point to copy.");
+ return false;
+ }
+
+ var selectedPoints = Points
+ .Where(point => selectedIds.Contains(point.Id))
+ .OrderBy(point => point.TimeMs)
+ .ThenBy(point => HitSoundSortOrder(point.HitSound))
+ .ThenBy(point => SampleSetSortOrder(point.SampleSet))
+ .ToList();
+
+ if (selectedPoints.Count == 0)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "No hitsound points available to copy.");
+ return false;
+ }
+
+ var baseTimeMs = selectedPoints.Min(point => point.TimeMs);
+ var clipboard = new PointClipboardPayload
+ {
+ Format = PointClipboardFormatId,
+ Points = selectedPoints.Select(point => new PointClipboardItem
+ {
+ OffsetMs = point.TimeMs - baseTimeMs,
+ SampleSet = SampleSetToDisplay(point.SampleSet),
+ HitSound = HitSoundToDisplay(point.HitSound)
+ }).ToList()
+ };
+
+ payload = JsonSerializer.Serialize(clipboard);
+ return true;
+ }
+
+ public void PastePointClipboardPayload(string? clipboardText)
+ {
+ if (!HasLoadedMap)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Load a beatmap first.");
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(clipboardText))
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Clipboard is empty.");
+ return;
+ }
+
+ PointClipboardPayload? payload;
+ try
+ {
+ payload = JsonSerializer.Deserialize(clipboardText);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Clipboard does not contain valid hitsound point data.");
+ return;
+ }
+
+ if (payload?.Points is null || payload.Points.Count == 0 || payload.Format != PointClipboardFormatId)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Clipboard does not contain hitsound point data.");
+ return;
+ }
+
+ var minOffset = payload.Points.Min(point => point.OffsetMs);
+ var anchorTimeMs = Math.Clamp(CursorTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ var pastedPoints = new List(payload.Points.Count);
+ var nextPointId = _nextPointId;
+
+ foreach (var item in payload.Points.OrderBy(point => point.OffsetMs))
+ {
+ var pastedTime = anchorTimeMs + (item.OffsetMs - minOffset);
+ var candidate = new HitSoundVisualizerPoint
+ {
+ Id = nextPointId++,
+ TimeMs = Math.Clamp(pastedTime, 0, (int)Math.Ceiling(TimelineEndMs)),
+ SampleSet = ParseSampleSet(item.SampleSet ?? "Normal"),
+ HitSound = ParseHitSound(item.HitSound ?? "Hitnormal"),
+ IsDraggable = false
+ };
+
+ pastedPoints.Add(candidate);
+ }
+
+ var duplicateInPaste = pastedPoints
+ .GroupBy(point => (point.TimeMs, point.HitSound, point.SampleSet))
+ .FirstOrDefault(group => group.Count() > 1);
+ if (duplicateInPaste is not null)
+ {
+ toastManager.ShowToast(
+ NotificationType.Error,
+ "Hitsound Visualizer",
+ $"Paste would create duplicate points at {duplicateInPaste.Key.TimeMs}ms.");
+ return;
+ }
+
+ var conflictingPastedAdditionTime = pastedPoints
+ .Where(point => point.HitSound != HitSound.Normal)
+ .GroupBy(point => point.TimeMs)
+ .FirstOrDefault(group => group.Select(x => x.SampleSet).Distinct().Count() > 1);
+ if (conflictingPastedAdditionTime is not null)
+ {
+ toastManager.ShowToast(
+ NotificationType.Error,
+ "Hitsound Visualizer",
+ $"Clipboard contains conflicting addition sample sets at {conflictingPastedAdditionTime.Key}ms.");
+ return;
+ }
+
+ var updated = Points.ToList();
+ foreach (var candidate in pastedPoints)
+ {
+ updated.RemoveAll(point =>
+ point.TimeMs == candidate.TimeMs &&
+ (
+ point.HitSound == candidate.HitSound ||
+ (candidate.HitSound != HitSound.Normal && point.HitSound != HitSound.Normal && point.SampleSet != candidate.SampleSet)));
+ }
+
+ updated.AddRange(pastedPoints);
+
+ IsSampleRowContextActive = false;
+ if (!ApplyUpdatedPoints(updated, selectPointId: pastedPoints[0].Id))
+ {
+ return;
+ }
+
+ _nextPointId = nextPointId;
+ ApplySelection(pastedPoints.Select(point => point.Id), pastedPoints[0].Id);
+ UpdateSelectedPointSummary();
+ }
+
+ [RelayCommand]
+ private void RemoveSelectedPoint()
+ {
+ if (SelectedPointIds.Count == 0 && SelectedPointId < 0)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Select a point to remove.");
+ return;
+ }
+
+ var idsToRemove = SelectedPointIds.Count > 0
+ ? SelectedPointIds.ToHashSet()
+ : new[] { SelectedPointId }.ToHashSet();
+ var updated = Points.Where(x => !idsToRemove.Contains(x.Id)).ToList();
+ ApplyUpdatedPoints(updated, selectPointId: -1);
+ }
+
+ [RelayCommand]
+ private void UpdateSelectedPoint()
+ {
+ var ids = SelectedPointIds.Where(id => id > 0).Distinct().ToList();
+ if (ids.Count == 0 && SelectedPointId > 0)
+ {
+ ids.Add(SelectedPointId);
+ }
+
+ if (ids.Count == 0)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Select a point to edit.");
+ return;
+ }
+
+ if (ids.Count > 1)
+ {
+ ApplySelectedPointAttributes();
+ return;
+ }
+
+ var selected = Points.FirstOrDefault(x => x.Id == ids[0]);
+ if (selected is null)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Select a point to edit.");
+ return;
+ }
+
+ var editedTimeMs = Math.Clamp(CursorTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ var editedHitSound = ParseHitSound(SelectedHitSoundName);
+ var usesAutoSampleSet = IsAutoSampleSetDisplay(SelectedSampleSetName);
+ var editedSampleSet = usesAutoSampleSet
+ ? ResolveAutoSampleSetForPointAtTime(editedHitSound, editedTimeMs)
+ : ParseSampleSet(SelectedSampleSetName);
+
+ var edited = new HitSoundVisualizerPoint
+ {
+ Id = selected.Id,
+ TimeMs = editedTimeMs,
+ SampleSet = editedSampleSet,
+ SampleIndexOverride = selected.SampleIndexOverride,
+ SampleVolumeOverridePercent = selected.SampleVolumeOverridePercent,
+ HitSound = editedHitSound,
+ IsAutoSampleSet = usesAutoSampleSet,
+ IsDraggable = false
+ };
+
+ if (HasPointConflict(edited, out var conflictMessage, ignorePointId: selected.Id))
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", conflictMessage);
+ return;
+ }
+
+ var updated = Points.Select(x => x.Id == selected.Id ? edited : x).ToList();
+
+ ApplyUpdatedPoints(updated, selectPointId: edited.Id);
+ }
+
+ [RelayCommand]
+ private void ApplySelectedPointAttributes()
+ {
+ var ids = SelectedPointIds.Where(id => id > 0).Distinct().ToHashSet();
+ if (ids.Count == 0 && SelectedPointId > 0)
+ {
+ ids.Add(SelectedPointId);
+ }
+
+ if (ids.Count == 0)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Select at least one point first.");
+ return;
+ }
+
+ var targetUsesAutoSampleSet = IsAutoSampleSetDisplay(SelectedSampleSetName);
+ var targetSampleSet = targetUsesAutoSampleSet ? SampleSet.Normal : ParseSampleSet(SelectedSampleSetName);
+ var targetHitSound = ParseHitSound(SelectedHitSoundName);
+ var selectedPoints = Points
+ .Where(point => ids.Contains(point.Id))
+ .ToList();
+
+ var destinationLanes = selectedPoints
+ .Select(point => (point.TimeMs, HitSound: targetHitSound))
+ .ToHashSet();
+
+ var transformedPoints = selectedPoints
+ .Select(point => new HitSoundVisualizerPoint
+ {
+ Id = point.Id,
+ TimeMs = point.TimeMs,
+ SampleSet = targetUsesAutoSampleSet
+ ? ResolveAutoSampleSetForPointAtTime(targetHitSound, point.TimeMs)
+ : targetSampleSet,
+ SampleIndexOverride = point.SampleIndexOverride,
+ SampleVolumeOverridePercent = point.SampleVolumeOverridePercent,
+ HitSound = targetHitSound,
+ IsAutoSampleSet = targetUsesAutoSampleSet,
+ IsDraggable = false
+ })
+ .GroupBy(point => (point.TimeMs, point.HitSound))
+ .Select(group => group.First())
+ .ToList();
+ var destinationTimes = selectedPoints
+ .Select(point => point.TimeMs)
+ .Distinct()
+ .ToHashSet();
+
+ var normalizeAllAdditionsPerTargetColumn = targetHitSound != HitSound.Normal;
+ var preservedPoints = Points
+ .Where(point => !ids.Contains(point.Id) && !destinationLanes.Contains((point.TimeMs, point.HitSound)))
+ .Select(point =>
+ {
+ if (!normalizeAllAdditionsPerTargetColumn ||
+ point.HitSound == HitSound.Normal ||
+ !destinationTimes.Contains(point.TimeMs))
+ {
+ return point;
+ }
+
+ return new HitSoundVisualizerPoint
+ {
+ Id = point.Id,
+ TimeMs = point.TimeMs,
+ SampleSet = targetUsesAutoSampleSet
+ ? ResolveAutoSampleSetForPointAtTime(point.HitSound, point.TimeMs)
+ : targetSampleSet,
+ SampleIndexOverride = point.SampleIndexOverride,
+ SampleVolumeOverridePercent = point.SampleVolumeOverridePercent,
+ HitSound = point.HitSound,
+ IsAutoSampleSet = targetUsesAutoSampleSet,
+ IsDraggable = point.IsDraggable
+ };
+ })
+ .ToList();
+
+ var updated = preservedPoints
+ .Concat(transformedPoints)
+ .ToList();
+
+ var primaryId = SelectedPointId > 0 ? SelectedPointId : ids.First();
+ var selectedIds = transformedPoints.Select(point => point.Id).ToArray();
+ if (selectedIds.Length == 0)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Batch edit produced no points.");
+ return;
+ }
+
+ if (!selectedIds.Contains(primaryId))
+ {
+ primaryId = selectedIds[0];
+ }
+
+ ApplyUpdatedPoints(updated, selectPointId: primaryId);
+ ApplySelection(selectedIds, primaryId);
+ UpdateSelectedPointSummary();
+ }
+
+ [RelayCommand]
+ private void ApplyContextSamplePoint()
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ var timeMs = Math.Clamp(ContextSamplePointTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ var updated = SampleChanges
+ .Where(x => x.TimeMs != timeMs)
+ .ToList();
+
+ updated.Add(new HitSoundVisualizerSampleChange
+ {
+ TimeMs = timeMs,
+ SampleSet = ParseSampleSet(ContextSampleSetName),
+ Index = Math.Clamp(ContextSampleIndex, 1, 99),
+ Volume = Math.Clamp(ContextSampleVolume, 0, 100)
+ });
+
+ ApplyUpdatedSampleChanges(updated, timeMs);
+ }
+
+ [RelayCommand]
+ private void RemoveContextSamplePoint()
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ var timeMs = Math.Clamp(ContextSamplePointTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ if (!SampleChanges.Any(x => x.TimeMs == timeMs))
+ {
+ return;
+ }
+
+ var updated = SampleChanges.Where(x => x.TimeMs != timeMs).ToList();
+ ApplyUpdatedSampleChanges(updated, timeMs);
+ }
+
+ [RelayCommand]
+ private async Task PlaySelectedPoint()
+ {
+ var point = Points.FirstOrDefault(x => x.Id == SelectedPointId);
+ if (point is null)
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", "Select a point first.");
+ return;
+ }
+
+ StopPlaybackCore(resetPausedState: true);
+ PlaybackStatus = $"Playing {point.TimeMs}ms";
+ await PlayPointAsync(point, CancellationToken.None);
+ PlaybackStatus = "Idle";
+ }
+
+ [RelayCommand]
+ private void TogglePlayback()
+ {
+ if (!HasLoadedMap || IsPreparingPlayback)
+ {
+ return;
+ }
+
+ if (IsPlaybackRunning)
+ {
+ PausePlayback();
+ return;
+ }
+
+ if (IsPlaybackPaused)
+ {
+ ResumePlayback();
+ return;
+ }
+
+ PlayPlaybackFromCursor();
+ }
+
+ [RelayCommand]
+ private void PlayPlaybackFromCursor()
+ {
+ if (!HasLoadedMap || IsPreparingPlayback)
+ {
+ return;
+ }
+
+ var startTime = CursorTimeMs >= Math.Max(0, TimelineEndMs - 1) ? 0 : CursorTimeMs;
+ StartPlaybackAt(startTime);
+ }
+
+ [RelayCommand]
+ private void PausePlayback()
+ {
+ if (!IsPlaybackRunning)
+ {
+ return;
+ }
+
+ var currentTime = PlaybackRunner.Pause();
+ IsPlaybackRunning = false;
+ CursorTimeMs = currentTime;
+ PlaybackStatus = "Paused";
+ IsPlaybackPaused = true;
+ RefreshTimingDebugStatus(force: true);
+ }
+
+ [RelayCommand]
+ private void ResumePlayback()
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ StartPlaybackAt(CursorTimeMs);
+ }
+
+ [RelayCommand]
+ private void StopPlaybackSession()
+ {
+ StopPlaybackCore(resetPausedState: true);
+ ResetPlaybackPositionToStart();
+ PlaybackStatus = "Stopped";
+ }
+
+ [RelayCommand]
+ private void CommitPlaybackSeek()
+ {
+ SeekTime(PlaybackSeekMs);
+ }
+
+ [RelayCommand]
+ private void StopPlayback()
+ {
+ StopPlaybackCore(resetPausedState: true);
+ ResetPlaybackPositionToStart();
+ PlaybackStatus = "Stopped";
+ }
+
+ [RelayCommand]
+ private void BeginTimelinePeek()
+ {
+ if (!IsPlaybackRunning || !FollowPlaybackCursor)
+ {
+ return;
+ }
+
+ IsTimelinePeekActive = true;
+ }
+
+ [RelayCommand]
+ private void EndTimelinePeek()
+ {
+ IsTimelinePeekActive = false;
+ }
+
+ [RelayCommand]
+ private void ToggleSongMute()
+ {
+ if (SongVolumePercent <= 0)
+ {
+ SongVolumePercent = Math.Max(1, _lastNonZeroSongVolumePercent);
+ return;
+ }
+
+ _lastNonZeroSongVolumePercent = Math.Max(1, SongVolumePercent);
+ SongVolumePercent = 0;
+ }
+
+ [RelayCommand]
+ private void ToggleHitSoundMute()
+ {
+ if (HitSoundVolumePercent <= 0)
+ {
+ HitSoundVolumePercent = Math.Max(1, _lastNonZeroHitSoundVolumePercent);
+ return;
+ }
+
+ _lastNonZeroHitSoundVolumePercent = Math.Max(1, HitSoundVolumePercent);
+ HitSoundVolumePercent = 0;
+ }
+
+ [RelayCommand]
+ private void ApplyAdditionShortcut(string bankName)
+ {
+ if (!HasLoadedMap || string.IsNullOrWhiteSpace(bankName))
+ {
+ return;
+ }
+
+ var shouldForceApply = string.Equals(HeaderAdditionBankName, bankName, StringComparison.OrdinalIgnoreCase);
+ HeaderAdditionBankName = bankName;
+ if (shouldForceApply)
+ {
+ ApplyHeaderBankQuickChange(isNormalBank: false, bankName);
+ }
+ }
+
+ [RelayCommand]
+ private void ApplySampleSetShortcut(string bankName)
+ {
+ if (!HasLoadedMap || string.IsNullOrWhiteSpace(bankName))
+ {
+ return;
+ }
+
+ var shouldForceApply = string.Equals(HeaderNormalBankName, bankName, StringComparison.OrdinalIgnoreCase);
+ HeaderNormalBankName = bankName;
+ if (shouldForceApply)
+ {
+ ApplyHeaderBankQuickChange(isNormalBank: true, bankName);
+ }
+ }
+
+ [RelayCommand]
+ private void AddSelectionHitSound(string hitSoundName)
+ {
+ if (!HasLoadedMap)
+ {
+ return;
+ }
+
+ var targetHitSound = ParseHitSound(hitSoundName);
+ if (targetHitSound == HitSound.Normal)
+ {
+ return;
+ }
+
+ var selectedIds = SelectedPointIds.Where(id => id > 0).Distinct().ToHashSet();
+ if (selectedIds.Count == 0 && SelectedPointId > 0)
+ {
+ selectedIds.Add(SelectedPointId);
+ }
+
+ var selectedTimes = selectedIds.Count > 0
+ ? Points
+ .Where(point => selectedIds.Contains(point.Id))
+ .Select(point => point.TimeMs)
+ .Distinct()
+ .OrderBy(time => time)
+ .ToList()
+ : [ResolveCursorColumnTimeOrFallback()];
+ if (selectedTimes.Count == 0)
+ {
+ return;
+ }
+
+ IsSampleRowContextActive = false;
+ var nextPointId = _nextPointId;
+ var updated = Points.ToList();
+ var addedOrMatchedIds = new List(selectedTimes.Count);
+
+ foreach (var timeMs in selectedTimes)
+ {
+ var existingAtTime = updated.Where(point => point.TimeMs == timeMs).ToList();
+ var existingTarget = existingAtTime.FirstOrDefault(point => point.HitSound == targetHitSound);
+ if (existingTarget is not null)
+ {
+ addedOrMatchedIds.Add(existingTarget.Id);
+ continue;
+ }
+
+ var existingAdditionSampleSet = existingAtTime
+ .Where(point => point.HitSound != HitSound.Normal)
+ .Select(point => point.SampleSet)
+ .Distinct()
+ .FirstOrDefault();
+ var normalBank = existingAtTime.FirstOrDefault(point => point.HitSound == HitSound.Normal)?.SampleSet
+ ?? ResolveHeaderBankAtTime(true, timeMs);
+ var additionBank = existingAdditionSampleSet is SampleSet.Normal or SampleSet.Soft or SampleSet.Drum
+ ? existingAdditionSampleSet
+ : ResolveHeaderBankAtTime(false, timeMs, normalBank);
+
+ var candidate = new HitSoundVisualizerPoint
+ {
+ Id = nextPointId++,
+ TimeMs = timeMs,
+ SampleSet = additionBank,
+ HitSound = targetHitSound,
+ IsDraggable = false
+ };
+
+ if (HasPointConflict(candidate, out _))
+ {
+ continue;
+ }
+
+ updated.Add(candidate);
+ addedOrMatchedIds.Add(candidate.Id);
+ }
+
+ if (addedOrMatchedIds.Count == 0)
+ {
+ return;
+ }
+
+ var primaryId = SelectedPointId > 0 ? SelectedPointId : addedOrMatchedIds[0];
+ if (!ApplyUpdatedPoints(updated, selectPointId: primaryId))
+ {
+ return;
+ }
+
+ _nextPointId = nextPointId;
+ var mergedSelection = selectedIds.Concat(addedOrMatchedIds).Distinct().ToArray();
+ var mergedPrimaryId = mergedSelection.Contains(primaryId) ? primaryId : mergedSelection[0];
+ ApplySelection(mergedSelection, mergedPrimaryId);
+ var primaryPoint = Points.FirstOrDefault(point => point.Id == mergedPrimaryId);
+ if (primaryPoint is not null)
+ {
+ SyncEditorFromPoint(primaryPoint);
+ }
+
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ }
+
+ [RelayCommand]
+ private void Undo()
+ {
+ if (_undoStack.Count == 0)
+ {
+ return;
+ }
+
+ var currentState = CaptureHistoryState();
+ var targetState = _undoStack.Pop();
+ if (!RestoreHistoryState(targetState))
+ {
+ _undoStack.Push(targetState);
+ return;
+ }
+
+ _redoStack.Push(currentState);
+ }
+
+ [RelayCommand]
+ private void Redo()
+ {
+ if (_redoStack.Count == 0)
+ {
+ return;
+ }
+
+ var currentState = CaptureHistoryState();
+ var targetState = _redoStack.Pop();
+ if (!RestoreHistoryState(targetState))
+ {
+ _redoStack.Push(targetState);
+ return;
+ }
+
+ _undoStack.Push(currentState);
+ }
+
+ private void StartPlaybackAt(int startTimeMs)
+ {
+ startTimeMs = Math.Clamp(startTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+
+ StopPlaybackCore(resetPausedState: false);
+ ResetHitsoundPlaybackDebugTelemetry();
+
+ audioPlaybackService.SetSongVolume(SongVolumePercent / 100f);
+ audioPlaybackService.SetHitsoundVolume(HitSoundVolumePercent / 100f);
+ var playbackSampleChanges = SampleChanges.OrderBy(x => x.TimeMs).ToList();
+ var startResult = PlaybackRunner.Start(
+ _loadedAudioFilePath,
+ startTimeMs,
+ TimelineEndMs,
+ Points,
+ postToUi: action => Dispatcher.UIThread.Post(action),
+ onCursorUpdatedOnUi: UpdatePlaybackCursor,
+ onPlaybackCompletedOnUi: OnPlaybackRunnerCompleted,
+ onPlayPoint: (point, token) => PlayPointForPlayback(point, playbackSampleChanges, token));
+ if (startResult != PlaybackRunnerStartResult.Started)
+ {
+ CursorTimeMs = startTimeMs;
+ PlaybackSeekMs = startTimeMs;
+ PlaybackStatus = "Playback requires song audio";
+ RefreshTimingDebugStatus(force: true);
+ return;
+ }
+
+ CursorTimeMs = startTimeMs;
+ PlaybackSeekMs = startTimeMs;
+ IsPlaybackRunning = true;
+ IsPlaybackPaused = false;
+
+ PlaybackStatus = $"Playing from {FormatTimeLabel(startTimeMs)}";
+ RefreshTimingDebugStatus(force: true);
+ }
+
+ private void RestartPlaybackAt(int startTimeMs)
+ {
+ if (!IsPlaybackRunning)
+ {
+ return;
+ }
+
+ StartPlaybackAt(startTimeMs);
+ }
+
+ private int GetCurrentPlaybackTimeMs()
+ {
+ return PlaybackRunner.GetCurrentTimeMs();
+ }
+
+ private void UpdatePlaybackCursor(int currentTimeMs)
+ {
+ if (FollowPlaybackCursor && IsPlaybackRunning && !IsTimelinePeekActive)
+ {
+ var window = Math.Max(100, ViewWindowMs);
+ const double playheadAnchorRatio = 0.38d;
+ var desiredStart = currentTimeMs - (window * playheadAnchorRatio);
+ ViewStartMs = Math.Clamp(desiredStart, 0, Math.Max(0, TimelineEndMs - window));
+ }
+
+ CursorTimeMs = currentTimeMs;
+ RefreshTimingDebugStatus();
+ }
+
+ private void OnPlaybackRunnerCompleted(int endTimeMs)
+ {
+ IsPlaybackRunning = false;
+ IsPlaybackPaused = false;
+ IsTimelinePeekActive = false;
+ CursorTimeMs = Math.Clamp(endTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ PlaybackStatus = "Playback complete";
+ RefreshTimingDebugStatus(force: true);
+ }
+
+ private void StopPlaybackCore(bool resetPausedState)
+ {
+ PlaybackRunner.Stop();
+
+ if (resetPausedState)
+ {
+ IsPlaybackPaused = false;
+ }
+
+ IsPlaybackRunning = false;
+ IsTimelinePeekActive = false;
+ RefreshTimingDebugStatus(force: true);
+ }
+
+ private void ResetPlaybackPositionToStart()
+ {
+ CursorTimeMs = 0;
+ PlaybackSeekMs = 0;
+ ContextSamplePointTimeMs = 0;
+ }
+
+ private void RefreshTimingDebugStatus(bool force = false)
+ {
+ var nowTickMs = Environment.TickCount64;
+ var lastTickMs = Interlocked.Read(ref _playbackDebugStatusLastUiTickMs);
+ if (!force && lastTickMs > 0 && nowTickMs - lastTickMs < PlaybackDebugInfoUpdateIntervalMs)
+ {
+ return;
+ }
+
+ Interlocked.Exchange(ref _playbackDebugStatusLastUiTickMs, nowTickMs);
+
+ var runnerTiming = _playbackRunner?.GetTimingTelemetryStatus() ?? "PlaybackRunner[idle]";
+ var audioTiming = audioPlaybackService.GetTimingTelemetryStatus();
+ TimingDebugStatus = $"Timing | {runnerTiming} | {audioTiming}";
+ AudioDebugStatus = audioPlaybackService.GetSongDebugStatus();
+ HitsoundDebugStatus = BuildHitsoundDebugStatus();
+ }
+
+ private string ResolveLegacySkinDirectory()
+ {
+ foreach (var candidate in EnumerateLegacySkinDirectoryCandidates())
+ {
+ if (LooksLikeLegacyHitsoundDirectory(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ return string.Empty;
+ }
+
+ private IEnumerable EnumerateLegacySkinDirectoryCandidates()
+ {
+ var seen = new HashSet(OperatingSystem.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal);
+ var candidatePaths = new List();
+
+ void Add(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ try
+ {
+ var fullPath = Path.GetFullPath(path);
+ if (seen.Add(fullPath))
+ {
+ candidatePaths.Add(fullPath);
+ }
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore invalid paths while probing.
+ }
+ }
+
+ Add(Path.Combine(AppContext.BaseDirectory, "Assets", "Skins", "Legacy"));
+ Add(Path.Combine(AppContext.BaseDirectory, "Skins", "Legacy"));
+ Add(Path.Combine(AppContext.BaseDirectory, "Legacy"));
+
+ foreach (var root in EnumerateAncestorDirectories(AppContext.BaseDirectory))
+ {
+ Add(Path.Combine(root, "osu-resources", "osu.Game.Resources", "Skins", "Legacy"));
+ Add(Path.Combine(root, "osu.Game.Resources", "Skins", "Legacy"));
+ }
+
+ if (!string.IsNullOrWhiteSpace(_loadedMapsetDirectoryPath))
+ {
+ foreach (var root in EnumerateAncestorDirectories(_loadedMapsetDirectoryPath))
+ {
+ Add(Path.Combine(root, "osu-resources", "osu.Game.Resources", "Skins", "Legacy"));
+ Add(Path.Combine(root, "osu.Game.Resources", "Skins", "Legacy"));
+ }
+ }
+
+ return candidatePaths;
+ }
+
+ private bool LooksLikeLegacyHitsoundDirectory(string directoryPath)
+ {
+ if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath))
+ {
+ return false;
+ }
+
+ // Accept only folders that look like the osu! legacy skin hitsound set.
+ // This avoids false positives when probing parent directories.
+ string[] requiredBaseNames =
+ [
+ "normal-hitnormal",
+ "normal-hitwhistle",
+ "normal-hitfinish",
+ "normal-hitclap",
+ "soft-hitnormal",
+ "drum-hitnormal"
+ ];
+
+ foreach (var baseName in requiredBaseNames)
+ {
+ var exists = _sampleExtensions.Any(ext => File.Exists(Path.Combine(directoryPath, baseName + ext)));
+ if (!exists)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static IEnumerable EnumerateAncestorDirectories(string startPath)
+ {
+ if (string.IsNullOrWhiteSpace(startPath))
+ {
+ yield break;
+ }
+
+ DirectoryInfo? current;
+ try
+ {
+ current = Directory.Exists(startPath)
+ ? new DirectoryInfo(startPath)
+ : new FileInfo(startPath).Directory;
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ yield break;
+ }
+
+ while (current is not null)
+ {
+ yield return current.FullName;
+ current = current.Parent;
+ }
+ }
+
+ private void SetOriginBeatmapPath(string beatmapPath)
+ {
+ var preservePlaybackPosition = ShouldPreservePlaybackPosition(beatmapPath);
+ OriginBeatmap = new SelectedMap { Path = beatmapPath };
+ PreferredDirectory = Path.GetDirectoryName(beatmapPath) ?? string.Empty;
+
+ if (!string.IsNullOrWhiteSpace(beatmapPath) && File.Exists(beatmapPath))
+ {
+ _ = LoadTimelineCore(preservePlaybackPosition);
+ }
+ }
+
+ private int CapturePlaybackPositionMsForReload()
+ {
+ if (IsPlaybackRunning)
+ {
+ return Math.Max(0, GetCurrentPlaybackTimeMs());
+ }
+
+ return Math.Max(0, Math.Max(CursorTimeMs, PlaybackSeekMs));
+ }
+
+ private bool ShouldPreservePlaybackPosition(string nextBeatmapPath)
+ {
+ if (!HasLoadedMap || string.IsNullOrWhiteSpace(_loadedMapsetDirectoryPath))
+ {
+ return false;
+ }
+
+ var nextMapsetDirectoryPath = Path.GetDirectoryName(nextBeatmapPath);
+ if (string.IsNullOrWhiteSpace(nextMapsetDirectoryPath))
+ {
+ return false;
+ }
+
+ try
+ {
+ var currentMapsetDirectoryPath = Path.GetFullPath(_loadedMapsetDirectoryPath)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var targetMapsetDirectoryPath = Path.GetFullPath(nextMapsetDirectoryPath)
+ .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ return string.Equals(currentMapsetDirectoryPath, targetMapsetDirectoryPath, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private string? GetBeatmapFromMemory()
+ {
+ return BeatmapSelectionUtils.TryGetBeatmapFromMemory(
+ osuMemoryReaderService,
+ (type, title, message) => toastManager.ShowToast(type, title, message),
+ "Memory Error",
+ "Something went wrong while getting the beatmap path from memory.",
+ "No Beatmap",
+ "No beatmap found in memory.");
+ }
+
+ private Task?> ShowSongSelectDialogAsync(
+ bool allowMultiple,
+ CancellationToken token,
+ string? preferredMapsetDirectoryPath = null)
+ => MapPickerDialogUtils.ShowSongSelectDialogAsync(
+ dialogManager,
+ toastManager,
+ songLibraryService,
+ filesService,
+ settingsService,
+ "Hitsound Visualizer",
+ allowMultiple,
+ token,
+ preferredMapsetDirectoryPath);
+
+ private bool ApplyUpdatedPoints(
+ IReadOnlyCollection updatedPoints,
+ int selectPointId,
+ bool recordHistory = true)
+ {
+ if (!TryRebuildWorkingTimeline(updatedPoints, out var timeline, out var error))
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", error);
+ return false;
+ }
+
+ var before = recordHistory && !_isRestoringHistory
+ ? CaptureHistoryState()
+ : null;
+
+ _workingTimeline = timeline;
+ Points = new ObservableCollection(
+ updatedPoints
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => HitSoundSortOrder(x.HitSound))
+ .ThenBy(x => SampleSetSortOrder(x.SampleSet)));
+
+ ApplySelection(selectPointId > 0 ? [selectPointId] : [], selectPointId);
+ SyncContextSamplePointEditor(CursorTimeMs);
+ RefreshHeaderBankQuickState();
+ UpdateTimelineStats();
+ UpdateSelectedPointSummary();
+ OnPropertyChanged(nameof(ViewEndMs));
+ OnPropertyChanged(nameof(VisiblePointCount));
+ PushUndoStateIfChanged(before);
+ return true;
+ }
+
+ private bool ApplyUpdatedSampleChanges(
+ IReadOnlyCollection updatedSampleChanges,
+ int focusTimeMs,
+ bool recordHistory = true)
+ {
+ if (!TryRebuildWorkingTimeline(Points.ToList(), out var timeline, out var error, updatedSampleChanges))
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", error);
+ return false;
+ }
+
+ var before = recordHistory && !_isRestoringHistory
+ ? CaptureHistoryState()
+ : null;
+
+ _workingTimeline = timeline;
+ SampleChanges = new ObservableCollection(
+ updatedSampleChanges
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => SampleSetSortOrder(x.SampleSet))
+ .ThenBy(x => x.Index));
+
+ SyncContextSamplePointEditor(focusTimeMs);
+ RefreshHeaderBankQuickState();
+ UpdateTimelineStats();
+ OnPropertyChanged(nameof(ViewEndMs));
+ PushUndoStateIfChanged(before);
+ return true;
+ }
+
+ private void PushUndoStateIfChanged(TimelineHistoryState? before)
+ {
+ if (before is null)
+ {
+ return;
+ }
+
+ if (HistoryPointsEqual(before.Points, Points) &&
+ HistorySampleChangesEqual(before.SampleChanges, SampleChanges))
+ {
+ return;
+ }
+
+ _undoStack.Push(before);
+ _redoStack.Clear();
+ }
+
+ private TimelineHistoryState CaptureHistoryState()
+ {
+ return new TimelineHistoryState(
+ Points
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => HitSoundSortOrder(x.HitSound))
+ .ThenBy(x => SampleSetSortOrder(x.SampleSet))
+ .ThenBy(x => x.Id)
+ .Select(ClonePoint)
+ .ToArray(),
+ SampleChanges
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => SampleSetSortOrder(x.SampleSet))
+ .ThenBy(x => x.Index)
+ .Select(CloneSampleChange)
+ .ToArray(),
+ CursorTimeMs,
+ ContextSamplePointTimeMs,
+ IsSampleRowContextActive,
+ SelectedPointId,
+ SelectedPointIds.Where(id => id > 0).Distinct().ToArray());
+ }
+
+ private bool RestoreHistoryState(TimelineHistoryState state)
+ {
+ if (!TryRebuildWorkingTimeline(state.Points, out var timeline, out var error, state.SampleChanges))
+ {
+ toastManager.ShowToast(NotificationType.Error, "Hitsound Visualizer", error);
+ return false;
+ }
+
+ StopPlaybackCore(resetPausedState: true);
+ PlaybackStatus = "Idle";
+
+ _isRestoringHistory = true;
+ try
+ {
+ _workingTimeline = timeline;
+ Points = new ObservableCollection(
+ state.Points
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => HitSoundSortOrder(x.HitSound))
+ .ThenBy(x => SampleSetSortOrder(x.SampleSet))
+ .Select(ClonePoint));
+ SampleChanges = new ObservableCollection(
+ state.SampleChanges
+ .OrderBy(x => x.TimeMs)
+ .ThenBy(x => SampleSetSortOrder(x.SampleSet))
+ .ThenBy(x => x.Index)
+ .Select(CloneSampleChange));
+
+ _nextPointId = Math.Max(1, Points.Any() ? Points.Max(x => x.Id) + 1 : 1);
+ CursorTimeMs = Math.Clamp(state.CursorTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ IsSampleRowContextActive = state.IsSampleRowContextActive;
+
+ var existingIds = Points.Select(x => x.Id).ToHashSet();
+ var selectedIds = state.SelectedPointIds.Where(existingIds.Contains).ToArray();
+ var primaryId = selectedIds.Contains(state.SelectedPointId)
+ ? state.SelectedPointId
+ : selectedIds.FirstOrDefault();
+ ApplySelection(selectedIds, primaryId);
+
+ ContextSamplePointTimeMs = Math.Clamp(state.ContextSamplePointTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ SyncContextSamplePointEditor(ContextSamplePointTimeMs);
+
+ var primary = Points.FirstOrDefault(x => x.Id == SelectedPointId);
+ if (primary is not null && !IsSampleRowContextActive)
+ {
+ SyncEditorFromPoint(primary);
+ }
+
+ RefreshHeaderBankQuickState();
+ UpdateTimelineStats();
+ UpdateSelectedPointSummary();
+ OnPropertyChanged(nameof(ViewEndMs));
+ OnPropertyChanged(nameof(VisiblePointCount));
+ return true;
+ }
+ finally
+ {
+ _isRestoringHistory = false;
+ }
+ }
+
+ private static HitSoundVisualizerPoint ClonePoint(HitSoundVisualizerPoint point)
+ {
+ return new HitSoundVisualizerPoint
+ {
+ Id = point.Id,
+ TimeMs = point.TimeMs,
+ SampleSet = point.SampleSet,
+ SampleIndexOverride = point.SampleIndexOverride,
+ SampleVolumeOverridePercent = point.SampleVolumeOverridePercent,
+ IsAutoSampleSet = point.IsAutoSampleSet,
+ HitSound = point.HitSound,
+ IsDraggable = point.IsDraggable
+ };
+ }
+
+ private static HitSoundVisualizerSampleChange CloneSampleChange(HitSoundVisualizerSampleChange sampleChange)
+ {
+ return new HitSoundVisualizerSampleChange
+ {
+ TimeMs = sampleChange.TimeMs,
+ SampleSet = sampleChange.SampleSet,
+ Index = sampleChange.Index,
+ Volume = sampleChange.Volume
+ };
+ }
+
+ private static bool HistoryPointsEqual(
+ IReadOnlyList left,
+ IReadOnlyList right)
+ {
+ if (left.Count != right.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < left.Count; i++)
+ {
+ var l = left[i];
+ var r = right[i];
+ if (l.Id != r.Id ||
+ l.TimeMs != r.TimeMs ||
+ l.SampleSet != r.SampleSet ||
+ l.SampleIndexOverride != r.SampleIndexOverride ||
+ l.SampleVolumeOverridePercent != r.SampleVolumeOverridePercent ||
+ l.HitSound != r.HitSound ||
+ l.IsAutoSampleSet != r.IsAutoSampleSet ||
+ l.IsDraggable != r.IsDraggable)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool HistorySampleChangesEqual(
+ IReadOnlyList left,
+ IReadOnlyList right)
+ {
+ if (left.Count != right.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < left.Count; i++)
+ {
+ var l = left[i];
+ var r = right[i];
+ if (l.TimeMs != r.TimeMs ||
+ l.SampleSet != r.SampleSet ||
+ l.Index != r.Index ||
+ l.Volume != r.Volume)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private void ApplySelection(IEnumerable pointIds, int primaryPointId)
+ {
+ var ids = pointIds
+ .Where(id => id > 0)
+ .Distinct()
+ .ToList();
+
+ SelectedPointIds = new ObservableCollection(ids);
+ SelectedPointId = ids.Count == 0 ? -1 : (primaryPointId > 0 && ids.Contains(primaryPointId) ? primaryPointId : ids[0]);
+ }
+
+ private void SyncEditorFromPoint(HitSoundVisualizerPoint point)
+ {
+ SelectedSampleSetName = point.IsAutoSampleSet ? "Auto" : SampleSetToDisplay(point.SampleSet);
+ SelectedHitSoundName = HitSoundToDisplay(point.HitSound);
+ NewPointIsSliderBody = false;
+ }
+
+ private void SyncContextSamplePointEditor(int timeMs)
+ {
+ ContextSamplePointTimeMs = Math.Clamp(timeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+
+ var exact = SampleChanges
+ .Where(x => x.TimeMs == ContextSamplePointTimeMs)
+ .OrderBy(x => SampleSetSortOrder(x.SampleSet))
+ .FirstOrDefault();
+
+ var effective = exact ?? SampleChanges
+ .Where(x => x.TimeMs <= ContextSamplePointTimeMs)
+ .OrderBy(x => x.TimeMs)
+ .LastOrDefault();
+
+ ContextSamplePointExists = exact is not null;
+ ContextSampleSetName = SampleSetToDisplay((exact ?? effective)?.SampleSet ?? SampleSet.Normal);
+ ContextSampleIndex = Math.Max(1, (exact ?? effective)?.Index ?? 1);
+ ContextSampleVolume = Math.Clamp((exact ?? effective)?.Volume ?? 100, 0, 100);
+ UpdateContextSamplePointSummary();
+ if (IsSampleRowContextActive)
+ {
+ RefreshHeaderBankQuickState();
+ }
+ }
+
+ private void UpdateContextSamplePointSummary()
+ {
+ ContextSamplePointSummary = ContextSamplePointExists
+ ? $"Sample point at {FormatTimeLabel(ContextSamplePointTimeMs)}"
+ : $"New sample point at {FormatTimeLabel(ContextSamplePointTimeMs)} (uses current values)";
+ }
+
+ private int ResolveCursorColumnTimeOrFallback()
+ {
+ return TryResolveCursorColumnTime(out var columnTimeMs)
+ ? columnTimeMs
+ : Math.Clamp(CursorTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ }
+
+ private bool TryResolveCursorColumnTime(out int columnTimeMs)
+ {
+ columnTimeMs = Math.Clamp(CursorTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ if (Points.Count == 0)
+ {
+ return false;
+ }
+
+ var bestDistanceMs = CursorColumnToleranceMs + 1;
+ var hasCandidate = false;
+ var candidateTimeMs = columnTimeMs;
+ foreach (var point in Points)
+ {
+ var distanceMs = Math.Abs(point.TimeMs - columnTimeMs);
+ if (distanceMs > CursorColumnToleranceMs)
+ {
+ continue;
+ }
+
+ if (distanceMs < bestDistanceMs ||
+ (distanceMs == bestDistanceMs && point.TimeMs < candidateTimeMs))
+ {
+ bestDistanceMs = distanceMs;
+ candidateTimeMs = point.TimeMs;
+ hasCandidate = true;
+ }
+ }
+
+ if (!hasCandidate)
+ {
+ return false;
+ }
+
+ columnTimeMs = candidateTimeMs;
+ return true;
+ }
+
+ private void RefreshHeaderBankQuickState()
+ {
+ _suppressHeaderBankQuickApply = true;
+ try
+ {
+ if (IsSampleRowContextActive)
+ {
+ HeaderNormalBankName = ContextSampleSetName;
+ HeaderAdditionBankName = "Auto";
+ return;
+ }
+
+ var primaryPoint = Points.FirstOrDefault(x => x.Id == SelectedPointId);
+ if (primaryPoint is null)
+ {
+ var cursorTimeMs = ResolveCursorColumnTimeOrFallback();
+ var pointsNearCursor = Points
+ .Where(x => Math.Abs(x.TimeMs - cursorTimeMs) <= CursorColumnToleranceMs)
+ .OrderBy(x => HitSoundSortOrder(x.HitSound))
+ .ToList();
+
+ var cursorNormalPoint = pointsNearCursor.FirstOrDefault(x => x.HitSound == HitSound.Normal);
+ var cursorAdditionPoint = pointsNearCursor.FirstOrDefault(x => x.HitSound != HitSound.Normal);
+ var cursorNormalBank = cursorNormalPoint?.SampleSet
+ ?? ResolveHeaderBankAtTime(true, cursorTimeMs);
+ var cursorAdditionBank = cursorAdditionPoint?.SampleSet
+ ?? cursorNormalBank;
+
+ HeaderNormalBankName = cursorNormalPoint?.IsAutoSampleSet == true
+ ? "Auto"
+ : SampleSetToDisplay(cursorNormalBank);
+ HeaderAdditionBankName = cursorAdditionPoint?.IsAutoSampleSet == true
+ ? "Auto"
+ : (cursorAdditionPoint is null && cursorAdditionBank == cursorNormalBank
+ ? "Auto"
+ : SampleSetToDisplay(cursorAdditionBank));
+ return;
+ }
+
+ var selectedPointIds = SelectedPointIds.Where(id => id > 0).Distinct().ToHashSet();
+ if (selectedPointIds.Count == 0 && SelectedPointId > 0)
+ {
+ selectedPointIds.Add(SelectedPointId);
+ }
+
+ var selectedPoints = selectedPointIds.Count == 0
+ ? []
+ : Points
+ .Where(point => selectedPointIds.Contains(point.Id))
+ .OrderBy(point => point.TimeMs)
+ .ThenBy(point => HitSoundSortOrder(point.HitSound))
+ .ToList();
+
+ var sameTimePoints = Points
+ .Where(x => x.TimeMs == primaryPoint.TimeMs)
+ .OrderBy(x => HitSoundSortOrder(x.HitSound))
+ .ToList();
+
+ var normalPoint = sameTimePoints.FirstOrDefault(x => x.HitSound == HitSound.Normal) ??
+ selectedPoints.FirstOrDefault(x => x.HitSound == HitSound.Normal);
+ var additionPoint = sameTimePoints.FirstOrDefault(x => x.HitSound != HitSound.Normal) ??
+ selectedPoints.FirstOrDefault(x => x.HitSound != HitSound.Normal);
+ var normalBank = normalPoint?.SampleSet ?? primaryPoint.SampleSet;
+ var additionBank = additionPoint?.SampleSet ?? normalBank;
+
+ HeaderNormalBankName = normalPoint?.IsAutoSampleSet == true
+ ? "Auto"
+ : SampleSetToDisplay(normalBank);
+ HeaderAdditionBankName = additionPoint?.IsAutoSampleSet == true
+ ? "Auto"
+ : (additionPoint is null && additionBank == normalBank
+ ? "Auto"
+ : SampleSetToDisplay(additionBank));
+ }
+ finally
+ {
+ _suppressHeaderBankQuickApply = false;
+ }
+ }
+
+ private void ApplyHeaderBankQuickChange(bool isNormalBank, string value)
+ {
+ if (_suppressHeaderBankQuickApply || !HasLoadedMap)
+ {
+ return;
+ }
+
+ var applyAuto = string.Equals(value, "Auto", StringComparison.OrdinalIgnoreCase);
+ var sampleSet = applyAuto ? SampleSet.Normal : ParseSampleSet(value);
+
+ if (IsSampleRowContextActive)
+ {
+ if (applyAuto)
+ {
+ return;
+ }
+
+ ContextSampleSetName = SampleSetToDisplay(sampleSet);
+
+ _suppressHeaderBankQuickApply = true;
+ HeaderNormalBankName = ContextSampleSetName;
+ HeaderAdditionBankName = ContextSampleSetName;
+ _suppressHeaderBankQuickApply = false;
+
+ ApplyContextSamplePoint();
+ return;
+ }
+
+ if (!HasSelectedPoint)
+ {
+ // No selected point: apply to points at the current cursor column when possible.
+ // If nothing exists there, keep the quick bank values as defaults for subsequent actions.
+ var cursorTimeMs = ResolveCursorColumnTimeOrFallback();
+ bool IsTargetPointAtCursor(HitSoundVisualizerPoint point) =>
+ Math.Abs(point.TimeMs - cursorTimeMs) <= CursorColumnToleranceMs &&
+ MatchesQuickBankTarget(point, isNormalBank);
+
+ if (!Points.Any(IsTargetPointAtCursor))
+ {
+ return;
+ }
+
+ var updatedAtCursor = Points
+ .Select(point => IsTargetPointAtCursor(point)
+ ? ApplyQuickBankEditToPoint(
+ point,
+ applyAuto,
+ sampleSet)
+ : point)
+ .ToList();
+
+ if (!ApplyUpdatedPoints(updatedAtCursor, selectPointId: -1))
+ {
+ return;
+ }
+
+ ApplySelection([], -1);
+ UpdateSelectedPointSummary();
+ RefreshHeaderBankQuickState();
+ return;
+ }
+
+ ApplySampleSetToSelectionSubset(isNormalBank, sampleSet, applyAuto);
+ RefreshHeaderBankQuickState();
+ }
+
+ private bool MatchesHsSelectorFilter(HitSoundVisualizerPoint point)
+ {
+ if (!MatchesSelectedAudioType(point.HitSound))
+ {
+ return false;
+ }
+
+ var bankFilter = point.HitSound == HitSound.Normal
+ ? HsSelectorNormalBankFilterName
+ : HsSelectorAdditionBankFilterName;
+
+ if (string.Equals(bankFilter, "Any", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (string.Equals(bankFilter, "Auto", StringComparison.OrdinalIgnoreCase))
+ {
+ return point.IsAutoSampleSet;
+ }
+
+ return point.SampleSet == ParseSampleSet(bankFilter);
+ }
+
+ private bool MatchesSelectedAudioType(HitSound hitSound)
+ {
+ return hitSound switch
+ {
+ HitSound.Normal => HsSelectorIncludeHitNormal,
+ HitSound.Whistle => HsSelectorIncludeWhistle,
+ HitSound.Finish => HsSelectorIncludeFinish,
+ HitSound.Clap => HsSelectorIncludeClap,
+ _ => false
+ };
+ }
+
+ private SampleSet ResolveHeaderBankAtTime(bool isNormalBank, int timeMs, SampleSet? resolvedNormalBank = null)
+ {
+ var headerValue = isNormalBank ? HeaderNormalBankName : HeaderAdditionBankName;
+ if (!string.Equals(headerValue, "Auto", StringComparison.OrdinalIgnoreCase))
+ {
+ return ParseSampleSet(headerValue);
+ }
+
+ if (!isNormalBank && resolvedNormalBank is not null)
+ {
+ return resolvedNormalBank.Value;
+ }
+
+ var effectiveSample = SampleChanges
+ .Where(x => x.TimeMs <= timeMs)
+ .OrderBy(x => x.TimeMs)
+ .LastOrDefault()
+ ?.SampleSet;
+
+ return effectiveSample is SampleSet.Normal or SampleSet.Soft or SampleSet.Drum
+ ? effectiveSample.Value
+ : SampleSet.Normal;
+ }
+
+ private static bool TryGetHitSoundFromRowIndex(int rowIndex, out HitSound hitSound)
+ {
+ hitSound = rowIndex switch
+ {
+ 1 => HitSound.Normal,
+ 2 => HitSound.Whistle,
+ 3 => HitSound.Finish,
+ 4 => HitSound.Clap,
+ _ => HitSound.Normal
+ };
+
+ return rowIndex is >= 1 and <= 4;
+ }
+
+ private void ApplySampleSetToSelectionSubset(bool affectNormalPoints, SampleSet sampleSet, bool applyAuto)
+ {
+ var selectedIds = SelectedPointIds.Where(id => id > 0).Distinct().ToHashSet();
+ if (selectedIds.Count == 0 && SelectedPointId > 0)
+ {
+ selectedIds.Add(SelectedPointId);
+ }
+
+ if (selectedIds.Count == 0)
+ {
+ return;
+ }
+
+ // Header bank quick-edit is column-based (timestamp-based), not point-based.
+ // It only edits points from the selected bank (normal or additions), never the opposite bank.
+ var selectedTimes = Points
+ .Where(point => selectedIds.Contains(point.Id))
+ .Select(point => point.TimeMs)
+ .Distinct()
+ .ToHashSet();
+
+ if (selectedTimes.Count == 0)
+ {
+ return;
+ }
+
+ bool IsTargetPoint(HitSoundVisualizerPoint point) =>
+ selectedTimes.Contains(point.TimeMs) &&
+ MatchesQuickBankTarget(point, affectNormalPoints);
+
+ if (!Points.Any(IsTargetPoint))
+ {
+ return;
+ }
+
+ var updated = Points
+ .Select(point => IsTargetPoint(point)
+ ? ApplyQuickBankEditToPoint(
+ point,
+ applyAuto,
+ sampleSet)
+ : point)
+ .ToList();
+
+ var primaryId = SelectedPointId > 0 ? SelectedPointId : selectedIds.First();
+ var selectedIdArray = selectedIds.ToArray();
+ ApplyUpdatedPoints(updated, selectPointId: primaryId);
+ ApplySelection(selectedIdArray, primaryId);
+ UpdateSelectedPointSummary();
+ }
+
+ private HitSoundVisualizerPoint ApplyQuickBankEditToPoint(
+ HitSoundVisualizerPoint point,
+ bool applyAuto,
+ SampleSet manualSampleSet)
+ {
+ var resolvedNormalSampleSet = ResolveHeaderBankAtTime(isNormalBank: true, point.TimeMs);
+ var resolvedSampleSet = applyAuto
+ ? (point.HitSound == HitSound.Normal
+ ? resolvedNormalSampleSet
+ : ResolveHeaderBankAtTime(isNormalBank: false, point.TimeMs, resolvedNormalSampleSet))
+ : manualSampleSet;
+
+ return new HitSoundVisualizerPoint
+ {
+ Id = point.Id,
+ TimeMs = point.TimeMs,
+ SampleSet = resolvedSampleSet,
+ SampleIndexOverride = point.SampleIndexOverride,
+ SampleVolumeOverridePercent = point.SampleVolumeOverridePercent,
+ IsAutoSampleSet = applyAuto,
+ HitSound = point.HitSound,
+ IsDraggable = point.IsDraggable
+ };
+ }
+
+ private static bool MatchesQuickBankTarget(HitSoundVisualizerPoint point, bool normalBank)
+ {
+ return normalBank
+ ? point.HitSound == HitSound.Normal
+ : point.HitSound != HitSound.Normal;
+ }
+
+ private bool TryRebuildWorkingTimeline(
+ IReadOnlyCollection sourcePoints,
+ out HitSoundTimeline timeline,
+ out string error,
+ IReadOnlyCollection? sampleChangesOverride = null)
+ {
+ error = string.Empty;
+
+ if (!TryBuildSoundTimeline(sourcePoints.Where(x => !x.IsDraggable), out var nonDraggable, out error))
+ {
+ timeline = new HitSoundTimeline();
+ return false;
+ }
+
+ var sampleChanges = sampleChangesOverride ?? SampleChanges.ToList();
+
+ timeline = new HitSoundTimeline
+ {
+ NonDraggableSoundTimeline = nonDraggable,
+ DraggableSoundTimeline = new SoundTimeline(),
+ SampleSetTimeline = new SampleSetTimeline
+ {
+ HitSamples = sampleChanges
+ .OrderBy(x => x.TimeMs)
+ .Select(x => new SampleSetEvent(x.TimeMs, x.SampleSet, x.Index, x.Volume))
+ .ToList()
+ }
+ };
+
+ return true;
+ }
+
+ private static bool TryBuildSoundTimeline(
+ IEnumerable sourcePoints,
+ out SoundTimeline timeline,
+ out string error)
+ {
+ error = string.Empty;
+ var events = new List();
+
+ foreach (var timeGroup in sourcePoints
+ .GroupBy(x => x.TimeMs)
+ .OrderBy(x => x.Key))
+ {
+ var groupPoints = timeGroup.ToList();
+
+ var normalPoints = groupPoints.Where(x => x.HitSound == HitSound.Normal).ToList();
+ if (normalPoints.Select(x => x.SampleSet).Distinct().Count() > 1)
+ {
+ error = $"Conflicting hitnormal sample sets at {timeGroup.Key}ms.";
+ timeline = new SoundTimeline();
+ return false;
+ }
+
+ var additionPoints = groupPoints.Where(x => x.HitSound != HitSound.Normal).ToList();
+ if (additionPoints.Select(x => x.SampleSet).Distinct().Count() > 1)
+ {
+ error = $"Conflicting addition sample sets at {timeGroup.Key}ms. Current format only supports one addition sample set per timestamp/source.";
+ timeline = new SoundTimeline();
+ return false;
+ }
+
+ var distinctSounds = groupPoints
+ .Select(x => x.HitSound)
+ .Distinct()
+ .OrderBy(HitSoundSortOrder)
+ .ToList();
+
+ var normalSample = normalPoints.FirstOrDefault()?.SampleSet
+ ?? additionPoints.FirstOrDefault()?.SampleSet
+ ?? SampleSet.Normal;
+ var additionSample = additionPoints.FirstOrDefault()?.SampleSet ?? normalSample;
+ var sampleIndexOverride = normalPoints.FirstOrDefault()?.SampleIndexOverride
+ ?? additionPoints.FirstOrDefault()?.SampleIndexOverride;
+ var sampleVolumeOverride = normalPoints.FirstOrDefault()?.SampleVolumeOverridePercent
+ ?? additionPoints.FirstOrDefault()?.SampleVolumeOverridePercent;
+
+ events.Add(new SoundEvent(
+ TimeSpan.FromMilliseconds(timeGroup.Key),
+ distinctSounds,
+ normalSample,
+ additionSample,
+ sampleIndexOverride: sampleIndexOverride,
+ sampleVolumeOverride: sampleVolumeOverride));
+ }
+
+ timeline = new SoundTimeline(events);
+ return true;
+ }
+
+ private bool HasPointConflict(HitSoundVisualizerPoint candidate, out string message, int ignorePointId = -1)
+ {
+ message = string.Empty;
+ var otherPoints = Points.Where(x => x.Id != ignorePointId);
+
+ if (otherPoints.Any(x =>
+ x.TimeMs == candidate.TimeMs &&
+ x.HitSound == candidate.HitSound &&
+ x.SampleSet == candidate.SampleSet))
+ {
+ message = "That point already exists on the same lane.";
+ return true;
+ }
+
+ if (candidate.HitSound == HitSound.Normal)
+ {
+ var existingNormal = otherPoints.FirstOrDefault(x =>
+ x.TimeMs == candidate.TimeMs &&
+ x.HitSound == HitSound.Normal);
+
+ if (existingNormal is not null && existingNormal.SampleSet != candidate.SampleSet)
+ {
+ message = "A hitnormal point already exists at this timestamp with another sample set.";
+ return true;
+ }
+ }
+ else
+ {
+ var additionSampleSet = otherPoints
+ .Where(x => x.TimeMs == candidate.TimeMs && x.HitSound != HitSound.Normal)
+ .Select(x => x.SampleSet)
+ .Distinct()
+ .ToList();
+
+ if (additionSampleSet.Count > 0 && additionSampleSet[0] != candidate.SampleSet)
+ {
+ message = "This timeline format only supports one addition sample set per timestamp/source.";
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void RebuildPointTimeCache(IReadOnlyList? points)
+ {
+ if (points is null || points.Count == 0)
+ {
+ _sortedPointTimeCache = [];
+ return;
+ }
+
+ var times = new int[points.Count];
+ for (var i = 0; i < points.Count; i++)
+ {
+ times[i] = points[i].TimeMs;
+ }
+
+ _sortedPointTimeCache = times;
+ }
+
+ private int CountVisiblePointsCached(double viewStartMs, double viewEndMs)
+ {
+ var times = _sortedPointTimeCache;
+ if (times.Length == 0)
+ {
+ return 0;
+ }
+
+ var minTimeMs = (int)Math.Floor(Math.Min(viewStartMs, viewEndMs));
+ var maxTimeMs = (int)Math.Ceiling(Math.Max(viewStartMs, viewEndMs));
+ var startIndex = LowerBound(times, minTimeMs);
+ var endIndexExclusive = UpperBound(times, maxTimeMs);
+ return Math.Max(0, endIndexExclusive - startIndex);
+ }
+
+ private static int LowerBound(int[] values, int target)
+ {
+ var lo = 0;
+ var hi = values.Length;
+ while (lo < hi)
+ {
+ var mid = lo + ((hi - lo) / 2);
+ if (values[mid] < target)
+ {
+ lo = mid + 1;
+ }
+ else
+ {
+ hi = mid;
+ }
+ }
+
+ return lo;
+ }
+
+ private static int UpperBound(int[] values, int target)
+ {
+ var lo = 0;
+ var hi = values.Length;
+ while (lo < hi)
+ {
+ var mid = lo + ((hi - lo) / 2);
+ if (values[mid] <= target)
+ {
+ lo = mid + 1;
+ }
+ else
+ {
+ hi = mid;
+ }
+ }
+
+ return lo;
+ }
+
+ private void NormalizeViewWindow()
+ {
+ ViewWindowMs = Math.Clamp(ViewWindowMs, 100, Math.Max(100, TimelineEndMs));
+ ViewStartMs = Math.Clamp(ViewStartMs, 0, Math.Max(0, TimelineEndMs - ViewWindowMs));
+ CursorTimeMs = Math.Clamp(CursorTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ PlaybackSeekMs = Math.Clamp(PlaybackSeekMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ ContextSamplePointTimeMs = Math.Clamp(ContextSamplePointTimeMs, 0, (int)Math.Ceiling(TimelineEndMs));
+ }
+
+ private void UpdateTimelineStats()
+ {
+ TimelineStats =
+ $"Points: {Points.Count} (hittables) | " +
+ $"Sample changes: {SampleChanges.Count} | Visible: {VisiblePointCount}";
+ }
+
+ private void UpdateSelectedPointSummary()
+ {
+ if (SelectedPointIds.Count > 1)
+ {
+ var primary = Points.FirstOrDefault(x => x.Id == SelectedPointId)
+ ?? Points.FirstOrDefault(x => x.Id == SelectedPointIds[0]);
+ SelectedPointSummary = primary is null
+ ? $"{SelectedPointIds.Count} points selected."
+ : $"{SelectedPointIds.Count} points selected | Primary: {primary.TimeMs}ms | {SampleSetToDisplay(primary.SampleSet)}-{HitSoundToDisplay(primary.HitSound).ToLowerInvariant()}";
+ return;
+ }
+
+ var point = Points.FirstOrDefault(x => x.Id == SelectedPointId);
+ SelectedPointSummary = point is null
+ ? "No point selected."
+ : $"{point.TimeMs}ms | {SampleSetToDisplay(point.SampleSet)}-{HitSoundToDisplay(point.HitSound).ToLowerInvariant()}";
+ }
+
+ private async Task PlayPointAsync(HitSoundVisualizerPoint point, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (TryPlayPointSample(point))
+ {
+ return;
+ }
+
+ await PlayToneFallbackAsync(point, cancellationToken);
+ }
+
+ private void PlayPointForPlayback(
+ HitSoundVisualizerPoint point,
+ IReadOnlyList playbackSampleChanges,
+ CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ try
+ {
+ if (TryPlayPointSample(point, playbackSampleChanges))
+ {
+ return;
+ }
+
+ _ = PlayToneFallbackAsync(point, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore point playback failures during playback preview.
+ }
+ }
+
+ private bool TryPlayPointSample(HitSoundVisualizerPoint point)
+ {
+ return TryPlayPointSample(point, SampleChanges);
+ }
+
+ private bool TryPlayPointSample(HitSoundVisualizerPoint point, IReadOnlyList sampleChanges)
+ {
+ var sampleState = ResolvePlaybackSampleState(point, sampleChanges);
+ var sampleFilePath = ResolveSampleFilePath(point, sampleState.Index);
+ var playbackBusKey = ResolvePlaybackBusKey(point);
+ var played = !string.IsNullOrWhiteSpace(sampleFilePath) &&
+ audioPlaybackService.PlayHitsound(sampleFilePath, sampleState.Volume / 100f, playbackBusKey);
+ RecordHitsoundPlaybackDebug(point, sampleState, sampleFilePath, played);
+ return played;
+ }
+
+ private string ResolveSampleFilePath(HitSoundVisualizerPoint point, int index)
+ {
+ var mapsetPath = ResolveSampleFilePathFromDirectoryCached(_loadedMapsetDirectoryPath, point.SampleSet, point.HitSound, index);
+ if (!string.IsNullOrWhiteSpace(mapsetPath))
+ {
+ return mapsetPath;
+ }
+
+ return ResolveSampleFilePathFromDirectoryCached(_legacySkinDirectoryPath, point.SampleSet, point.HitSound, index);
+ }
+
+ private static string ResolvePlaybackBusKey(HitSoundVisualizerPoint point)
+ {
+ var hitSoundFlags = (int)point.HitSound;
+ return point.HitSound switch
+ {
+ HitSound.Whistle => "hs-whistle",
+ HitSound.Finish => "hs-finish",
+ HitSound.Clap => "hs-clap",
+ _ when (hitSoundFlags & (int)HitSound.Clap) == (int)HitSound.Clap => "hs-clap",
+ _ when (hitSoundFlags & (int)HitSound.Finish) == (int)HitSound.Finish => "hs-finish",
+ _ when (hitSoundFlags & (int)HitSound.Whistle) == (int)HitSound.Whistle => "hs-whistle",
+ _ => "hs-normal"
+ };
+ }
+
+ private static PlaybackSampleState ResolvePlaybackSampleState(HitSoundVisualizerPoint point, IReadOnlyList sampleChanges)
+ {
+ var normalizedPointIndexOverride = NormalizePointSampleIndexOverride(point.SampleIndexOverride);
+ var normalizedPointVolumeOverride = NormalizePointSampleVolumeOverride(point.SampleVolumeOverridePercent);
+
+ if (sampleChanges.Count == 0)
+ {
+ var fallbackIndex = Math.Max(1, normalizedPointIndexOverride ?? 1);
+ var fallbackVolume = ResolveEffectivePlaybackVolume(normalizedPointVolumeOverride, 100);
+ return new PlaybackSampleState(
+ fallbackIndex,
+ fallbackVolume,
+ normalizedPointIndexOverride.HasValue,
+ normalizedPointVolumeOverride.HasValue);
+ }
+
+ var targetTimeMs = point.TimeMs;
+ var lo = 0;
+ var hi = sampleChanges.Count - 1;
+ var matched = -1;
+
+ while (lo <= hi)
+ {
+ var mid = lo + ((hi - lo) / 2);
+ if (sampleChanges[mid].TimeMs <= targetTimeMs)
+ {
+ matched = mid;
+ lo = mid + 1;
+ }
+ else
+ {
+ hi = mid - 1;
+ }
+ }
+
+ if (matched < 0)
+ {
+ var fallbackIndex = Math.Max(1, normalizedPointIndexOverride ?? 1);
+ var fallbackVolume = ResolveEffectivePlaybackVolume(normalizedPointVolumeOverride, 100);
+ return new PlaybackSampleState(
+ fallbackIndex,
+ fallbackVolume,
+ normalizedPointIndexOverride.HasValue,
+ normalizedPointVolumeOverride.HasValue);
+ }
+
+ var sampleChange = sampleChanges[matched];
+ var effectiveIndex = Math.Max(1, normalizedPointIndexOverride ?? sampleChange.Index);
+ var effectiveVolume = ResolveEffectivePlaybackVolume(
+ normalizedPointVolumeOverride,
+ Math.Clamp(sampleChange.Volume, 0, 100));
+
+ return new PlaybackSampleState(
+ effectiveIndex,
+ effectiveVolume,
+ normalizedPointIndexOverride.HasValue,
+ normalizedPointVolumeOverride.HasValue);
+ }
+
+ private readonly record struct PlaybackSampleState(
+ int Index,
+ int Volume,
+ bool UsedPointIndexOverride,
+ bool UsedPointVolumeOverride);
+
+ private static int ResolveEffectivePlaybackVolume(int? pointVolumeOverridePercent, int timelineVolumePercent)
+ {
+ if (pointVolumeOverridePercent is > 0)
+ {
+ return Math.Clamp(pointVolumeOverridePercent.Value, 1, 100);
+ }
+
+ // Timing-point volume 0 is treated as unspecified/default in playback context.
+ var normalizedTimelineVolume = timelineVolumePercent <= 0 ? 100 : timelineVolumePercent;
+ return Math.Clamp(normalizedTimelineVolume, 1, 100);
+ }
+
+ private static int? NormalizePointSampleIndexOverride(int? value)
+ {
+ return value is > 0 ? value.Value : null;
+ }
+
+ private static int? NormalizePointSampleVolumeOverride(int? value)
+ {
+ return value is > 0 ? Math.Clamp(value.Value, 1, 100) : null;
+ }
+
+ private void ResetHitsoundPlaybackDebugTelemetry()
+ {
+ lock (_hitsoundDebugSync)
+ {
+ _hitsoundDebugAttemptCount = 0;
+ _hitsoundDebugSuccessCount = 0;
+ _hitsoundDebugFailureCount = 0;
+ _hitsoundDebugLastPointTimeMs = 0;
+ _hitsoundDebugLastResolvedIndex = 1;
+ _hitsoundDebugLastResolvedVolume = 100;
+ _hitsoundDebugLastUsedPointIndexOverride = false;
+ _hitsoundDebugLastUsedPointVolumeOverride = false;
+ _hitsoundDebugLastSampleName = "-";
+ _hitsoundDebugRecentSuccessTickMs.Clear();
+ }
+ }
+
+ private void RecordHitsoundPlaybackDebug(
+ HitSoundVisualizerPoint point,
+ PlaybackSampleState sampleState,
+ string sampleFilePath,
+ bool played)
+ {
+ var nowTickMs = Environment.TickCount64;
+ lock (_hitsoundDebugSync)
+ {
+ _hitsoundDebugAttemptCount++;
+ if (played)
+ {
+ _hitsoundDebugSuccessCount++;
+ _hitsoundDebugRecentSuccessTickMs.Enqueue(nowTickMs);
+ }
+ else
+ {
+ _hitsoundDebugFailureCount++;
+ }
+
+ _hitsoundDebugLastPointTimeMs = point.TimeMs;
+ _hitsoundDebugLastResolvedIndex = sampleState.Index;
+ _hitsoundDebugLastResolvedVolume = sampleState.Volume;
+ _hitsoundDebugLastUsedPointIndexOverride = sampleState.UsedPointIndexOverride;
+ _hitsoundDebugLastUsedPointVolumeOverride = sampleState.UsedPointVolumeOverride;
+ _hitsoundDebugLastSampleName = string.IsNullOrWhiteSpace(sampleFilePath)
+ ? "-"
+ : Path.GetFileName(sampleFilePath);
+
+ PruneHitsoundDebugWindow(nowTickMs);
+ }
+ }
+
+ private string BuildHitsoundDebugStatus()
+ {
+ lock (_hitsoundDebugSync)
+ {
+ PruneHitsoundDebugWindow(Environment.TickCount64);
+ var activeVoicesApprox = _hitsoundDebugRecentSuccessTickMs.Count;
+ var indexSourceTag = _hitsoundDebugLastUsedPointIndexOverride ? "obj" : "timing";
+ var volumeSourceTag = _hitsoundDebugLastUsedPointVolumeOverride ? "obj" : "timing";
+
+ return $"HSDbg | ok {_hitsoundDebugSuccessCount}/{_hitsoundDebugAttemptCount} fail {_hitsoundDebugFailureCount} " +
+ $"active~{activeVoicesApprox} " +
+ $"last t {_hitsoundDebugLastPointTimeMs}ms idx {_hitsoundDebugLastResolvedIndex}({indexSourceTag}) " +
+ $"vol {_hitsoundDebugLastResolvedVolume}%({volumeSourceTag}) file {_hitsoundDebugLastSampleName}";
+ }
+ }
+
+ private void PruneHitsoundDebugWindow(long nowTickMs)
+ {
+ while (_hitsoundDebugRecentSuccessTickMs.Count > 0 &&
+ nowTickMs - _hitsoundDebugRecentSuccessTickMs.Peek() > HitsoundDebugActiveWindowMs)
+ {
+ _hitsoundDebugRecentSuccessTickMs.Dequeue();
+ }
+ }
+
+ private string ResolveSampleFilePathFromDirectoryCached(string directoryPath, SampleSet sampleSet, HitSound hitSound, int index)
+ {
+ if (string.IsNullOrWhiteSpace(directoryPath))
+ {
+ return string.Empty;
+ }
+
+ var cacheKey = $"{directoryPath}|{(int)sampleSet}|{(int)hitSound}|{Math.Max(1, index)}";
+ return _resolvedPlaybackSamplePathCache.GetOrAdd(
+ cacheKey,
+ _ => ResolveSampleFilePathFromDirectoryUncached(directoryPath, sampleSet, hitSound, index));
+ }
+
+ private string ResolveSampleFilePathFromDirectoryUncached(string directoryPath, SampleSet sampleSet, HitSound hitSound, int index)
+ {
+ if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath))
+ {
+ return string.Empty;
+ }
+
+ var baseName = BuildSampleBaseName(sampleSet, hitSound, index);
+ foreach (var extension in _sampleExtensions)
+ {
+ var path = Path.Combine(directoryPath, baseName + extension);
+ if (File.Exists(path))
+ {
+ return path;
+ }
+ }
+
+ if (index <= 1)
+ {
+ return string.Empty;
+ }
+
+ var fallbackBaseName = BuildSampleBaseName(sampleSet, hitSound, 1);
+ foreach (var extension in _sampleExtensions)
+ {
+ var path = Path.Combine(directoryPath, fallbackBaseName + extension);
+ if (File.Exists(path))
+ {
+ return path;
+ }
+ }
+
+ return string.Empty;
+ }
+
+ private static string BuildSampleBaseName(SampleSet sampleSet, HitSound hitSound, int index)
+ {
+ var prefix = sampleSet switch
+ {
+ SampleSet.Soft => "soft",
+ SampleSet.Drum => "drum",
+ _ => "normal"
+ };
+
+ var hitSoundFlags = (int)hitSound;
+ var suffix = hitSound switch
+ {
+ HitSound.Whistle => "hitwhistle",
+ HitSound.Finish => "hitfinish",
+ HitSound.Clap => "hitclap",
+ _ when (hitSoundFlags & (int)HitSound.Clap) == (int)HitSound.Clap => "hitclap",
+ _ when (hitSoundFlags & (int)HitSound.Finish) == (int)HitSound.Finish => "hitfinish",
+ _ when (hitSoundFlags & (int)HitSound.Whistle) == (int)HitSound.Whistle => "hitwhistle",
+ _ => "hitnormal"
+ };
+
+ var indexSuffix = index > 1 ? index.ToString() : string.Empty;
+ return $"{prefix}-{suffix}{indexSuffix}";
+ }
+
+ private static async Task PlayToneFallbackAsync(HitSoundVisualizerPoint point, CancellationToken cancellationToken)
+ {
+ var frequency = (point.SampleSet, point.HitSound) switch
+ {
+ (SampleSet.Normal, HitSound.Normal) => 900,
+ (SampleSet.Normal, HitSound.Whistle) => 1200,
+ (SampleSet.Normal, HitSound.Finish) => 700,
+ (SampleSet.Normal, HitSound.Clap) => 1000,
+ (SampleSet.Soft, HitSound.Normal) => 650,
+ (SampleSet.Soft, HitSound.Whistle) => 850,
+ (SampleSet.Soft, HitSound.Finish) => 500,
+ (SampleSet.Soft, HitSound.Clap) => 760,
+ (SampleSet.Drum, HitSound.Normal) => 420,
+ (SampleSet.Drum, HitSound.Whistle) => 560,
+ (SampleSet.Drum, HitSound.Finish) => 320,
+ (SampleSet.Drum, HitSound.Clap) => 620,
+ _ => 880
+ };
+
+ const int duration = 26;
+
+ try
+ {
+ await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ Console.Beep(frequency, duration);
+ }, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Silent fallback when local platform cannot emit beeps.
+ }
+ }
+
+ private static string FormatTimeLabel(int timeMs)
+ {
+ var clamped = Math.Max(0, timeMs);
+ var ts = TimeSpan.FromMilliseconds(clamped);
+ return ts.TotalHours >= 1
+ ? $"{(int)ts.TotalHours}:{ts.Minutes:00}:{ts.Seconds:00}.{ts.Milliseconds:000}"
+ : $"{ts.Minutes}:{ts.Seconds:00}.{ts.Milliseconds:000}";
+ }
+
+ private void PersistVisualizerVolumeSettings()
+ {
+ try
+ {
+ var settings = settingsService.GetMainSettings();
+ var songVolume = Math.Clamp(SongVolumePercent, 0, 100);
+ var hitsoundVolume = Math.Clamp(HitSoundVolumePercent, 0, 100);
+
+ if (settings.AudioPreviewSongVolumePercent == songVolume &&
+ settings.AudioPreviewHitSoundVolumePercent == hitsoundVolume)
+ {
+ return;
+ }
+
+ settings.AudioPreviewSongVolumePercent = songVolume;
+ settings.AudioPreviewHitSoundVolumePercent = hitsoundVolume;
+ settingsService.SaveMainSettings(settings);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ // Ignore settings persistence failures to avoid breaking volume interaction.
+ }
+ }
+
+ private static int LoadPersistedSongVolumePercent(ISettingsService settingsService)
+ {
+ try
+ {
+ var settings = settingsService.GetMainSettings();
+ return Math.Clamp(settings.AudioPreviewSongVolumePercent, 0, 100);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return 80;
+ }
+ }
+
+ private static int LoadPersistedHitSoundVolumePercent(ISettingsService settingsService)
+ {
+ try
+ {
+ var settings = settingsService.GetMainSettings();
+ return Math.Clamp(settings.AudioPreviewHitSoundVolumePercent, 0, 100);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ return 100;
+ }
+ }
+
+ private sealed class TimelineHistoryState(
+ IReadOnlyList points,
+ IReadOnlyList sampleChanges,
+ int cursorTimeMs,
+ int contextSamplePointTimeMs,
+ bool isSampleRowContextActive,
+ int selectedPointId,
+ IReadOnlyList selectedPointIds)
+ {
+ public IReadOnlyList Points { get; } = points;
+ public IReadOnlyList SampleChanges { get; } = sampleChanges;
+ public int CursorTimeMs { get; } = cursorTimeMs;
+ public int ContextSamplePointTimeMs { get; } = contextSamplePointTimeMs;
+ public bool IsSampleRowContextActive { get; } = isSampleRowContextActive;
+ public int SelectedPointId { get; } = selectedPointId;
+ public IReadOnlyList SelectedPointIds { get; } = selectedPointIds;
+ }
+
+ private sealed class PointClipboardPayload
+ {
+ public string Format { get; set; } = string.Empty;
+ public List Points { get; set; } = [];
+ }
+
+ private sealed class PointClipboardItem
+ {
+ public int OffsetMs { get; set; }
+ public string? SampleSet { get; set; }
+ public string? HitSound { get; set; }
+ }
+
+ private static bool IsAutoSampleSetDisplay(string value)
+ {
+ return string.Equals(value, "Auto", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private SampleSet ResolveAutoSampleSetForPointAtTime(HitSound hitSound, int timeMs)
+ {
+ var sameTimePoints = Points
+ .Where(point => point.TimeMs == timeMs)
+ .ToList();
+
+ var normalFromColumn = sameTimePoints.FirstOrDefault(point => point.HitSound == HitSound.Normal)?.SampleSet;
+ var resolvedNormal = normalFromColumn ?? ResolveHeaderBankAtTime(isNormalBank: true, timeMs);
+
+ if (hitSound == HitSound.Normal)
+ {
+ return resolvedNormal;
+ }
+
+ var additionFromColumn = sameTimePoints.FirstOrDefault(point => point.HitSound != HitSound.Normal)?.SampleSet;
+ return additionFromColumn ?? ResolveHeaderBankAtTime(isNormalBank: false, timeMs, resolvedNormal);
+ }
+
+ private static SampleSet ParseSampleSet(string display) => display switch
+ {
+ "Soft" => SampleSet.Soft,
+ "Drum" => SampleSet.Drum,
+ _ => SampleSet.Normal
+ };
+
+ private static string SampleSetToDisplay(SampleSet sampleSet) => sampleSet switch
+ {
+ SampleSet.Soft => "Soft",
+ SampleSet.Drum => "Drum",
+ _ => "Normal"
+ };
+
+ private static HitSound ParseHitSound(string display) => display switch
+ {
+ "Whistle" => HitSound.Whistle,
+ "Finish" => HitSound.Finish,
+ "Clap" => HitSound.Clap,
+ _ => HitSound.Normal
+ };
+
+ private static string HitSoundToDisplay(HitSound hitSound) => hitSound switch
+ {
+ HitSound.Whistle => "Whistle",
+ HitSound.Finish => "Finish",
+ HitSound.Clap => "Clap",
+ _ => "Hitnormal"
+ };
+
+ private static int HitSoundSortOrder(HitSound hitSound) => hitSound switch
+ {
+ HitSound.Normal => 0,
+ HitSound.Whistle => 1,
+ HitSound.Finish => 2,
+ HitSound.Clap => 3,
+ _ => 9
+ };
+
+ private static int SampleSetSortOrder(SampleSet sampleSet) => sampleSet switch
+ {
+ SampleSet.Normal => 0,
+ SampleSet.Soft => 1,
+ SampleSet.Drum => 2,
+ _ => 9
+ };
+}
diff --git a/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs b/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs
index 13bdf4e..6d9488e 100644
--- a/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/MapWizard.Desktop/ViewModels/MainWindowViewModel.cs
@@ -1,4 +1,5 @@
using System;
+using System.ComponentModel;
using Avalonia.Controls.Notifications;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -12,18 +13,19 @@ namespace MapWizard.Desktop.ViewModels
{
public partial class MainWindowViewModel : ObservableObject
{
- public ISukiToastManager ToastManager { get; }
+ private ISukiToastManager ToastManager { get; }
public ISukiDialogManager DialogManager { get; }
private readonly IThemeService _themeService;
private bool _isUpdatingFromThemeService;
- public ViewModelBase HitSoundCopierViewModel { get; }
- public ViewModelBase MetadataManagerViewModel { get; }
- public ViewModelBase ComboColourStudioViewModel { get; }
- public ViewModelBase MapCleanerViewModel { get; }
- public ViewModelBase WelcomePageViewModel { get; }
- public ViewModelBase SettingsViewModel { get; }
+ private ViewModelBase HitSoundCopierViewModel { get; }
+ private ViewModelBase HitSoundVisualizerViewModel { get; }
+ private ViewModelBase MetadataManagerViewModel { get; }
+ private ViewModelBase ComboColourStudioViewModel { get; }
+ private ViewModelBase MapCleanerViewModel { get; }
+ private ViewModelBase WelcomePageViewModel { get; }
+ private ViewModelBase SettingsPageViewModel { get; }
[ObservableProperty]
private bool _isDarkTheme;
@@ -43,6 +45,12 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
private bool _isHitSoundCopierSelected;
+ [ObservableProperty]
+ private bool _isHitSoundVisualizerSelected;
+
+ [ObservableProperty]
+ private bool _isHitSoundVisualizerEnabled;
+
[ObservableProperty]
private bool _isMetadataManagerSelected;
@@ -69,6 +77,7 @@ partial void OnIsDarkThemeChanged(bool value)
public MainWindowViewModel(
WelcomePageViewModel welcomePageViewModel,
HitSoundCopierViewModel hitSoundCopierViewModel,
+ HitSoundVisualizerViewModel hitSoundVisualizerViewModel,
MetadataManagerViewModel metadataManagerViewModel,
ComboColourStudioViewModel comboColourStudioViewModel,
MapCleanerViewModel mapCleanerViewModel,
@@ -82,11 +91,12 @@ public MainWindowViewModel(
ToastManager = toastManager;
DialogManager = dialogManager;
HitSoundCopierViewModel = hitSoundCopierViewModel;
+ HitSoundVisualizerViewModel = hitSoundVisualizerViewModel;
MetadataManagerViewModel = metadataManagerViewModel;
ComboColourStudioViewModel = comboColourStudioViewModel;
MapCleanerViewModel = mapCleanerViewModel;
WelcomePageViewModel = welcomePageViewModel;
- SettingsViewModel = settingsViewModel;
+ SettingsPageViewModel = settingsViewModel;
CurrentPageViewModel = WelcomePageViewModel;
Version = updateService.VersionLabel;
@@ -96,6 +106,8 @@ public MainWindowViewModel(
SetPage(NavigationPage.Welcome);
settingsViewModel.Initialize();
+ UpdateHitSoundVisualizerAvailability(settingsViewModel.IsHitSoundVisualizerEnabled);
+ settingsViewModel.PropertyChanged += OnSettingsViewModelPropertyChanged;
_ = welcomePageViewModel.CheckForUpdatesOnStartupAsync();
}
@@ -103,6 +115,10 @@ public MainWindowViewModel(
public void NavigateToHitSoundCopier() => SetPage(NavigationPage.HitSoundCopier);
+ public void NavigateToHitSoundVisualizer() => SetPage(IsHitSoundVisualizerEnabled
+ ? NavigationPage.HitSoundVisualizer
+ : NavigationPage.Welcome);
+
public void NavigateToMetadataManager() => SetPage(NavigationPage.MetadataManager);
public void NavigateToComboColourStudio() => SetPage(NavigationPage.ComboColourStudio);
@@ -113,19 +129,26 @@ public MainWindowViewModel(
private void SetPage(NavigationPage page)
{
+ if (page == NavigationPage.HitSoundVisualizer && !IsHitSoundVisualizerEnabled)
+ {
+ page = NavigationPage.Welcome;
+ }
+
CurrentPageViewModel = page switch
{
NavigationPage.Welcome => WelcomePageViewModel,
NavigationPage.HitSoundCopier => HitSoundCopierViewModel,
+ NavigationPage.HitSoundVisualizer => HitSoundVisualizerViewModel,
NavigationPage.MetadataManager => MetadataManagerViewModel,
NavigationPage.ComboColourStudio => ComboColourStudioViewModel,
NavigationPage.MapCleaner => MapCleanerViewModel,
- NavigationPage.Settings => SettingsViewModel,
+ NavigationPage.Settings => SettingsPageViewModel,
_ => WelcomePageViewModel
};
IsWelcomeSelected = page == NavigationPage.Welcome;
IsHitSoundCopierSelected = page == NavigationPage.HitSoundCopier;
+ IsHitSoundVisualizerSelected = page == NavigationPage.HitSoundVisualizer;
IsMetadataManagerSelected = page == NavigationPage.MetadataManager;
IsComboColourStudioSelected = page == NavigationPage.ComboColourStudio;
IsMapCleanerSelected = page == NavigationPage.MapCleaner;
@@ -157,6 +180,14 @@ private void OpenHitSoundCopier()
SetPage(NavigationPage.HitSoundCopier);
}
+ [RelayCommand]
+ private void OpenHitSoundVisualizer()
+ {
+ SetPage(IsHitSoundVisualizerEnabled
+ ? NavigationPage.HitSoundVisualizer
+ : NavigationPage.Welcome);
+ }
+
[RelayCommand]
private void OpenMetadataManager()
{
@@ -206,5 +237,28 @@ private void OpenGithub()
.Queue();
}
}
+
+ private void OnSettingsViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(MapWizard.Desktop.ViewModels.SettingsViewModel.IsHitSoundVisualizerEnabled))
+ {
+ return;
+ }
+
+ if (sender is SettingsViewModel settingsViewModel)
+ {
+ UpdateHitSoundVisualizerAvailability(settingsViewModel.IsHitSoundVisualizerEnabled);
+ }
+ }
+
+ private void UpdateHitSoundVisualizerAvailability(bool isEnabled)
+ {
+ IsHitSoundVisualizerEnabled = isEnabled;
+
+ if (!isEnabled && CurrentPageViewModel == HitSoundVisualizerViewModel)
+ {
+ SetPage(NavigationPage.Welcome);
+ }
+ }
}
}
diff --git a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs
index 4d7c920..e82db27 100644
--- a/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs
+++ b/MapWizard.Desktop/ViewModels/MapCleanerViewModel.cs
@@ -14,6 +14,8 @@
using MapWizard.Desktop.Extensions;
using MapWizard.Desktop.Models;
using MapWizard.Desktop.Services;
+using MapWizard.Desktop.Services.MapCleanerService;
+using MapWizard.Desktop.Services.MemoryService;
using MapWizard.Desktop.Utils;
using MapWizard.Tools.MapCleaner;
using SukiUI.Dialogs;
@@ -92,6 +94,19 @@ private void RemoveMap(string _)
{
}
+ [RelayCommand]
+ private void SelectOriginMap(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) ||
+ string.Equals(OriginBeatmap.Path, path, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ SetOriginBeatmapPath(path);
+ LoadOriginBeatmapHeader();
+ }
+
[RelayCommand]
private async Task PickOriginFile(CancellationToken token)
{
@@ -111,6 +126,7 @@ private async Task PickOriginFile(CancellationToken token)
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
toastManager.ShowToast(NotificationType.Error, "Map Cleaner", ex.Message);
}
}
@@ -279,8 +295,9 @@ private void LoadOriginBeatmapHeader()
HeaderBackgroundImage = new Bitmap(resolvedBackgroundPath);
}
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
ClearOriginBeatmapHeader();
}
}
diff --git a/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs b/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs
index c8b5e6f..291dd6b 100644
--- a/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs
+++ b/MapWizard.Desktop/ViewModels/MetadataManagerViewModel.cs
@@ -16,6 +16,8 @@
using MapWizard.Desktop.Extensions;
using MapWizard.Desktop.Models;
using MapWizard.Desktop.Services;
+using MapWizard.Desktop.Services.MemoryService;
+using MapWizard.Desktop.Services.MetadataService;
using MapWizard.Desktop.Utils;
using MapWizard.Tools.MetadataManager;
using SukiUI.Dialogs;
@@ -101,6 +103,26 @@ private void ToggleDestinationMap(string path)
HasMultiple = BeatmapPanelViewModelUtils.HasMultipleDestinationBeatmaps(DestinationBeatmaps);
}
+ [RelayCommand]
+ private async Task SelectOriginMap(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) ||
+ string.Equals(OriginBeatmap.Path, path, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ try
+ {
+ await SetOriginBeatmapPath(path, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ toastManager.ShowToast(NotificationType.Error, "Metadata Manager", ex.Message);
+ }
+ }
+
private async Task ImportMetadataFromOriginAsync(CancellationToken token)
{
var origin = OriginBeatmap.Path;
@@ -157,9 +179,10 @@ private async Task ImportMetadataFromOriginAsync(CancellationToken token)
toastManager.ShowToast(NotificationType.Success, "Import Success", "Successfully imported metadata!");
}
- catch (Exception exception)
+ catch (Exception ex)
{
- Console.WriteLine(exception.Message);
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ Console.WriteLine(ex.Message);
ClearOriginBeatmapHeader();
toastManager.ShowToast(NotificationType.Error, "Import Error", "Failed to import metadata!");
}
@@ -232,9 +255,10 @@ private void RemoveColour(AvaloniaComboColour colour)
Metadata.Colours[i].Number = i + 1;
}
}
- catch (Exception exception)
+ catch (Exception ex)
{
- Console.WriteLine(exception.Message);
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ Console.WriteLine(ex.Message);
}
}
@@ -260,9 +284,10 @@ private async Task PickOriginFile(CancellationToken token)
await SetOriginBeatmapPath(selectedPaths[0], token);
}
- catch (Exception exception)
+ catch (Exception ex)
{
- Console.WriteLine(exception.Message);
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ Console.WriteLine(ex.Message);
}
}
@@ -282,9 +307,10 @@ private async Task PickDestinationFile(CancellationToken token)
SetDestinationBeatmaps(selectedPaths);
}
- catch (Exception exception)
+ catch (Exception ex)
{
- Console.WriteLine(exception.Message);
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ Console.WriteLine(ex.Message);
}
}
@@ -470,10 +496,11 @@ private void ApplyMetadata()
message = $"Successfully exported metadata to {destinationPaths.Length} beatmap(s)!";
type = NotificationType.Success;
}
- catch (Exception exception)
+ catch (Exception ex)
{
- Console.WriteLine(exception);
- message = exception.Message;
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ Console.WriteLine(ex);
+ message = ex.Message;
type = NotificationType.Error;
}
}
diff --git a/MapWizard.Desktop/ViewModels/SettingsViewModel.cs b/MapWizard.Desktop/ViewModels/SettingsViewModel.cs
index abcd607..bd3ee1e 100644
--- a/MapWizard.Desktop/ViewModels/SettingsViewModel.cs
+++ b/MapWizard.Desktop/ViewModels/SettingsViewModel.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
@@ -7,6 +9,7 @@
using CommunityToolkit.Mvvm.Input;
using MapWizard.Desktop.Models.Settings;
using MapWizard.Desktop.Services;
+using MapWizard.Desktop.Services.Playback;
using Velopack;
namespace MapWizard.Desktop.ViewModels;
@@ -16,17 +19,19 @@ public partial class SettingsViewModel(
ISettingsService settingsService,
IFilesService filesService,
ISongLibraryService songLibraryService,
- IUpdateService updateService) : ViewModelBase
+ IUpdateService updateService,
+ IAudioPlaybackService audioPlaybackService) : ViewModelBase
{
private bool _isUpdatingFromThemeService;
private bool _isUpdatingSongsPath;
+ private bool _isLoadingMainSettings;
private int _updateStatusRequestId;
private UpdateInfo? _availableUpdate;
private bool _isUpdateActionRunning;
-
+
[ObservableProperty]
private ThemeMode _selectedThemeMode;
-
+
[ObservableProperty]
private UpdateStream _updateStream;
@@ -42,19 +47,44 @@ public partial class SettingsViewModel(
[ObservableProperty]
private string _songsPathStatusText = "Songs folder not configured.";
+ [ObservableProperty]
+ private bool _isHitSoundVisualizerEnabled;
+
+ [ObservableProperty]
+ private int _audioPreviewSongVolumePercent = 80;
+
+ [ObservableProperty]
+ private int _audioPreviewHitSoundVolumePercent = 100;
+
+ [ObservableProperty]
+ private IReadOnlyList _audioOutputDevices = Array.Empty();
+
+ [ObservableProperty]
+ private AudioOutputDeviceOption? _selectedAudioOutputDevice;
+
+ [ObservableProperty]
+ private string _audioOutputDeviceStatusText = "Using system default output device.";
+
public string ConfigDirectoryPath { get; } = settingsService.ConfigDirectoryPath;
public UpdateStream[] UpdateStreams { get; } = [UpdateStream.Release, UpdateStream.PreRelease];
public ThemeMode[] ThemeModes { get; } = [ThemeMode.System, ThemeMode.Light, ThemeMode.Dark];
public void Initialize()
{
+ LoadMainSettingsValues();
UpdateThemeState(themeService.ThemeMode);
themeService.ThemeModeChanged += OnThemeModeChanged;
UpdateStream = updateService.CurrentStream;
InitializeSongsPath();
+ LoadAudioOutputDevices();
_ = RefreshUpdateStreamBadgeAsync();
}
+ public void RefreshPersistedValues()
+ {
+ LoadMainSettingsValues();
+ }
+
private void OnThemeModeChanged(object? sender, ThemeMode themeMode)
{
UpdateThemeState(themeMode);
@@ -97,6 +127,67 @@ partial void OnSongsPathChanged(string value)
: "Folder not found. Map Picker will use manual picker fallback.";
}
+ partial void OnIsHitSoundVisualizerEnabledChanged(bool value)
+ {
+ if (_isLoadingMainSettings)
+ {
+ return;
+ }
+
+ SaveHitSoundVisualizerEnabled(value);
+ }
+
+ partial void OnAudioPreviewSongVolumePercentChanged(int value)
+ {
+ AudioPreviewSongVolumePercent = Math.Clamp(value, 0, 100);
+ if (_isLoadingMainSettings)
+ {
+ return;
+ }
+
+ SaveAudioPreviewVolumeDefaults();
+ }
+
+ partial void OnAudioPreviewHitSoundVolumePercentChanged(int value)
+ {
+ AudioPreviewHitSoundVolumePercent = Math.Clamp(value, 0, 100);
+ if (_isLoadingMainSettings)
+ {
+ return;
+ }
+
+ SaveAudioPreviewVolumeDefaults();
+ }
+
+ partial void OnSelectedAudioOutputDeviceChanged(AudioOutputDeviceOption? value)
+ {
+ if (_isLoadingMainSettings || value is null)
+ {
+ return;
+ }
+
+ var applied = false;
+ try
+ {
+ applied = audioPlaybackService.SetSelectedAudioOutputDevice(value.Id);
+ }
+ catch (Exception ex)
+ {
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ applied = false;
+ }
+
+ if (!applied)
+ {
+ AudioOutputDeviceStatusText = "Could not switch audio output device. Reverting selection.";
+ LoadAudioOutputDevices();
+ return;
+ }
+
+ SaveAudioOutputDevice(value.Id);
+ AudioOutputDeviceStatusText = $"Using {value.DisplayName}.";
+ }
+
[RelayCommand]
private async Task RestartToApplyUpdate(CancellationToken cancellationToken)
{
@@ -131,12 +222,14 @@ private async Task RestartToApplyUpdate(CancellationToken cancellationToken)
_availableUpdate = null;
await RefreshUpdateStreamBadgeAsync();
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
await RefreshUpdateStreamBadgeAsync();
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
UpdateStreamBadgeText = "Could not download update right now.";
CanRestartToApplyUpdate = true;
}
@@ -221,8 +314,9 @@ private async Task RefreshUpdateStreamBadgeAsync()
_availableUpdate = update;
CanRestartToApplyUpdate = update is not null;
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
if (requestId != _updateStatusRequestId)
{
return;
@@ -261,6 +355,55 @@ private void InitializeSongsPath()
SongsPathStatusText = "Songs folder not found. Use Browse or Auto Detect.";
}
+ private void LoadMainSettingsValues()
+ {
+ _isLoadingMainSettings = true;
+ try
+ {
+ var settings = settingsService.GetMainSettings();
+ IsHitSoundVisualizerEnabled = settings.EnableHitSoundVisualizer;
+ AudioPreviewSongVolumePercent = Math.Clamp(settings.AudioPreviewSongVolumePercent, 0, 100);
+ AudioPreviewHitSoundVolumePercent = Math.Clamp(settings.AudioPreviewHitSoundVolumePercent, 0, 100);
+ }
+ finally
+ {
+ _isLoadingMainSettings = false;
+ }
+ }
+
+ private void LoadAudioOutputDevices()
+ {
+ _isLoadingMainSettings = true;
+ try
+ {
+ var settings = settingsService.GetMainSettings();
+ var devices = audioPlaybackService.GetAudioOutputDevices();
+ AudioOutputDevices = devices;
+
+ var preferredId = string.IsNullOrWhiteSpace(settings.AudioOutputDeviceId)
+ ? audioPlaybackService.GetSelectedAudioOutputDeviceId()
+ : settings.AudioOutputDeviceId;
+
+ var selected = devices.FirstOrDefault(x => string.Equals(x.Id, preferredId, StringComparison.Ordinal))
+ ?? devices.FirstOrDefault(x => string.Equals(x.Id, audioPlaybackService.GetSelectedAudioOutputDeviceId(), StringComparison.Ordinal))
+ ?? devices.FirstOrDefault();
+
+ if (selected is not null)
+ {
+ SelectedAudioOutputDevice = selected;
+ AudioOutputDeviceStatusText = $"Using {selected.DisplayName}.";
+ }
+ else
+ {
+ AudioOutputDeviceStatusText = "No audio output devices available.";
+ }
+ }
+ finally
+ {
+ _isLoadingMainSettings = false;
+ }
+ }
+
private void SetSongsPath(string path, bool persist)
{
_isUpdatingSongsPath = true;
@@ -285,6 +428,48 @@ private void SaveSongsPath(string path)
settingsService.SaveMainSettings(settings);
}
+ private void SaveHitSoundVisualizerEnabled(bool enabled)
+ {
+ var settings = settingsService.GetMainSettings();
+ if (settings.EnableHitSoundVisualizer == enabled)
+ {
+ return;
+ }
+
+ settings.EnableHitSoundVisualizer = enabled;
+ settingsService.SaveMainSettings(settings);
+ }
+
+ private void SaveAudioPreviewVolumeDefaults()
+ {
+ var settings = settingsService.GetMainSettings();
+ var songVolume = Math.Clamp(AudioPreviewSongVolumePercent, 0, 100);
+ var hitsoundVolume = Math.Clamp(AudioPreviewHitSoundVolumePercent, 0, 100);
+
+ if (settings.AudioPreviewSongVolumePercent == songVolume &&
+ settings.AudioPreviewHitSoundVolumePercent == hitsoundVolume)
+ {
+ return;
+ }
+
+ settings.AudioPreviewSongVolumePercent = songVolume;
+ settings.AudioPreviewHitSoundVolumePercent = hitsoundVolume;
+ settingsService.SaveMainSettings(settings);
+ }
+
+ private void SaveAudioOutputDevice(string deviceId)
+ {
+ var normalizedDeviceId = string.IsNullOrWhiteSpace(deviceId) ? "default" : deviceId.Trim();
+ var settings = settingsService.GetMainSettings();
+ if (string.Equals(settings.AudioOutputDeviceId, normalizedDeviceId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ settings.AudioOutputDeviceId = normalizedDeviceId;
+ settingsService.SaveMainSettings(settings);
+ }
+
private static string NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -297,9 +482,11 @@ private static string NormalizePath(string? path)
{
return Path.GetFullPath(trimmed);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return trimmed;
}
}
+
}
diff --git a/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs b/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs
index 23bb410..211519a 100644
--- a/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs
+++ b/MapWizard.Desktop/ViewModels/SongSelectDialogViewModel.cs
@@ -49,14 +49,18 @@ public partial class SongSelectDialogViewModel(
private SongMapsetCardViewModel? _lastToggledMapset;
private DateTime _lastMapsetToggleUtc = DateTime.MinValue;
- [ObservableProperty] private bool _isLoading;
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(ReloadLibraryCommand))]
+ private bool _isLoading;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowBusyBox))]
+ [NotifyCanExecuteChangedFor(nameof(ReloadLibraryCommand))]
private bool _isBusyAreaActive;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowBusyBox))]
+ [NotifyCanExecuteChangedFor(nameof(ReloadLibraryCommand))]
private bool _isSearchPending;
[ObservableProperty] private string? _searchText = string.Empty;
@@ -74,6 +78,7 @@ public partial class SongSelectDialogViewModel(
VisibleMapsets.Count == 0 &&
GetSearchQuery().Length > 0;
public bool CanConfirmSelection => AllowMultipleSelection && SelectedDifficultyCount > 0;
+ public bool CanReloadLibrary => HasSongsPath && !IsBusyAreaActive && !IsLoading && !IsSearchPending;
public event Action>? SelectionSubmitted;
@@ -144,13 +149,15 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default)
StatusMessage = $"Found {_mapsetDirectories.Count} mapset folders. Loading recent entries...";
await StartSearchFromBeginningAsync(cancellationToken);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
StatusMessage = "Song indexing was cancelled.";
}
- catch (Exception exception)
+ catch (Exception ex)
{
- StatusMessage = $"Failed to prepare song list: {exception.Message}";
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ StatusMessage = $"Failed to prepare song list: {ex.Message}";
}
finally
{
@@ -266,6 +273,15 @@ private async Task PickFilesManually(CancellationToken cancellationToken)
}
}
+ [RelayCommand(CanExecute = nameof(CanReloadLibrary))]
+ private async Task ReloadLibrary(CancellationToken cancellationToken)
+ {
+ CancelPendingSearchOperations();
+ songLibraryService.InvalidateCache(songsPath);
+ ClearMapsetViewModelCache();
+ await InitializeAsync(cancellationToken);
+ }
+
[RelayCommand]
private void ConfirmSelection()
{
@@ -345,9 +361,10 @@ private async Task LoadMoreFromScrollAsync(int queryVersion)
{
await LoadNextPageAsync(queryVersion, CancellationToken.None);
}
- catch (Exception exception)
+ catch (Exception ex)
{
- StatusMessage = $"Failed to load more mapsets: {exception.Message}";
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
+ StatusMessage = $"Failed to load more mapsets: {ex.Message}";
}
finally
{
@@ -386,8 +403,9 @@ private async Task DebouncedSearchAsync(CancellationTokenSource debounceCts)
executionCts.Dispose();
}
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// A newer keystroke superseded this pending search.
}
finally
@@ -527,8 +545,9 @@ private async Task LoadNextPageAsync(int queryVersion, CancellationToken cancell
UpdateBackgroundCacheForCurrentViewport();
UpdateStatusMessage();
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
// Expected during debounced typing or dialog dismissal.
}
finally
@@ -648,8 +667,9 @@ private void PrioritizePreferredMapsetEntry()
{
return Path.GetFullPath(path);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return null;
}
}
@@ -741,6 +761,45 @@ private void DeactivateAllCachedBackgrounds()
}
}
+ private void CancelPendingSearchOperations()
+ {
+ _searchDebounceCts?.Cancel();
+ _searchDebounceCts?.Dispose();
+ _searchDebounceCts = null;
+
+ _searchExecutionCts?.Cancel();
+ _searchExecutionCts?.Dispose();
+ _searchExecutionCts = null;
+
+ _isScrollLoadScheduled = false;
+ IsSearchPending = false;
+ }
+
+ private void ClearMapsetViewModelCache()
+ {
+ DeactivateAllCachedBackgrounds();
+
+ foreach (var mapset in _mapsetViewModelCache.Values)
+ {
+ mapset.Dispose();
+ }
+
+ _mapsetViewModelCache.Clear();
+ _visibleDirectoryPaths.Clear();
+ _filteredDirectoryEntries.Clear();
+ _mapsetDirectoryEntries.Clear();
+ _mapsetDirectories.Clear();
+ VisibleMapsets.Clear();
+ _filteredCursor = 0;
+ SelectedDifficultyCount = 0;
+ _lastFirstVisibleIndex = null;
+ _lastLastVisibleIndex = null;
+ _lastToggledMapset = null;
+ _lastMapsetToggleUtc = DateTime.MinValue;
+ NotifyListStateChanged();
+ OnPropertyChanged(nameof(CanConfirmSelection));
+ }
+
private readonly record struct MapsetDirectoryEntry(string DirectoryPath, string FolderName);
}
@@ -876,8 +935,9 @@ private void ReleaseBackground()
return new Bitmap(fullPath);
}
- catch
+ catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
return null;
}
}
diff --git a/MapWizard.Desktop/ViewModels/WelcomePageViewModel.cs b/MapWizard.Desktop/ViewModels/WelcomePageViewModel.cs
index 6b0ea09..9400569 100644
--- a/MapWizard.Desktop/ViewModels/WelcomePageViewModel.cs
+++ b/MapWizard.Desktop/ViewModels/WelcomePageViewModel.cs
@@ -63,6 +63,7 @@ await dialogManager.CreateDialog()
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
toastManager.Dismiss(checkingToast);
toastManager.CreateToast()
.OfType(NotificationType.Error)
@@ -119,6 +120,7 @@ await updateService.DownloadUpdatesAsync(info, percentage =>
}
catch (Exception ex)
{
+ MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex);
toastManager.Dismiss(downloadingToast);
toastManager.CreateToast()
.OfType(NotificationType.Error)
diff --git a/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml b/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml
index 24cb404..c81fb31 100644
--- a/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml
+++ b/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml
@@ -12,7 +12,7 @@
mc:Ignorable="d"
d:DesignWidth="900"
d:DesignHeight="700"
- x:Class="MapWizard.Desktop.Views.ComboColourStudioView"
+ x:Class="MapWizard.Desktop.Views.ComboColourStudio.ComboColourStudioView"
x:DataType="vm:ComboColourStudioViewModel"
Padding="16">
diff --git a/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml.cs b/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml.cs
index feb586f..086327f 100644
--- a/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml.cs
+++ b/MapWizard.Desktop/Views/ComboColourStudio/ComboColourStudioView.axaml.cs
@@ -7,7 +7,7 @@
using Avalonia.VisualTree;
using MapWizard.Desktop.Models;
-namespace MapWizard.Desktop.Views;
+namespace MapWizard.Desktop.Views.ComboColourStudio;
public partial class ComboColourStudioView : UserControl
{
diff --git a/MapWizard.Desktop/Views/Controls/AppDropdown.axaml b/MapWizard.Desktop/Views/Controls/AppDropdown.axaml
new file mode 100644
index 0000000..f5efbde
--- /dev/null
+++ b/MapWizard.Desktop/Views/Controls/AppDropdown.axaml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MapWizard.Desktop/Views/Controls/AppDropdown.axaml.cs b/MapWizard.Desktop/Views/Controls/AppDropdown.axaml.cs
new file mode 100644
index 0000000..b888eaf
--- /dev/null
+++ b/MapWizard.Desktop/Views/Controls/AppDropdown.axaml.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Collections;
+using System.Reflection;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+
+namespace MapWizard.Desktop.Views.Controls;
+
+public partial class AppDropdown : UserControl
+{
+ public static readonly StyledProperty ItemsSourceProperty =
+ AvaloniaProperty.Register(nameof(ItemsSource));
+
+ public static readonly StyledProperty