// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Extensions; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Skinning; using osu.Game.Storyboards; namespace osu.Game.Beatmaps { public abstract class WorkingBeatmap : IWorkingBeatmap { public readonly BeatmapInfo BeatmapInfo; public readonly BeatmapSetInfo BeatmapSetInfo; // TODO: remove once the fallback lookup is not required (and access via `working.BeatmapInfo.Metadata` directly). public BeatmapMetadata Metadata => BeatmapInfo.Metadata; public Storyboard Storyboard => storyboard.Value; public Texture Background => GetBackground(); // Texture uses ref counting, so we want to return a new instance every usage. public ISkin Skin => skin.Value; private AudioManager audioManager { get; } private CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private readonly object beatmapFetchLock = new object(); private readonly Lazy storyboard; private readonly Lazy skin; private Track track; // track is not Lazy as we allow transferring and loading multiple times. private Waveform waveform; // waveform is also not Lazy as the track may change. protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager) { this.audioManager = audioManager; BeatmapInfo = beatmapInfo; BeatmapSetInfo = beatmapInfo.BeatmapSet ?? new BeatmapSetInfo(); storyboard = new Lazy(GetStoryboard); skin = new Lazy(GetSkin); } #region Resource getters protected virtual Waveform GetWaveform() => new Waveform(null); protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo }; protected abstract IBeatmap GetBeatmap(); protected abstract Texture GetBackground(); protected abstract Track GetBeatmapTrack(); /// /// Creates a new skin instance for this beatmap. /// /// /// This should only be called externally in scenarios where it is explicitly desired to get a new instance of a skin /// (e.g. for editing purposes, to avoid state pollution). /// For standard reading purposes, should always be used directly. /// protected internal abstract ISkin GetSkin(); #endregion #region Async load control public void BeginAsyncLoad() => loadBeatmapAsync(); public void CancelAsyncLoad() { lock (beatmapFetchLock) { loadCancellationSource?.Cancel(); loadCancellationSource = new CancellationTokenSource(); if (beatmapLoadTask?.IsCompleted != true) beatmapLoadTask = null; } } #endregion #region Track public virtual bool TrackLoaded => track != null; public Track LoadTrack() { track = GetBeatmapTrack() ?? GetVirtualTrack(1000); // the track may have changed, recycle the current waveform. waveform?.Dispose(); waveform = null; return track; } public void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0) { Track.Looping = looping; Track.RestartPoint = Metadata.PreviewTime; if (Track.RestartPoint == -1) { if (!Track.IsLoaded) { // force length to be populated (https://github.com/ppy/osu-framework/issues/4202) Track.Seek(Track.CurrentTime); } Track.RestartPoint = 0.4f * Track.Length; } Track.RestartPoint += offsetFromPreviewPoint; } /// /// Attempts to transfer the audio track to a target working beatmap, if valid for transferring. /// Used as an optimisation to avoid reload / track swap across difficulties in the same beatmap set. /// /// The target working beatmap to transfer this track to. /// Whether the track has been transferred to the . public virtual bool TryTransferTrack([NotNull] WorkingBeatmap target) { if (BeatmapInfo?.AudioEquals(target.BeatmapInfo) != true || Track.IsDummyDevice) return false; target.track = Track; return true; } /// /// Get the loaded audio track instance. must have first been called. /// This generally happens via MusicController when changing the global beatmap. /// [NotNull] public Track Track { get { if (!TrackLoaded) throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}."); return track; } } protected Track GetVirtualTrack(double emptyLength = 0) { const double excess_length = 1000; double length = (BeatmapInfo?.Length + excess_length) ?? emptyLength; return audioManager.Tracks.GetVirtual(length); } #endregion #region Waveform public Waveform Waveform => waveform ??= GetWaveform(); #endregion #region Beatmap public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; public IBeatmap Beatmap { get { try { return loadBeatmapAsync().GetResultSafely(); } catch (AggregateException ae) { // This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException) return null; Logger.Error(ae, "Beatmap failed to load"); return null; } catch (Exception e) { Logger.Error(e, "Beatmap failed to load"); return null; } } } private Task beatmapLoadTask; private Task loadBeatmapAsync() { lock (beatmapFetchLock) { return beatmapLoadTask ??= Task.Factory.StartNew(() => { // Todo: Handle cancellation during beatmap parsing var b = GetBeatmap() ?? new Beatmap(); // The original beatmap version needs to be preserved as the database doesn't contain it BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion; // Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc) b.BeatmapInfo = BeatmapInfo; return b; }, loadCancellationSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } } #endregion #region Playable beatmap public IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods = null) { try { using (var cancellationTokenSource = new CancellationTokenSource(10_000)) { // don't apply the default timeout when debugger is attached (may be breakpointing / debugging). return GetPlayableBeatmap(ruleset, mods ?? Array.Empty(), Debugger.IsAttached ? new CancellationToken() : cancellationTokenSource.Token); } } catch (OperationCanceledException) { throw new BeatmapLoadTimeoutException(BeatmapInfo); } } public virtual IBeatmap GetPlayableBeatmap(IRulesetInfo ruleset, IReadOnlyList mods, CancellationToken token) { var rulesetInstance = ruleset.CreateInstance(); if (rulesetInstance == null) throw new RulesetLoadException("Creating ruleset instance failed when attempting to create playable beatmap."); IBeatmapConverter converter = CreateBeatmapConverter(Beatmap, rulesetInstance); // Check if the beatmap can be converted if (Beatmap.HitObjects.Count > 0 && !converter.CanConvert()) throw new BeatmapInvalidForRulesetException($"{nameof(Beatmaps.Beatmap)} can not be converted for the ruleset (ruleset: {ruleset.InstantiationInfo}, converter: {converter})."); // Apply conversion mods foreach (var mod in mods.OfType()) { token.ThrowIfCancellationRequested(); mod.ApplyToBeatmapConverter(converter); } // Convert IBeatmap converted = converter.Convert(token); // Apply conversion mods to the result foreach (var mod in mods.OfType()) { token.ThrowIfCancellationRequested(); mod.ApplyToBeatmap(converted); } // Apply difficulty mods if (mods.Any(m => m is IApplicableToDifficulty)) { foreach (var mod in mods.OfType()) { token.ThrowIfCancellationRequested(); mod.ApplyToDifficulty(converted.Difficulty); } } var processor = rulesetInstance.CreateBeatmapProcessor(converted); if (processor != null) { foreach (var mod in mods.OfType()) mod.ApplyToBeatmapProcessor(processor); processor.PreProcess(); } // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed foreach (var obj in converted.HitObjects) { token.ThrowIfCancellationRequested(); obj.ApplyDefaults(converted.ControlPointInfo, converted.Difficulty, token); } foreach (var mod in mods.OfType()) { foreach (var obj in converted.HitObjects) { token.ThrowIfCancellationRequested(); mod.ApplyToHitObject(obj); } } processor?.PostProcess(); foreach (var mod in mods.OfType()) { token.ThrowIfCancellationRequested(); mod.ApplyToBeatmap(converted); } return converted; } /// /// Creates a to convert a for a specified . /// /// The to be converted. /// The for which should be converted. /// The applicable . protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap); #endregion public override string ToString() => BeatmapInfo.ToString(); public abstract Stream GetStream(string storagePath); IBeatmapInfo IWorkingBeatmap.BeatmapInfo => BeatmapInfo; private class BeatmapLoadTimeoutException : TimeoutException { public BeatmapLoadTimeoutException(BeatmapInfo beatmapInfo) : base($"Timed out while loading beatmap ({beatmapInfo}).") { } } } }