-
-
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 all 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,101 @@ | ||
| // 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 osu.Framework.Audio.Track; | ||
| using osu.Framework.Logging; | ||
| using osu.Game.Beatmaps; | ||
| using osu.Game.Database; | ||
| using Realms; | ||
|
|
||
| namespace osu.Game.Audio | ||
| { | ||
| /// <summary> | ||
| /// Audio Normalization Data | ||
| /// </summary> | ||
| public class AudioNormalization : EmbeddedObject, 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; } | ||
|
|
||
| /// <summary> | ||
| /// A volume offset that can be applied upon audio to reach <see cref="TARGET_LEVEL"/> (converted from integrated loudness). | ||
| /// </summary> | ||
| public double? IntegratedLoudnessInVolumeOffset => IntegratedLoudness != null ? TrackLoudness.ConvertToVolumeOffset(TARGET_LEVEL, (float)IntegratedLoudness) : null; | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| /// <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 | ||
| }; | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| /// <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,65 @@ | ||
| // 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.Audio.Track; | ||
| using osu.Framework.Bindables; | ||
| using osu.Framework.Logging; | ||
| using osu.Game.Beatmaps; | ||
| using osu.Game.Configuration; | ||
|
|
||
| namespace osu.Game.Audio | ||
| { | ||
| /// <summary> | ||
| /// Manages audio normalization. | ||
| /// </summary> | ||
| public class AudioNormalizationManager | ||
| { | ||
| /// <summary> | ||
| /// Fallback volume for tracks that <see cref="TrackLoudness"/> failed to measure loudness. | ||
| /// </summary> | ||
| public const double FALLBACK_VOLUME = 0.8; | ||
|
|
||
| private readonly Bindable<bool> audioNormalizationSetting; | ||
|
|
||
| /// <summary> | ||
| /// Samples assigned to hitobjects needs to bind to this bindable to normalize their volume in line with track. | ||
| /// </summary> | ||
| public readonly BindableDouble SampleNormalizeVolume = new BindableDouble(1.0); | ||
|
|
||
| /// <summary> | ||
| /// Creates a new <see cref="AudioNormalizationManager"/>. | ||
| /// </summary> | ||
| /// <param name="config">An osu config to get beatmap hitsounds bindable from.</param> | ||
| /// <param name="beatmap">A bindable for beatmap.</param> | ||
| public AudioNormalizationManager(OsuConfigManager config, IBindable<WorkingBeatmap> beatmap) | ||
| { | ||
| audioNormalizationSetting = config.GetBindable<bool>(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); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | |
| }); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Go through every beatmap and calculate the audio normalization values if they are missing | ||
| /// </summary> | ||
| private void processAudioNormalization() | ||
| { | ||
| 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(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); | ||
| } | ||
|
|
||
| /// <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. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please follow the example set by every other method in this file...
.Write()inside a.Run().Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The 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
totalcountbe only what needs to be processed rather than the number of total difficulties a user has. The loop populates other difficulties then skips over them in the proceeding loop. This behaviour would be hard to replicate in another loopn since it relies on the difficulties being populated during the loop. Sure It can be done but it would be hacky at best.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
So to me, it's either everything gets deferred and we wrap the entire function in a
RealmAccess.Run, or we go with this.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As an update since the memory leak fixes:
My argument to point 1 and 2 stand.
While I still want to avoid deferring the realm updates until everything is complete (this will take a LONG time for big libraries, so being able to quit but save progress would be useful), I do understand that this is more efficient. For the time being I've decided to just defer everything, but If its possible not to, I would prefer that. Also the diff shown still does not work.