-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Implement audio normalization using BASS #27793
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 57 commits
d4d2187
c095da6
e7366fb
ac14b5a
7a3ccf3
ba4f6e3
913da37
5fdbb79
9f077ae
6a8d377
19b5ee9
2b6919e
d2ed3f4
34b95df
1925e70
d3d0cb3
ac0c333
b487d23
5c5d1d7
43e057d
f82d60e
3d9a628
5220f5f
a3e6eb6
e29e747
c02ce73
3b0aac5
47dde89
ccb75e8
f1ffd9f
f2d9105
50fa394
f2cb249
fc736f2
1f276f4
9ac373c
73ca394
9639e0b
1de1b69
a06089d
d65fb1a
cb2828a
1633f2e
7080626
c0c7ae2
a65d6d1
f65ec52
4e1ebc5
4ef45ff
36db481
82b4102
ebefb70
bdb027a
b848f81
249663c
a277ac3
8eee827
62645f3
fe69efc
b50e343
5964a3f
db43af4
2e140c7
ca7e80d
192dc5b
b81277f
e58115f
6f2d873
663aa15
729f22d
baede41
2ce022d
89aabae
6182878
46f11c7
7abcb5f
953d661
b5ceb48
7b9360e
da8a99f
477d765
16af168
ee6abe9
fce862a
3e8c124
e1a87aa
7d53453
7bbb6b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| // 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; | ||
| using System.Linq; | ||
| using ManagedBass; | ||
| using ManagedBass.Fx; | ||
| using osu.Framework.Audio.Mixing; | ||
| using osu.Framework.Logging; | ||
| using osu.Game.Beatmaps; | ||
| using osu.Game.Database; | ||
| using Realms; | ||
|
|
||
| namespace osu.Game.Audio | ||
| { | ||
| /// <summary> | ||
| /// Audio Normalization Manager | ||
| /// </summary> | ||
| public class AudioNormalization : EmbeddedObject, IAudioNormalization, IEquatable<AudioNormalization> | ||
| { | ||
| /// <summary> | ||
| /// The target level for audio normalization | ||
| /// https://en.wikipedia.org/wiki/EBU_R_128 | ||
| /// </summary> | ||
| public const int TARGET_LEVEL = -14; | ||
|
|
||
| /// <summary> | ||
| /// The integrated (average) loudness of the audio | ||
| /// </summary> | ||
| public float? IntegratedLoudness { get; init; } | ||
|
|
||
| public AudioNormalization() | ||
| { | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Get the audio normalization for a beatmap. The loudness normalization value is stored in the object under <see cref="IntegratedLoudness"/> | ||
| /// </summary> | ||
| /// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> of the beatmap</param> | ||
| /// <param name="beatmapSetInfo">The parent <see cref="BeatmapSetInfo"/> of the <see cref="BeatmapInfo"/> supplied</param> | ||
| /// <param name="realmFileStore">The <see cref="RealmFileStore"/> to use in getting the full path of the audio file</param> | ||
| 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; | ||
| } | ||
|
|
||
| filepath = realmFileStore.Storage.GetFullPath(filepath); | ||
|
|
||
| BassAudioNormalization loudnessDetection = new BassAudioNormalization(filepath); | ||
| float? integratedLoudness = loudnessDetection.IntegratedLoudness; | ||
|
|
||
| if (integratedLoudness == null) | ||
| { | ||
| Logger.Log("Failed to get loudness level for " + audiofile, LoggingTarget.Runtime, LogLevel.Error); | ||
| return; | ||
| } | ||
|
|
||
| IntegratedLoudness = integratedLoudness; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Populate the audio normalization for every <see cref="BeatmapInfo"/> in a <see cref="BeatmapSetInfo"/>. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// This takes a <see cref="BeatmapInfo"/> and applies the same normalization to every <see cref="BeatmapInfo"/> in the <see cref="BeatmapSetInfo"/> that has the same audio file as the given <see cref="BeatmapInfo"/> to avoid having to calculate the loudness for an audio file multiple times | ||
| /// </remarks> | ||
| /// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to clone from</param> | ||
| /// <param name="beatmapSetInfo">The <see cref="BeatmapSetInfo"/> to populate</param> | ||
| 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 | ||
| }; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Convert integrated loudness to a volume offset | ||
| /// </summary> | ||
| /// <param name="integratedLoudness">The integrated loudness value</param> | ||
| /// <returns>The volume offset needed to reach <see cref="TARGET_LEVEL"/></returns> | ||
| public static float IntegratedLoudnessToVolumeOffset(float integratedLoudness) => (float)Math.Pow(10, (TARGET_LEVEL - integratedLoudness) / 20); | ||
|
|
||
| /// <summary> | ||
| /// Get the loudness values of a <see cref="BeatmapInfo"/> and apply the audio normalization effect to an <see cref="IAudioMixer"/> | ||
| /// </summary> | ||
| /// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the loudness values of</param> | ||
| /// <param name="mixer">The <see cref="IAudioMixer"/> to apply the effect on</param> | ||
| public static void AddAudioNormalization(BeatmapInfo beatmapInfo, IAudioMixer mixer) | ||
| { | ||
| AudioNormalization? audioNormalizationModule = beatmapInfo.AudioNormalization; | ||
|
|
||
| VolumeParameters volumeParameters = new VolumeParameters | ||
| { | ||
| fTarget = audioNormalizationModule?.IntegratedLoudness != null ? IntegratedLoudnessToVolumeOffset((float)audioNormalizationModule.IntegratedLoudness) : 0.8f, | ||
| fCurrent = -1.0f, | ||
| fTime = 0.3f, | ||
| lCurve = 1, | ||
| lChannel = FXChannelFlags.All | ||
| }; | ||
|
|
||
| addFx(volumeParameters, mixer); | ||
|
|
||
| Logger.Log("Normalization Status: " + (audioNormalizationModule?.IntegratedLoudness != null ? $"on ({Math.Round(IntegratedLoudnessToVolumeOffset((float)audioNormalizationModule.IntegratedLoudness) * 100)}%)" : "off")); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Add an effect to a <see cref="IAudioMixer"/>, replacing it if it already exists | ||
| /// </summary> | ||
| /// <param name="effectParameter">The <see cref="IEffectParameter"/> to add to the <paramref name="mixer"/></param> | ||
| /// <param name="mixer">The <see cref="IAudioMixer"/> to add the effect to</param> | ||
| private static void addFx(IEffectParameter effectParameter, IAudioMixer mixer) | ||
| { | ||
| IEffectParameter? effect = mixer.Effects.SingleOrDefault(e => e.FXType == effectParameter.FXType); | ||
|
|
||
| if (effect != null) | ||
| { | ||
| int i = mixer.Effects.IndexOf(effect); | ||
| mixer.Effects[i] = effectParameter; | ||
| } | ||
| else | ||
| { | ||
| mixer.Effects.Add(effectParameter); | ||
| } | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public bool Equals(IAudioNormalization? other) => other is AudioNormalization audioNormalization && Equals(audioNormalization); | ||
|
|
||
| /// <inheritdoc /> | ||
| public bool Equals(AudioNormalization? other) => other?.IntegratedLoudness != null && IntegratedLoudness != null && Math.Abs((float)(IntegratedLoudness - other.IntegratedLoudness)) < 0.0000001; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| // 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; | ||
| using ManagedBass.Loud; | ||
| using osu.Framework.Logging; | ||
|
|
||
| namespace osu.Game.Audio | ||
| { | ||
| /// <summary> | ||
| /// Audio Normalization Implementation using Bass | ||
| /// </summary> | ||
| public class BassAudioNormalization | ||
| { | ||
| /// <summary> | ||
| /// The integrated loudness of the audio | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Applicable range = -70 to -1<br/>Null if the loudness could not be calculated due to an error | ||
| /// </remarks> | ||
| public float? IntegratedLoudness { get; } | ||
|
|
||
| /// <summary> | ||
| /// Calculate the integrated loudness of an audio file using Bass | ||
| /// </summary> | ||
| /// <returns>The integrated loudness or 1 in <see cref="IntegratedLoudness"/></returns> | ||
| /// <param name="filePath">A path to an audio file</param> | ||
| public BassAudioNormalization(string filePath) | ||
|
smallketchup82 marked this conversation as resolved.
Outdated
|
||
| { | ||
| int decodeStream = Bass.CreateStream(filePath, 0, 0, BassFlags.Decode | BassFlags.Float); | ||
|
smallketchup82 marked this conversation as resolved.
Outdated
|
||
|
|
||
| if (decodeStream == 0) | ||
| { | ||
| Logger.Log("Failed to create stream for loudness measurement!\nError Code: " + Bass.LastError, LoggingTarget.Runtime, LogLevel.Error); | ||
| IntegratedLoudness = null; | ||
| Bass.StreamFree(decodeStream); | ||
| return; | ||
| } | ||
|
|
||
| int loudness = BassLoud.BASS_Loudness_Start(decodeStream, BassFlags.BassLoudnessIntegrated | BassFlags.BassLoudnessAutofree, 0); | ||
|
smallketchup82 marked this conversation as resolved.
Outdated
|
||
|
|
||
| if (loudness == 0) | ||
| { | ||
| Logger.Log("Failed to start loudness measurement!\nError Code: " + Bass.LastError, LoggingTarget.Runtime, LogLevel.Error); | ||
| IntegratedLoudness = null; | ||
| Bass.StreamFree(decodeStream); | ||
| return; | ||
| } | ||
|
|
||
| byte[] buffer = new byte[10000]; | ||
|
|
||
| while (Bass.ChannelGetData(decodeStream, buffer, buffer.Length) >= 0) | ||
| { | ||
| } | ||
|
smallketchup82 marked this conversation as resolved.
Outdated
|
||
|
|
||
| float integratedLoudness = 0; | ||
| bool gotLevel = BassLoud.BASS_Loudness_GetLevel(loudness, BassFlags.BassLoudnessIntegrated, ref integratedLoudness); | ||
|
|
||
| if (!gotLevel || integratedLoudness == 0) | ||
|
smallketchup82 marked this conversation as resolved.
Outdated
|
||
| { | ||
| Logger.Log("Failed to get loudness level!\nError Code: " + Bass.LastError, LoggingTarget.Runtime, LogLevel.Error); | ||
| IntegratedLoudness = null; | ||
| Bass.StreamFree(decodeStream); | ||
| return; | ||
| } | ||
|
|
||
| IntegratedLoudness = integratedLoudness; | ||
|
|
||
| bool freedStream = Bass.StreamFree(decodeStream); | ||
| if (!freedStream) | ||
| Logger.Log("Failed to free stream!\nError Code: " + Bass.LastError, LoggingTarget.Runtime, LogLevel.Error); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| // 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; | ||
|
|
||
| namespace osu.Game.Audio | ||
| { | ||
| public interface IAudioNormalization : IEquatable<IAudioNormalization> | ||
| { | ||
| public float? IntegratedLoudness { get; } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,8 @@ | |
| using osu.Framework.Bindables; | ||
| 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; | ||
|
|
@@ -58,6 +60,9 @@ public partial class BackgroundDataStoreProcessor : Component | |
| [Resolved] | ||
| private INotificationOverlay? notificationOverlay { get; set; } | ||
|
|
||
| [Resolved] | ||
| private Storage? storage { get; set; } | ||
|
|
||
| [Resolved] | ||
| private IAPIProvider api { get; set; } = null!; | ||
|
|
||
|
|
@@ -78,6 +83,7 @@ protected override void LoadComplete() | |
| processScoresWithMissingStatistics(); | ||
| convertLegacyTotalScoreToStandardised(); | ||
| upgradeScoreRanks(); | ||
| processAudioNormalization(); | ||
| }, TaskCreationOptions.LongRunning).ContinueWith(t => | ||
| { | ||
| if (t.Exception?.InnerException is ObjectDisposedException) | ||
|
|
@@ -90,6 +96,71 @@ protected override void LoadComplete() | |
| }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Go through every beatmap and calculate the audio normalization values if they are missing | ||
| /// </summary> | ||
| private void processAudioNormalization() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please follow the example set by every other method in this file...
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The problem with 1 and 2 is that the way that the loops work don't allow for calculating what to process ahead of time. If it were possible, I would have 3rd point was done to have the beatmaps update instantly instead of being deferred until after everything is calculated. I did try to avoid nesting because common sense states that it's a bad idea, but well, the loop seems to break if I try this instead diff --git a/osu.Game/Database/BackgroundDataStoreProcessor.cs b/osu.Game/Database/BackgroundDataStoreProcessor.cs
index fc38f3532c..d50d86b97d 100644
--- a/osu.Game/Database/BackgroundDataStoreProcessor.cs
+++ b/osu.Game/Database/BackgroundDataStoreProcessor.cs
@@ -119,37 +119,38 @@ private void processAudioNormalization()
var notification = showProgressNotification(beatmapsCount, "Verifying loudness level for beatmaps", "all loudness levels have been verified");
int processedCount = 0;
+ IQueryable<BeatmapSetInfo> beatmaps = null!;
realmAccess.Run(r =>
{
- r.Refresh();
+ beatmaps = r.All<BeatmapSetInfo>().Where(b => !b.DeletePending);
+ });
- foreach (BeatmapSetInfo beatmapSetInfo in r.All<BeatmapSetInfo>().Where(b => !b.DeletePending))
+ foreach (BeatmapSetInfo beatmapSetInfo in beatmaps)
+ {
+ foreach (BeatmapInfo beatmapInfo in beatmapSetInfo.Beatmaps)
{
- foreach (BeatmapInfo beatmapInfo in beatmapSetInfo.Beatmaps)
- {
- if (notification?.State == ProgressNotificationState.Cancelled)
- break;
-
- updateNotificationProgress(notification, processedCount, beatmapsCount);
- processedCount++;
+ if (notification?.State == ProgressNotificationState.Cancelled)
+ break;
- sleepIfRequired();
- if (beatmapInfo.AudioNormalization != null) continue;
+ updateNotificationProgress(notification, processedCount, beatmapsCount);
+ processedCount++;
- realmAccess.Write(realm =>
- {
- AudioNormalization audioNormalization = new AudioNormalization(beatmapInfo, beatmapSetInfo, new RealmFileStore(realmAccess, storage!));
- beatmapInfo.AudioNormalization = audioNormalization;
- realm.Add(audioNormalization.PopulateSet(beatmapInfo, beatmapSetInfo), true);
- });
- beatmapManager.GetWorkingBeatmap(beatmapInfo, true);
- }
+ sleepIfRequired();
+ if (beatmapInfo.AudioNormalization != null) continue;
- Logger.Log($"Processed audio normalization for {beatmapSetInfo.Metadata.Title}");
+ realmAccess.Write(realm =>
+ {
+ AudioNormalization audioNormalization = new AudioNormalization(beatmapInfo, beatmapSetInfo, new RealmFileStore(realmAccess, storage!));
+ beatmapInfo.AudioNormalization = audioNormalization;
+ realm.Add(audioNormalization.PopulateSet(beatmapInfo, beatmapSetInfo), true);
+ });
+ beatmapManager.GetWorkingBeatmap(beatmapInfo, true);
}
- r.Refresh();
- });
+ Logger.Log($"Processed audio normalization for {beatmapSetInfo.Metadata.Title}");
+ }
+
+ realmAccess.Run(r => r.Refresh());
Logger.Log("Finished processing audio normalization readings");
completeNotification(notification, processedCount, beatmapsCount);So to me, it's either everything gets deferred and we wrap the entire function in a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As an update since the memory leak fixes: |
||
| { | ||
| var filestorage = new RealmFileStore(realmAccess, storage!); | ||
| int beatmapsCount = 0; | ||
| int beatmapsToProcess = 0; | ||
|
|
||
| realmAccess.Run(r => | ||
| { | ||
| foreach (BeatmapSetInfo beatmapSetInfo in r.All<BeatmapSetInfo>().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<BeatmapSetInfo>().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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Maybe store a default value like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think a solution where we log the error, then set a non-standard value for the loudness, and filter them out from the loop could work to stop the processor from continuously attempting to process these broken audio tracks on startup. The issue then becomes, how will we know if the audio file for that beatmap has been fixed if the processor will gloss over it every time it processes the map? I think it would be better to use the non-standard value as a sort of "silence" indicator to which if the beatmap is processed again, and fails to do so, it won't alert the user of the failure. That way we don't annoy the user with failure notifications on every startup, but also continue to process the audio files in case they've been replaced with a working version.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest creating a Realm model for looking up normalization parameters from audio file hash, and calculate when the hash exists but the corresponding parameters isn't. Or, if it seems too complex, a quick hack is to recalculate ever beatmap update, in |
||
| continue; | ||
| } | ||
|
|
||
| beatmapInfo.AudioNormalization = audioNormalization; | ||
| audioNormalization.PopulateSet(beatmapInfo, beatmapSetInfo); | ||
| ((IWorkingBeatmapCache)beatmapManager).Invalidate(beatmapInfo); | ||
| Logger.Log($"Processed audio normalization for {beatmapSetInfo.Metadata.Title} [{beatmapInfo.DifficultyName}]"); | ||
| } | ||
| } | ||
|
|
||
| r.Refresh(); | ||
| }); | ||
|
|
||
| Logger.Log("Finished processing audio normalization readings"); | ||
| completeNotification(notification, processedCount, beatmapsCount); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.