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());
}
}
}
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,
}
}
5 changes: 5 additions & 0 deletions osu.Game/OsuGameBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
using osu.Game.Overlays;
using osu.Game.Overlays.Settings;
using osu.Game.Overlays.Settings.Sections;
using osu.Game.Overlays.Settings.Sections.Audio;
using osu.Game.Overlays.Settings.Sections.Input;
using osu.Game.Resources;
using osu.Game.Rulesets;
Expand Down Expand Up @@ -200,6 +201,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 +352,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 +412,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
159 changes: 159 additions & 0 deletions osu.Game/Overlays/Settings/Sections/Audio/AudioOffsetAdjustControl.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// 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.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.Count < 3 ? null : -averageHitErrorHistory.Average();
peppy marked this conversation as resolved.
Show resolved Hide resolved
}

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} plays, the suggested offset is {suggestedOffset.Value:N0} ms.";
applySuggestion.Enabled.Value = suggestedOffset.Value != null;
}
}
}
}
5 changes: 1 addition & 4 deletions osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;

namespace osu.Game.Overlays.Settings.Sections.Audio
Expand All @@ -23,11 +22,9 @@ private void load(OsuConfigManager config)
{
Children = new Drawable[]
{
new SettingsSlider<double, TimeSlider>
new AudioOffsetAdjustControl
{
LabelText = AudioSettingsStrings.AudioOffset,
Current = config.GetBindable<double>(OsuSetting.AudioOffset),
KeyboardStep = 1f
},
new SettingsButton
{
Expand Down