diff --git a/osu.Game/Audio/AudioNormalization.cs b/osu.Game/Audio/AudioNormalization.cs new file mode 100644 index 000000000000..0e149e33bb27 --- /dev/null +++ b/osu.Game/Audio/AudioNormalization.cs @@ -0,0 +1,101 @@ +// 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 osu.Framework.Audio.Track; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using Realms; + +namespace osu.Game.Audio +{ + /// + /// Audio Normalization Data + /// + public class AudioNormalization : EmbeddedObject, IEquatable + { + /// + /// The target level for audio normalization + /// https://en.wikipedia.org/wiki/EBU_R_128 + /// + public const int TARGET_LEVEL = -14; + + /// + /// The integrated (average) loudness of the audio + /// + public float? IntegratedLoudness { get; init; } + + /// + /// A volume offset that can be applied upon audio to reach (converted from integrated loudness). + /// + public double? IntegratedLoudnessInVolumeOffset => IntegratedLoudness != null ? TrackLoudness.ConvertToVolumeOffset(TARGET_LEVEL, (float)IntegratedLoudness) : null; + + public AudioNormalization() + { + } + + /// + /// Get the audio normalization for a beatmap. The loudness normalization value is stored in the object under + /// + /// The of the beatmap + /// The parent of the supplied + /// The to use in getting the full path of the audio file + public AudioNormalization(BeatmapInfo beatmapInfo, BeatmapSetInfo beatmapSetInfo, RealmFileStore realmFileStore) + { + string audiofile = beatmapInfo.Metadata.AudioFile; + + if (string.IsNullOrEmpty(audiofile)) + { + Logger.Log("Audio file not found for " + beatmapInfo.Metadata.Title, LoggingTarget.Runtime, LogLevel.Error); + return; + } + + string? filepath = beatmapSetInfo.GetPathForFile(audiofile); + + if (string.IsNullOrEmpty(filepath)) + { + Logger.Log("File path not found for " + audiofile, LoggingTarget.Runtime, LogLevel.Error); + return; + } + + using TrackLoudness loudness = new TrackLoudness(realmFileStore.Storage.GetStream(filepath)); + + float? integratedLoudness = loudness.GetIntegratedLoudness(); + + if (integratedLoudness == null) + { + Logger.Log("Failed to get loudness level for " + audiofile, LoggingTarget.Runtime, LogLevel.Error); + return; + } + + IntegratedLoudness = integratedLoudness; + } + + /// + /// Populate the audio normalization for every in a . + /// + /// + /// This takes a and applies the same normalization to every in the that has the same audio file as the given to avoid having to calculate the loudness for an audio file multiple times + /// + /// The to clone from + /// The to populate + public void PopulateSet(BeatmapInfo beatmapInfo, BeatmapSetInfo beatmapSetInfo) + { + foreach (BeatmapInfo beatmap in beatmapSetInfo.Beatmaps) + { + if (beatmap.AudioNormalization == null && beatmap.AudioEquals(beatmapInfo)) + { + beatmap.AudioNormalization = new AudioNormalization + { + IntegratedLoudness = IntegratedLoudness + }; + } + } + } + + + /// + public bool Equals(AudioNormalization? other) => other?.IntegratedLoudness != null && IntegratedLoudness != null && Math.Abs((float)(IntegratedLoudness - other.IntegratedLoudness)) < 0.0000001; + } +} diff --git a/osu.Game/Audio/AudioNormalizationManager.cs b/osu.Game/Audio/AudioNormalizationManager.cs new file mode 100644 index 000000000000..00293d965dc6 --- /dev/null +++ b/osu.Game/Audio/AudioNormalizationManager.cs @@ -0,0 +1,65 @@ +// 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.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Configuration; + +namespace osu.Game.Audio +{ + /// + /// Manages audio normalization. + /// + public class AudioNormalizationManager + { + /// + /// Fallback volume for tracks that failed to measure loudness. + /// + public const double FALLBACK_VOLUME = 0.8; + + private readonly Bindable audioNormalizationSetting; + + /// + /// Samples assigned to hitobjects needs to bind to this bindable to normalize their volume in line with track. + /// + public readonly BindableDouble SampleNormalizeVolume = new BindableDouble(1.0); + + /// + /// Creates a new . + /// + /// An osu config to get beatmap hitsounds bindable from. + /// A bindable for beatmap. + public AudioNormalizationManager(OsuConfigManager config, IBindable beatmap) + { + audioNormalizationSetting = config.GetBindable(OsuSetting.AudioNormalization); + + updateNormalization(audioNormalizationSetting.Value, beatmap.Value); + audioNormalizationSetting.BindValueChanged(change => updateNormalization(change.NewValue, beatmap.Value)); + beatmap.BindValueChanged(ev => onBeatmapChanged(ev.OldValue, ev.NewValue)); + } + + private void updateNormalization(bool value, WorkingBeatmap current) + { + if (value) + { + SampleNormalizeVolume.BindTo(current.TrackNormalizeVolume); + current.EnableTrackNormlization(); + Logger.Log($"Normalization value: {(int)(current.TrackNormalizeVolume.Value * 100)}%"); + } + else + { + SampleNormalizeVolume.UnbindFrom(current.TrackNormalizeVolume); + SampleNormalizeVolume.Value = 1.0; + current.DisableTrackNormalization(); + } + } + + private void onBeatmapChanged(WorkingBeatmap oldBeatmap, WorkingBeatmap newBeatmap) + { + SampleNormalizeVolume.UnbindFrom(oldBeatmap.TrackNormalizeVolume); + updateNormalization(audioNormalizationSetting.Value, newBeatmap); + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 94144e46952f..d1c1fb306de5 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -12,6 +12,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.Collections; using osu.Game.Database; @@ -173,6 +174,23 @@ protected override void Populate(BeatmapSetInfo beatmapSet, ArchiveReader? archi b.Ruleset = realm.Find(b.Ruleset.ShortName) ?? throw new ArgumentNullException(nameof(b.Ruleset)); } + foreach (BeatmapInfo beatmapInfo in beatmapSet.Beatmaps) + { + if (beatmapInfo.AudioNormalization != null) continue; + + AudioNormalization audioNormalization = new AudioNormalization(beatmapInfo, beatmapSet, Files); + + if (audioNormalization.IntegratedLoudness == null) + { + Logger.Log($"Failed to calculate audio normalization values for {beatmapInfo.Metadata.Title} [{beatmapInfo.DifficultyName}]", LoggingTarget.Runtime, LogLevel.Error); + continue; + } + + beatmapInfo.AudioNormalization = audioNormalization; + beatmapInfo.AudioNormalization.PopulateSet(beatmapInfo, beatmapSet); + Logger.Log($"Processed loudness values for {beatmapInfo.Metadata.Title} [{beatmapInfo.DifficultyName}]"); + } + validateOnlineIds(beatmapSet, realm); bool hadOnlineIDs = beatmapSet.Beatmaps.Any(b => b.OnlineID > 0); diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 425fd98d27e9..7998e841b6d3 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -6,6 +6,7 @@ using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; +using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Collections; using osu.Game.Database; @@ -41,6 +42,8 @@ public class BeatmapInfo : RealmObject, IHasGuidPrimaryKey, IBeatmapInfo, IEquat public BeatmapMetadata Metadata { get; set; } = null!; + public AudioNormalization? AudioNormalization { get; set; } + [JsonIgnore] [Backlink(nameof(ScoreInfo.BeatmapInfo))] public IQueryable Scores { get; } = null!; diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 07bf4c028af0..db23f75770a9 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -13,9 +13,11 @@ using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; +using osu.Game.Audio; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -111,9 +113,32 @@ public Track LoadTrack() waveform?.Dispose(); waveform = null; + TrackNormalizeVolume = new BindableDouble(AudioNormalizationManager.FALLBACK_VOLUME); + track.AddAdjustment(AdjustableProperty.Volume, TrackNormalizeVolume); + return track; } + // Normalization is here because it's not so good to apply normalization in global state through MusicController or TrackStore. + // Each beatmap has its own track and its own track loudness value. + public BindableDouble TrackNormalizeVolume { get; private set; } + + public void EnableTrackNormlization() + { + if (TrackNormalizeVolume == null) + throw new InvalidOperationException($"{nameof(TrackNormalizeVolume)} is not available. You must call {nameof(LoadTrack)} before calling this."); + + TrackNormalizeVolume.Value = BeatmapInfo.AudioNormalization?.IntegratedLoudnessInVolumeOffset ?? AudioNormalizationManager.FALLBACK_VOLUME; + } + + public void DisableTrackNormalization() + { + if (TrackNormalizeVolume == null) + throw new InvalidOperationException($"{nameof(TrackNormalizeVolume)} is not available. You must call {nameof(LoadTrack)} before calling this."); + + TrackNormalizeVolume.Value = AudioNormalizationManager.FALLBACK_VOLUME; + } + public void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0) { Track.Looping = looping; @@ -144,6 +169,7 @@ public virtual bool TryTransferTrack([NotNull] WorkingBeatmap target) if (BeatmapInfo?.AudioEquals(target.BeatmapInfo) != true || Track.IsDummyDevice) return false; + target.TrackNormalizeVolume = TrackNormalizeVolume; target.track = Track; return true; } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 8d6c244b350c..3ba9de65d851 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -103,6 +103,8 @@ protected override void InitialiseDefaults() SetDefault(OsuSetting.AudioOffset, 0, -500.0, 500.0, 1); + SetDefault(OsuSetting.AudioNormalization, true); + // Input SetDefault(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); SetDefault(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); @@ -355,6 +357,7 @@ public enum OsuSetting /// This is added to the audio track's current time. Higher values will cause gameplay to occur earlier, relative to the audio track. /// AudioOffset, + AudioNormalization, VolumeInactive, MenuMusic, diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs index 3efd4da3aa58..54d5c3f760b6 100644 --- a/osu.Game/Database/BackgroundDataStoreProcessor.cs +++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.API; @@ -84,6 +85,7 @@ protected override void LoadComplete() convertLegacyTotalScoreToStandardised(); upgradeScoreRanks(); backpopulateMissingSubmissionAndRankDates(); + processAudioNormalization(); }, TaskCreationOptions.LongRunning).ContinueWith(t => { if (t.Exception?.InnerException is ObjectDisposedException) @@ -96,6 +98,71 @@ protected override void LoadComplete() }); } + /// + /// Go through every beatmap and calculate the audio normalization values if they are missing + /// + private void processAudioNormalization() + { + var filestorage = new RealmFileStore(realmAccess, storage); + int beatmapsCount = 0; + int beatmapsToProcess = 0; + + realmAccess.Run(r => + { + foreach (BeatmapSetInfo beatmapSetInfo in r.All().Where(b => !b.DeletePending)) + { + beatmapsCount += beatmapSetInfo.Beatmaps.Count; + beatmapsToProcess += beatmapSetInfo.Beatmaps.Count(b => b.AudioNormalization == null); + } + }); + + if (beatmapsToProcess == 0) + { + Logger.Log("No audio normalization processing required."); + return; + } + + var notification = showProgressNotification(beatmapsCount, "Verifying loudness level for beatmaps", "all loudness levels have been verified"); + int processedCount = 0; + + realmAccess.Write(r => + { + foreach (BeatmapSetInfo beatmapSetInfo in r.All().Where(b => !b.DeletePending)) + { + foreach (BeatmapInfo beatmapInfo in beatmapSetInfo.Beatmaps) + { + if (notification?.State == ProgressNotificationState.Cancelled) + break; + + updateNotificationProgress(notification, processedCount, beatmapsCount); + processedCount++; + + if (beatmapInfo.AudioNormalization != null) continue; + + sleepIfRequired(); + + AudioNormalization audioNormalization = new AudioNormalization(beatmapInfo, beatmapSetInfo, filestorage); + + if (audioNormalization.IntegratedLoudness == null) + { + Logger.Log($"Failed to get loudness level for {beatmapSetInfo.Metadata.Title} [{beatmapInfo.DifficultyName}]", LoggingTarget.Runtime, LogLevel.Error); + continue; + } + + beatmapInfo.AudioNormalization = audioNormalization; + audioNormalization.PopulateSet(beatmapInfo, beatmapSetInfo); + ((IWorkingBeatmapCache)beatmapManager).Invalidate(beatmapSetInfo); + Logger.Log($"Processed audio normalization for {beatmapSetInfo.Metadata.Title} [{beatmapInfo.DifficultyName}]"); + } + } + + r.Refresh(); + }); + + Logger.Log("Finished processing audio normalization readings"); + completeNotification(notification, processedCount, beatmapsCount); + } + /// /// Check whether the databased difficulty calculation version matches the latest ruleset provided version. /// If it doesn't, clear out any existing difficulties so they can be incrementally recalculated. diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index a437b3313e5a..3fdb2010f22f 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -94,8 +94,9 @@ public class RealmAccess : IDisposable /// 41 2024-04-17 Add ScoreInfo.TotalScoreWithoutMods for future mod multiplier rebalances. /// 42 2024-08-07 Update mania key bindings to reflect changes to ManiaAction /// 43 2024-10-14 Reset keybind for toggling FPS display to avoid conflict with "convert to stream" in the editor, if not already changed by user. + /// 44 2024-10-16 Add AudioNormalization to BeatmapInfo. /// - private const int schema_version = 43; + private const int schema_version = 44; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 2fa3b8a88072..a86d1ae051ad 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -9,6 +9,7 @@ using AutoMapper; using AutoMapper.Internal; using osu.Framework.Logging; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Input.Bindings; using osu.Game.Models; @@ -32,6 +33,7 @@ public static class RealmObjectExtensions copyChangesToRealm(s.Author, d.Author); }); c.CreateMap(); + c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); @@ -41,6 +43,7 @@ public static class RealmObjectExtensions .ForMember(s => s.UserSettings, cc => cc.Ignore()) .ForMember(s => s.Difficulty, cc => cc.Ignore()) .ForMember(s => s.BeatmapSet, cc => cc.Ignore()) + .ForMember(s => s.AudioNormalization, cc => cc.Ignore()) .AfterMap((s, d) => { d.Ruleset = d.Realm!.Find(s.Ruleset.ShortName)!; @@ -173,6 +176,7 @@ private static void applyCommonConfiguration(IMapperConfigurationExpression c) c.CreateMap(); c.CreateMap(); c.CreateMap(); + c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs index 89db60d8a69c..a77384971d82 100644 --- a/osu.Game/Localisation/AudioSettingsStrings.cs +++ b/osu.Game/Localisation/AudioSettingsStrings.cs @@ -64,6 +64,11 @@ public static class AudioSettingsStrings /// public static LocalisableString AudioOffset => new TranslatableString(getKey(@"audio_offset"), @"Audio offset"); + /// + /// "Normalize Beatmap Audio" + /// + public static LocalisableString AudioNormalization => new TranslatableString(getKey(@"audio_normalization"), @"Normalize Beatmap Audio"); + /// /// "Play a few beatmaps to receive a suggested offset!" /// diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index d4704d1c72d4..6236a397179c 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -99,11 +99,6 @@ public partial class OsuGameBase : Framework.Game, ICanAcceptFiles, IBeatSyncPro /// public const int SAMPLE_DEBOUNCE_TIME = 20; - /// - /// The maximum volume at which audio tracks should play back at. This can be set lower than 1 to create some head-room for sound effects. - /// - private const double global_track_volume_adjust = 0.8; - public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild; public virtual EndpointConfiguration CreateEndpoints() => @@ -217,6 +212,8 @@ public virtual string Version protected SafeAreaContainer SafeAreaContainer { get; private set; } + protected AudioNormalizationManager AudioNormalizationManager { get; private set; } + /// /// For now, this is used as a source specifically for beat synced components. /// Going forward, it could potentially be used as the single source-of-truth for beatmap timing. @@ -229,8 +226,6 @@ public virtual string Version private DependencyContainer dependencies; - private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(global_track_volume_adjust); - private Bindable frameworkLocale = null!; private IBindable localisationParameters = null!; @@ -353,11 +348,6 @@ private void load(ReadableKeyCombinationProvider keyCombinationProvider, Framewo RegisterImportHandler(ScoreManager); RegisterImportHandler(SkinManager); - // drop track volume game-wide to leave some head-room for UI effects / samples. - // this means that for the time being, gameplay sample playback is louder relative to the audio track, compared to stable. - // we may want to revisit this if users notice or complain about the difference (consider this a bit of a trial). - Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); - Beatmap = new NonNullableBindable(defaultBeatmap); dependencies.CacheAs>(Beatmap); @@ -377,6 +367,8 @@ private void load(ReadableKeyCombinationProvider keyCombinationProvider, Framewo dependencies.Cache(previewTrackManager = new PreviewTrackManager(BeatmapManager.BeatmapTrackStore)); base.Content.Add(previewTrackManager); + dependencies.Cache(AudioNormalizationManager = new AudioNormalizationManager(LocalConfig, Beatmap)); + base.Content.Add(MusicController = new MusicController()); dependencies.CacheAs(MusicController); diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index 2bb5fa983f54..9d304faed2b9 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -49,6 +49,12 @@ private void load(AudioManager audio, OsuConfigManager config) KeyboardStep = 0.01f, DisplayAsPercentage = true }, + new SettingsCheckbox + { + LabelText = AudioSettingsStrings.AudioNormalization, + Current = config.GetBindable(OsuSetting.AudioNormalization), + Keywords = new []{ "normalization", "normalisation", "normalise" } + } }; } diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index a28b2716cb7b..a053fe6dce27 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Cursor; @@ -185,8 +186,10 @@ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnl private OsuConfigManager config { get; set; } [BackgroundDependencyLoader] - private void load(CancellationToken? cancellationToken) + private void load(CancellationToken? cancellationToken, AudioNormalizationManager audioNormalizationManager) { + audioContainer.AddAdjustment(AdjustableProperty.Volume, audioNormalizationManager.SampleNormalizeVolume); + InternalChild = frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) { FrameStablePlayback = FrameStablePlayback,