From 2a4a376b87ef04d7eb25e689159570e42acbd422 Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Sat, 2 Oct 2021 01:21:55 +0900 Subject: [PATCH] Refactor Filter to behave closer to a Transformable --- .../Visual/Audio/TestSceneFilter.cs | 108 ++++++++++-------- osu.Game/Audio/Effects/Filter.cs | 97 ++++++++++------ .../Audio/Effects/ITransformableFilter.cs | 54 +++++++++ osu.Game/Overlays/DialogOverlay.cs | 9 +- 4 files changed, 175 insertions(+), 93 deletions(-) create mode 100644 osu.Game/Audio/Effects/ITransformableFilter.cs diff --git a/osu.Game.Tests/Visual/Audio/TestSceneFilter.cs b/osu.Game.Tests/Visual/Audio/TestSceneFilter.cs index 6a7a404d8a..79d8f7da8c 100644 --- a/osu.Game.Tests/Visual/Audio/TestSceneFilter.cs +++ b/osu.Game.Tests/Visual/Audio/TestSceneFilter.cs @@ -6,74 +6,77 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Game.Audio.Effects; using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Tests.Visual.Audio { public class TestSceneFilter : OsuTestScene { - [Resolved] - private AudioManager audio { get; set; } - private WorkingBeatmap testBeatmap; - private Filter lowPassFilter; - private Filter highPassFilter; - private Filter bandPassFilter; + + private OsuSpriteText lowpassText; + private OsuSpriteText highpassText; + private Filter lowpassFilter; + private Filter highpassFilter; [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { testBeatmap = new WaveformTestBeatmap(audio); - AddRange(new Drawable[] + lowpassFilter = new Filter(audio.TrackMixer); + highpassFilter = new Filter(audio.TrackMixer, BQFType.HighPass); + Add(new FillFlowContainer { - lowPassFilter = new Filter(audio.TrackMixer) + Children = new Drawable[] { - FilterType = BQFType.LowPass, - SweepCutoffStart = 2000, - SweepCutoffEnd = 150, - SweepDuration = 1000 - }, - highPassFilter = new Filter(audio.TrackMixer) - { - FilterType = BQFType.HighPass, - SweepCutoffStart = 150, - SweepCutoffEnd = 2000, - SweepDuration = 1000 - }, - bandPassFilter = new Filter(audio.TrackMixer) - { - FilterType = BQFType.BandPass, - SweepCutoffStart = 150, - SweepCutoffEnd = 20000, - SweepDuration = 1000 - }, + lowpassText = new OsuSpriteText + { + Padding = new MarginPadding(20), + Text = $"Low Pass: {lowpassFilter.Cutoff.Value}hz", + Font = new FontUsage(size: 40) + }, + new OsuSliderBar + { + Width = 500, + Height = 50, + Padding = new MarginPadding(20), + Current = { BindTarget = lowpassFilter.Cutoff } + }, + highpassText = new OsuSpriteText + { + Padding = new MarginPadding(20), + Text = $"High Pass: {highpassFilter.Cutoff.Value}hz", + Font = new FontUsage(size: 40) + }, + new OsuSliderBar + { + Width = 500, + Height = 50, + Padding = new MarginPadding(20), + Current = { BindTarget = highpassFilter.Cutoff } + } + } }); + lowpassFilter.Cutoff.ValueChanged += e => lowpassText.Text = $"Low Pass: {e.NewValue}hz"; + highpassFilter.Cutoff.ValueChanged += e => highpassText.Text = $"High Pass: {e.NewValue}hz"; } [Test] - public void TestLowPass() - { - testFilter(lowPassFilter); - } + public void TestLowPass() => testFilter(lowpassFilter, lowpassFilter.MaxCutoff, 0); [Test] - public void TestHighPass() - { - testFilter(highPassFilter); - } + public void TestHighPass() => testFilter(highpassFilter, 0, highpassFilter.MaxCutoff); - [Test] - public void TestBandPass() - { - testFilter(bandPassFilter); - } - - private void testFilter(Filter filter) + private void testFilter(Filter filter, int cutoffFrom, int cutoffTo) { + Add(filter); AddStep("Prepare Track", () => { - testBeatmap = new WaveformTestBeatmap(audio); testBeatmap.LoadTrack(); }); AddStep("Play Track", () => @@ -81,14 +84,19 @@ namespace osu.Game.Tests.Visual.Audio testBeatmap.Track.Start(); }); AddWaitStep("Let track play", 10); - AddStep("Enable Filter", filter.Enable); - AddWaitStep("Let track play", 10); - AddStep("Disable Filter", filter.Disable); - AddWaitStep("Let track play", 10); - AddStep("Stop Track", () => + AddStep("Filter Sweep", () => { - testBeatmap.Track.Stop(); + filter.CutoffTo(cutoffFrom).Then() + .CutoffTo(cutoffTo, 2000, cutoffFrom > cutoffTo ? Easing.OutCubic : Easing.InCubic); }); + AddWaitStep("Let track play", 10); + AddStep("Filter Sweep (reverse)", () => + { + filter.CutoffTo(cutoffTo).Then() + .CutoffTo(cutoffFrom, 2000, cutoffTo > cutoffFrom ? Easing.OutCubic : Easing.InCubic); + }); + AddWaitStep("Let track play", 10); + AddStep("Stop track", () => testBeatmap.Track.Stop()); } } } diff --git a/osu.Game/Audio/Effects/Filter.cs b/osu.Game/Audio/Effects/Filter.cs index 04ad862879..142e6b8fff 100644 --- a/osu.Game/Audio/Effects/Filter.cs +++ b/osu.Game/Audio/Effects/Filter.cs @@ -8,66 +8,87 @@ using osu.Framework.Graphics; namespace osu.Game.Audio.Effects { - public class Filter : Component + public class Filter : Component, ITransformableFilter { - public BQFType FilterType = BQFType.LowPass; - public float SweepCutoffStart = 2000; - public float SweepCutoffEnd = 150; - public float SweepDuration = 100; - public Easing SweepEasing = Easing.None; - - public bool IsActive { get; private set; } - - private readonly Bindable filterFreq = new Bindable(); + public readonly int MaxCutoff; private readonly AudioMixer mixer; - private BQFParameters filter; + private readonly BQFParameters filter; + private readonly BQFType type; + + public BindableNumber Cutoff { get; } /// /// A BiQuad filter that performs a filter-sweep when toggled on or off. /// /// The mixer this effect should be attached to. - public Filter(AudioMixer mixer) + /// The type of filter (e.g. LowPass, HighPass, etc) + public Filter(AudioMixer mixer, BQFType type = BQFType.LowPass) { this.mixer = mixer; - } + this.type = type; - public void Enable() - { - attachFilter(); - this.TransformBindableTo(filterFreq, SweepCutoffEnd, SweepDuration, SweepEasing); - } + var initialCutoff = 1; - public void Disable() - { - this.TransformBindableTo(filterFreq, SweepCutoffStart, SweepDuration, SweepEasing).OnComplete(_ => detachFilter()); - } + // These max cutoff values are a work-around for BASS' BiQuad filters behaving weirdly when approaching nyquist. + // Note that these values assume a sample rate of 44100 (as per BassAudioMixer in osu.Framework) + // See also https://www.un4seen.com/forum/?topic=19542.0 for more information. + switch (type) + { + case BQFType.HighPass: + MaxCutoff = 21968; // beyond this value, the high-pass cuts out + break; - private void attachFilter() - { - if (IsActive) return; + case BQFType.LowPass: + MaxCutoff = initialCutoff = 14000; // beyond (roughly) this value, the low-pass filter audibly wraps/reflects + break; + case BQFType.BandPass: + MaxCutoff = 16000; // beyond (roughly) this value, the band-pass filter audibly wraps/reflects + break; + + default: + MaxCutoff = 22050; // default to nyquist for other filter types, TODO: handle quirks of other filter types + break; + } + + Cutoff = new BindableNumber + { + MinValue = 1, + MaxValue = MaxCutoff + }; filter = new BQFParameters { - lFilter = FilterType, - fCenter = filterFreq.Value = SweepCutoffStart + lFilter = type, + fCenter = initialCutoff }; - mixer.Effects.Add(filter); - filterFreq.ValueChanged += updateFilter; - IsActive = true; + attachFilter(); + + Cutoff.ValueChanged += updateFilter; + Cutoff.Value = initialCutoff; } - private void detachFilter() - { - if (!IsActive) return; + private void attachFilter() => mixer.Effects.Add(filter); - filterFreq.ValueChanged -= updateFilter; - mixer.Effects.Remove(filter); - IsActive = false; - } + private void detachFilter() => mixer.Effects.Remove(filter); - private void updateFilter(ValueChangedEvent cutoff) + private void updateFilter(ValueChangedEvent cutoff) { + // This is another workaround for quirks in BASS' BiQuad filters. + // Because the cutoff can't be set above ~14khz (i.e. outside of human hearing range) without the aforementioned wrapping/reflecting quirk occuring, we instead + // remove the effect from the mixer when the cutoff is at maximum so that a LowPass filter isn't always attenuating high frequencies just by existing. + if (type == BQFType.LowPass) + { + if (cutoff.NewValue >= MaxCutoff) + { + detachFilter(); + return; + } + + if (cutoff.OldValue >= MaxCutoff && cutoff.NewValue < MaxCutoff) + attachFilter(); + } + var filterIndex = mixer.Effects.IndexOf(filter); if (filterIndex < 0) return; diff --git a/osu.Game/Audio/Effects/ITransformableFilter.cs b/osu.Game/Audio/Effects/ITransformableFilter.cs new file mode 100644 index 0000000000..e4de4cf8ff --- /dev/null +++ b/osu.Game/Audio/Effects/ITransformableFilter.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; + +namespace osu.Game.Audio.Effects +{ + public interface ITransformableFilter + { + /// + /// The filter cutoff. + /// + BindableNumber Cutoff { get; } + } + + public static class FilterableAudioComponentExtensions + { + /// + /// Smoothly adjusts filter cutoff over time. + /// + /// A to which further transforms can be added. + public static TransformSequence CutoffTo(this T component, int newCutoff, double duration = 0, Easing easing = Easing.None) + where T : class, ITransformableFilter, IDrawable + => component.CutoffTo(newCutoff, duration, new DefaultEasingFunction(easing)); + + /// + /// Smoothly adjusts filter cutoff over time. + /// + /// A to which further transforms can be added. + public static TransformSequence CutoffTo(this TransformSequence sequence, int newCutoff, double duration = 0, Easing easing = Easing.None) + where T : class, ITransformableFilter, IDrawable + => sequence.CutoffTo(newCutoff, duration, new DefaultEasingFunction(easing)); + + /// + /// Smoothly adjusts filter cutoff over time. + /// + /// A to which further transforms can be added. + public static TransformSequence CutoffTo(this T component, int newCutoff, double duration, TEasing easing) + where T : class, ITransformableFilter, IDrawable + where TEasing : IEasingFunction + => component.TransformBindableTo(component.Cutoff, newCutoff, duration, easing); + + /// + /// Smoothly adjusts filter cutoff over time. + /// + /// A to which further transforms can be added. + public static TransformSequence CutoffTo(this TransformSequence sequence, int newCutoff, double duration, TEasing easing) + where T : class, ITransformableFilter, IDrawable + where TEasing : IEasingFunction + => sequence.Append(o => o.TransformBindableTo(o.Cutoff, newCutoff, duration, easing)); + } +} diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 8a274968b7..6016d16d29 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -21,7 +21,7 @@ namespace osu.Game.Overlays protected override string PopInSampleName => "UI/dialog-pop-in"; protected override string PopOutSampleName => "UI/dialog-pop-out"; - private Filter filter; + private Filter lpFilter; public PopupDialog CurrentDialog { get; private set; } @@ -42,7 +42,7 @@ namespace osu.Game.Overlays [BackgroundDependencyLoader] private void load(AudioManager audio) { - AddInternal(filter = new Filter(audio.TrackMixer)); + AddInternal(lpFilter = new Filter(audio.TrackMixer)); } public void Push(PopupDialog dialog) @@ -82,15 +82,13 @@ namespace osu.Game.Overlays { base.PopIn(); this.FadeIn(PopupDialog.ENTER_DURATION, Easing.OutQuint); - filter.Enable(); + lpFilter.CutoffTo(2000).Then().CutoffTo(150, 100, Easing.OutCubic); } protected override void PopOut() { base.PopOut(); - filter.Disable(); - if (CurrentDialog?.State.Value == Visibility.Visible) { CurrentDialog.Hide(); @@ -98,6 +96,7 @@ namespace osu.Game.Overlays } this.FadeOut(PopupDialog.EXIT_DURATION, Easing.InSine); + lpFilter.CutoffTo(2000, 100, Easing.InCubic).Then().CutoffTo(lpFilter.MaxCutoff); } public override bool OnPressed(KeyBindingPressEvent e)