From bb8f8e8d8c709069d41338afb01206869dee4c99 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Wed, 26 Mar 2025 15:41:48 +0100 Subject: [PATCH 1/8] Use median instead of mean for automatic beatmap offset adjustment --- .../Rulesets/Scoring/HitEventExtensions.cs | 19 +++++++++++++++++++ .../PlayerSettings/BeatmapOffsetControl.cs | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index fed0c3b51b..da1ac9f2a1 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -71,6 +71,25 @@ namespace osu.Game.Rulesets.Scoring return timeOffsets.Average(); } + /// + /// Calculates the median hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average. + /// + /// + /// A non-null value if unstable rate could be calculated, + /// and if unstable rate cannot be calculated due to being empty. + /// + public static double? CalculateMedianHitError(this IEnumerable hitEvents) + { + double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).OrderBy(x => x).ToArray(); + + if (timeOffsets.Length == 0) + return null; + + int center = timeOffsets.Length / 2; + + return timeOffsets.Length % 2 == 0 ? (timeOffsets[center - 1] + timeOffsets[center]) / 2 : timeOffsets[center]; + } + public static bool AffectsUnstableRate(HitEvent e) => AffectsUnstableRate(e.HitObject, e.Result); public static bool AffectsUnstableRate(HitObject hitObject, HitResult result) => hitObject.HitWindows != HitWindows.Empty && result.IsHit(); diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index cef5884d39..ce474ed594 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -196,7 +196,7 @@ namespace osu.Game.Screens.Play.PlayerSettings var hitEvents = score.NewValue.HitEvents; - if (!(hitEvents.CalculateAverageHitError() is double average)) + if (!(hitEvents.CalculateMedianHitError() is double median)) return; referenceScoreContainer.Children = new Drawable[] @@ -226,7 +226,7 @@ namespace osu.Game.Screens.Play.PlayerSettings return; } - lastPlayAverage = average; + lastPlayAverage = median; lastPlayBeatmapOffset = Current.Value; LinkFlowContainer globalOffsetText; From 6452514066161dd6c38e533c5564f6c0249abb59 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:22:57 +0100 Subject: [PATCH 2/8] Add comment --- osu.Game/Rulesets/Scoring/HitEventExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index da1ac9f2a1..01d800a351 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -87,6 +87,7 @@ namespace osu.Game.Rulesets.Scoring int center = timeOffsets.Length / 2; + // Use average of the 2 central values if length is even return timeOffsets.Length % 2 == 0 ? (timeOffsets[center - 1] + timeOffsets[center]) / 2 : timeOffsets[center]; } From 77d73c5f50ef2a36877f0a1b33e0cdab356ead98 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:23:19 +0100 Subject: [PATCH 3/8] Increase number of timed hits needed to activate button --- osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index ce474ed594..c2cd09c56f 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -210,7 +210,7 @@ namespace osu.Game.Screens.Play.PlayerSettings // affecting unstable rate here is used as a substitute of determining if a hit event represents a *timed* hit event, // i.e. an user input that the user had to *time to the track*, // i.e. one that it *makes sense to use* when doing anything with timing and offsets. - if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 10) + if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 50) { referenceScoreContainer.AddRange(new Drawable[] { From 4b8fe015e56614b1a98c3e2081ff9606d0d3bd9b Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:53:03 +0100 Subject: [PATCH 4/8] Apply median to `SessionAverageHitErrorTracker` --- osu.Game/Configuration/SessionAverageHitErrorTracker.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs index cd21eb6fa8..49f7657f91 100644 --- a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs +++ b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs @@ -40,10 +40,10 @@ namespace osu.Game.Configuration if (newScore.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs)) return; - if (newScore.HitEvents.Count < 10) + if (newScore.HitEvents.Count < 50) return; - if (newScore.HitEvents.CalculateAverageHitError() is not double averageError) + if (newScore.HitEvents.CalculateMedianHitError() is not double medianError) return; // keep a sane maximum number of entries. @@ -51,7 +51,7 @@ namespace osu.Game.Configuration averageHitErrorHistory.RemoveAt(0); double globalOffset = configManager.Get(OsuSetting.AudioOffset); - averageHitErrorHistory.Add(new DataPoint(averageError, globalOffset)); + averageHitErrorHistory.Add(new DataPoint(medianError, globalOffset)); } public void ClearHistory() => averageHitErrorHistory.Clear(); From fa06643bb6c0aacde659640ae0a65c68ab9b0c61 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Sat, 29 Mar 2025 13:44:14 +0100 Subject: [PATCH 5/8] Use median for statistic display --- osu.Game/Screens/Ranking/Statistics/AverageHitError.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs index fb7107cc88..29df085c62 100644 --- a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs +++ b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs @@ -8,18 +8,18 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Ranking.Statistics { /// - /// Displays the unstable rate statistic for a given play. + /// Displays the average hit error statistic for a given play. /// public partial class AverageHitError : SimpleStatisticItem { /// /// Creates and computes an statistic. /// - /// Sequence of s to calculate the unstable rate based on. + /// Sequence of s to calculate the average hit error based on. public AverageHitError(IEnumerable hitEvents) : base("Average Hit Error") { - Value = hitEvents.CalculateAverageHitError(); + Value = hitEvents.CalculateMedianHitError(); } protected override string DisplayValue(double? value) => value == null ? "(not available)" : $"{Math.Abs(value.Value):N2} ms {(value.Value < 0 ? "early" : "late")}"; From b3c578e5455c572e34e2def301ba657182747149 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Sat, 29 Mar 2025 13:45:39 +0100 Subject: [PATCH 6/8] Remove mean hit error calculation --- osu.Game/Rulesets/Scoring/HitEventExtensions.cs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index 01d800a351..39fc8b357b 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -54,23 +54,6 @@ namespace osu.Game.Rulesets.Scoring return result; } - /// - /// Calculates the average hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average. - /// - /// - /// A non-null value if unstable rate could be calculated, - /// and if unstable rate cannot be calculated due to being empty. - /// - public static double? CalculateAverageHitError(this IEnumerable hitEvents) - { - double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).ToArray(); - - if (timeOffsets.Length == 0) - return null; - - return timeOffsets.Average(); - } - /// /// Calculates the median hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average. /// From 403b24ef9bb32d95dc3f701ee9fce8b217d16848 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Sat, 29 Mar 2025 20:43:59 +0100 Subject: [PATCH 7/8] Adapt `TestNotEnoughTimedHitEvents` with new minimum hit amount --- .../Gameplay/TestSceneBeatmapOffsetControl.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index aa99b22701..92a10628ff 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -50,21 +50,17 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("Set short reference score", () => { + // 50 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows List hitEvents = [ - // 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), ]; + for (int i = 0; i < 49; i++) + { + hitEvents.Add(new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null)); + } + foreach (var ev in hitEvents) ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); From 332b08160388cc0eaef2d22f5197ce9e5ee910ba Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 3 Apr 2025 18:55:45 +0900 Subject: [PATCH 8/8] Rename some variables --- .../Play/PlayerSettings/BeatmapOffsetControl.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index c2cd09c56f..23ccb3311b 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -63,11 +63,11 @@ namespace osu.Game.Screens.Play.PlayerSettings [Resolved] private IGameplayClock? gameplayClock { get; set; } - private double lastPlayAverage; + private double lastPlayMedian; private double lastPlayBeatmapOffset; private HitEventTimingDistributionGraph? lastPlayGraph; - private SettingsButton? useAverageButton; + private SettingsButton? calibrateFromLastPlayButton; private IDisposable? beatmapOffsetSubscription; @@ -226,7 +226,7 @@ namespace osu.Game.Screens.Play.PlayerSettings return; } - lastPlayAverage = median; + lastPlayMedian = median; lastPlayBeatmapOffset = Current.Value; LinkFlowContainer globalOffsetText; @@ -239,7 +239,7 @@ namespace osu.Game.Screens.Play.PlayerSettings Height = 50, }, new AverageHitError(hitEvents), - useAverageButton = new SettingsButton + calibrateFromLastPlayButton = new SettingsButton { Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay, Action = () => @@ -247,7 +247,7 @@ namespace osu.Game.Screens.Play.PlayerSettings if (Current.Disabled) return; - Current.Value = lastPlayBeatmapOffset - lastPlayAverage; + Current.Value = lastPlayBeatmapOffset - lastPlayMedian; lastAppliedScore.Value = ReferenceScore.Value; }, }, @@ -281,8 +281,8 @@ namespace osu.Game.Screens.Play.PlayerSettings bool allow = allowOffsetAdjust; - if (useAverageButton != null) - useAverageButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2); + if (calibrateFromLastPlayButton != null) + calibrateFromLastPlayButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayMedian, adjustmentSinceLastPlay, Current.Precision / 2); Current.Disabled = !allow; }