1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-14 16:52:54 +08:00

Merge pull request #14892 from nekodex/popup-filter-effect

Add a dynamic audio `Filter` effect component and use it with `DialogOverlay` popups
This commit is contained in:
Dean Herbert 2021-10-07 20:45:57 +09:00 committed by GitHub
commit 9b404d983c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 338 additions and 1 deletions

View File

@ -0,0 +1,127 @@
// 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 ManagedBass.Fx;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Game.Audio.Effects;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Tests.Visual.Audio
{
public class TestSceneAudioFilter : OsuTestScene
{
private OsuSpriteText lowpassText;
private AudioFilter lowpassFilter;
private OsuSpriteText highpassText;
private AudioFilter highpassFilter;
private Track track;
private WaveformTestBeatmap beatmap;
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
beatmap = new WaveformTestBeatmap(audio);
track = beatmap.LoadTrack();
Add(new FillFlowContainer
{
Children = new Drawable[]
{
lowpassFilter = new AudioFilter(audio.TrackMixer),
highpassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass),
lowpassText = new OsuSpriteText
{
Padding = new MarginPadding(20),
Text = $"Low Pass: {lowpassFilter.Cutoff.Value}hz",
Font = new FontUsage(size: 40)
},
new OsuSliderBar<int>
{
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<int>
{
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";
}
[SetUpSteps]
public void SetUpSteps()
{
AddStep("Play Track", () => track.Start());
waitTrackPlay();
}
[Test]
public void TestLowPass()
{
AddStep("Filter Sweep", () =>
{
lowpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then()
.CutoffTo(0, 2000, Easing.OutCubic);
});
waitTrackPlay();
AddStep("Filter Sweep (reverse)", () =>
{
lowpassFilter.CutoffTo(0).Then()
.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic);
});
waitTrackPlay();
AddStep("Stop track", () => track.Stop());
}
[Test]
public void TestHighPass()
{
AddStep("Filter Sweep", () =>
{
highpassFilter.CutoffTo(0).Then()
.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic);
});
waitTrackPlay();
AddStep("Filter Sweep (reverse)", () =>
{
highpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then()
.CutoffTo(0, 2000, Easing.OutCubic);
});
waitTrackPlay();
AddStep("Stop track", () => track.Stop());
}
private void waitTrackPlay() => AddWaitStep("Let track play", 10);
}
}

View File

@ -0,0 +1,137 @@
// 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.Diagnostics;
using ManagedBass.Fx;
using osu.Framework.Audio.Mixing;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
namespace osu.Game.Audio.Effects
{
public class AudioFilter : Component, ITransformableFilter
{
/// <summary>
/// The maximum cutoff frequency that can be used with a low-pass filter.
/// This is equal to nyquist - 1hz.
/// </summary>
public const int MAX_LOWPASS_CUTOFF = 22049; // nyquist - 1hz
private readonly AudioMixer mixer;
private readonly BQFParameters filter;
private readonly BQFType type;
/// <summary>
/// The current cutoff of this filter.
/// </summary>
public BindableNumber<int> Cutoff { get; }
/// <summary>
/// A Component that implements a BASS FX BiQuad Filter Effect.
/// </summary>
/// <param name="mixer">The mixer this effect should be applied to.</param>
/// <param name="type">The type of filter (e.g. LowPass, HighPass, etc)</param>
public AudioFilter(AudioMixer mixer, BQFType type = BQFType.LowPass)
{
this.mixer = mixer;
this.type = type;
int initialCutoff;
switch (type)
{
case BQFType.HighPass:
initialCutoff = 1;
break;
case BQFType.LowPass:
initialCutoff = MAX_LOWPASS_CUTOFF;
break;
default:
initialCutoff = 500; // A default that should ensure audio remains audible for other filters.
break;
}
Cutoff = new BindableNumber<int>(initialCutoff)
{
MinValue = 1,
MaxValue = MAX_LOWPASS_CUTOFF
};
filter = new BQFParameters
{
lFilter = type,
fCenter = initialCutoff,
fBandwidth = 0,
fQ = 0.7f // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0)
};
// Don't start attached if this is low-pass or high-pass filter (as they have special auto-attach/detach logic)
if (type != BQFType.LowPass && type != BQFType.HighPass)
attachFilter();
Cutoff.ValueChanged += updateFilter;
}
private void attachFilter()
{
Debug.Assert(!mixer.Effects.Contains(filter));
mixer.Effects.Add(filter);
}
private void detachFilter()
{
Debug.Assert(mixer.Effects.Contains(filter));
mixer.Effects.Remove(filter);
}
private void updateFilter(ValueChangedEvent<int> cutoff)
{
// Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz.
if (type == BQFType.LowPass)
{
if (cutoff.NewValue >= MAX_LOWPASS_CUTOFF)
{
detachFilter();
return;
}
if (cutoff.OldValue >= MAX_LOWPASS_CUTOFF && cutoff.NewValue < MAX_LOWPASS_CUTOFF)
attachFilter();
}
// Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz.
if (type == BQFType.HighPass)
{
if (cutoff.NewValue <= 1)
{
detachFilter();
return;
}
if (cutoff.OldValue <= 1 && cutoff.NewValue > 1)
attachFilter();
}
var filterIndex = mixer.Effects.IndexOf(filter);
if (filterIndex < 0) return;
if (mixer.Effects[filterIndex] is BQFParameters existingFilter)
{
existingFilter.fCenter = cutoff.NewValue;
// required to update effect with new parameters.
mixer.Effects[filterIndex] = existingFilter;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (mixer.Effects.Contains(filter))
detachFilter();
}
}
}

View File

@ -0,0 +1,54 @@
// 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 osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Transforms;
namespace osu.Game.Audio.Effects
{
public interface ITransformableFilter
{
/// <summary>
/// The filter cutoff.
/// </summary>
BindableNumber<int> Cutoff { get; }
}
public static class FilterableAudioComponentExtensions
{
/// <summary>
/// Smoothly adjusts filter cutoff over time.
/// </summary>
/// <returns>A <see cref="TransformSequence{T}"/> to which further transforms can be added.</returns>
public static TransformSequence<T> CutoffTo<T>(this T component, int newCutoff, double duration = 0, Easing easing = Easing.None)
where T : class, ITransformableFilter, IDrawable
=> component.CutoffTo(newCutoff, duration, new DefaultEasingFunction(easing));
/// <summary>
/// Smoothly adjusts filter cutoff over time.
/// </summary>
/// <returns>A <see cref="TransformSequence{T}"/> to which further transforms can be added.</returns>
public static TransformSequence<T> CutoffTo<T>(this TransformSequence<T> sequence, int newCutoff, double duration = 0, Easing easing = Easing.None)
where T : class, ITransformableFilter, IDrawable
=> sequence.CutoffTo(newCutoff, duration, new DefaultEasingFunction(easing));
/// <summary>
/// Smoothly adjusts filter cutoff over time.
/// </summary>
/// <returns>A <see cref="TransformSequence{T}"/> to which further transforms can be added.</returns>
public static TransformSequence<T> CutoffTo<T, TEasing>(this T component, int newCutoff, double duration, TEasing easing)
where T : class, ITransformableFilter, IDrawable
where TEasing : IEasingFunction
=> component.TransformBindableTo(component.Cutoff, newCutoff, duration, easing);
/// <summary>
/// Smoothly adjusts filter cutoff over time.
/// </summary>
/// <returns>A <see cref="TransformSequence{T}"/> to which further transforms can be added.</returns>
public static TransformSequence<T> CutoffTo<T, TEasing>(this TransformSequence<T> 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));
}
}

View File

@ -7,7 +7,10 @@ using osu.Game.Overlays.Dialog;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using System.Linq; using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Audio.Effects;
namespace osu.Game.Overlays namespace osu.Game.Overlays
{ {
@ -18,6 +21,8 @@ namespace osu.Game.Overlays
protected override string PopInSampleName => "UI/dialog-pop-in"; protected override string PopInSampleName => "UI/dialog-pop-in";
protected override string PopOutSampleName => "UI/dialog-pop-out"; protected override string PopOutSampleName => "UI/dialog-pop-out";
private AudioFilter lowPassFilter;
public PopupDialog CurrentDialog { get; private set; } public PopupDialog CurrentDialog { get; private set; }
public DialogOverlay() public DialogOverlay()
@ -34,6 +39,12 @@ namespace osu.Game.Overlays
Origin = Anchor.BottomCentre; Origin = Anchor.BottomCentre;
} }
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
AddInternal(lowPassFilter = new AudioFilter(audio.TrackMixer));
}
public void Push(PopupDialog dialog) public void Push(PopupDialog dialog)
{ {
if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return; if (dialog == CurrentDialog || dialog.State.Value != Visibility.Visible) return;
@ -71,12 +82,15 @@ namespace osu.Game.Overlays
{ {
base.PopIn(); base.PopIn();
this.FadeIn(PopupDialog.ENTER_DURATION, Easing.OutQuint); this.FadeIn(PopupDialog.ENTER_DURATION, Easing.OutQuint);
lowPassFilter.CutoffTo(300, 100, Easing.OutCubic);
} }
protected override void PopOut() protected override void PopOut()
{ {
base.PopOut(); base.PopOut();
lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic);
if (CurrentDialog?.State.Value == Visibility.Visible) if (CurrentDialog?.State.Value == Visibility.Visible)
{ {
CurrentDialog.Hide(); CurrentDialog.Hide();

View File

@ -508,6 +508,7 @@ namespace osu.Game.Screens.Edit
if (isNewBeatmap || HasUnsavedChanges) if (isNewBeatmap || HasUnsavedChanges)
{ {
samplePlaybackDisabled.Value = true;
dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave, cancelExit)); dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave, cancelExit));
return true; return true;
} }
@ -756,7 +757,11 @@ namespace osu.Game.Screens.Edit
ClipboardContent = editorBeatmap.BeatmapInfo.RulesetID == nextBeatmap.RulesetID ? clipboard.Value : string.Empty ClipboardContent = editorBeatmap.BeatmapInfo.RulesetID == nextBeatmap.RulesetID ? clipboard.Value : string.Empty
}); });
private void cancelExit() => loader?.CancelPendingDifficultySwitch(); private void cancelExit()
{
samplePlaybackDisabled.Value = false;
loader?.CancelPendingDifficultySwitch();
}
public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime);