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 SelectedItemProperty = + AvaloniaProperty.Register( + nameof(SelectedItem), + defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + + public static readonly StyledProperty PlaceholderTextProperty = + AvaloniaProperty.Register(nameof(PlaceholderText), "Select..."); + + public static readonly StyledProperty DisplayMemberPathProperty = + AvaloniaProperty.Register(nameof(DisplayMemberPath)); + + public static readonly StyledProperty FlyoutMinWidthProperty = + AvaloniaProperty.Register(nameof(FlyoutMinWidth), 180d); + + public static readonly StyledProperty FlyoutMaxHeightProperty = + AvaloniaProperty.Register(nameof(FlyoutMaxHeight), 280d); + + public static readonly StyledProperty SelectedTextProperty = + AvaloniaProperty.Register(nameof(SelectedText), "Select..."); + + public IEnumerable? ItemsSource + { + get => GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public object? SelectedItem + { + get => GetValue(SelectedItemProperty); + set => SetValue(SelectedItemProperty, value); + } + + public string PlaceholderText + { + get => GetValue(PlaceholderTextProperty); + set => SetValue(PlaceholderTextProperty, value); + } + + public string? DisplayMemberPath + { + get => GetValue(DisplayMemberPathProperty); + set => SetValue(DisplayMemberPathProperty, value); + } + + public double FlyoutMinWidth + { + get => GetValue(FlyoutMinWidthProperty); + set => SetValue(FlyoutMinWidthProperty, value); + } + + public double FlyoutMaxHeight + { + get => GetValue(FlyoutMaxHeightProperty); + set => SetValue(FlyoutMaxHeightProperty, value); + } + + public string SelectedText + { + get => GetValue(SelectedTextProperty); + private set => SetValue(SelectedTextProperty, value); + } + + public AppDropdown() + { + InitializeComponent(); + UpdateSelectedText(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SelectedItemProperty || + change.Property == PlaceholderTextProperty || + change.Property == DisplayMemberPathProperty) + { + UpdateSelectedText(); + } + } + + private void TriggerButton_OnClick(object? sender, RoutedEventArgs e) + { + // Button.Flyout opens automatically; keep handler to mirror custom dropdown controls and future extensibility. + } + + private void OptionsListBox_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (FlyoutBase.GetAttachedFlyout(TriggerButton) is FlyoutBase flyout) + { + flyout.Hide(); + } + } + + private void UpdateSelectedText() + { + SelectedText = GetItemDisplayText(SelectedItem) ?? PlaceholderText; + } + + private string? GetItemDisplayText(object? item) + { + if (item is null) + { + return null; + } + + var path = DisplayMemberPath; + if (string.IsNullOrWhiteSpace(path)) + { + return item.ToString(); + } + + try + { + var property = item.GetType().GetProperty(path, BindingFlags.Public | BindingFlags.Instance); + var value = property?.GetValue(item); + return value?.ToString() ?? item.ToString(); + } + catch (Exception ex) + { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); + return item.ToString(); + } + } +} diff --git a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml index c8fdb22..7ca0e36 100644 --- a/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml +++ b/MapWizard.Desktop/Views/Controls/BeatmapSelectionPanel.axaml @@ -438,21 +438,11 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - _observedDestinationMaps = []; private readonly Dictionary _destinationMapsetExpansionStates = new(System.StringComparer.OrdinalIgnoreCase); + private bool _isBulkUpdatingDestinationMaps; public static readonly StyledProperty SectionTitleProperty = AvaloniaProperty.Register(nameof(SectionTitle), "Beatmap Selection"); @@ -117,6 +119,15 @@ public partial class BeatmapSelectionPanel : UserControl public static readonly StyledProperty SelectedDestinationCountProperty = AvaloniaProperty.Register(nameof(SelectedDestinationCount)); + public static readonly StyledProperty HasOriginDifficultyOptionsProperty = + AvaloniaProperty.Register(nameof(HasOriginDifficultyOptions)); + + public static readonly StyledProperty SelectedOriginDifficultyLabelProperty = + AvaloniaProperty.Register(nameof(SelectedOriginDifficultyLabel), string.Empty); + + public static readonly StyledProperty IsOriginDiffExpandedProperty = + AvaloniaProperty.Register(nameof(IsOriginDiffExpanded), true); + public static readonly StyledProperty HasMapsetDifficultyOptionsProperty = AvaloniaProperty.Register(nameof(HasMapsetDifficultyOptions)); @@ -156,6 +167,7 @@ public BeatmapSelectionPanel() UpdateOriginEmptyPrompt(); } + public ObservableCollection OriginDifficultyCards { get; } = []; public ObservableCollection MapsetDifficultyCards { get; } = []; public ObservableCollection VisibleDestinationMapsets { get; } = []; @@ -345,6 +357,24 @@ public int SelectedDestinationCount private set => SetValue(SelectedDestinationCountProperty, value); } + public bool HasOriginDifficultyOptions + { + get => GetValue(HasOriginDifficultyOptionsProperty); + private set => SetValue(HasOriginDifficultyOptionsProperty, value); + } + + public string SelectedOriginDifficultyLabel + { + get => GetValue(SelectedOriginDifficultyLabelProperty); + private set => SetValue(SelectedOriginDifficultyLabelProperty, value); + } + + public bool IsOriginDiffExpanded + { + get => GetValue(IsOriginDiffExpandedProperty); + set => SetValue(IsOriginDiffExpandedProperty, value); + } + public bool HasMapsetDifficultyOptions { get => GetValue(HasMapsetDifficultyOptionsProperty); @@ -432,11 +462,24 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + AttachOriginMapObserver(OriginMap); + AttachDestinationMapCollectionObserver(DestinationMaps); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + DetachOriginMapObserver(_observedOriginMap); + DetachDestinationMapCollectionObserver(_observedDestinationCollection); + } + private void AttachOriginMapObserver(SelectedMap? originMap) { - if (originMap is null) + if (originMap is null || ReferenceEquals(originMap, _observedOriginMap)) { - _observedOriginMap = null; return; } @@ -452,6 +495,10 @@ private void DetachOriginMapObserver(SelectedMap? originMap) } originMap.PropertyChanged -= OriginMapOnPropertyChanged; + if (ReferenceEquals(_observedOriginMap, originMap)) + { + _observedOriginMap = null; + } } private void OriginMapOnPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -469,9 +516,8 @@ private void OriginMapOnPropertyChanged(object? sender, PropertyChangedEventArgs private void AttachDestinationMapCollectionObserver(ObservableCollection? destinationMaps) { - if (destinationMaps is null) + if (destinationMaps is null || ReferenceEquals(destinationMaps, _observedDestinationCollection)) { - _observedDestinationCollection = null; return; } @@ -487,6 +533,11 @@ private void DetachDestinationMapCollectionObserver(INotifyCollectionChanged? de destinationMaps.CollectionChanged -= DestinationMapsOnCollectionChanged; } + if (ReferenceEquals(_observedDestinationCollection, destinationMaps)) + { + _observedDestinationCollection = null; + } + foreach (var map in _observedDestinationMaps) { map.PropertyChanged -= DestinationMapOnPropertyChanged; @@ -502,6 +553,11 @@ private void DestinationMapsOnCollectionChanged(object? sender, NotifyCollection return; } + if (_isBulkUpdatingDestinationMaps) + { + return; + } + SyncDestinationMapItemObservers(DestinationMaps); UpdateDestinationSelectionState(); RefreshMapsetDifficultySelectionState(); @@ -574,10 +630,14 @@ private void UpdateDestinationSelectionState() private void RebuildMapsetDifficultyCards() { + OriginDifficultyCards.Clear(); MapsetDifficultyCards.Clear(); if (OriginMap is null || !OriginMap.HasPath) { + HasOriginDifficultyOptions = false; + SelectedOriginDifficultyLabel = string.Empty; + IsOriginDiffExpanded = false; HasMapsetDifficultyOptions = false; SelectedMapsetDifficultyCount = 0; HasSelectedMapsetDifficulties = false; @@ -589,13 +649,33 @@ private void RebuildMapsetDifficultyCards() return; } + var siblingDiffPaths = BeatmapSelectionUtils.GetSiblingDifficultyPaths(OriginMap.Path) + .ToArray(); + + foreach (var path in siblingDiffPaths) + { + OriginDifficultyCards.Add(new MapsetDifficultyCard(path) + { + IsSelected = string.Equals(path, OriginMap.Path, StringComparison.OrdinalIgnoreCase) + }); + } + + var selectedOriginCard = OriginDifficultyCards.FirstOrDefault(card => card.IsSelected); + SelectedOriginDifficultyLabel = selectedOriginCard?.DifficultyLabel ?? string.Empty; + HasOriginDifficultyOptions = OriginDifficultyCards.Count > 0; + if (!HasOriginDifficultyOptions) + { + IsOriginDiffExpanded = false; + } + else if (string.IsNullOrWhiteSpace(SelectedOriginDifficultyLabel)) + { + IsOriginDiffExpanded = true; + } + var selectedDestinationPaths = new HashSet( DestinationMaps?.Where(map => map.HasPath).Select(map => map.Path) ?? [], System.StringComparer.OrdinalIgnoreCase); - var siblingDiffPaths = BeatmapSelectionUtils.GetSiblingDifficultyPaths(OriginMap.Path) - .ToArray(); - foreach (var path in siblingDiffPaths) { var card = new MapsetDifficultyCard(path) @@ -738,8 +818,9 @@ private static string GetDestinationMapsetKey(string beatmapPath) { return System.IO.Path.GetFullPath(beatmapPath); } - catch + catch (Exception ex) { + MapWizard.Tools.HelperExtensions.MapWizardLogger.LogException(ex); return beatmapPath; } } @@ -786,46 +867,68 @@ private void MapsetSelectAllButton_OnClick(object? sender, RoutedEventArgs e) return; } - var selectedPaths = new HashSet( - DestinationMaps.Where(map => map.HasPath).Select(map => map.Path), - System.StringComparer.OrdinalIgnoreCase); - - var allMapsetSelected = MapsetDifficultyCards.All(card => selectedPaths.Contains(card.Path)); - if (allMapsetSelected) + _isBulkUpdatingDestinationMaps = true; + try { - var mapsetPaths = new HashSet( - MapsetDifficultyCards.Select(card => card.Path), + var selectedPaths = new HashSet( + DestinationMaps.Where(map => map.HasPath).Select(map => map.Path), System.StringComparer.OrdinalIgnoreCase); - for (var i = DestinationMaps.Count - 1; i >= 0; i--) + var allMapsetSelected = MapsetDifficultyCards.All(card => selectedPaths.Contains(card.Path)); + if (allMapsetSelected) { - var map = DestinationMaps[i]; - if (!map.HasPath || !mapsetPaths.Contains(map.Path)) + var mapsetPaths = new HashSet( + MapsetDifficultyCards.Select(card => card.Path), + System.StringComparer.OrdinalIgnoreCase); + + for (var i = DestinationMaps.Count - 1; i >= 0; i--) { - continue; - } + var map = DestinationMaps[i]; + if (!map.HasPath || !mapsetPaths.Contains(map.Path)) + { + continue; + } - DestinationMaps.RemoveAt(i); + DestinationMaps.RemoveAt(i); + } } + else + { + foreach (var card in MapsetDifficultyCards) + { + if (selectedPaths.Contains(card.Path)) + { + continue; + } + + DestinationMaps.Add(new SelectedMap + { + Path = card.Path + }); + } - e.Handled = true; - return; + IsMapsetDiffExpanded = true; + } } - - foreach (var card in MapsetDifficultyCards) + finally { - if (selectedPaths.Contains(card.Path)) - { - continue; - } + _isBulkUpdatingDestinationMaps = false; + } - DestinationMaps.Add(new SelectedMap - { - Path = card.Path - }); + SyncDestinationMapItemObservers(DestinationMaps); + UpdateDestinationSelectionState(); + RefreshMapsetDifficultySelectionState(); + e.Handled = true; + } + + private void OriginDiffCollapseHeaderButton_OnClick(object? sender, RoutedEventArgs e) + { + if (!HasOriginDifficultyOptions) + { + return; } - IsMapsetDiffExpanded = true; + IsOriginDiffExpanded = !IsOriginDiffExpanded; e.Handled = true; } diff --git a/MapWizard.Desktop/Views/Controls/HitSoundTimelineOverviewBar.cs b/MapWizard.Desktop/Views/Controls/HitSoundTimelineOverviewBar.cs new file mode 100644 index 0000000..d9c5623 --- /dev/null +++ b/MapWizard.Desktop/Views/Controls/HitSoundTimelineOverviewBar.cs @@ -0,0 +1,440 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Styling; +using BeatmapParser.Enums; +using MapWizard.Desktop.Models.HitSoundVisualizer; + +namespace MapWizard.Desktop.Views.Controls; + +public class HitSoundTimelineOverviewBar : Control +{ + private enum OverviewInteractionMode + { + None, + SeekCursor, + PanView + } + + private static readonly Pen NormalSampleChangeTintPen = new(new SolidColorBrush(Color.Parse("#4D9BC53D")), 1); + private static readonly Pen SoftSampleChangeTintPen = new(new SolidColorBrush(Color.Parse("#4D5BC0EB")), 1); + private static readonly Pen DrumSampleChangeTintPen = new(new SolidColorBrush(Color.Parse("#4DE55934")), 1); + private static readonly IBrush TrackBackgroundBrush = new SolidColorBrush(Color.Parse("#10151D")); + private static readonly IBrush TrackStripeBrush = new SolidColorBrush(Color.Parse("#1A2230")); + private static readonly IBrush TrackViewFillBrush = new SolidColorBrush(Color.Parse("#2A5D8F")); + private static readonly Pen TrackViewBorderPen = new(new SolidColorBrush(Color.Parse("#8CC8FF")), 1.2); + private static readonly Pen TrackCursorPen = new(new SolidColorBrush(Color.Parse("#FFD166")), 1.8); + private static readonly Pen TrackBorderPen = new(new SolidColorBrush(Color.Parse("#334255")), 1); + private static readonly IBrush LightTrackBackgroundBrush = new SolidColorBrush(Color.Parse("#EEF3F9")); + private static readonly IBrush LightTrackStripeBrush = new SolidColorBrush(Color.Parse("#DCE6F2")); + private static readonly IBrush LightTrackViewFillBrush = new SolidColorBrush(Color.Parse("#B8D9F6")); + private static readonly Pen LightTrackViewBorderPen = new(new SolidColorBrush(Color.Parse("#2F74B5")), 1.2); + private static readonly Pen LightTrackCursorPen = new(new SolidColorBrush(Color.Parse("#C08900")), 1.8); + private static readonly Pen LightTrackBorderPen = new(new SolidColorBrush(Color.Parse("#B8C7D8")), 1); + private const double SampleChangeOverviewMinSpacingPx = 1.5d; + private const double OverviewCornerRadius = 7d; + private const long PlaybackSeekInteractionGraceMs = 500; + private bool _isScrubbing; + private bool _seekIssuedForCurrentPointerSequence; + private long _lastPlaybackRunningTickMs = long.MinValue; + private OverviewInteractionMode _interactionMode; + private HitSoundVisualizerSampleChange[] _sampleChangesCache = []; + + public static readonly StyledProperty TimelineEndMsProperty = + AvaloniaProperty.Register(nameof(TimelineEndMs), 1000d); + + public static readonly StyledProperty ViewStartMsProperty = + AvaloniaProperty.Register(nameof(ViewStartMs)); + + public static readonly StyledProperty ViewEndMsProperty = + AvaloniaProperty.Register(nameof(ViewEndMs), 1000d); + + public static readonly StyledProperty CursorTimeMsProperty = + AvaloniaProperty.Register(nameof(CursorTimeMs)); + + public static readonly StyledProperty?> SampleChangesProperty = + AvaloniaProperty.Register?>(nameof(SampleChanges)); + + public static readonly StyledProperty PanTimelineCommandProperty = + AvaloniaProperty.Register(nameof(PanTimelineCommand)); + + public static readonly StyledProperty SeekTimeCommandProperty = + AvaloniaProperty.Register(nameof(SeekTimeCommand)); + + public static readonly StyledProperty BeginPeekCommandProperty = + AvaloniaProperty.Register(nameof(BeginPeekCommand)); + + public static readonly StyledProperty EndPeekCommandProperty = + AvaloniaProperty.Register(nameof(EndPeekCommand)); + + public static readonly StyledProperty IsPlaybackRunningProperty = + AvaloniaProperty.Register(nameof(IsPlaybackRunning)); + + public static readonly StyledProperty IsPlaybackPausedProperty = + AvaloniaProperty.Register(nameof(IsPlaybackPaused)); + + static HitSoundTimelineOverviewBar() + { + AffectsRender( + TimelineEndMsProperty, + ViewStartMsProperty, + ViewEndMsProperty, + CursorTimeMsProperty, + SampleChangesProperty); + } + + public double TimelineEndMs + { + get => GetValue(TimelineEndMsProperty); + set => SetValue(TimelineEndMsProperty, value); + } + + public double ViewStartMs + { + get => GetValue(ViewStartMsProperty); + set => SetValue(ViewStartMsProperty, value); + } + + public double ViewEndMs + { + get => GetValue(ViewEndMsProperty); + set => SetValue(ViewEndMsProperty, value); + } + + public int CursorTimeMs + { + get => GetValue(CursorTimeMsProperty); + set => SetValue(CursorTimeMsProperty, value); + } + + public IEnumerable? SampleChanges + { + get => GetValue(SampleChangesProperty); + set => SetValue(SampleChangesProperty, value); + } + + public ICommand? SeekTimeCommand + { + get => GetValue(SeekTimeCommandProperty); + set => SetValue(SeekTimeCommandProperty, value); + } + + public ICommand? PanTimelineCommand + { + get => GetValue(PanTimelineCommandProperty); + set => SetValue(PanTimelineCommandProperty, value); + } + + public ICommand? BeginPeekCommand + { + get => GetValue(BeginPeekCommandProperty); + set => SetValue(BeginPeekCommandProperty, value); + } + + public ICommand? EndPeekCommand + { + get => GetValue(EndPeekCommandProperty); + set => SetValue(EndPeekCommandProperty, value); + } + + public bool IsPlaybackRunning + { + get => GetValue(IsPlaybackRunningProperty); + set => SetValue(IsPlaybackRunningProperty, value); + } + + public bool IsPlaybackPaused + { + get => GetValue(IsPlaybackPausedProperty); + set => SetValue(IsPlaybackPausedProperty, value); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + var bounds = Bounds; + if (bounds.Width <= 1 || bounds.Height <= 1) + { + return; + } + + var total = Math.Max(1d, TimelineEndMs); + var trackRect = new Rect(0, 0, bounds.Width, bounds.Height); + var trackRoundedRect = new RoundedRect(trackRect, OverviewCornerRadius); + context.DrawRectangle(CurrentTrackBackgroundBrush(), null, trackRoundedRect); + + // subtle measure-ish stripes to improve spatial reading + var stripeBrush = CurrentTrackStripeBrush(); + using (context.PushClip(trackRect.Deflate(1))) + { + for (var i = 0; i < 40; i++) + { + var x = (i / 40d) * bounds.Width; + context.FillRectangle(stripeBrush, new Rect(x, 0, 1, bounds.Height)); + } + + DrawSampleChangeTint(context, bounds, total); + } + + var viewStartRatio = Math.Clamp(ViewStartMs / total, 0, 1); + var viewEndRatio = Math.Clamp(ViewEndMs / total, 0, 1); + var viewRect = new Rect( + viewStartRatio * bounds.Width, + 2, + Math.Max(2, (viewEndRatio - viewStartRatio) * bounds.Width), + Math.Max(2, bounds.Height - 4)); + + var viewRoundedRect = new RoundedRect(viewRect, Math.Min(OverviewCornerRadius - 1d, Math.Max(2d, viewRect.Height / 2d))); + context.DrawRectangle(CurrentTrackViewFillBrush(), null, viewRoundedRect); + context.DrawRectangle(null, CurrentTrackViewBorderPen(), viewRoundedRect); + + var cursorRatio = Math.Clamp(CursorTimeMs / total, 0, 1); + var cursorX = cursorRatio * bounds.Width; + context.DrawLine(CurrentTrackCursorPen(), new Point(cursorX, 0), new Point(cursorX, bounds.Height)); + + context.DrawRectangle(null, CurrentTrackBorderPen(), trackRoundedRect); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SampleChangesProperty) + { + RebuildSampleChangeCache(); + return; + } + + if (change.Property == IsPlaybackRunningProperty && change.GetNewValue()) + { + _lastPlaybackRunningTickMs = Environment.TickCount64; + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var width = double.IsInfinity(availableSize.Width) ? 320 : availableSize.Width; + return new Size(width, 18); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var currentPoint = e.GetCurrentPoint(this); + var isLeftPress = currentPoint.Properties.IsLeftButtonPressed || + currentPoint.Properties.PointerUpdateKind == PointerUpdateKind.LeftButtonPressed; + if (!isLeftPress) + { + return; + } + + var position = e.GetPosition(this); + _seekIssuedForCurrentPointerSequence = false; + if (IsPlaybackPaused && e.ClickCount >= 2) + { + SeekFromPoint(position); + } + + _isScrubbing = true; + _interactionMode = ResolveInteractionMode(position); + if (BeginPeekCommand?.CanExecute(null) == true) + { + BeginPeekCommand.Execute(null); + } + + e.Pointer.Capture(this); + HandlePointerAction(position); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (!_isScrubbing || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + HandlePointerAction(e.GetPosition(this)); + e.Handled = true; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (IsPlaybackSeekInteractionActive && + !_seekIssuedForCurrentPointerSequence && + e.InitialPressMouseButton == MouseButton.Left) + { + SeekFromPoint(e.GetPosition(this)); + } + + _isScrubbing = false; + _seekIssuedForCurrentPointerSequence = false; + _interactionMode = OverviewInteractionMode.None; + if (EndPeekCommand?.CanExecute(null) == true) + { + EndPeekCommand.Execute(null); + } + + if (e.Pointer.Captured == this) + { + e.Pointer.Capture(null); + } + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + _isScrubbing = false; + _seekIssuedForCurrentPointerSequence = false; + _interactionMode = OverviewInteractionMode.None; + if (EndPeekCommand?.CanExecute(null) == true) + { + EndPeekCommand.Execute(null); + } + } + + private void HandlePointerAction(Point point) + { + switch (_interactionMode) + { + case OverviewInteractionMode.SeekCursor: + SeekFromPoint(point); + break; + case OverviewInteractionMode.PanView: + PanViewToPoint(point); + break; + } + } + + private void SeekFromPoint(Point point) + { + var bounds = Bounds; + if (bounds.Width <= 0) + { + return; + } + + var ratio = Math.Clamp(point.X / bounds.Width, 0, 1); + var timeMs = (int)Math.Round(ratio * Math.Max(1d, TimelineEndMs)); + if (SeekTimeCommand?.CanExecute(timeMs) == true) + { + _seekIssuedForCurrentPointerSequence = true; + SeekTimeCommand.Execute(timeMs); + } + } + + private void PanViewToPoint(Point point) + { + var bounds = Bounds; + if (bounds.Width <= 0) + { + return; + } + + var ratio = Math.Clamp(point.X / bounds.Width, 0, 1); + var totalMs = Math.Max(1d, TimelineEndMs); + var targetTimeMs = ratio * totalMs; + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + var maxStartMs = Math.Max(0d, totalMs - windowMs); + var targetStartMs = Math.Clamp(targetTimeMs - (windowMs / 2d), 0d, maxStartMs); + var deltaMs = targetStartMs - ViewStartMs; + if (Math.Abs(deltaMs) < 0.5d) + { + return; + } + + if (PanTimelineCommand?.CanExecute(deltaMs) == true) + { + PanTimelineCommand.Execute(deltaMs); + } + } + + private OverviewInteractionMode ResolveInteractionMode(Point point) + { + _ = point; + return OverviewInteractionMode.SeekCursor; + } + + private void DrawSampleChangeTint(DrawingContext context, Rect bounds, double totalMs) + { + if (_sampleChangesCache.Length == 0 || totalMs <= 1d || bounds.Width <= 1d) + { + return; + } + + var lastDrawnX = double.NegativeInfinity; + var y1 = 1d; + var y2 = Math.Max(1d, bounds.Height - 1d); + for (var i = 0; i < _sampleChangesCache.Length; i++) + { + var marker = _sampleChangesCache[i]; + var ratio = Math.Clamp(marker.TimeMs / totalMs, 0d, 1d); + var x = ratio * bounds.Width; + if (x - lastDrawnX < SampleChangeOverviewMinSpacingPx) + { + continue; + } + + lastDrawnX = x; + context.DrawLine(SampleChangeTintPen(marker.SampleSet), new Point(x, y1), new Point(x, y2)); + } + } + + private void RebuildSampleChangeCache() + { + if (SampleChanges is null) + { + _sampleChangesCache = []; + return; + } + + _sampleChangesCache = SampleChanges + .Where(static x => x is not null) + .OrderBy(static x => x.TimeMs) + .ToArray(); + } + + private bool UseLightPalette => ActualThemeVariant == ThemeVariant.Light; + + private IBrush CurrentTrackBackgroundBrush() => UseLightPalette ? LightTrackBackgroundBrush : TrackBackgroundBrush; + + private IBrush CurrentTrackStripeBrush() => UseLightPalette ? LightTrackStripeBrush : TrackStripeBrush; + + private IBrush CurrentTrackViewFillBrush() => UseLightPalette ? LightTrackViewFillBrush : TrackViewFillBrush; + + private Pen CurrentTrackViewBorderPen() => UseLightPalette ? LightTrackViewBorderPen : TrackViewBorderPen; + + private Pen CurrentTrackCursorPen() => UseLightPalette ? LightTrackCursorPen : TrackCursorPen; + + private Pen CurrentTrackBorderPen() => UseLightPalette ? LightTrackBorderPen : TrackBorderPen; + + private static Pen SampleChangeTintPen(SampleSet sampleSet) + { + return sampleSet switch + { + SampleSet.Soft => SoftSampleChangeTintPen, + SampleSet.Drum => DrumSampleChangeTintPen, + _ => NormalSampleChangeTintPen + }; + } + + private bool IsPlaybackSeekInteractionActive + { + get + { + if (IsPlaybackRunning) + { + return true; + } + + var elapsedMs = Environment.TickCount64 - _lastPlaybackRunningTickMs; + return elapsedMs >= 0 && elapsedMs <= PlaybackSeekInteractionGraceMs; + } + } +} diff --git a/MapWizard.Desktop/Views/Controls/HitSoundTimelinePlot.cs b/MapWizard.Desktop/Views/Controls/HitSoundTimelinePlot.cs new file mode 100644 index 0000000..64380a5 --- /dev/null +++ b/MapWizard.Desktop/Views/Controls/HitSoundTimelinePlot.cs @@ -0,0 +1,2055 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Styling; +using MapWizard.Desktop.Models.HitSoundVisualizer; +using BeatmapParser.Enums; + +namespace MapWizard.Desktop.Views.Controls; + +public class HitSoundTimelinePlot : Control +{ + private const double DefaultRowHeight = 34d; + private const double PointRadius = 5d; + private const int SampleRowIndex = 0; + private const int HitSoundRowStartIndex = 1; + private const int TotalRows = 5; + private const double PointHitTestRadius = 10d; + private const double SampleChangeHitTestRadius = 10d; + private const double SampleChangeBarMinWidth = 18d; + private const double SampleChangeBarHorizontalPadding = 2d; + private const double SampleChangeBarVerticalPadding = 5d; + private const double SampleChangeBarCornerRadius = 4d; + private const double SampleChangeTextPadding = 6d; + private const double SampleChangeTextFontSize = 10d; + private const double SampleChangeDenseLineSpacingPx = 2d; + private const double SampleChangeDenseModeMaxAvgSpacingPx = 36d; + private const double SnapTickTargetMinSpacingPx = 3d; + private const double SnapTickMaxSimplifiedSpacingPx = 14d; + private const double SnapTickDenseStrongTargetMinSpacingPx = 5d; + private const double SnapTickDenseMeasuresTargetMinSpacingPx = 9d; + private const double SnapTickDenseMeasuresMaxSpacingPx = 20d; + private static readonly IBrush BackgroundBrush = new SolidColorBrush(Color.Parse("#14181D")); + private static readonly IBrush SampleRowBrush = new SolidColorBrush(Color.Parse("#1F2430")); + private static readonly IBrush EvenRowBrush = new SolidColorBrush(Color.Parse("#151B22")); + private static readonly IBrush OddRowBrush = new SolidColorBrush(Color.Parse("#131920")); + private static readonly Pen GridPen = new(new SolidColorBrush(Color.Parse("#2B3440")), 1); + private static readonly Pen BorderPen = new(new SolidColorBrush(Color.Parse("#324050")), 1); + private static readonly Pen PointOutlinePen = new(new SolidColorBrush(Color.Parse("#0C1117")), 1); + private static readonly IBrush LightBackgroundBrush = new SolidColorBrush(Color.Parse("#F6F8FB")); + private static readonly IBrush LightSampleRowBrush = new SolidColorBrush(Color.Parse("#E8EEF6")); + private static readonly IBrush LightEvenRowBrush = new SolidColorBrush(Color.Parse("#F8FAFD")); + private static readonly IBrush LightOddRowBrush = new SolidColorBrush(Color.Parse("#F1F5FA")); + private static readonly Pen LightGridPen = new(new SolidColorBrush(Color.Parse("#CCD8E6")), 1); + private static readonly Pen LightBorderPen = new(new SolidColorBrush(Color.Parse("#B8C7D8")), 1); + private static readonly Pen LightPointOutlinePen = new(new SolidColorBrush(Color.Parse("#E6ECF4")), 1); + private static readonly Pen DenseNormalPen = new(new SolidColorBrush(Color.Parse("#9BC53D")), 1.4); + private static readonly Pen DenseSoftPen = new(new SolidColorBrush(Color.Parse("#5BC0EB")), 1.4); + private static readonly Pen DenseDrumPen = new(new SolidColorBrush(Color.Parse("#E55934")), 1.4); + private static readonly Pen DenseAutoPen = new(new SolidColorBrush(Color.Parse("#8A96A3")), 1.4); + private static readonly IBrush NormalPointBrush = new SolidColorBrush(Color.Parse("#9BC53D")); + private static readonly IBrush SoftPointBrush = new SolidColorBrush(Color.Parse("#5BC0EB")); + private static readonly IBrush DrumPointBrush = new SolidColorBrush(Color.Parse("#E55934")); + private static readonly IBrush AutoPointBrush = new SolidColorBrush(Color.Parse("#8A96A3")); + private static readonly Pen PrimarySelectionPen = new(new SolidColorBrush(Color.Parse("#FFD166")), 2); + private static readonly Pen SecondarySelectionPen = new(new SolidColorBrush(Color.Parse("#FFFFFF")), 1.2); + private static readonly Pen LightSecondarySelectionPen = new(new SolidColorBrush(Color.Parse("#1F2937")), 1.2); + private static readonly Pen SampleChangeSelectionPen = new(new SolidColorBrush(Color.Parse("#89E5FF")), 1.8); + private static readonly Pen SampleChangeSelectionAccentPen = new(new SolidColorBrush(Color.Parse("#F5FBFF")), 1); + private static readonly Pen LightSampleChangeSelectionAccentPen = new(new SolidColorBrush(Color.Parse("#0F172A")), 1); + private static readonly IBrush SampleChangeLabelBrush = new SolidColorBrush(Color.Parse("#E6EDF7")); + private static readonly IBrush LightSampleChangeLabelBrush = new SolidColorBrush(Color.Parse("#0F172A")); + private static readonly Typeface SampleChangeLabelTypeface = new("Consolas"); + private static readonly Pen NormalSampleChangeLinePen = new(new SolidColorBrush(Color.Parse("#9BC53D")), 1); + private static readonly Pen SoftSampleChangeLinePen = new(new SolidColorBrush(Color.Parse("#5BC0EB")), 1); + private static readonly Pen DrumSampleChangeLinePen = new(new SolidColorBrush(Color.Parse("#E55934")), 1); + private static readonly Pen CursorPen = new(new SolidColorBrush(Color.Parse("#F4C95D")), 1.5); + private static readonly Pen GhostPreviewPen = new(new SolidColorBrush(Color.Parse("#B3FFD166")), 1.4); + private static readonly IBrush GhostPreviewFillBrush = new SolidColorBrush(Color.Parse("#33FFD166")); + private static readonly Pen MeasureTickPen = new(new SolidColorBrush(Color.Parse("#FFFFFF")), 1.6); + private static readonly Pen LightMeasureTickPen = new(new SolidColorBrush(Color.Parse("#334155")), 1.6); + private static readonly Pen HalfTickPen = new(new SolidColorBrush(Color.Parse("#FF5A5A")), 1.2); + private static readonly Pen TripletTickPen = new(new SolidColorBrush(Color.Parse("#C99BFF")), 1.2); + private static readonly Pen QuarterTickPen = new(new SolidColorBrush(Color.Parse("#4EA3FF")), 1.2); + private static readonly Pen SixthTickPen = new(new SolidColorBrush(Color.Parse("#8E5CFF")), 1.0); + private static readonly Pen EighthTickPen = new(new SolidColorBrush(Color.Parse("#F0D94B")), 1.0); + private static readonly Pen GenericTickPen = new(new SolidColorBrush(Color.Parse("#7A848F")), 1.0); + private static readonly Pen LightGenericTickPen = new(new SolidColorBrush(Color.Parse("#64748B")), 1.0); + private bool _isMiddlePanning; + private bool _isRangeSelecting; + private bool _isSelectionDragActive; + private bool _isAdditiveSelection; + private Point _selectionStartPosition; + private Point _selectionCurrentPosition; + private int _selectionStartTimeMs; + private int _selectionCurrentTimeMs; + private int _selectionStartRowIndex = -1; + private int _selectionCurrentRowIndex = -1; + private Point _lastPanPosition; + private bool _showPlacementGhost; + private int _placementGhostRowIndex = -1; + private int _placementGhostTimeMs = -1; + private HitSoundVisualizerPoint[] _pointsCache = []; + private HitSoundVisualizerSampleChange[] _sampleChangesCache = []; + private HitSoundVisualizerSnapTick[] _snapTicksCache = []; + private HashSet _selectedPointIdsCache = []; + + public static readonly StyledProperty?> PointsProperty = + AvaloniaProperty.Register?>(nameof(Points)); + + public static readonly StyledProperty?> SampleChangesProperty = + AvaloniaProperty.Register?>(nameof(SampleChanges)); + + public static readonly StyledProperty?> SnapTicksProperty = + AvaloniaProperty.Register?>(nameof(SnapTicks)); + + public static readonly StyledProperty ViewStartMsProperty = + AvaloniaProperty.Register(nameof(ViewStartMs)); + + public static readonly StyledProperty ViewEndMsProperty = + AvaloniaProperty.Register(nameof(ViewEndMs), 1000d); + + public static readonly StyledProperty CursorTimeMsProperty = + AvaloniaProperty.Register(nameof(CursorTimeMs)); + + public static readonly StyledProperty SnapDivisorDenominatorProperty = + AvaloniaProperty.Register(nameof(SnapDivisorDenominator), 4); + + public static readonly StyledProperty IsPlaybackRunningProperty = + AvaloniaProperty.Register(nameof(IsPlaybackRunning)); + + public static readonly StyledProperty FollowPlaybackCursorProperty = + AvaloniaProperty.Register(nameof(FollowPlaybackCursor)); + + public static readonly StyledProperty SelectedPointIdProperty = + AvaloniaProperty.Register(nameof(SelectedPointId), -1); + + public static readonly StyledProperty SelectedSampleChangeTimeMsProperty = + AvaloniaProperty.Register(nameof(SelectedSampleChangeTimeMs), -1); + + public static readonly StyledProperty?> SelectedPointIdsProperty = + AvaloniaProperty.Register?>(nameof(SelectedPointIds)); + + public static readonly StyledProperty SelectPointCommandProperty = + AvaloniaProperty.Register(nameof(SelectPointCommand)); + + public static readonly StyledProperty SelectPointsCommandProperty = + AvaloniaProperty.Register(nameof(SelectPointsCommand)); + + public static readonly StyledProperty AddPointsToSelectionCommandProperty = + AvaloniaProperty.Register(nameof(AddPointsToSelectionCommand)); + + public static readonly StyledProperty TogglePointSelectionCommandProperty = + AvaloniaProperty.Register(nameof(TogglePointSelectionCommand)); + + public static readonly StyledProperty AddPointOnSnapLineCommandProperty = + AvaloniaProperty.Register(nameof(AddPointOnSnapLineCommand)); + + public static readonly StyledProperty SeekTimeCommandProperty = + AvaloniaProperty.Register(nameof(SeekTimeCommand)); + + public static readonly StyledProperty PanTimelineCommandProperty = + AvaloniaProperty.Register(nameof(PanTimelineCommand)); + + public static readonly StyledProperty ZoomTimelineCommandProperty = + AvaloniaProperty.Register(nameof(ZoomTimelineCommand)); + + public static readonly StyledProperty OpenContextEditorCommandProperty = + AvaloniaProperty.Register(nameof(OpenContextEditorCommand)); + + public static readonly StyledProperty DeleteSelectedPointCommandProperty = + AvaloniaProperty.Register(nameof(DeleteSelectedPointCommand)); + + public static readonly StyledProperty DeleteSampleChangeCommandProperty = + AvaloniaProperty.Register(nameof(DeleteSampleChangeCommand)); + + static HitSoundTimelinePlot() + { + AffectsRender( + PointsProperty, + SampleChangesProperty, + SnapTicksProperty, + ViewStartMsProperty, + ViewEndMsProperty, + CursorTimeMsProperty, + SnapDivisorDenominatorProperty, + SelectedPointIdProperty, + SelectedSampleChangeTimeMsProperty, + SelectedPointIdsProperty); + } + + public IEnumerable? Points + { + get => GetValue(PointsProperty); + set => SetValue(PointsProperty, value); + } + + public IEnumerable? SampleChanges + { + get => GetValue(SampleChangesProperty); + set => SetValue(SampleChangesProperty, value); + } + + public IEnumerable? SnapTicks + { + get => GetValue(SnapTicksProperty); + set => SetValue(SnapTicksProperty, value); + } + + public double ViewStartMs + { + get => GetValue(ViewStartMsProperty); + set => SetValue(ViewStartMsProperty, value); + } + + public double ViewEndMs + { + get => GetValue(ViewEndMsProperty); + set => SetValue(ViewEndMsProperty, value); + } + + public int CursorTimeMs + { + get => GetValue(CursorTimeMsProperty); + set => SetValue(CursorTimeMsProperty, value); + } + + public int SnapDivisorDenominator + { + get => GetValue(SnapDivisorDenominatorProperty); + set => SetValue(SnapDivisorDenominatorProperty, value); + } + + public bool IsPlaybackRunning + { + get => GetValue(IsPlaybackRunningProperty); + set => SetValue(IsPlaybackRunningProperty, value); + } + + public bool FollowPlaybackCursor + { + get => GetValue(FollowPlaybackCursorProperty); + set => SetValue(FollowPlaybackCursorProperty, value); + } + + public int SelectedPointId + { + get => GetValue(SelectedPointIdProperty); + set => SetValue(SelectedPointIdProperty, value); + } + + public int SelectedSampleChangeTimeMs + { + get => GetValue(SelectedSampleChangeTimeMsProperty); + set => SetValue(SelectedSampleChangeTimeMsProperty, value); + } + + public IEnumerable? SelectedPointIds + { + get => GetValue(SelectedPointIdsProperty); + set => SetValue(SelectedPointIdsProperty, value); + } + + public ICommand? SelectPointCommand + { + get => GetValue(SelectPointCommandProperty); + set => SetValue(SelectPointCommandProperty, value); + } + + public ICommand? SelectPointsCommand + { + get => GetValue(SelectPointsCommandProperty); + set => SetValue(SelectPointsCommandProperty, value); + } + + public ICommand? AddPointsToSelectionCommand + { + get => GetValue(AddPointsToSelectionCommandProperty); + set => SetValue(AddPointsToSelectionCommandProperty, value); + } + + public ICommand? TogglePointSelectionCommand + { + get => GetValue(TogglePointSelectionCommandProperty); + set => SetValue(TogglePointSelectionCommandProperty, value); + } + + public ICommand? AddPointOnSnapLineCommand + { + get => GetValue(AddPointOnSnapLineCommandProperty); + set => SetValue(AddPointOnSnapLineCommandProperty, value); + } + + public ICommand? SeekTimeCommand + { + get => GetValue(SeekTimeCommandProperty); + set => SetValue(SeekTimeCommandProperty, value); + } + + public ICommand? PanTimelineCommand + { + get => GetValue(PanTimelineCommandProperty); + set => SetValue(PanTimelineCommandProperty, value); + } + + public ICommand? ZoomTimelineCommand + { + get => GetValue(ZoomTimelineCommandProperty); + set => SetValue(ZoomTimelineCommandProperty, value); + } + + public ICommand? OpenContextEditorCommand + { + get => GetValue(OpenContextEditorCommandProperty); + set => SetValue(OpenContextEditorCommandProperty, value); + } + + public ICommand? DeleteSelectedPointCommand + { + get => GetValue(DeleteSelectedPointCommandProperty); + set => SetValue(DeleteSelectedPointCommandProperty, value); + } + + public ICommand? DeleteSampleChangeCommand + { + get => GetValue(DeleteSampleChangeCommandProperty); + set => SetValue(DeleteSampleChangeCommandProperty, value); + } + + private bool UseLightPalette => ActualThemeVariant == ThemeVariant.Light; + + public override void Render(DrawingContext context) + { + base.Render(context); + + var bounds = Bounds; + if (bounds.Width <= 1 || bounds.Height <= 1) + { + return; + } + + var timelineWidth = bounds.Width; + var timelineHeight = Math.Max(bounds.Height, TotalRows * DefaultRowHeight); + var timelineRect = new Rect(0, 0, timelineWidth, timelineHeight); + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + + context.FillRectangle(CurrentBackgroundBrush(), timelineRect); + + DrawRowBackgrounds(context, timelineWidth, DefaultRowHeight, TotalRows); + DrawTicks(context, timelineWidth, timelineHeight, windowMs); + DrawCursor(context, timelineWidth, timelineHeight, windowMs); + DrawSampleChanges(context, timelineWidth, DefaultRowHeight, windowMs); + DrawPoints(context, timelineWidth, DefaultRowHeight, windowMs); + DrawPlacementGhost(context, timelineWidth, DefaultRowHeight, windowMs); + DrawSelectionBox(context, timelineWidth, DefaultRowHeight, windowMs); + DrawGridLines(context, timelineWidth, DefaultRowHeight, TotalRows); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == PointsProperty) + { + RebuildPointsCache(); + } + else if (change.Property == SampleChangesProperty) + { + RebuildSampleChangesCache(); + } + else if (change.Property == SnapTicksProperty) + { + RebuildSnapTicksCache(); + } + else if (change.Property == SelectedPointIdsProperty) + { + RebuildSelectedPointIdsCache(); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + var point = e.GetCurrentPoint(this); + var bounds = Bounds; + if (bounds.Width <= 1 || bounds.Height <= 1) + { + return; + } + + if (point.Properties.IsMiddleButtonPressed) + { + _isMiddlePanning = true; + _lastPanPosition = point.Position; + e.Pointer.Capture(this); + e.Handled = true; + return; + } + + if (point.Properties.IsRightButtonPressed) + { + HandleRightClick(point.Position, bounds, e.KeyModifiers); + return; + } + + if (!point.Properties.IsLeftButtonPressed) + { + return; + } + + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + var clickPosition = point.Position; + var clickedRow = YToRowIndex(clickPosition.Y, DefaultRowHeight); + var clickedTimeMs = ResolveClickedTimeMs(clickPosition.X, bounds.Width, windowMs, snapTolerancePx: 12d); + var (nearest, bestDistance) = FindNearestPoint(clickPosition, bounds.Width, windowMs); + var (nearestSampleChange, sampleChangeDistance) = clickedRow == SampleRowIndex + ? FindNearestSampleChange(clickPosition, bounds.Width, windowMs) + : (null, double.MaxValue); + + if (clickedRow == SampleRowIndex && + nearestSampleChange is not null && + sampleChangeDistance <= SampleChangeHitTestRadius) + { + if (SeekTimeCommand?.CanExecute(nearestSampleChange.TimeMs) == true) + { + SeekTimeCommand.Execute(nearestSampleChange.TimeMs); + } + + var contextRequest = new HitSoundTimelineContextRequest + { + TimeMs = nearestSampleChange.TimeMs, + PointId = -1, + SampleChangeTimeMs = nearestSampleChange.TimeMs, + IsSampleRow = true + }; + + if (OpenContextEditorCommand?.CanExecute(contextRequest) == true) + { + OpenContextEditorCommand.Execute(contextRequest); + } + + e.Handled = true; + return; + } + + if (e.ClickCount >= 2 && clickedRow != SampleRowIndex) + { + var rowIds = new List(); + for (var i = 0; i < _pointsCache.Length; i++) + { + var p = _pointsCache[i]; + if (GetRowIndex(p) == clickedRow) + { + rowIds.Add(p.Id); + } + } + + var rowIdArray = rowIds.ToArray(); + if (rowIdArray.Length > 0 && SelectPointsCommand?.CanExecute(rowIdArray) == true) + { + SelectPointsCommand.Execute(rowIdArray); + } + + if (SeekTimeCommand?.CanExecute(clickedTimeMs) == true) + { + SeekTimeCommand.Execute(clickedTimeMs); + } + + e.Handled = true; + return; + } + + if (nearest is not null && bestDistance <= PointHitTestRadius) + { + var isCtrl = e.KeyModifiers.HasFlag(KeyModifiers.Control); + if (isCtrl) + { + if (TogglePointSelectionCommand?.CanExecute(nearest.Id) == true) + { + TogglePointSelectionCommand.Execute(nearest.Id); + } + } + else if (SelectPointCommand?.CanExecute(nearest.Id) == true) + { + SelectPointCommand.Execute(nearest.Id); + } + + if ((IsPlaybackRunning || !isCtrl) && SeekTimeCommand?.CanExecute(nearest.TimeMs) == true) + { + SeekTimeCommand.Execute(nearest.TimeMs); + } + + e.Handled = true; + return; + } + + if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && + clickedRow != SampleRowIndex && + TryGetNearestSnapTickTimeMs(clickPosition.X, bounds.Width, windowMs, 10d, out var snappedTimeMs)) + { + var addRequest = new HitSoundTimelineRowAddRequest + { + TimeMs = snappedTimeMs, + RowIndex = clickedRow + }; + + if (AddPointOnSnapLineCommand?.CanExecute(addRequest) == true) + { + AddPointOnSnapLineCommand.Execute(addRequest); + } + + if (SeekTimeCommand?.CanExecute(snappedTimeMs) == true) + { + SeekTimeCommand.Execute(snappedTimeMs); + } + + e.Handled = true; + return; + } + + if (IsPlaybackRunning) + { + if (SeekTimeCommand?.CanExecute(clickedTimeMs) == true) + { + SeekTimeCommand.Execute(clickedTimeMs); + } + + e.Handled = true; + return; + } + + _isRangeSelecting = true; + _isSelectionDragActive = false; + _isAdditiveSelection = e.KeyModifiers.HasFlag(KeyModifiers.Control); + _selectionStartPosition = clickPosition; + _selectionCurrentPosition = clickPosition; + _selectionStartTimeMs = clickedTimeMs; + _selectionCurrentTimeMs = clickedTimeMs; + _selectionStartRowIndex = clickedRow; + _selectionCurrentRowIndex = clickedRow; + e.Pointer.Capture(this); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + var point = e.GetCurrentPoint(this); + var bounds = Bounds; + if (bounds.Width <= 1 || bounds.Height <= 1) + { + return; + } + + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + + if (_isMiddlePanning && point.Properties.IsMiddleButtonPressed) + { + ClearPlacementGhost(); + var dx = point.Position.X - _lastPanPosition.X; + _lastPanPosition = point.Position; + var deltaMs = -(dx / Math.Max(1d, bounds.Width)) * windowMs; + + if (PanTimelineCommand?.CanExecute(deltaMs) == true) + { + PanTimelineCommand.Execute(deltaMs); + } + + e.Handled = true; + return; + } + + if (_isRangeSelecting && point.Properties.IsLeftButtonPressed) + { + ClearPlacementGhost(); + _selectionCurrentPosition = ClampPointToBounds(point.Position, bounds); + _selectionCurrentTimeMs = XToTime(_selectionCurrentPosition.X, bounds.Width, windowMs); + _selectionCurrentRowIndex = YToRowIndex(_selectionCurrentPosition.Y, DefaultRowHeight); + _isSelectionDragActive = Math.Abs(_selectionCurrentPosition.X - _selectionStartPosition.X) > 4d || + Math.Abs(_selectionCurrentPosition.Y - _selectionStartPosition.Y) > 4d; + InvalidateVisual(); + e.Handled = true; + return; + } + + UpdatePlacementGhost(point.Position, bounds, e.KeyModifiers); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + if (_isRangeSelecting) + { + var bounds = Bounds; + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + var releasedPos = ClampPointToBounds(e.GetPosition(this), bounds); + _selectionCurrentPosition = releasedPos; + _selectionCurrentTimeMs = XToTime(_selectionCurrentPosition.X, bounds.Width, windowMs); + _selectionCurrentRowIndex = YToRowIndex(_selectionCurrentPosition.Y, DefaultRowHeight); + + if (_isSelectionDragActive) + { + var startTime = _selectionStartTimeMs; + var endTime = _selectionCurrentTimeMs; + var minTime = Math.Min(startTime, endTime); + var maxTime = Math.Max(startTime, endTime); + var startRow = _selectionStartRowIndex >= 0 + ? _selectionStartRowIndex + : YToRowIndex(_selectionStartPosition.Y, DefaultRowHeight); + var endRow = _selectionCurrentRowIndex >= 0 + ? _selectionCurrentRowIndex + : YToRowIndex(_selectionCurrentPosition.Y, DefaultRowHeight); + var minRow = Math.Min(startRow, endRow); + var maxRow = Math.Max(startRow, endRow); + + var selectedIdsList = new List(); + var startIndex = LowerBoundPoints(minTime); + var endIndexExclusive = UpperBoundPoints(maxTime); + for (var i = startIndex; i < endIndexExclusive; i++) + { + var p = _pointsCache[i]; + var row = GetRowIndex(p); + if (row >= minRow && row <= maxRow) + { + selectedIdsList.Add(p.Id); + } + } + var selectedIds = selectedIdsList.ToArray(); + + var selectionCommand = _isAdditiveSelection ? AddPointsToSelectionCommand : SelectPointsCommand; + if (selectionCommand?.CanExecute(selectedIds) == true) + { + selectionCommand.Execute(selectedIds); + } + } + else if (!_isAdditiveSelection) + { + var clickedTimeMs = ResolveClickedTimeMs(_selectionCurrentPosition.X, bounds.Width, windowMs, 12d); + if (SeekTimeCommand?.CanExecute(clickedTimeMs) == true) + { + SeekTimeCommand.Execute(clickedTimeMs); + } + + if (SelectPointsCommand?.CanExecute(Array.Empty()) == true) + { + SelectPointsCommand.Execute(Array.Empty()); + } + } + + ResetRangeSelectionState(); + InvalidateVisual(); + } + + _isMiddlePanning = false; + ClearPlacementGhost(); + if (e.Pointer.Captured == this) + { + e.Pointer.Capture(null); + } + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + ResetRangeSelectionState(); + _isMiddlePanning = false; + ClearPlacementGhost(); + InvalidateVisual(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + ClearPlacementGhost(); + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + + var bounds = Bounds; + if (bounds.Width <= 1 || bounds.Height <= 1) + { + return; + } + + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + var cursorTime = (int)Math.Round(ViewStartMs + (e.GetPosition(this).X / bounds.Width) * windowMs); + + if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + var factor = e.Delta.Y > 0 ? 0.85d : 1.15d; + var request = new HitSoundTimelineZoomRequest + { + AnchorTimeMs = cursorTime, + ZoomFactor = factor + }; + + if (ZoomTimelineCommand?.CanExecute(request) == true) + { + ZoomTimelineCommand.Execute(request); + RefreshRangeSelectionAnchorFromCurrentViewport(bounds); + e.Handled = true; + } + + return; + } + + var wheelDelta = Math.Abs(e.Delta.Y) >= Math.Abs(e.Delta.X) ? e.Delta.Y : e.Delta.X; + if (FollowPlaybackCursor && + IsPlaybackRunning && + wheelDelta != 0 && + TryResolvePlaybackWheelSeekTarget(wheelDelta, out var seekTimeMs)) + { + e.Handled = true; + if (SeekTimeCommand?.CanExecute(seekTimeMs) == true) + { + SeekTimeCommand.Execute(seekTimeMs); + RefreshRangeSelectionAnchorFromCurrentViewport(bounds); + } + + return; + } + + var panDeltaMs = -wheelDelta * windowMs * 0.1d; + if (PanTimelineCommand?.CanExecute(panDeltaMs) == true) + { + PanTimelineCommand.Execute(panDeltaMs); + RefreshRangeSelectionAnchorFromCurrentViewport(bounds); + e.Handled = true; + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var height = DefaultRowHeight * TotalRows; + var width = double.IsInfinity(availableSize.Width) ? 600d : availableSize.Width; + return new Size(width, height); + } + + private void RebuildPointsCache() + { + _pointsCache = Points switch + { + null => [], + HitSoundVisualizerPoint[] array => array, + IReadOnlyCollection collection when collection.Count == 0 => [], + _ => (Points ?? []).ToArray() + }; + } + + private void RebuildSampleChangesCache() + { + if (SampleChanges is null) + { + _sampleChangesCache = []; + return; + } + + _sampleChangesCache = (SampleChanges as IEnumerable ?? []) + .OrderBy(x => x.TimeMs) + .ToArray(); + } + + private void RebuildSnapTicksCache() + { + if (SnapTicks is null) + { + _snapTicksCache = []; + return; + } + + foreach (var tick in SnapTicks) + { + if (tick is null) + { + continue; + } + + if (tick.ExactTimeMs == 0d && tick.TimeMs != 0) + { + tick.ExactTimeMs = tick.TimeMs; + } + } + + _snapTicksCache = (SnapTicks as IEnumerable ?? []) + .OrderBy(x => x.ExactTimeMs) + .ToArray(); + } + + private void RebuildSelectedPointIdsCache() + { + _selectedPointIdsCache = SelectedPointIds is null + ? [] + : SelectedPointIds.Where(id => id > 0).ToHashSet(); + } + + private (int StartIndex, int EndIndexExclusive) GetVisiblePointRange() + { + if (_pointsCache.Length == 0) + { + return (0, 0); + } + + var minTimeMs = (int)Math.Floor(ViewStartMs); + var maxTimeMs = (int)Math.Ceiling(ViewEndMs); + var start = LowerBoundPoints(minTimeMs); + var end = UpperBoundPoints(maxTimeMs); + return (start, end); + } + + private (int StartIndex, int EndIndexExclusive) GetVisibleSampleChangeRange() + { + if (_sampleChangesCache.Length == 0) + { + return (0, 0); + } + + var minTimeMs = (int)Math.Floor(ViewStartMs); + var maxTimeMs = (int)Math.Ceiling(ViewEndMs); + + // Include one preceding change so segment bars that start before the viewport still render into it. + var start = Math.Max(0, LowerBoundSampleChanges(minTimeMs) - 1); + var end = UpperBoundSampleChanges(maxTimeMs); + return (start, Math.Max(start, end)); + } + + private (int StartIndex, int EndIndexExclusive) GetVisibleSnapTickRange() + { + if (_snapTicksCache.Length == 0) + { + return (0, 0); + } + + var minTimeMs = (int)Math.Floor(ViewStartMs); + var maxTimeMs = (int)Math.Ceiling(ViewEndMs); + var start = LowerBoundSnapTicks(minTimeMs); + var end = UpperBoundSnapTicks(maxTimeMs); + return (start, end); + } + + private int LowerBoundPoints(int targetTimeMs) + { + var lo = 0; + var hi = _pointsCache.Length; + while (lo < hi) + { + var mid = lo + ((hi - lo) / 2); + if (_pointsCache[mid].TimeMs < targetTimeMs) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + + return lo; + } + + private int UpperBoundPoints(int targetTimeMs) + { + var lo = 0; + var hi = _pointsCache.Length; + while (lo < hi) + { + var mid = lo + ((hi - lo) / 2); + if (_pointsCache[mid].TimeMs <= targetTimeMs) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + + return lo; + } + + private int LowerBoundSampleChanges(int targetTimeMs) + { + var lo = 0; + var hi = _sampleChangesCache.Length; + while (lo < hi) + { + var mid = lo + ((hi - lo) / 2); + if (_sampleChangesCache[mid].TimeMs < targetTimeMs) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + + return lo; + } + + private int UpperBoundSampleChanges(int targetTimeMs) + { + var lo = 0; + var hi = _sampleChangesCache.Length; + while (lo < hi) + { + var mid = lo + ((hi - lo) / 2); + if (_sampleChangesCache[mid].TimeMs <= targetTimeMs) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + + return lo; + } + + private int LowerBoundSnapTicks(int targetTimeMs) + { + var lo = 0; + var hi = _snapTicksCache.Length; + while (lo < hi) + { + var mid = lo + ((hi - lo) / 2); + if (_snapTicksCache[mid].ExactTimeMs < targetTimeMs) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + + return lo; + } + + private int UpperBoundSnapTicks(int targetTimeMs) + { + var lo = 0; + var hi = _snapTicksCache.Length; + while (lo < hi) + { + var mid = lo + ((hi - lo) / 2); + if (_snapTicksCache[mid].ExactTimeMs <= targetTimeMs) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + + return lo; + } + + + private void DrawRowBackgrounds(DrawingContext context, double width, double rowHeight, int totalRows) + { + for (var row = 0; row < totalRows; row++) + { + var brush = row == 0 + ? CurrentSampleRowBrush() + : row % 2 == 0 + ? CurrentEvenRowBrush() + : CurrentOddRowBrush(); + + context.FillRectangle(brush, new Rect(0, row * rowHeight, width, rowHeight)); + } + } + + private void DrawGridLines(DrawingContext context, double width, double rowHeight, int totalRows) + { + for (var row = 0; row <= totalRows; row++) + { + var y = row * rowHeight; + context.DrawLine(CurrentGridPen(), new Point(0, y), new Point(width, y)); + } + + context.DrawRectangle(CurrentBorderPen(), new Rect(0, 0, width, totalRows * rowHeight)); + } + + private void DrawTicks(DrawingContext context, double width, double height, double windowMs) + { + if (_snapTicksCache.Length == 0) + { + return; + } + + var selectedDivisor = Math.Clamp(SnapDivisorDenominator, 1, 16); + var (tickStart, tickEnd) = GetVisibleSnapTickRange(); + var visibleTicksByRoundedTime = new Dictionary(Math.Max(0, tickEnd - tickStart)); + for (var i = tickStart; i < tickEnd; i++) + { + var tick = _snapTicksCache[i]; + if (tick.Denominator > selectedDivisor || selectedDivisor % Math.Max(1, tick.Denominator) != 0) + { + continue; + } + + if (!visibleTicksByRoundedTime.TryGetValue(tick.TimeMs, out var current)) + { + visibleTicksByRoundedTime[tick.TimeMs] = tick; + continue; + } + + if (GetTickRenderPriority(tick) > GetTickRenderPriority(current)) + { + visibleTicksByRoundedTime[tick.TimeMs] = tick; + } + } + + var visibleTicks = visibleTicksByRoundedTime.Values + .OrderBy(tick => tick.ExactTimeMs) + .ToList(); + + if (visibleTicks.Count == 0) + { + return; + } + + var tickMode = ResolveTickRenderMode(visibleTicks, width, windowMs); + + var tickCandidates = new List<(HitSoundVisualizerSnapTick Tick, double X)>(visibleTicks.Count); + foreach (var tick in visibleTicks) + { + if (!ShouldRenderTickForMode(tick, tickMode)) + { + continue; + } + + var x = TimeToX(tick.ExactTimeMs, width, windowMs); + tickCandidates.Add((tick, x)); + } + + if (tickCandidates.Count == 0) + { + return; + } + + var ticksToRender = SimplifyTickCandidatesForDensity(tickCandidates, width, tickMode); + foreach (var (tick, x) in ticksToRender) + { + context.DrawLine(SnapTickPen(tick.Denominator), new Point(x, 0), new Point(x, height)); + } + } + + private void DrawCursor(DrawingContext context, double width, double height, double windowMs) + { + if (CursorTimeMs < ViewStartMs || CursorTimeMs > ViewEndMs) + { + return; + } + + var x = TimeToX(CursorTimeMs, width, windowMs); + context.DrawLine(CursorPen, new Point(x, 0), new Point(x, height)); + } + + private void DrawSampleChanges(DrawingContext context, double width, double rowHeight, double windowMs) + { + if (_sampleChangesCache.Length == 0) + { + return; + } + + var allSampleChanges = _sampleChangesCache; + if (allSampleChanges.Length == 0) + { + return; + } + + var rowTop = (SampleRowIndex * rowHeight) + SampleChangeBarVerticalPadding; + var barHeight = Math.Max(8d, rowHeight - (SampleChangeBarVerticalPadding * 2d)); + + var visibleOnlyStart = LowerBoundSampleChanges((int)Math.Floor(ViewStartMs)); + var visibleOnlyEnd = UpperBoundSampleChanges((int)Math.Ceiling(ViewEndMs)); + var visibleChangeCount = Math.Max(0, visibleOnlyEnd - visibleOnlyStart); + if (ShouldUseDenseSampleChangeMarkers(visibleChangeCount, width)) + { + DrawDenseSampleChangeMarkers(context, width, windowMs, rowTop, barHeight, visibleOnlyStart, visibleOnlyEnd); + return; + } + + var (sampleStart, sampleEnd) = GetVisibleSampleChangeRange(); + for (var i = sampleStart; i < sampleEnd && i < allSampleChanges.Length; i++) + { + var marker = allSampleChanges[i]; + var nextTimeMs = i + 1 < allSampleChanges.Length + ? allSampleChanges[i + 1].TimeMs + : (int)Math.Ceiling(ViewEndMs); + var segmentEndMs = Math.Max(nextTimeMs, marker.TimeMs); + var overlapsViewport = segmentEndMs > ViewStartMs && marker.TimeMs <= ViewEndMs; + if (!overlapsViewport) + { + continue; + } + + var isSelected = marker.TimeMs == SelectedSampleChangeTimeMs; + var barRect = CreateSampleChangeBarRect(marker.TimeMs, nextTimeMs, width, windowMs, rowTop, barHeight); + if (barRect.Width <= 0.5d || barRect.Right <= 0 || barRect.X >= width) + { + continue; + } + + DrawSampleChangeBar(context, marker, barRect, isSelected); + } + } + + private void DrawDenseSampleChangeMarkers( + DrawingContext context, + double width, + double windowMs, + double rowTop, + double barHeight, + int startIndex, + int endIndexExclusive) + { + if (startIndex >= endIndexExclusive) + { + return; + } + + var lastDrawnX = double.NegativeInfinity; + + for (var i = startIndex; i < endIndexExclusive && i < _sampleChangesCache.Length; i++) + { + var marker = _sampleChangesCache[i]; + var x = TimeToX(marker.TimeMs, width, windowMs); + if (x < 0 || x > width) + { + continue; + } + + var isSelected = marker.TimeMs == SelectedSampleChangeTimeMs; + if (!isSelected && x - lastDrawnX < SampleChangeDenseLineSpacingPx) + { + continue; + } + + lastDrawnX = x; + var pen = SampleChangeLinePen(marker.SampleSet); + context.DrawLine(pen, new Point(x, rowTop), new Point(x, rowTop + barHeight)); + + if (isSelected) + { + context.DrawLine(SampleChangeSelectionPen, new Point(x, rowTop - 1d), new Point(x, rowTop + barHeight + 1d)); + } + } + } + + private void DrawPoints(DrawingContext context, double width, double rowHeight, double windowMs) + { + if (_pointsCache.Length == 0) + { + return; + } + + var (pointStart, pointEnd) = GetVisiblePointRange(); + var visibleCount = Math.Max(0, pointEnd - pointStart); + + if (visibleCount == 0) + { + return; + } + + var selectedIds = _selectedPointIdsCache; + var denseMode = visibleCount > Math.Max(300, width * 2.25d) || (windowMs / Math.Max(1d, width)) > 24d; + var lastDrawnXByRowAndSample = denseMode + ? InitializeLastDrawnX() + : null; + var renderRadius = denseMode ? 3d : PointRadius; + + for (var i = pointStart; i < pointEnd; i++) + { + var point = _pointsCache[i]; + var rowIndex = GetRowIndex(point); + var x = TimeToX(point.TimeMs, width, windowMs); + var y = RowCenterY(rowIndex, rowHeight); + var isSelected = selectedIds.Contains(point.Id); + if (denseMode && !isSelected && lastDrawnXByRowAndSample is not null) + { + var sampleSlot = SampleSetSlot(point.SampleSet); + if (x - lastDrawnXByRowAndSample[rowIndex, sampleSlot] < 1.1d) + { + continue; + } + + lastDrawnXByRowAndSample[rowIndex, sampleSlot] = x; + } + + if (denseMode) + { + context.DrawLine( + DenseSampleSetPen(point), + new Point(x, y - 5), + new Point(x, y + 5)); + } + else + { + context.DrawEllipse( + SampleSetBrush(point), + CurrentPointOutlinePen(), + new Point(x, y), + renderRadius, + renderRadius); + } + + if (isSelected) + { + context.DrawEllipse( + null, + point.Id == SelectedPointId ? PrimarySelectionPen : CurrentSecondarySelectionPen(), + new Point(x, y), + renderRadius + 3, + renderRadius + 3); + } + } + } + + private void DrawSelectionBox(DrawingContext context, double width, double rowHeight, double windowMs) + { + if (!_isRangeSelecting || !_isSelectionDragActive) + { + return; + } + + var startX = TimeToX(_selectionStartTimeMs, width, windowMs); + var currentX = TimeToX(_selectionCurrentTimeMs, width, windowMs); + var minX = Math.Clamp(Math.Min(startX, currentX), 0, width); + var maxX = Math.Clamp(Math.Max(startX, currentX), 0, width); + var startRow = _selectionStartRowIndex >= 0 + ? _selectionStartRowIndex + : YToRowIndex(_selectionStartPosition.Y, rowHeight); + var endRow = _selectionCurrentRowIndex >= 0 + ? _selectionCurrentRowIndex + : YToRowIndex(_selectionCurrentPosition.Y, rowHeight); + var minRow = Math.Min(startRow, endRow); + var maxRow = Math.Max(startRow, endRow); + var y = minRow * rowHeight; + var rect = new Rect(minX, y, Math.Max(1, maxX - minX), Math.Max(1, ((maxRow - minRow) + 1) * rowHeight)); + + context.FillRectangle(new SolidColorBrush(Color.Parse("#66FFD166")), rect); + context.DrawRectangle(new Pen(new SolidColorBrush(Color.Parse("#FFD166")), 1.5), rect); + } + + private void DrawPlacementGhost(DrawingContext context, double width, double rowHeight, double windowMs) + { + if (!_showPlacementGhost || _placementGhostRowIndex <= SampleRowIndex) + { + return; + } + + if (_placementGhostTimeMs < ViewStartMs || _placementGhostTimeMs > ViewEndMs) + { + return; + } + + var x = TimeToX(_placementGhostTimeMs, width, windowMs); + var y = RowCenterY(_placementGhostRowIndex, rowHeight); + context.DrawEllipse(GhostPreviewFillBrush, GhostPreviewPen, new Point(x, y), PointRadius + 1.5, PointRadius + 1.5); + context.DrawLine(GhostPreviewPen, new Point(x, y - 8), new Point(x, y + 8)); + } + + private double TimeToX(double timeMs, double width, double windowMs) + { + return ((timeMs - ViewStartMs) / windowMs) * width; + } + + private int XToTime(double x, double width, double windowMs) + { + if (width <= 0) + { + return (int)Math.Round(ViewStartMs); + } + + return (int)Math.Round(ViewStartMs + (Math.Clamp(x, 0, width) / width) * windowMs); + } + + private static double RowCenterY(int rowIndex, double rowHeight) + { + return (rowIndex * rowHeight) + (rowHeight / 2d); + } + + private static int GetRowIndex(HitSoundVisualizerPoint point) + { + var hitSoundFlags = (int)point.HitSound; + var soundOffset = point.HitSound switch + { + HitSound.Whistle => 1, + HitSound.Finish => 2, + HitSound.Clap => 3, + _ when (hitSoundFlags & (int)HitSound.Clap) == (int)HitSound.Clap => 3, + _ when (hitSoundFlags & (int)HitSound.Finish) == (int)HitSound.Finish => 2, + _ when (hitSoundFlags & (int)HitSound.Whistle) == (int)HitSound.Whistle => 1, + _ => 0 + }; + + return HitSoundRowStartIndex + soundOffset; + } + + private static int YToRowIndex(double y, double rowHeight) + { + return Math.Clamp((int)(y / Math.Max(1d, rowHeight)), 0, TotalRows - 1); + } + + private int ResolveClickedTimeMs(double clickX, double width, double windowMs, double snapTolerancePx) + { + var rawTimeMs = XToTime(clickX, width, windowMs); + if (IsPlaybackRunning) + { + return rawTimeMs; + } + + return TryGetNearestSnapTickTimeMs(clickX, width, windowMs, snapTolerancePx, out var snappedTimeMs) + ? snappedTimeMs + : rawTimeMs; + } + + private static Point ClampPointToBounds(Point point, Rect bounds) + { + return new Point( + Math.Clamp(point.X, 0, Math.Max(0, bounds.Width)), + Math.Clamp(point.Y, 0, Math.Max(0, bounds.Height))); + } + + private void UpdatePlacementGhost(Point pointerPosition, Rect bounds, KeyModifiers keyModifiers) + { + if (IsPlaybackRunning || !keyModifiers.HasFlag(KeyModifiers.Control)) + { + ClearPlacementGhost(); + return; + } + + var rowIndex = YToRowIndex(pointerPosition.Y, DefaultRowHeight); + if (rowIndex == SampleRowIndex) + { + ClearPlacementGhost(); + return; + } + + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + if (!TryGetNearestSnapTickTimeMs(pointerPosition.X, bounds.Width, windowMs, 10d, out var snappedTimeMs)) + { + ClearPlacementGhost(); + return; + } + + var changed = !_showPlacementGhost || _placementGhostRowIndex != rowIndex || _placementGhostTimeMs != snappedTimeMs; + _showPlacementGhost = true; + _placementGhostRowIndex = rowIndex; + _placementGhostTimeMs = snappedTimeMs; + + if (changed) + { + InvalidateVisual(); + } + } + + private void ClearPlacementGhost() + { + if (!_showPlacementGhost) + { + return; + } + + _showPlacementGhost = false; + _placementGhostRowIndex = -1; + _placementGhostTimeMs = -1; + InvalidateVisual(); + } + + private IBrush CurrentBackgroundBrush() => UseLightPalette ? LightBackgroundBrush : BackgroundBrush; + + private IBrush CurrentSampleRowBrush() => UseLightPalette ? LightSampleRowBrush : SampleRowBrush; + + private IBrush CurrentEvenRowBrush() => UseLightPalette ? LightEvenRowBrush : EvenRowBrush; + + private IBrush CurrentOddRowBrush() => UseLightPalette ? LightOddRowBrush : OddRowBrush; + + private Pen CurrentGridPen() => UseLightPalette ? LightGridPen : GridPen; + + private Pen CurrentBorderPen() => UseLightPalette ? LightBorderPen : BorderPen; + + private Pen CurrentPointOutlinePen() => UseLightPalette ? LightPointOutlinePen : PointOutlinePen; + + private Pen CurrentSecondarySelectionPen() => UseLightPalette ? LightSecondarySelectionPen : SecondarySelectionPen; + + private Pen CurrentSampleChangeSelectionAccentPen() => UseLightPalette ? LightSampleChangeSelectionAccentPen : SampleChangeSelectionAccentPen; + + private IBrush CurrentSampleChangeLabelBrush() => UseLightPalette ? LightSampleChangeLabelBrush : SampleChangeLabelBrush; + + private Pen CurrentMeasureTickPen() => UseLightPalette ? LightMeasureTickPen : MeasureTickPen; + + private Pen CurrentGenericTickPen() => UseLightPalette ? LightGenericTickPen : GenericTickPen; + + private static IBrush SampleSetBrush(HitSoundVisualizerPoint point) + { + if (point.IsAutoSampleSet) + { + return AutoPointBrush; + } + + return SampleSetBrush(point.SampleSet); + } + + private static IBrush SampleSetBrush(SampleSet sampleSet) + { + return sampleSet switch + { + SampleSet.Soft => SoftPointBrush, + SampleSet.Drum => DrumPointBrush, + _ => NormalPointBrush + }; + } + + private static Pen DenseSampleSetPen(HitSoundVisualizerPoint point) + { + if (point.IsAutoSampleSet) + { + return DenseAutoPen; + } + + return DenseSampleSetPen(point.SampleSet); + } + + private static Pen DenseSampleSetPen(SampleSet sampleSet) + { + return sampleSet switch + { + SampleSet.Soft => DenseSoftPen, + SampleSet.Drum => DenseDrumPen, + _ => DenseNormalPen + }; + } + + private static int SampleSetSlot(SampleSet sampleSet) + { + return sampleSet switch + { + SampleSet.Soft => 1, + SampleSet.Drum => 2, + _ => 0 + }; + } + + private static double[,] InitializeLastDrawnX() + { + var values = new double[TotalRows, 3]; + for (var row = 0; row < TotalRows; row++) + { + for (var sample = 0; sample < 3; sample++) + { + values[row, sample] = double.NegativeInfinity; + } + } + + return values; + } + + private (HitSoundVisualizerPoint? Point, double Distance) FindNearestPoint(Point clickPosition, double width, double windowMs) + { + HitSoundVisualizerPoint? nearest = null; + var bestDistance = double.MaxValue; + + var (startIndex, endIndex) = GetVisiblePointRange(); + for (var i = startIndex; i < endIndex; i++) + { + var item = _pointsCache[i]; + var x = TimeToX(item.TimeMs, width, windowMs); + var y = RowCenterY(GetRowIndex(item), DefaultRowHeight); + var distance = Math.Sqrt(Math.Pow(clickPosition.X - x, 2) + Math.Pow(clickPosition.Y - y, 2)); + if (distance < bestDistance) + { + bestDistance = distance; + nearest = item; + } + } + + return (nearest, bestDistance); + } + + private (HitSoundVisualizerSampleChange? Marker, double Distance) FindNearestSampleChange(Point clickPosition, double width, double windowMs) + { + HitSoundVisualizerSampleChange? nearest = null; + var bestDistance = double.MaxValue; + var allSampleChanges = _sampleChangesCache; + if (allSampleChanges.Length == 0) + { + return (null, bestDistance); + } + + var rowTop = (SampleRowIndex * DefaultRowHeight) + SampleChangeBarVerticalPadding; + var barHeight = Math.Max(8d, DefaultRowHeight - (SampleChangeBarVerticalPadding * 2d)); + + var (startIndex, endIndex) = GetVisibleSampleChangeRange(); + for (var i = startIndex; i < endIndex && i < allSampleChanges.Length; i++) + { + var marker = allSampleChanges[i]; + var nextTimeMs = i + 1 < allSampleChanges.Length + ? allSampleChanges[i + 1].TimeMs + : (int)Math.Ceiling(ViewEndMs); + var segmentEndMs = Math.Max(nextTimeMs, marker.TimeMs); + if (!(segmentEndMs > ViewStartMs && marker.TimeMs <= ViewEndMs)) + { + continue; + } + + var distance = DistanceToSampleChangeBar( + clickPosition, + marker.TimeMs, + nextTimeMs, + width, + windowMs, + rowTop, + barHeight); + + if (distance < bestDistance) + { + bestDistance = distance; + nearest = marker; + } + } + + return (nearest, bestDistance); + } + + private void DrawSampleChangeBar(DrawingContext context, HitSoundVisualizerSampleChange marker, Rect barRect, bool isSelected) + { + var baseColor = SampleSetColor(marker.SampleSet); + var fillBrush = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative), + GradientStops = new GradientStops + { + new GradientStop(Color.FromArgb(190, baseColor.R, baseColor.G, baseColor.B), 0d), + new GradientStop(Color.FromArgb(72, baseColor.R, baseColor.G, baseColor.B), 0.72d), + new GradientStop(Color.FromArgb(12, baseColor.R, baseColor.G, baseColor.B), 1d) + } + }; + + var outlinePen = new Pen(new SolidColorBrush(Color.FromArgb(170, baseColor.R, baseColor.G, baseColor.B)), 1); + var roundedRect = new RoundedRect(barRect, SampleChangeBarCornerRadius); + context.DrawRectangle(fillBrush, outlinePen, roundedRect); + + context.DrawLine( + new Pen(new SolidColorBrush(Color.FromArgb(220, baseColor.R, baseColor.G, baseColor.B)), 1.6), + new Point(barRect.X + 0.5d, barRect.Y + 1d), + new Point(barRect.X + 0.5d, barRect.Bottom - 1d)); + + if (isSelected) + { + var selectionRect = new Rect(barRect.X - 1d, barRect.Y - 1d, barRect.Width + 2d, barRect.Height + 2d); + context.DrawRectangle(null, SampleChangeSelectionPen, new RoundedRect(selectionRect, SampleChangeBarCornerRadius + 1d)); + context.DrawRectangle(null, CurrentSampleChangeSelectionAccentPen(), roundedRect); + } + + var label = FormatSampleChangeLabel(marker); + var text = new FormattedText( + label, + CultureInfo.InvariantCulture, + FlowDirection.LeftToRight, + SampleChangeLabelTypeface, + SampleChangeTextFontSize, + CurrentSampleChangeLabelBrush()); + + var maxTextWidth = barRect.Width - (SampleChangeTextPadding * 2d); + if (maxTextWidth < 28d || text.Width > maxTextWidth) + { + return; + } + + var textX = barRect.X + SampleChangeTextPadding; + var textY = barRect.Y + Math.Max(0d, (barRect.Height - text.Height) / 2d) - 0.5d; + using (context.PushClip(new Rect(barRect.X + 1d, barRect.Y + 1d, Math.Max(0d, barRect.Width - 2d), Math.Max(0d, barRect.Height - 2d)))) + { + context.DrawText(text, new Point(textX, textY)); + } + } + + private Rect CreateSampleChangeBarRect( + int timeMs, + int nextTimeMs, + double width, + double windowMs, + double rowTop, + double barHeight) + { + var startX = TimeToX(timeMs, width, windowMs); + var nextX = TimeToX(Math.Max(timeMs, nextTimeMs), width, windowMs) - SampleChangeBarHorizontalPadding; + var rightX = Math.Max(startX + SampleChangeBarMinWidth, nextX); + var clippedLeft = Math.Clamp(startX, 0, width); + var clippedRight = Math.Clamp(rightX, 0, width); + if (clippedRight < clippedLeft) + { + (clippedLeft, clippedRight) = (clippedRight, clippedLeft); + } + + return new Rect(clippedLeft, rowTop, Math.Max(0d, clippedRight - clippedLeft), barHeight); + } + + private double DistanceToSampleChangeBar( + Point clickPosition, + int timeMs, + int nextTimeMs, + double width, + double windowMs, + double rowTop, + double barHeight) + { + var rect = CreateSampleChangeBarRect(timeMs, nextTimeMs, width, windowMs, rowTop, barHeight); + if (rect.Width <= 0.5d) + { + return double.MaxValue; + } + + var dx = clickPosition.X < rect.X + ? rect.X - clickPosition.X + : clickPosition.X > rect.Right + ? clickPosition.X - rect.Right + : 0d; + var dy = clickPosition.Y < rect.Y + ? rect.Y - clickPosition.Y + : clickPosition.Y > rect.Bottom + ? clickPosition.Y - rect.Bottom + : 0d; + + return Math.Sqrt((dx * dx) + (dy * dy)); + } + + private static Color SampleSetColor(SampleSet sampleSet) + { + return sampleSet switch + { + SampleSet.Soft => Color.Parse("#5BC0EB"), + SampleSet.Drum => Color.Parse("#E55934"), + _ => Color.Parse("#9BC53D") + }; + } + + private static bool ShouldUseDenseSampleChangeMarkers(int visibleChangeCount, double width) + { + if (visibleChangeCount <= 0 || width <= 1) + { + return false; + } + + var avgSpacingPx = width / visibleChangeCount; + return avgSpacingPx <= SampleChangeDenseModeMaxAvgSpacingPx; + } + + private static Pen SampleChangeLinePen(SampleSet sampleSet) + { + return sampleSet switch + { + SampleSet.Soft => SoftSampleChangeLinePen, + SampleSet.Drum => DrumSampleChangeLinePen, + _ => NormalSampleChangeLinePen + }; + } + + private static string FormatSampleChangeLabel(HitSoundVisualizerSampleChange marker) + { + var bank = marker.SampleSet switch + { + SampleSet.Soft => "soft", + SampleSet.Drum => "drum", + _ => "normal" + }; + + return $"{bank}{Math.Max(1, marker.Index)} - {NormalizeDisplayVolumePercent(marker.Volume)}%"; + } + + private static int NormalizeDisplayVolumePercent(int volume) + { + if (volume is > 0 and <= 1) + { + return volume * 100; + } + + return Math.Clamp(volume, 0, 100); + } + + private void HandleRightClick(Point clickPosition, Rect bounds, KeyModifiers keyModifiers) + { + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + var clickedRow = YToRowIndex(clickPosition.Y, DefaultRowHeight); + var isSampleRow = clickedRow == SampleRowIndex; + var isCtrlDelete = keyModifiers.HasFlag(KeyModifiers.Control); + var clickedTimeMs = ResolveClickedTimeMs(clickPosition.X, bounds.Width, windowMs, 12d); + var (nearestPoint, pointDistance) = isSampleRow + ? (null, double.MaxValue) + : FindNearestPoint(clickPosition, bounds.Width, windowMs); + var (nearestSampleChange, sampleChangeDistance) = isSampleRow + ? FindNearestSampleChange(clickPosition, bounds.Width, windowMs) + : (null, double.MaxValue); + + var hasPointTarget = nearestPoint is not null && pointDistance <= PointHitTestRadius; + var hasSampleChangeTarget = nearestSampleChange is not null && sampleChangeDistance <= SampleChangeHitTestRadius; + + if (isCtrlDelete) + { + if (hasPointTarget && nearestPoint is not null) + { + if (SelectPointCommand?.CanExecute(nearestPoint.Id) == true) + { + SelectPointCommand.Execute(nearestPoint.Id); + } + + if (DeleteSelectedPointCommand?.CanExecute(null) == true) + { + DeleteSelectedPointCommand.Execute(null); + } + + return; + } + + if (hasSampleChangeTarget && nearestSampleChange is not null) + { + var deleteContextRequest = new HitSoundTimelineContextRequest + { + TimeMs = nearestSampleChange.TimeMs, + PointId = -1, + SampleChangeTimeMs = nearestSampleChange.TimeMs, + IsSampleRow = true + }; + + if (OpenContextEditorCommand?.CanExecute(deleteContextRequest) == true) + { + OpenContextEditorCommand.Execute(deleteContextRequest); + } + + if (DeleteSampleChangeCommand?.CanExecute(null) == true) + { + DeleteSampleChangeCommand.Execute(null); + } + + return; + } + + return; + } + + if (hasPointTarget && nearestPoint is not null) + { + var selectedIds = (SelectedPointIds ?? []).ToHashSet(); + if (!selectedIds.Contains(nearestPoint.Id)) + { + if (SelectPointCommand?.CanExecute(nearestPoint.Id) == true) + { + SelectPointCommand.Execute(nearestPoint.Id); + } + } + + clickedTimeMs = nearestPoint.TimeMs; + } + else if (hasSampleChangeTarget && nearestSampleChange is not null) + { + clickedTimeMs = nearestSampleChange.TimeMs; + } + + if (SeekTimeCommand?.CanExecute(clickedTimeMs) == true) + { + SeekTimeCommand.Execute(clickedTimeMs); + } + + var contextRequest = new HitSoundTimelineContextRequest + { + TimeMs = clickedTimeMs, + PointId = hasPointTarget && nearestPoint is not null ? nearestPoint.Id : -1, + SampleChangeTimeMs = hasSampleChangeTarget && nearestSampleChange is not null + ? nearestSampleChange.TimeMs + : null, + IsSampleRow = isSampleRow + }; + + if (OpenContextEditorCommand?.CanExecute(contextRequest) == true) + { + OpenContextEditorCommand.Execute(contextRequest); + } + } + + private bool TryGetNearestSnapTickTimeMs( + double clickX, + double width, + double windowMs, + double tolerancePx, + out int snappedTimeMs) + { + snappedTimeMs = 0; + if (_snapTicksCache.Length == 0 || width <= 1 || windowMs <= 0) + { + return false; + } + + var selectedDivisor = Math.Clamp(SnapDivisorDenominator, 1, 16); + var bestDistancePx = double.MaxValue; + HitSoundVisualizerSnapTick? bestTick = null; + + var (startIndex, endIndex) = GetVisibleSnapTickRange(); + for (var i = startIndex; i < endIndex; i++) + { + var tick = _snapTicksCache[i]; + + if (tick.Denominator > selectedDivisor || selectedDivisor % Math.Max(1, tick.Denominator) != 0) + { + continue; + } + + var x = TimeToX(tick.ExactTimeMs, width, windowMs); + var distancePx = Math.Abs(x - clickX); + if (distancePx < bestDistancePx) + { + bestDistancePx = distancePx; + bestTick = tick; + } + } + + if (bestTick is null || bestDistancePx > tolerancePx) + { + return false; + } + + snappedTimeMs = bestTick.TimeMs; + return true; + } + + private bool TryResolvePlaybackWheelSeekTarget(double wheelDelta, out int seekTimeMs) + { + seekTimeMs = 0; + if (wheelDelta == 0 || !IsPlaybackRunning || !FollowPlaybackCursor) + { + return false; + } + + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + // Free wheel scrubbing while playback-follow is active: no snap quantization. + var stepMs = Math.Clamp(windowMs * 0.03d, 8d, 420d); + var deltaMs = wheelDelta * stepMs; + var target = (int)Math.Round(CursorTimeMs - deltaMs); + + if (target == CursorTimeMs) + { + target = CursorTimeMs - Math.Sign(wheelDelta); + } + + seekTimeMs = target; + return true; + } + + private void RefreshRangeSelectionAnchorFromCurrentViewport(Rect bounds) + { + if (!_isRangeSelecting || !_isSelectionDragActive) + { + return; + } + + var windowMs = Math.Max(1d, ViewEndMs - ViewStartMs); + _selectionCurrentPosition = ClampPointToBounds(_selectionCurrentPosition, bounds); + _selectionCurrentTimeMs = XToTime(_selectionCurrentPosition.X, bounds.Width, windowMs); + _selectionCurrentRowIndex = YToRowIndex(_selectionCurrentPosition.Y, DefaultRowHeight); + InvalidateVisual(); + } + + private void ResetRangeSelectionState() + { + _isRangeSelecting = false; + _isSelectionDragActive = false; + _isAdditiveSelection = false; + _selectionStartRowIndex = -1; + _selectionCurrentRowIndex = -1; + } + + private static Color SnapTickColor(int denominator) + { + return denominator switch + { + 1 => Color.Parse("#FFFFFF"), // 1/1 + 2 => Color.Parse("#FF5A5A"), // 1/2 + 3 => Color.Parse("#C99BFF"), // 1/3 + 4 => Color.Parse("#4EA3FF"), // 1/4 + 6 => Color.Parse("#8E5CFF"), // 1/6 + 8 => Color.Parse("#F0D94B"), // 1/8 + 16 => Color.Parse("#7A848F"), // 1/16 + + // Triplet-family fallback for 1/12, 1/15, etc. + _ when denominator % 6 == 0 => Color.Parse("#8E5CFF"), + _ when denominator % 3 == 0 => Color.Parse("#C99BFF"), + _ => Color.Parse("#7A848F") + }; + } + + private Pen SnapTickPen(int denominator) + { + return denominator switch + { + 1 => CurrentMeasureTickPen(), + 2 => HalfTickPen, + 3 => TripletTickPen, + 4 => QuarterTickPen, + 6 => SixthTickPen, + 8 => EighthTickPen, + _ when denominator % 6 == 0 => SixthTickPen, + _ when denominator % 3 == 0 => TripletTickPen, + _ => CurrentGenericTickPen() + }; + } + + private static TickRenderMode ResolveTickRenderMode( + IReadOnlyList visibleTicks, + double width, + double windowMs) + { + if (visibleTicks.Count < 2 || width <= 1 || windowMs <= 0) + { + return TickRenderMode.Full; + } + + var spacings = new List(Math.Min(visibleTicks.Count - 1, 512)); + for (var i = 1; i < visibleTicks.Count && spacings.Count < 512; i++) + { + var dt = visibleTicks[i].TimeMs - visibleTicks[i - 1].TimeMs; + if (dt <= 0) + { + continue; + } + + var spacingPx = (dt / windowMs) * width; + if (spacingPx > 0) + { + spacings.Add(spacingPx); + } + } + + if (spacings.Count == 0) + { + return TickRenderMode.Full; + } + + spacings.Sort(); + var medianSpacing = spacings[spacings.Count / 2]; + + if (medianSpacing < 3.5) + { + return TickRenderMode.MeasuresOnly; + } + + if (medianSpacing < 6) + { + return TickRenderMode.StrongOnly; + } + + if (medianSpacing < 9) + { + return TickRenderMode.MidOnly; + } + + return TickRenderMode.Full; + } + + private static bool ShouldRenderTickForMode(HitSoundVisualizerSnapTick tick, TickRenderMode mode) + { + return mode switch + { + TickRenderMode.MeasuresOnly => tick.IsMeasureLine, + TickRenderMode.StrongOnly => tick.Denominator is 1 or 2 or 3 or 4, + TickRenderMode.MidOnly => tick.Denominator is 1 or 2 or 3 or 4 or 6 or 8, + _ => true + }; + } + + private static IReadOnlyList<(HitSoundVisualizerSnapTick Tick, double X)> SimplifyTickCandidatesForDensity( + IReadOnlyList<(HitSoundVisualizerSnapTick Tick, double X)> candidates, + double width, + TickRenderMode tickMode) + { + if (candidates.Count < 2 || width <= 1) + { + return candidates; + } + + var baseTargetSpacingPx = tickMode switch + { + TickRenderMode.MeasuresOnly => SnapTickDenseMeasuresTargetMinSpacingPx, + TickRenderMode.StrongOnly => SnapTickDenseStrongTargetMinSpacingPx, + _ => SnapTickTargetMinSpacingPx + }; + var maxSpacingPx = tickMode == TickRenderMode.MeasuresOnly + ? SnapTickDenseMeasuresMaxSpacingPx + : SnapTickMaxSimplifiedSpacingPx; + + var targetMaxTicks = Math.Max(32d, width / baseTargetSpacingPx); + if (candidates.Count <= targetMaxTicks) + { + return candidates; + } + + var overloadRatio = candidates.Count / targetMaxTicks; + var minSpacingPx = Math.Clamp( + baseTargetSpacingPx * Math.Sqrt(overloadRatio), + baseTargetSpacingPx, + maxSpacingPx); + + var simplified = new List<(HitSoundVisualizerSnapTick Tick, double X)>(Math.Min(candidates.Count, (int)Math.Ceiling(width))); + foreach (var candidate in candidates) + { + if (simplified.Count == 0) + { + simplified.Add(candidate); + continue; + } + + var last = simplified[^1]; + if (candidate.X - last.X >= minSpacingPx) + { + simplified.Add(candidate); + continue; + } + + if (GetTickRenderPriority(candidate.Tick) > GetTickRenderPriority(last.Tick)) + { + simplified[^1] = candidate; + } + } + + return simplified; + } + + private static int GetTickRenderPriority(HitSoundVisualizerSnapTick tick) + { + var priority = tick.IsMeasureLine ? 10_000 : 0; + priority += tick.Denominator switch + { + 1 => 2_000, + 2 or 3 or 4 => 1_000, + 6 or 8 => 500, + _ => 0 + }; + + // Prefer lower denominators when density pruning needs to choose. + priority += Math.Max(0, 256 - Math.Min(255, tick.Denominator)); + return priority; + } + + private enum TickRenderMode + { + Full, + MidOnly, + StrongOnly, + MeasuresOnly + } +} diff --git a/MapWizard.Desktop/Views/Controls/ModernColorPicker.axaml.cs b/MapWizard.Desktop/Views/Controls/ModernColorPicker.axaml.cs index 09ba549..0456b3a 100644 --- a/MapWizard.Desktop/Views/Controls/ModernColorPicker.axaml.cs +++ b/MapWizard.Desktop/Views/Controls/ModernColorPicker.axaml.cs @@ -146,8 +146,8 @@ private static bool TryParseHexColor(string input, out Color color) } if (hex.Length != 6 || !byte.TryParse(hex[..2], System.Globalization.NumberStyles.HexNumber, null, out var r) - || !byte.TryParse(hex.Substring(2, 2), System.Globalization.NumberStyles.HexNumber, null, out var g) - || !byte.TryParse(hex.Substring(4, 2), System.Globalization.NumberStyles.HexNumber, null, out var b)) + || !byte.TryParse(hex.AsSpan(2, 2), System.Globalization.NumberStyles.HexNumber, null, out var g) + || !byte.TryParse(hex.AsSpan(4, 2), System.Globalization.NumberStyles.HexNumber, null, out var b)) { return false; } diff --git a/MapWizard.Desktop/Views/Dialogs/SongSelectDialog.axaml b/MapWizard.Desktop/Views/Dialogs/SongSelectDialog.axaml index f076d03..c5cacd0 100644 --- a/MapWizard.Desktop/Views/Dialogs/SongSelectDialog.axaml +++ b/MapWizard.Desktop/Views/Dialogs/SongSelectDialog.axaml @@ -31,12 +31,24 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml.cs b/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml.cs new file mode 100644 index 0000000..86e4c8b --- /dev/null +++ b/MapWizard.Desktop/Views/HitSoundVisualizer/HitSoundVisualizerView.axaml.cs @@ -0,0 +1,384 @@ +using System; +using System.ComponentModel; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using MapWizard.Desktop.ViewModels; + +namespace MapWizard.Desktop.Views.HitSoundVisualizer; + +public partial class HitSoundVisualizerView : UserControl +{ + private const int SpacePlaybackToggleDebounceMs = 130; + private const int VolumeWheelStepPercent = 1; + private long _lastSpacePlaybackToggleTickMs = -SpacePlaybackToggleDebounceMs; + private HitSoundVisualizerViewModel? _boundViewModel; + + public HitSoundVisualizerView() + { + InitializeComponent(); + AttachedToVisualTree += (_, _) => + { + if (DataContext is HitSoundVisualizerViewModel vm) + { + vm.RefreshPersistedPlaybackVolumes(); + } + }; + DetachedFromVisualTree += (_, _) => BindViewModel(null); + AddHandler(KeyDownEvent, Root_OnKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel); + AddHandler( + InputElement.PointerPressedEvent, + Root_OnPointerPressedTunnel, + Avalonia.Interactivity.RoutingStrategies.Tunnel, + handledEventsToo: true); + BindViewModel(DataContext as HitSoundVisualizerViewModel); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + BindViewModel(DataContext as HitSoundVisualizerViewModel); + } + + private async void Root_OnKeyDown(object? sender, KeyEventArgs e) + { + if (DataContext is not HitSoundVisualizerViewModel vm) + { + return; + } + + if (HasTextEntryLikeFocus()) + { + return; + } + + if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.Z) + { + if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) + { + vm.RedoCommand.Execute(null); + } + else + { + vm.UndoCommand.Execute(null); + } + + e.Handled = true; + return; + } + + if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.Y) + { + vm.RedoCommand.Execute(null); + e.Handled = true; + return; + } + + if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.A) + { + vm.SelectAllPointsCommand.Execute(null); + e.Handled = true; + return; + } + + if (e.Key == Key.Delete) + { + if (vm.IsSampleRowContextActive) + { + vm.RemoveContextSamplePointCommand.Execute(null); + } + else + { + vm.RemoveSelectedPointCommand.Execute(null); + } + + e.Handled = true; + return; + } + + if (TryHandleBankShortcuts(e, vm)) + { + return; + } + + if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.C) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard is not null && vm.TryBuildPointClipboardPayload(out var payload)) + { + await topLevel.Clipboard.SetTextAsync(payload); + e.Handled = true; + } + + return; + } + + if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && e.Key == Key.V) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard is not null) + { + var text = await topLevel.Clipboard.GetTextAsync(); + vm.PastePointClipboardPayload(text); + e.Handled = true; + } + + return; + } + + if (e.KeyModifiers == KeyModifiers.None) + { + if (e.Key == Key.W) + { + vm.AddSelectionHitSoundCommand.Execute("Whistle"); + e.Handled = true; + return; + } + + if (e.Key == Key.E) + { + vm.AddSelectionHitSoundCommand.Execute("Finish"); + e.Handled = true; + return; + } + + if (e.Key == Key.R) + { + vm.AddSelectionHitSoundCommand.Execute("Clap"); + e.Handled = true; + return; + } + } + + if (e.Key != Key.Space) + { + return; + } + + var now = Environment.TickCount64; + if (now - _lastSpacePlaybackToggleTickMs < SpacePlaybackToggleDebounceMs) + { + e.Handled = true; + return; + } + + _lastSpacePlaybackToggleTickMs = now; + vm.TogglePlaybackCommand.Execute(null); + e.Handled = true; + } + + private void Root_OnPointerPressedTunnel(object? sender, PointerPressedEventArgs e) + { + if (DataContext is not HitSoundVisualizerViewModel vm || !vm.ShowPlaybackTimelineSection) + { + return; + } + + var currentPoint = e.GetCurrentPoint(this); + var isLeftPress = currentPoint.Properties.IsLeftButtonPressed || + currentPoint.Properties.PointerUpdateKind == PointerUpdateKind.LeftButtonPressed; + if (!isLeftPress || IsPointerEventFromOverviewBar(e)) + { + return; + } + + var bounds = PlaybackOverviewBar.Bounds; + if (!PlaybackOverviewBar.IsVisible || bounds.Width <= 1d || bounds.Height <= 1d) + { + return; + } + + var localPosition = e.GetPosition(PlaybackOverviewBar); + if (localPosition.X < 0d || localPosition.X > bounds.Width || localPosition.Y < 0d || localPosition.Y > bounds.Height) + { + return; + } + + var ratio = Math.Clamp(localPosition.X / bounds.Width, 0d, 1d); + var timeMs = (int)Math.Round(ratio * Math.Max(1d, vm.TimelineEndMs)); + if (vm.SeekTimeCommand.CanExecute(timeMs)) + { + vm.SeekTimeCommand.Execute(timeMs); + } + } + + private bool HasTextEntryLikeFocus() + { + var focused = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement(); + if (focused is TextBox) + { + return true; + } + + if (focused is Avalonia.Visual visual) + { + return visual.GetVisualAncestors().OfType().Any() || + visual.GetVisualAncestors().OfType().Any(); + } + + return false; + } + + private async void CopyHitsoundSelectionToClipboard_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (DataContext is not HitSoundVisualizerViewModel vm) + { + return; + } + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard is null) + { + return; + } + + if (!vm.TryBuildPointClipboardPayload(out var payload)) + { + return; + } + + await topLevel.Clipboard.SetTextAsync(payload); + e.Handled = true; + } + + private void SongVolumeButton_OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (DataContext is not HitSoundVisualizerViewModel vm) + { + return; + } + + if (!TryGetWheelDirection(e, out var direction)) + { + return; + } + + vm.SongVolumePercent = Math.Clamp(vm.SongVolumePercent + (direction * VolumeWheelStepPercent), 0, 100); + e.Handled = true; + } + + private void EffectsVolumeButton_OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (DataContext is not HitSoundVisualizerViewModel vm) + { + return; + } + + if (!TryGetWheelDirection(e, out var direction)) + { + return; + } + + vm.HitSoundVolumePercent = Math.Clamp(vm.HitSoundVolumePercent + (direction * VolumeWheelStepPercent), 0, 100); + e.Handled = true; + } + + private static bool TryGetWheelDirection(PointerWheelEventArgs e, out int direction) + { + var wheelDelta = Math.Abs(e.Delta.Y) >= Math.Abs(e.Delta.X) ? e.Delta.Y : e.Delta.X; + direction = Math.Sign(wheelDelta); + return direction != 0; + } + + private bool IsPointerEventFromOverviewBar(PointerPressedEventArgs e) + { + if (e.Source is not Avalonia.Visual sourceVisual) + { + return false; + } + + return ReferenceEquals(sourceVisual, PlaybackOverviewBar) || + sourceVisual.GetVisualAncestors().Any(ancestor => ReferenceEquals(ancestor, PlaybackOverviewBar)); + } + + private bool TryHandleBankShortcuts(KeyEventArgs e, HitSoundVisualizerViewModel vm) + { + if (e.KeyModifiers == KeyModifiers.Control) + { + var additionBank = e.Key switch + { + Key.Q => "Auto", + Key.W => "Normal", + Key.E => "Soft", + Key.R => "Drum", + _ => null + }; + + if (additionBank is not null) + { + vm.ApplyAdditionShortcutCommand.Execute(additionBank); + e.Handled = true; + return true; + } + } + + if (e.KeyModifiers == KeyModifiers.Shift) + { + var sampleSetBank = e.Key switch + { + Key.Q => "Auto", + Key.W => "Normal", + Key.E => "Soft", + Key.R => "Drum", + _ => null + }; + + if (sampleSetBank is not null) + { + vm.ApplySampleSetShortcutCommand.Execute(sampleSetBank); + e.Handled = true; + return true; + } + } + + return false; + } + + private void BindViewModel(HitSoundVisualizerViewModel? vm) + { + if (_boundViewModel is not null) + { + _boundViewModel.FocusPlaybackRequested -= OnFocusPlaybackRequested; + _boundViewModel.PropertyChanged -= OnViewModelPropertyChanged; + } + + _boundViewModel = vm; + if (_boundViewModel is null) + { + return; + } + + _boundViewModel.FocusPlaybackRequested += OnFocusPlaybackRequested; + _boundViewModel.PropertyChanged += OnViewModelPropertyChanged; + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not HitSoundVisualizerViewModel vm) + { + return; + } + + if (e.PropertyName == nameof(HitSoundVisualizerViewModel.IsPreparingPlayback) && + !vm.IsPreparingPlayback && + vm.HasLoadedMap) + { + FocusVisualizer(); + } + } + + private void OnFocusPlaybackRequested() + { + FocusVisualizer(); + } + + private void FocusVisualizer() + { + Dispatcher.UIThread.Post(() => + { + Focus(); + }, DispatcherPriority.Input); + } +} diff --git a/MapWizard.Desktop/Views/MainWindow.axaml b/MapWizard.Desktop/Views/MainWindow.axaml index e24fa13..7ad91c5 100644 --- a/MapWizard.Desktop/Views/MainWindow.axaml +++ b/MapWizard.Desktop/Views/MainWindow.axaml @@ -5,6 +5,12 @@ xmlns:views="clr-namespace:MapWizard.Desktop.Views" xmlns:suki="clr-namespace:SukiUI.Controls;assembly=SukiUI" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" + xmlns:welcomePage="clr-namespace:MapWizard.Desktop.Views.WelcomePage" + xmlns:hitSoundVisualizer="clr-namespace:MapWizard.Desktop.Views.HitSoundVisualizer" + xmlns:metadataManager="clr-namespace:MapWizard.Desktop.Views.MetadataManager" + xmlns:comboColourStudio="clr-namespace:MapWizard.Desktop.Views.ComboColourStudio" + xmlns:mapCleaner="clr-namespace:MapWizard.Desktop.Views.MapCleaner" + xmlns:settings="clr-namespace:MapWizard.Desktop.Views.Settings" x:Class="MapWizard.Desktop.Views.MainWindow" x:DataType="vm:MainWindowViewModel" IsMenuVisible="False" @@ -52,22 +58,25 @@ - + + + + - + - + - + - + @@ -196,6 +205,36 @@ + +