mirror of
https://github.com/ppy/osu.git
synced 2026-05-17 16:53:15 +08:00
978e380087
Based on feedback I've heard more than once ([most recently](https://github.com/ppy/osu/discussions/36933)), there's too much overlap compared to stable. And it can sound a bit "jumbled" as a result, especially when transitioning between two songs with loud and "complex" audio. This also fixes noticeable audio glitching that some users may have been experiencing when switching tracks. --- Unless there's any very loud objections, I'd ask that review is on the code not the audible change. This is the kind of change that I tweak to my personal spec and then push out to hear the general reaction. ###b8d0dfe2edBetter default preview time based on global fade This is an oversight. The preview point wasn't being offset to account for the fade in. This is something we do when generating web-side previews, which allows for the actual preview point to be audible, rather than mid-fade. ###8de323b68cAdjust fade in and fade out during track transitions to reduce overlap Yes, I'm aware that the delay is not accounted for in the constant. This is intentional because it sounds better. I want this to be an internal adjustment which is not to be considered by any external usages of those constants. Just sounds better. ###aab01b0fe5Remove weird/dated schedule usage This causes audible artifacts in playback. The track is not set to zero volume early enough. Original change was made in [this ancient PR](https://github.com/ppy/osu/pull/9792) (see [commit](https://github.com/ppy/osu/commit/688e4479506645bdacee7cdc7ae9ee9deaa5f1b4)). The original failure does not occur. Tests still pass. I'd argue that if we encounter this again, it's an issue at the call site, not this code. This is a drive-by fix, as I noticed this glitching while working on the main issue here. Arguably should be it's own PR 🤷 .
431 lines
20 KiB
C#
431 lines
20 KiB
C#
// 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.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using osu.Framework.Allocation;
|
|
using osu.Framework.Audio.Track;
|
|
using osu.Framework.Bindables;
|
|
using osu.Framework.Extensions;
|
|
using osu.Framework.Graphics.Textures;
|
|
using osu.Framework.Lists;
|
|
using osu.Framework.Logging;
|
|
using osu.Framework.Threading;
|
|
using osu.Game.Configuration;
|
|
using osu.Game.Database;
|
|
using osu.Game.Rulesets;
|
|
using osu.Game.Rulesets.Difficulty;
|
|
using osu.Game.Rulesets.Mods;
|
|
using osu.Game.Rulesets.Scoring;
|
|
using osu.Game.Rulesets.UI;
|
|
using osu.Game.Scoring;
|
|
using osu.Game.Skinning;
|
|
using osu.Game.Storyboards;
|
|
|
|
namespace osu.Game.Beatmaps
|
|
{
|
|
/// <summary>
|
|
/// A component which performs and acts as a central cache for difficulty calculations of beatmap/ruleset/mod combinations.
|
|
/// Currently not persisted between game sessions.
|
|
/// </summary>
|
|
public partial class BeatmapDifficultyCache : MemoryCachingComponent<BeatmapDifficultyCache.DifficultyCacheLookup, StarDifficulty?>
|
|
{
|
|
// Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes.
|
|
private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(1, nameof(BeatmapDifficultyCache));
|
|
|
|
/// <summary>
|
|
/// All bindables that should be updated along with the current ruleset + mods.
|
|
/// </summary>
|
|
private readonly WeakList<BindableStarDifficulty> trackedBindables = new WeakList<BindableStarDifficulty>();
|
|
|
|
/// <summary>
|
|
/// Cancellation sources used by tracked bindables.
|
|
/// </summary>
|
|
private readonly List<CancellationTokenSource> linkedCancellationSources = new List<CancellationTokenSource>();
|
|
|
|
/// <summary>
|
|
/// Lock to be held when operating on <see cref="trackedBindables"/> or <see cref="linkedCancellationSources"/>.
|
|
/// </summary>
|
|
private readonly object bindableUpdateLock = new object();
|
|
|
|
private CancellationTokenSource trackedUpdateCancellationSource = new CancellationTokenSource();
|
|
|
|
[Resolved]
|
|
private BeatmapManager beatmapManager { get; set; } = null!;
|
|
|
|
[Resolved]
|
|
private Bindable<RulesetInfo> currentRuleset { get; set; } = null!;
|
|
|
|
[Resolved]
|
|
private Bindable<IReadOnlyList<Mod>> currentMods { get; set; } = null!;
|
|
|
|
private ModSettingChangeTracker? modSettingChangeTracker;
|
|
private ScheduledDelegate? debouncedModSettingsChange;
|
|
|
|
protected override void LoadComplete()
|
|
{
|
|
base.LoadComplete();
|
|
|
|
currentRuleset.BindValueChanged(_ => Scheduler.AddOnce(updateTrackedBindables));
|
|
|
|
currentMods.BindValueChanged(mods =>
|
|
{
|
|
// A change in bindable here doesn't guarantee that mods have actually changed.
|
|
// However, we *do* want to make sure that the mod *references* are the same;
|
|
// `SequenceEqual()` without a comparer would fall back to `IEquatable`.
|
|
// Failing to ensure reference equality can cause setting change tracking to fail later.
|
|
if (mods.OldValue.SequenceEqual(mods.NewValue, ReferenceEqualityComparer.Instance))
|
|
return;
|
|
|
|
modSettingChangeTracker?.Dispose();
|
|
|
|
Scheduler.AddOnce(updateTrackedBindables);
|
|
|
|
modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue);
|
|
modSettingChangeTracker.SettingChanged += _ =>
|
|
{
|
|
lock (bindableUpdateLock)
|
|
{
|
|
debouncedModSettingsChange?.Cancel();
|
|
debouncedModSettingsChange = Scheduler.AddDelayed(updateTrackedBindables, 100);
|
|
}
|
|
};
|
|
}, true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notify this cache that a beatmap has been invalidated/updated.
|
|
/// </summary>
|
|
/// <param name="oldBeatmap">The old beatmap model.</param>
|
|
/// <param name="newBeatmap">The updated beatmap model.</param>
|
|
public void Invalidate(IBeatmapInfo oldBeatmap, IBeatmapInfo newBeatmap)
|
|
{
|
|
base.Invalidate(lookup => lookup.BeatmapInfo.Equals(oldBeatmap));
|
|
|
|
lock (bindableUpdateLock)
|
|
{
|
|
bool trackedBindablesRefreshRequired = false;
|
|
|
|
foreach (var bsd in trackedBindables.Where(bsd => bsd.BeatmapInfo.Equals(oldBeatmap)))
|
|
{
|
|
bsd.BeatmapInfo = newBeatmap;
|
|
trackedBindablesRefreshRequired = true;
|
|
}
|
|
|
|
if (trackedBindablesRefreshRequired)
|
|
Scheduler.AddOnce(updateTrackedBindables);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a bindable containing the star difficulty of a <see cref="BeatmapInfo"/> that follows the currently-selected ruleset and mods.
|
|
/// </summary>
|
|
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
|
|
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops updating the star difficulty for the given <see cref="BeatmapInfo"/>.</param>
|
|
/// <param name="computationDelay">A delay in milliseconds before performing the </param>
|
|
/// <returns>A bindable that is updated to contain the star difficulty when it becomes available. May be an approximation while in an initial calculating state.</returns>
|
|
public IBindable<StarDifficulty> GetBindableDifficulty(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken = default, int computationDelay = 0)
|
|
{
|
|
var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken)
|
|
{
|
|
// Start with an approximate known value instead of zero.
|
|
Value = new StarDifficulty(beatmapInfo.StarRating, 0)
|
|
};
|
|
|
|
updateBindable(bindable, currentRuleset.Value, currentMods.Value, cancellationToken, computationDelay);
|
|
|
|
lock (bindableUpdateLock)
|
|
trackedBindables.Add(bindable);
|
|
|
|
return bindable;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves the difficulty of a <see cref="IBeatmapInfo"/>.
|
|
/// </summary>
|
|
/// <param name="beatmapInfo">The <see cref="IBeatmapInfo"/> to get the difficulty of.</param>
|
|
/// <param name="rulesetInfo">The <see cref="IRulesetInfo"/> to get the difficulty with.</param>
|
|
/// <param name="mods">The <see cref="Mod"/>s to get the difficulty with.</param>
|
|
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops computing the star difficulty.</param>
|
|
/// <param name="computationDelay">In the case a cached lookup was not possible, a value in milliseconds of to wait until performing potentially intensive lookup.</param>
|
|
/// <returns>
|
|
/// The requested <see cref="StarDifficulty"/>, if non-<see langword="null"/>.
|
|
/// A <see langword="null"/> return value indicates that the difficulty process failed or was interrupted early,
|
|
/// and as such there is no usable star difficulty value to be returned.
|
|
/// </returns>
|
|
public virtual Task<StarDifficulty?> GetDifficultyAsync(IBeatmapInfo beatmapInfo, IRulesetInfo? rulesetInfo = null, IEnumerable<Mod>? mods = null,
|
|
CancellationToken cancellationToken = default, int computationDelay = 0)
|
|
{
|
|
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
|
|
rulesetInfo ??= beatmapInfo.Ruleset;
|
|
|
|
var localBeatmapInfo = beatmapInfo as BeatmapInfo;
|
|
var localRulesetInfo = rulesetInfo as RulesetInfo;
|
|
|
|
// Difficulty can only be computed if the beatmap and ruleset are locally available.
|
|
if (localBeatmapInfo == null || localRulesetInfo == null)
|
|
{
|
|
// If not, fall back to the existing star difficulty (e.g. from an online source).
|
|
return Task.FromResult<StarDifficulty?>(new StarDifficulty(beatmapInfo.StarRating, (beatmapInfo as IBeatmapOnlineInfo)?.MaxCombo ?? 0));
|
|
}
|
|
|
|
return GetAsync(new DifficultyCacheLookup(localBeatmapInfo, localRulesetInfo, mods), cancellationToken, computationDelay);
|
|
}
|
|
|
|
protected override Task<StarDifficulty?> ComputeValueAsync(DifficultyCacheLookup lookup, CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.Factory.StartNew(() =>
|
|
{
|
|
if (CheckExists(lookup, out var existing))
|
|
return existing;
|
|
|
|
return computeDifficulty(lookup, cancellationToken);
|
|
}, cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler);
|
|
}
|
|
|
|
protected override bool CacheNullValues => false;
|
|
|
|
public Task<List<TimedDifficultyAttributes>> GetTimedDifficultyAttributesAsync(IWorkingBeatmap beatmap, Ruleset ruleset, Mod[] mods, CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.Factory.StartNew(() => ruleset.CreateDifficultyCalculator(beatmap).CalculateTimed(mods, cancellationToken),
|
|
cancellationToken,
|
|
TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously,
|
|
updateScheduler);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates all tracked <see cref="BindableStarDifficulty"/> using the current ruleset and mods.
|
|
/// </summary>
|
|
private void updateTrackedBindables()
|
|
{
|
|
lock (bindableUpdateLock)
|
|
{
|
|
cancelTrackedBindableUpdate();
|
|
|
|
foreach (var b in trackedBindables)
|
|
{
|
|
var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(trackedUpdateCancellationSource.Token, b.CancellationToken);
|
|
linkedCancellationSources.Add(linkedSource);
|
|
|
|
updateBindable(b, currentRuleset.Value, currentMods.Value, linkedSource.Token);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancels the existing update of all tracked <see cref="BindableStarDifficulty"/> via <see cref="updateTrackedBindables"/>.
|
|
/// </summary>
|
|
private void cancelTrackedBindableUpdate()
|
|
{
|
|
lock (bindableUpdateLock)
|
|
{
|
|
debouncedModSettingsChange?.Cancel();
|
|
debouncedModSettingsChange = null;
|
|
|
|
trackedUpdateCancellationSource.Cancel();
|
|
trackedUpdateCancellationSource = new CancellationTokenSource();
|
|
|
|
foreach (var c in linkedCancellationSources)
|
|
c.Dispose();
|
|
|
|
linkedCancellationSources.Clear();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the value of a <see cref="BindableStarDifficulty"/> with a given ruleset + mods.
|
|
/// </summary>
|
|
/// <param name="bindable">The <see cref="BindableStarDifficulty"/> to update.</param>
|
|
/// <param name="rulesetInfo">The <see cref="IRulesetInfo"/> to update with.</param>
|
|
/// <param name="mods">The <see cref="Mod"/>s to update with.</param>
|
|
/// <param name="cancellationToken">A token that may be used to cancel this update.</param>
|
|
/// <param name="computationDelay">In the case a cached lookup was not possible, a value in milliseconds of to wait until performing potentially intensive lookup.</param>
|
|
private void updateBindable(BindableStarDifficulty bindable, IRulesetInfo? rulesetInfo, IEnumerable<Mod>? mods, CancellationToken cancellationToken = default, int computationDelay = 0)
|
|
{
|
|
// GetDifficultyAsync will fall back to existing data from IBeatmapInfo if not locally available
|
|
// (contrary to GetAsync)
|
|
GetDifficultyAsync(bindable.BeatmapInfo, rulesetInfo, mods, cancellationToken, computationDelay)
|
|
.ContinueWith(task =>
|
|
{
|
|
// We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events.
|
|
Schedule(() =>
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
return;
|
|
|
|
StarDifficulty? starDifficulty = task.GetResultSafely();
|
|
|
|
if (starDifficulty != null)
|
|
bindable.Value = starDifficulty.Value;
|
|
});
|
|
}, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the difficulty defined by a <see cref="DifficultyCacheLookup"/> key, and stores it to the timed cache.
|
|
/// </summary>
|
|
/// <param name="key">The <see cref="DifficultyCacheLookup"/> that defines the computation parameters.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>The <see cref="StarDifficulty"/>.</returns>
|
|
private StarDifficulty? computeDifficulty(in DifficultyCacheLookup key, CancellationToken cancellationToken = default)
|
|
{
|
|
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
|
|
var beatmapInfo = key.BeatmapInfo;
|
|
var rulesetInfo = key.Ruleset;
|
|
|
|
try
|
|
{
|
|
var ruleset = rulesetInfo.CreateInstance();
|
|
Debug.Assert(ruleset != null);
|
|
|
|
PlayableCachedWorkingBeatmap workingBeatmap = new PlayableCachedWorkingBeatmap(beatmapManager.GetWorkingBeatmap(key.BeatmapInfo));
|
|
IBeatmap playableBeatmap = workingBeatmap.GetPlayableBeatmap(ruleset.RulesetInfo, key.OrderedMods, cancellationToken);
|
|
|
|
var difficulty = ruleset.CreateDifficultyCalculator(workingBeatmap).Calculate(key.OrderedMods, cancellationToken);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var performanceCalculator = ruleset.CreatePerformanceCalculator();
|
|
if (performanceCalculator == null)
|
|
return new StarDifficulty(difficulty, new PerformanceAttributes());
|
|
|
|
ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
|
|
scoreProcessor.Mods.Value = key.OrderedMods;
|
|
scoreProcessor.ApplyBeatmap(playableBeatmap);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
ScoreInfo perfectScore = new ScoreInfo(key.BeatmapInfo, ruleset.RulesetInfo)
|
|
{
|
|
Passed = true,
|
|
Accuracy = 1,
|
|
Mods = key.OrderedMods,
|
|
MaxCombo = scoreProcessor.MaximumCombo,
|
|
Combo = scoreProcessor.MaximumCombo,
|
|
TotalScore = scoreProcessor.MaximumTotalScore,
|
|
Statistics = scoreProcessor.MaximumStatistics,
|
|
MaximumStatistics = scoreProcessor.MaximumStatistics
|
|
};
|
|
|
|
var performance = performanceCalculator.Calculate(perfectScore, difficulty);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
return new StarDifficulty(difficulty, performance);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// no need to log, cancellations are expected as part of normal operation.
|
|
return null;
|
|
}
|
|
catch (BeatmapInvalidForRulesetException invalidForRuleset)
|
|
{
|
|
if (rulesetInfo.Equals(beatmapInfo.Ruleset))
|
|
Logger.Error(invalidForRuleset, $"Failed to convert {beatmapInfo.OnlineID} to the beatmap's default ruleset ({beatmapInfo.Ruleset}).");
|
|
|
|
return null;
|
|
}
|
|
catch (Exception unknownException)
|
|
{
|
|
Logger.Error(unknownException, "Failed to calculate beatmap difficulty");
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
protected override void Dispose(bool isDisposing)
|
|
{
|
|
base.Dispose(isDisposing);
|
|
|
|
modSettingChangeTracker?.Dispose();
|
|
|
|
cancelTrackedBindableUpdate();
|
|
updateScheduler.Dispose();
|
|
}
|
|
|
|
public readonly struct DifficultyCacheLookup : IEquatable<DifficultyCacheLookup>
|
|
{
|
|
public readonly BeatmapInfo BeatmapInfo;
|
|
public readonly RulesetInfo Ruleset;
|
|
public readonly Mod[] OrderedMods;
|
|
|
|
public DifficultyCacheLookup(BeatmapInfo beatmapInfo, RulesetInfo? ruleset, IEnumerable<Mod>? mods)
|
|
{
|
|
BeatmapInfo = beatmapInfo;
|
|
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
|
|
Ruleset = ruleset ?? BeatmapInfo.Ruleset;
|
|
OrderedMods = mods?.OrderBy(m => m.Acronym).Select(mod => mod.DeepClone()).ToArray() ?? Array.Empty<Mod>();
|
|
}
|
|
|
|
public bool Equals(DifficultyCacheLookup other)
|
|
=> BeatmapInfo.Equals(other.BeatmapInfo)
|
|
&& Ruleset.Equals(other.Ruleset)
|
|
&& OrderedMods.SequenceEqual(other.OrderedMods);
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
var hashCode = new HashCode();
|
|
|
|
hashCode.Add(BeatmapInfo.ID);
|
|
hashCode.Add(Ruleset.ShortName);
|
|
|
|
foreach (var mod in OrderedMods)
|
|
hashCode.Add(mod);
|
|
|
|
return hashCode.ToHashCode();
|
|
}
|
|
}
|
|
|
|
private class BindableStarDifficulty : Bindable<StarDifficulty>
|
|
{
|
|
public IBeatmapInfo BeatmapInfo;
|
|
public readonly CancellationToken CancellationToken;
|
|
|
|
public BindableStarDifficulty(IBeatmapInfo beatmapInfo, CancellationToken cancellationToken)
|
|
{
|
|
BeatmapInfo = beatmapInfo;
|
|
CancellationToken = cancellationToken;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A working beatmap that caches its playable representation.
|
|
/// This is intended as single-use for when it is guaranteed that the playable beatmap can be reused.
|
|
/// </summary>
|
|
private class PlayableCachedWorkingBeatmap : IWorkingBeatmap
|
|
{
|
|
private readonly IWorkingBeatmap working;
|
|
private IBeatmap? playable;
|
|
|
|
public PlayableCachedWorkingBeatmap(IWorkingBeatmap working)
|
|
{
|
|
this.working = working;
|
|
}
|
|
|
|
public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList<Mod> mods)
|
|
=> playable ??= working.GetPlayableBeatmap(ruleset, mods);
|
|
|
|
public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList<Mod> mods, CancellationToken cancellationToken)
|
|
=> playable ??= working.GetPlayableBeatmap(ruleset, mods, cancellationToken);
|
|
|
|
IBeatmapInfo IWorkingBeatmap.BeatmapInfo => working.BeatmapInfo;
|
|
bool IWorkingBeatmap.BeatmapLoaded => working.BeatmapLoaded;
|
|
bool IWorkingBeatmap.TrackLoaded => working.TrackLoaded;
|
|
IBeatmap IWorkingBeatmap.Beatmap => working.Beatmap;
|
|
Texture IWorkingBeatmap.GetBackground() => working.GetBackground();
|
|
Texture IWorkingBeatmap.GetPanelBackground() => working.GetPanelBackground();
|
|
Waveform IWorkingBeatmap.Waveform => working.Waveform;
|
|
Storyboard IWorkingBeatmap.Storyboard => working.Storyboard;
|
|
ISkin IWorkingBeatmap.Skin => working.Skin;
|
|
Track IWorkingBeatmap.Track => working.Track;
|
|
Track IWorkingBeatmap.LoadTrack() => working.LoadTrack();
|
|
Stream IWorkingBeatmap.GetStream(string storagePath) => working.GetStream(storagePath);
|
|
void IWorkingBeatmap.BeginAsyncLoad() => working.BeginAsyncLoad();
|
|
void IWorkingBeatmap.CancelAsyncLoad() => working.CancelAsyncLoad();
|
|
void IWorkingBeatmap.PrepareTrackForPreview(bool looping, double? offsetFromPreviewPoint) => working.PrepareTrackForPreview(looping, offsetFromPreviewPoint);
|
|
}
|
|
}
|
|
}
|