Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement automatic suggestion of global audio offset based on last plays #26169

Merged
merged 12 commits into from
Dec 28, 2023
Merged
28 changes: 24 additions & 4 deletions osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Scoring;
using osu.Game.Screens.Play.PlayerSettings;
using osu.Game.Tests.Resources;
using osu.Game.Tests.Visual.Ranking;

namespace osu.Game.Tests.Visual.Gameplay
Expand Down Expand Up @@ -44,7 +45,23 @@ public void TestTooShortToDisplay()
{
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(0, 2)
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(0, 2),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
};
});

AddAssert("No calibration button", () => !offsetControl.ChildrenOfType<SettingsButton>().Any());
}

[Test]
public void TestScoreFromDifferentBeatmap()
{
AddStep("Set short reference score", () =>
{
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(10),
BeatmapInfo = TestResources.CreateTestBeatmapSetInfo().Beatmaps.First(),
};
});

Expand All @@ -59,7 +76,8 @@ public void TestModRemovingTimedInputs()
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(10),
Mods = new Mod[] { new OsuModRelax() }
Mods = new Mod[] { new OsuModRelax() },
BeatmapInfo = Beatmap.Value.BeatmapInfo,
};
});

Expand All @@ -77,7 +95,8 @@ public void TestCalibrationFromZero()
{
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error)
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
};
});

Expand Down Expand Up @@ -105,7 +124,8 @@ public void TestCalibrationFromNonZero()
{
offsetControl.ReferenceScore.Value = new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error)
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
};
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Overlays.Settings.Sections.Audio;
using osu.Game.Scoring;
using osu.Game.Tests.Visual.Ranking;

namespace osu.Game.Tests.Visual.Settings
{
public partial class TestSceneAudioOffsetAdjustControl : OsuTestScene
{
[Resolved]
private SessionStatics statics { get; set; } = null!;

[Cached]
private SessionAverageHitErrorTracker tracker = new SessionAverageHitErrorTracker();

private Container content = null!;
protected override Container Content => content;

[BackgroundDependencyLoader]
private void load()
{
base.Content.AddRange(new Drawable[]
{
tracker,
content = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 400,
AutoSizeAxes = Axes.Y
}
});
}

[Test]
public void TestBehaviour()
{
AddStep("create control", () => Child = new AudioOffsetAdjustControl
{
Current = new BindableDouble
{
MinValue = -500,
MaxValue = 500
}
});
AddStep("set new score", () => statics.SetValue(Static.LastLocalUserScore, new ScoreInfo
{
HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(RNG.NextDouble(-100, 100)),
BeatmapInfo = Beatmap.Value.BeatmapInfo,
}));
AddStep("clear history", () => tracker.ClearHistory());
}
}
}
54 changes: 54 additions & 0 deletions osu.Game/Configuration/SessionAverageHitErrorTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;

namespace osu.Game.Configuration
{
/// <summary>
/// Tracks the local user's average hit error during the ongoing play session.
/// </summary>
[Cached]
public partial class SessionAverageHitErrorTracker : Component
{
public IBindableList<double> AverageHitErrorHistory => averageHitErrorHistory;
private readonly BindableList<double> averageHitErrorHistory = new BindableList<double>();

private readonly Bindable<ScoreInfo?> latestScore = new Bindable<ScoreInfo?>();

[BackgroundDependencyLoader]
private void load(SessionStatics statics)
{
statics.BindWith(Static.LastLocalUserScore, latestScore);
latestScore.BindValueChanged(score => calculateAverageHitError(score.NewValue), true);
}

private void calculateAverageHitError(ScoreInfo? newScore)
{
if (newScore == null)
return;

if (newScore.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs))
return;

if (newScore.HitEvents.Count < 10)
return;

if (newScore.HitEvents.CalculateAverageHitError() is not double averageError)
return;

// keep a sane maximum number of entries.
if (averageHitErrorHistory.Count >= 50)
averageHitErrorHistory.RemoveAt(0);
averageHitErrorHistory.Add(averageError);
}

public void ClearHistory() => averageHitErrorHistory.Clear();
}
}
7 changes: 7 additions & 0 deletions osu.Game/Configuration/SessionStatics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
using osu.Game.Scoring;

namespace osu.Game.Configuration
{
Expand All @@ -27,6 +28,7 @@ protected override void InitialiseDefaults()
SetDefault(Static.LastModSelectPanelSamplePlaybackTime, (double?)null);
SetDefault<APISeasonalBackgrounds>(Static.SeasonalBackgrounds, null);
SetDefault(Static.TouchInputActive, RuntimeInfo.IsMobile);
SetDefault<ScoreInfo>(Static.LastLocalUserScore, null);
}

/// <summary>
Expand Down Expand Up @@ -73,5 +75,10 @@ public enum Static
/// Used in touchscreen detection scenarios (<see cref="TouchInputInterceptor"/>).
/// </summary>
TouchInputActive,

/// <summary>
/// Stores the local user's last score (can be completed or aborted).
/// </summary>
LastLocalUserScore,
}
}
4 changes: 4 additions & 0 deletions osu.Game/OsuGameBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ public virtual string Version

private RulesetConfigCache rulesetConfigCache;

private SessionAverageHitErrorTracker hitErrorTracker;

protected SpectatorClient SpectatorClient { get; private set; }

protected MultiplayerClient MultiplayerClient { get; private set; }
Expand Down Expand Up @@ -349,6 +351,7 @@ private void load(ReadableKeyCombinationProvider keyCombinationProvider, Framewo
dependencies.CacheAs(powerStatus);

dependencies.Cache(SessionStatics = new SessionStatics());
dependencies.Cache(hitErrorTracker = new SessionAverageHitErrorTracker());
dependencies.Cache(Colours = new OsuColour());

RegisterImportHandler(BeatmapManager);
Expand Down Expand Up @@ -408,6 +411,7 @@ private void load(ReadableKeyCombinationProvider keyCombinationProvider, Framewo
});

base.Content.Add(new TouchInputInterceptor());
base.Content.Add(hitErrorTracker);

KeyBindingStore = new RealmKeyBindingStore(realm, keyCombinationProvider);
KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets);
Expand Down
160 changes: 160 additions & 0 deletions osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
using osuTK;

namespace osu.Game.Overlays.Settings.Sections.Audio
{
public partial class AudioOffsetAdjustControl : SettingsItem<double>
{
[BackgroundDependencyLoader]
private void load()
{
LabelText = AudioSettingsStrings.AudioOffset;
}

protected override Drawable CreateControl() => new AudioOffsetPreview();

private partial class AudioOffsetPreview : CompositeDrawable, IHasCurrentValue<double>
{
public Bindable<double> Current
{
get => current.Current;
set => current.Current = value;
}

private readonly BindableNumberWithCurrent<double> current = new BindableNumberWithCurrent<double>();

private readonly IBindableList<double> averageHitErrorHistory = new BindableList<double>();

private readonly Bindable<double?> suggestedOffset = new Bindable<double?>();

private Container<Box> notchContainer = null!;
private TextFlowContainer hintText = null!;
private RoundedButton applySuggestion = null!;

[BackgroundDependencyLoader]
private void load(SessionAverageHitErrorTracker hitErrorTracker)
{
averageHitErrorHistory.BindTo(hitErrorTracker.AverageHitErrorHistory);

RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(10),
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
new TimeSlider
{
RelativeSizeAxes = Axes.X,
Current = { BindTarget = Current },
KeyboardStep = 1,
},
notchContainer = new Container<Box>
{
RelativeSizeAxes = Axes.X,
Height = 10,
Padding = new MarginPadding { Horizontal = Nub.DEFAULT_EXPANDED_SIZE / 2 },
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
hintText = new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 16))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
applySuggestion = new RoundedButton
{
RelativeSizeAxes = Axes.X,
Text = "Apply suggested offset",
Action = () =>
{
if (suggestedOffset.Value.HasValue)
current.Value = suggestedOffset.Value.Value;
hitErrorTracker.ClearHistory();
}
}
}
};
}

protected override void LoadComplete()
{
base.LoadComplete();

averageHitErrorHistory.BindCollectionChanged(updateDisplay, true);
suggestedOffset.BindValueChanged(_ => updateHintText(), true);
}

private void updateDisplay(object? _, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (double average in e.NewItems!)
{
notchContainer.ForEach(n => n.Alpha *= 0.95f);
notchContainer.Add(new Box
{
RelativeSizeAxes = Axes.Y,
Width = 2,
RelativePositionAxes = Axes.X,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
X = getXPositionForAverage(average)
});
}

break;

case NotifyCollectionChangedAction.Remove:
foreach (double average in e.OldItems!)
{
var notch = notchContainer.FirstOrDefault(n => n.X == getXPositionForAverage(average));
Debug.Assert(notch != null);
notchContainer.Remove(notch, true);
}

break;

case NotifyCollectionChangedAction.Reset:
notchContainer.Clear();
break;
}

suggestedOffset.Value = averageHitErrorHistory.Any() ? -averageHitErrorHistory.Average() : null;
}

private float getXPositionForAverage(double average) => (float)(Math.Clamp(-average, current.MinValue, current.MaxValue) / (2 * current.MaxValue));

private void updateHintText()
{
hintText.Text = suggestedOffset.Value == null
? @"Play a few beatmaps to receive a suggested offset!"
: $@"Based on the last {averageHitErrorHistory.Count} play(s), the suggested offset is {suggestedOffset.Value:N0} ms.";
applySuggestion.Enabled.Value = suggestedOffset.Value != null;
}
}
}
}
Loading
Loading