// Copyright (c) ppy Pty Ltd . 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 JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; using osu.Game.Skinning; using osu.Game.Storyboards; namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] public abstract class WorkingBeatmap : IWorkingBeatmap { public readonly BeatmapInfo BeatmapInfo; public readonly BeatmapSetInfo BeatmapSetInfo; public readonly BeatmapMetadata Metadata; protected AudioManager AudioManager { get; } protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager) { AudioManager = audioManager; BeatmapInfo = beatmapInfo; BeatmapSetInfo = beatmapInfo.BeatmapSet; Metadata = beatmapInfo.Metadata ?? BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); background = new RecyclableLazy(GetBackground, BackgroundStillValid); waveform = new RecyclableLazy(GetWaveform); storyboard = new RecyclableLazy(GetStoryboard); skin = new RecyclableLazy(GetSkin); } protected virtual Track GetVirtualTrack(double emptyLength = 0) { const double excess_length = 1000; var lastObject = Beatmap?.HitObjects.LastOrDefault(); double length; switch (lastObject) { case null: length = emptyLength; break; case IHasDuration endTime: length = endTime.EndTime + excess_length; break; default: length = lastObject.StartTime + excess_length; break; } return AudioManager.Tracks.GetVirtual(length); } /// /// 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); public virtual IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null, CancellationToken token = default) { mods ??= Array.Empty(); var rulesetInstance = ruleset.CreateInstance(); 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()) { if (token.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); mod.ApplyToBeatmapConverter(converter); } // Convert IBeatmap converted = converter.Convert(token); // Apply conversion mods to the result foreach (var mod in mods.OfType()) { if (token.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); mod.ApplyToBeatmap(converted); } // Apply difficulty mods if (mods.Any(m => m is IApplicableToDifficulty)) { foreach (var mod in mods.OfType()) { if (token.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); mod.ApplyToDifficulty(converted.Difficulty); } } IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); 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 try { foreach (var obj in converted.HitObjects) { if (token.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); obj.ApplyDefaults(converted.ControlPointInfo, converted.Difficulty, token); } } catch (OperationCanceledException) { throw new BeatmapLoadTimeoutException(BeatmapInfo); } foreach (var mod in mods.OfType()) { foreach (var obj in converted.HitObjects) { if (token.IsCancellationRequested) throw new BeatmapLoadTimeoutException(BeatmapInfo); mod.ApplyToHitObject(obj); } } processor?.PostProcess(); foreach (var mod in mods.OfType()) { token.ThrowIfCancellationRequested(); mod.ApplyToBeatmap(converted); } return converted; } private CancellationTokenSource loadCancellation = new CancellationTokenSource(); /// /// Beings loading the contents of this asynchronously. /// public void BeginAsyncLoad() { loadBeatmapAsync(); } /// /// Cancels the asynchronous loading of the contents of this . /// public void CancelAsyncLoad() { lock (beatmapFetchLock) { loadCancellation?.Cancel(); loadCancellation = new CancellationTokenSource(); if (beatmapLoadTask?.IsCompleted != true) beatmapLoadTask = null; } } private CancellationTokenSource createTimeoutTokenSource(TimeSpan? timeout) { if (Debugger.IsAttached) // ignore timeout when debugger is attached (may be breakpointing / debugging). return new CancellationTokenSource(); return new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(10)); } private readonly object beatmapFetchLock = new object(); 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; }, loadCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } } public override string ToString() => BeatmapInfo.ToString(); public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; public IBeatmap Beatmap { get { try { return loadBeatmapAsync().Result; } 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; } } } protected abstract IBeatmap GetBeatmap(); private Task beatmapLoadTask; public bool BackgroundLoaded => background.IsResultAvailable; public Texture Background => background.Value; protected virtual bool BackgroundStillValid(Texture b) => b == null || b.Available; protected abstract Texture GetBackground(); private readonly RecyclableLazy background; private Track loadedTrack; [NotNull] public Track LoadTrack() => loadedTrack = GetBeatmapTrack() ?? GetVirtualTrack(1000); /// /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. /// public void PrepareTrackForPreviewLooping() { Track.Looping = true; 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; } } /// /// Transfer a valid audio track into this working beatmap. Used as an optimisation to avoid reload / track swap /// across difficulties in the same beatmap set. /// /// The track to transfer. public void TransferTrack([NotNull] Track track) => loadedTrack = track ?? throw new ArgumentNullException(nameof(track)); /// /// Whether this beatmap's track has been loaded via . /// public virtual bool TrackLoaded => loadedTrack != null; /// /// Get the loaded audio track instance. must have first been called. /// This generally happens via MusicController when changing the global beatmap. /// public Track Track { get { if (!TrackLoaded) throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}."); return loadedTrack; } } protected abstract Track GetBeatmapTrack(); public bool WaveformLoaded => waveform.IsResultAvailable; public Waveform Waveform => waveform.Value; protected virtual Waveform GetWaveform() => new Waveform(null); private readonly RecyclableLazy waveform; public bool StoryboardLoaded => storyboard.IsResultAvailable; public Storyboard Storyboard => storyboard.Value; protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo }; private readonly RecyclableLazy storyboard; public bool SkinLoaded => skin.IsResultAvailable; public ISkin Skin => skin.Value; /// /// 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(); private readonly RecyclableLazy skin; public abstract Stream GetStream(string storagePath); public class RecyclableLazy { private Lazy lazy; private readonly Func valueFactory; private readonly Func stillValidFunction; private readonly object fetchLock = new object(); public RecyclableLazy(Func valueFactory, Func stillValidFunction = null) { this.valueFactory = valueFactory; this.stillValidFunction = stillValidFunction; recreate(); } public void Recycle() { if (!IsResultAvailable) return; (lazy.Value as IDisposable)?.Dispose(); recreate(); } public bool IsResultAvailable => stillValid; public T Value { get { lock (fetchLock) { if (!stillValid) recreate(); return lazy.Value; } } } private bool stillValid => lazy.IsValueCreated && (stillValidFunction?.Invoke(lazy.Value) ?? true); private void recreate() => lazy = new Lazy(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication); } private class BeatmapLoadTimeoutException : TimeoutException { public BeatmapLoadTimeoutException(BeatmapInfo beatmapInfo) : base($"Timed out while loading beatmap ({beatmapInfo}).") { } } } }