From 730b14b5bb102572c742b769e168c48febd9791c Mon Sep 17 00:00:00 2001 From: smoogipoo Date: Thu, 19 Nov 2020 19:51:09 +0900 Subject: [PATCH] Add initial hit sample pooling --- osu.Game.Rulesets.Catch/Objects/Banana.cs | 13 +- osu.Game/Audio/HitSampleInfo.cs | 15 +- osu.Game/Audio/SampleInfo.cs | 15 +- .../Objects/Drawables/DrawableHitObject.cs | 12 +- .../Objects/Legacy/ConvertHitObjectParser.cs | 29 ++- osu.Game/Rulesets/UI/Playfield.cs | 38 +++- osu.Game/Skinning/PausableSkinnableSound.cs | 8 +- osu.Game/Skinning/SkinReloadableDrawable.cs | 4 +- osu.Game/Skinning/SkinnableSound.cs | 203 +++++++++++++++--- .../Drawables/DrawableStoryboardSample.cs | 4 +- 10 files changed, 283 insertions(+), 58 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index 4ecfb7b16d..d61a88b7df 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs @@ -1,6 +1,7 @@ // 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 osu.Game.Audio; using osu.Game.Rulesets.Catch.Judgements; @@ -26,11 +27,17 @@ namespace osu.Game.Rulesets.Catch.Objects Samples = samples; } - private class BananaHitSampleInfo : HitSampleInfo + private class BananaHitSampleInfo : HitSampleInfo, IEquatable { - private static string[] lookupNames { get; } = { "metronomelow", "catch-banana" }; + private static readonly string[] lookup_names = { "metronomelow", "catch-banana" }; - public override IEnumerable LookupNames => lookupNames; + public override IEnumerable LookupNames => lookup_names; + + public bool Equals(BananaHitSampleInfo other) => true; + + public override bool Equals(object obj) => obj is BananaHitSampleInfo other && Equals(other); + + public override int GetHashCode() => lookup_names.GetHashCode(); } } } diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index 8efaeb3795..46f0abd7b7 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace osu.Game.Audio { @@ -10,7 +11,7 @@ namespace osu.Game.Audio /// Describes a gameplay hit sample. /// [Serializable] - public class HitSampleInfo : ISampleInfo + public class HitSampleInfo : ISampleInfo, IEquatable { public const string HIT_WHISTLE = @"hitwhistle"; public const string HIT_FINISH = @"hitfinish"; @@ -57,5 +58,17 @@ namespace osu.Game.Audio } public HitSampleInfo Clone() => (HitSampleInfo)MemberwiseClone(); + + public bool Equals(HitSampleInfo other) + => other != null && Bank == other.Bank && Name == other.Name && Suffix == other.Suffix; + + public override bool Equals(object obj) + => obj is HitSampleInfo other && Equals(other); + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] // This will have to be addressed eventually + public override int GetHashCode() + { + return HashCode.Combine(Bank, Name, Suffix); + } } } diff --git a/osu.Game/Audio/SampleInfo.cs b/osu.Game/Audio/SampleInfo.cs index 240d70c418..221bc31639 100644 --- a/osu.Game/Audio/SampleInfo.cs +++ b/osu.Game/Audio/SampleInfo.cs @@ -1,14 +1,16 @@ // 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.Linq; namespace osu.Game.Audio { /// /// Describes a gameplay sample. /// - public class SampleInfo : ISampleInfo + public class SampleInfo : ISampleInfo, IEquatable { private readonly string[] sampleNames; @@ -20,5 +22,16 @@ namespace osu.Game.Audio public IEnumerable LookupNames => sampleNames; public int Volume { get; } = 100; + + public override int GetHashCode() + { + return HashCode.Combine(sampleNames, Volume); + } + + public bool Equals(SampleInfo other) + => other != null && sampleNames.SequenceEqual(other.sampleNames); + + public override bool Equals(object obj) + => obj is SampleInfo other && Equals(other); } } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index ca49ed9e75..0d97066b35 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -10,7 +10,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Logging; using osu.Framework.Threading; @@ -139,8 +138,6 @@ namespace osu.Game.Rulesets.Objects.Drawables [Resolved(CanBeNull = true)] private IPooledHitObjectProvider pooledObjectProvider { get; set; } - private Container samplesContainer; - /// /// Creates a new . /// @@ -159,7 +156,7 @@ namespace osu.Game.Rulesets.Objects.Drawables config.BindWith(OsuSetting.PositionalHitSounds, userPositionalHitSounds); // Explicit non-virtual function call. - base.AddInternal(samplesContainer = new Container { RelativeSizeAxes = Axes.Both }); + base.AddInternal(Samples = new PausableSkinnableSound(Array.Empty())); } protected override void LoadAsyncComplete() @@ -269,6 +266,8 @@ namespace osu.Game.Rulesets.Objects.Drawables // In order to stop this needless update, the event is unbound and re-bound as late as possible in Apply(). samplesBindable.CollectionChanged -= onSamplesChanged; + Samples.Samples = Array.Empty(); + if (nestedHitObjects.IsValueCreated) { foreach (var obj in nestedHitObjects.Value) @@ -335,8 +334,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// protected virtual void LoadSamples() { - samplesContainer.Clear(); - Samples = null; + Samples.Samples = Array.Empty(); var samples = GetSamples().ToArray(); @@ -349,7 +347,7 @@ namespace osu.Game.Rulesets.Objects.Drawables + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); } - samplesContainer.Add(Samples = new PausableSkinnableSound(samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)))); + Samples.Samples = samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray(); } private void onSamplesChanged(object sender, NotifyCollectionChangedEventArgs e) => LoadSamples(); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 44b22033dc..19d573a55a 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -5,6 +5,7 @@ using osuTK; using osu.Game.Rulesets.Objects.Types; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using osu.Game.Beatmaps.Formats; using osu.Game.Audio; @@ -500,7 +501,7 @@ namespace osu.Game.Rulesets.Objects.Legacy public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); } - public class LegacyHitSampleInfo : HitSampleInfo + public class LegacyHitSampleInfo : HitSampleInfo, IEquatable { private int customSampleBank; @@ -524,9 +525,21 @@ namespace osu.Game.Rulesets.Objects.Legacy /// using the skin config option. /// public bool IsLayered { get; set; } + + public bool Equals(LegacyHitSampleInfo other) + => other != null && base.Equals(other) && CustomSampleBank == other.CustomSampleBank; + + public override bool Equals(object obj) + => obj is LegacyHitSampleInfo other && Equals(other); + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] // This will have to be addressed eventually + public override int GetHashCode() + { + return HashCode.Combine(base.GetHashCode(), customSampleBank); + } } - private class FileHitSampleInfo : LegacyHitSampleInfo + private class FileHitSampleInfo : LegacyHitSampleInfo, IEquatable { public string Filename; @@ -542,6 +555,18 @@ namespace osu.Game.Rulesets.Objects.Legacy Filename, Path.ChangeExtension(Filename, null) }; + + public bool Equals(FileHitSampleInfo other) + => other != null && Filename == other.Filename; + + public override bool Equals(object obj) + => obj is FileHitSampleInfo other && Equals(other); + + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] // This will have to be addressed eventually + public override int GetHashCode() + { + return HashCode.Combine(Filename); + } } } } diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 82ec653f31..9f3a4c508f 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -8,19 +8,23 @@ using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; +using osu.Game.Audio; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.UI { [Cached(typeof(IPooledHitObjectProvider))] - public abstract class Playfield : CompositeDrawable, IPooledHitObjectProvider + [Cached(typeof(IPooledSampleProvider))] + public abstract class Playfield : CompositeDrawable, IPooledHitObjectProvider, IPooledSampleProvider { /// /// Invoked when a is judged. @@ -80,6 +84,12 @@ namespace osu.Game.Rulesets.UI /// public readonly BindableBool DisplayJudgements = new BindableBool(true); + [Resolved(CanBeNull = true)] + private IReadOnlyList mods { get; set; } + + [Resolved] + private ISampleStore sampleStore { get; set; } + /// /// Creates a new . /// @@ -96,9 +106,6 @@ namespace osu.Game.Rulesets.UI })); } - [Resolved(CanBeNull = true)] - private IReadOnlyList mods { get; set; } - [BackgroundDependencyLoader] private void load() { @@ -336,6 +343,29 @@ namespace osu.Game.Rulesets.UI }); } + private readonly Dictionary> samplePools = new Dictionary>(); + + public PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo) + { + if (!samplePools.TryGetValue(sampleInfo, out var existingPool)) + samplePools[sampleInfo] = existingPool = new DrawableSamplePool(sampleInfo, 5); + + return existingPool.Get(); + } + + private class DrawableSamplePool : DrawablePool + { + private readonly ISampleInfo sampleInfo; + + public DrawableSamplePool(ISampleInfo sampleInfo, int initialSize, int? maximumSize = null) + : base(initialSize, maximumSize) + { + this.sampleInfo = sampleInfo; + } + + protected override PoolableSkinnableSample CreateNewDrawable() => base.CreateNewDrawable().With(d => d.Apply(sampleInfo)); + } + #endregion #region Editor logic diff --git a/osu.Game/Skinning/PausableSkinnableSound.cs b/osu.Game/Skinning/PausableSkinnableSound.cs index 4f09aec0b6..758b784649 100644 --- a/osu.Game/Skinning/PausableSkinnableSound.cs +++ b/osu.Game/Skinning/PausableSkinnableSound.cs @@ -14,13 +14,13 @@ namespace osu.Game.Skinning { protected bool RequestedPlaying { get; private set; } - public PausableSkinnableSound(ISampleInfo hitSamples) - : base(hitSamples) + public PausableSkinnableSound(ISampleInfo sample) + : base(sample) { } - public PausableSkinnableSound(IEnumerable hitSamples) - : base(hitSamples) + public PausableSkinnableSound(IEnumerable samples) + : base(samples) { } diff --git a/osu.Game/Skinning/SkinReloadableDrawable.cs b/osu.Game/Skinning/SkinReloadableDrawable.cs index cc9cbf7b59..50b4143375 100644 --- a/osu.Game/Skinning/SkinReloadableDrawable.cs +++ b/osu.Game/Skinning/SkinReloadableDrawable.cs @@ -27,7 +27,7 @@ namespace osu.Game.Skinning /// /// Whether fallback to default skin should be allowed if the custom skin is missing this resource. /// - private bool allowDefaultFallback => allowFallback == null || allowFallback.Invoke(CurrentSkin); + protected bool AllowDefaultFallback => allowFallback == null || allowFallback.Invoke(CurrentSkin); /// /// Create a new @@ -58,7 +58,7 @@ namespace osu.Game.Skinning private void skinChanged() { - SkinChanged(CurrentSkin, allowDefaultFallback); + SkinChanged(CurrentSkin, AllowDefaultFallback); OnSkinChanged?.Invoke(); } diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index ffa0a963ce..8410b7eeae 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -1,26 +1,149 @@ // 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.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; using osu.Game.Audio; namespace osu.Game.Skinning { - public class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent + public interface IPooledSampleProvider { - private readonly ISampleInfo[] hitSamples; + [CanBeNull] + PoolableSkinnableSample GetPooledSample(ISampleInfo sampleInfo); + } + + public class PoolableSkinnableSample : SkinReloadableDrawable, IAggregateAudioAdjustment, IAdjustableAudioComponent + { + private ISampleInfo sampleInfo; + private DrawableSample sample; [Resolved] - private ISampleStore samples { get; set; } + private ISampleStore sampleStore { get; set; } + [Cached] + private readonly AudioAdjustments adjustments = new AudioAdjustments(); + + public PoolableSkinnableSample() + { + } + + public PoolableSkinnableSample(ISampleInfo sampleInfo) + { + Apply(sampleInfo); + } + + public void Apply(ISampleInfo sampleInfo) + { + if (this.sampleInfo != null) + throw new InvalidOperationException($"A {nameof(PoolableSkinnableSample)} cannot be applied multiple {nameof(ISampleInfo)}s."); + + this.sampleInfo = sampleInfo; + + if (LoadState >= LoadState.Ready) + updateSample(); + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + updateSample(); + } + + private void updateSample() + { + ClearInternal(); + + var ch = CurrentSkin.GetSample(sampleInfo); + + if (ch == null && AllowDefaultFallback) + { + foreach (var lookup in sampleInfo.LookupNames) + { + if ((ch = sampleStore.Get(lookup)) != null) + break; + } + } + + if (ch == null) + return; + + AddInternal(sample = new DrawableSample(ch) + { + Looping = Looping, + Volume = { Value = sampleInfo.Volume / 100.0 } + }); + } + + public void Play(bool restart = true) => sample?.Play(restart); + + public void Stop() => sample?.Stop(); + + public bool Playing => sample?.Playing ?? false; + + private bool looping; + + public bool Looping + { + get => looping; + set + { + looping = value; + + if (sample != null) + sample.Looping = value; + } + } + + /// + /// The volume of this component. + /// + public BindableNumber Volume => adjustments.Volume; + + /// + /// The playback balance of this sample (-1 .. 1 where 0 is centered) + /// + public BindableNumber Balance => adjustments.Balance; + + /// + /// Rate at which the component is played back (affects pitch). 1 is 100% playback speed, or default frequency. + /// + public BindableNumber Frequency => adjustments.Frequency; + + /// + /// Rate at which the component is played back (does not affect pitch). 1 is 100% playback speed. + /// + public BindableNumber Tempo => adjustments.Tempo; + + public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) + => adjustments.AddAdjustment(type, adjustBindable); + + public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) + => adjustments.RemoveAdjustment(type, adjustBindable); + + public void RemoveAllAdjustments(AdjustableProperty type) => adjustments.RemoveAllAdjustments(type); + + public IBindable AggregateVolume => adjustments.AggregateVolume; + + public IBindable AggregateBalance => adjustments.AggregateBalance; + + public IBindable AggregateFrequency => adjustments.AggregateFrequency; + + public IBindable AggregateTempo => adjustments.AggregateTempo; + } + + public class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent + { public override bool RemoveWhenNotAlive => false; public override bool RemoveCompletedTransforms => false; @@ -34,17 +157,44 @@ namespace osu.Game.Skinning /// protected bool PlayWhenZeroVolume => Looping; - protected readonly AudioContainer SamplesContainer; + protected readonly AudioContainer SamplesContainer; - public SkinnableSound(ISampleInfo hitSamples) - : this(new[] { hitSamples }) + [Resolved] + private ISampleStore sampleStore { get; set; } + + [Resolved(CanBeNull = true)] + private IPooledSampleProvider pooledProvider { get; set; } + + public SkinnableSound(ISampleInfo sample) + : this(new[] { sample }) { } - public SkinnableSound(IEnumerable hitSamples) + public SkinnableSound(IEnumerable samples) { - this.hitSamples = hitSamples.ToArray(); - InternalChild = SamplesContainer = new AudioContainer(); + this.samples = samples.ToArray(); + + InternalChild = SamplesContainer = new AudioContainer(); + } + + private ISampleInfo[] samples; + + public ISampleInfo[] Samples + { + get => samples; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + if (samples == value) + return; + + samples = value; + + if (LoadState >= LoadState.Ready) + updateSamples(); + } } private bool looping; @@ -77,34 +227,23 @@ namespace osu.Game.Skinning } protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + // Start playback internally for the new samples if the previous ones were playing beforehand. + if (IsPlaying) + Play(); + } + + private void updateSamples() { bool wasPlaying = IsPlaying; - var channels = hitSamples.Select(s => - { - var ch = skin.GetSample(s); + // Remove all pooled samples (return them to the pool), and dispose the rest. + SamplesContainer.RemoveAll(s => s.IsInPool); + SamplesContainer.Clear(); - if (ch == null && allowFallback) - { - foreach (var lookup in s.LookupNames) - { - if ((ch = samples.Get(lookup)) != null) - break; - } - } + foreach (var s in samples) + SamplesContainer.Add(pooledProvider?.GetPooledSample(s) ?? new PoolableSkinnableSample(s)); - if (ch != null) - { - ch.Looping = looping; - ch.Volume.Value = s.Volume / 100.0; - } - - return ch; - }).Where(c => c != null); - - SamplesContainer.ChildrenEnumerable = channels.Select(c => new DrawableSample(c)); - - // Start playback internally for the new samples if the previous ones were playing beforehand. if (wasPlaying) Play(); } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index 08811b9b8c..b8d212d3c9 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs @@ -37,8 +37,8 @@ namespace osu.Game.Storyboards.Drawables foreach (var mod in mods.Value.OfType()) { - foreach (var sample in SamplesContainer) - mod.ApplyToSample(sample); + // foreach (var sample in SamplesContainer) + // mod.ApplyToSample(sample.Sample); } }