Improve audio normalization

* Move calculation of LUFS to a scheduled task as it's pretty slow
* Correctly calculate album LUFS
* Don't try to convert replaygain tags to LUFS values
This commit is contained in:
Bond_009 2024-04-28 15:18:53 +02:00
parent 5612cb8178
commit 88a38a61b5
11 changed files with 232 additions and 69 deletions

View File

@ -49,8 +49,8 @@ namespace Emby.Server.Implementations.Data
private const string SaveItemCommandText =
@"replace into TypedBaseItems
(guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId)
values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)";
(guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,NormalizationGain,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId)
values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@NormalizationGain,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)";
private readonly IServerConfigurationManager _config;
private readonly IServerApplicationHost _appHost;
@ -111,6 +111,7 @@ namespace Emby.Server.Implementations.Data
"DateLastMediaAdded",
"Album",
"LUFS",
"NormalizationGain",
"CriticRating",
"IsVirtualItem",
"SeriesName",
@ -478,6 +479,7 @@ namespace Emby.Server.Implementations.Data
AddColumn(connection, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "Album", "Text", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "LUFS", "Float", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "NormalizationGain", "Float", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "SeriesName", "Text", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames);
@ -886,6 +888,7 @@ namespace Emby.Server.Implementations.Data
saveItemStatement.TryBind("@Album", item.Album);
saveItemStatement.TryBind("@LUFS", item.LUFS);
saveItemStatement.TryBind("@NormalizationGain", item.NormalizationGain);
saveItemStatement.TryBind("@IsVirtualItem", item.IsVirtualItem);
if (item is IHasSeries hasSeriesName)
@ -1672,6 +1675,11 @@ namespace Emby.Server.Implementations.Data
item.LUFS = lUFS;
}
if (reader.TryGetSingle(index++, out var normalizationGain))
{
item.NormalizationGain = normalizationGain;
}
if (reader.TryGetSingle(index++, out var criticRating))
{
item.CriticRating = criticRating;

View File

@ -898,7 +898,14 @@ namespace Emby.Server.Implementations.Dto
dto.IsPlaceHolder = supportsPlaceHolders.IsPlaceHolder;
}
dto.LUFS = item.LUFS;
if (item.LUFS.HasValue)
{
dto.NormalizationGain = -18f - item.LUFS;
}
else if (item.NormalizationGain.HasValue)
{
dto.NormalizationGain = item.NormalizationGain;
}
// Add audio info
if (item is Audio audio)

View File

@ -13,7 +13,6 @@ using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;

View File

@ -106,6 +106,8 @@
"TaskCleanCacheDescription": "Deletes cache files no longer needed by the system.",
"TaskRefreshChapterImages": "Extract Chapter Images",
"TaskRefreshChapterImagesDescription": "Creates thumbnails for videos that have chapters.",
"TaskAudioNormalization": "Audio Normalization",
"TaskAudioNormalizationDescription": "Scans files for audio normalization data.",
"TaskRefreshLibrary": "Scan Media Library",
"TaskRefreshLibraryDescription": "Scans your media library for new files and refreshes metadata.",
"TaskCleanLogs": "Clean Log Directory",

View File

@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
/// <summary>
/// The splashscreen post scan task.
/// </summary>
public partial class AudioNormalizationTask : IScheduledTask
{
private readonly IItemRepository _itemRepository;
private readonly ILibraryManager _libraryManager;
private readonly IMediaEncoder _mediaEncoder;
private readonly IConfigurationManager _configurationManager;
private readonly ILocalizationManager _localization;
private readonly ILogger<AudioNormalizationTask> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class.
/// </summary>
/// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
/// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{AudioNormalizationTask}"/> interface.</param>
public AudioNormalizationTask(
IItemRepository itemRepository,
ILibraryManager libraryManager,
IMediaEncoder mediaEncoder,
IConfigurationManager configurationManager,
ILocalizationManager localizationManager,
ILogger<AudioNormalizationTask> logger)
{
_itemRepository = itemRepository;
_libraryManager = libraryManager;
_mediaEncoder = mediaEncoder;
_configurationManager = configurationManager;
_localization = localizationManager;
_logger = logger;
}
/// <inheritdoc />
public string Name => _localization.GetLocalizedString("TaskAudioNormalization");
/// <inheritdoc />
public string Description => _localization.GetLocalizedString("TaskAudioNormalizationDescription");
/// <inheritdoc />
public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
/// <inheritdoc />
public string Key => "AudioNormalization";
[GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
private static partial Regex LUFSRegex();
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
foreach (var library in _libraryManager.RootFolder.Children)
{
var libraryOptions = _libraryManager.GetLibraryOptions(library);
if (!libraryOptions.EnableLUFSScan)
{
continue;
}
// Album gain
var albums = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.MusicAlbum],
Parent = library,
Recursive = true
});
foreach (var a in albums)
{
if (a.NormalizationGain.HasValue || a.LUFS.HasValue)
{
continue;
}
var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList();
if (albumTracks.Count == 0)
{
continue;
}
var tempFile = Path.Join(_configurationManager.GetTranscodePath(), Guid.NewGuid() + ".concat");
var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal)));
await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false);
a.LUFS = await CalculateLUFSAsync(
string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
cancellationToken).ConfigureAwait(false);
File.Delete(tempFile);
}
_itemRepository.SaveItems(albums, cancellationToken);
var tracks = _libraryManager.GetItemList(new InternalItemsQuery
{
MediaTypes = [MediaType.Audio],
IncludeItemTypes = [BaseItemKind.Audio],
Parent = library,
Recursive = true
});
foreach (var t in tracks)
{
if (t.NormalizationGain.HasValue || t.LUFS.HasValue || !t.IsFileProtocol)
{
continue;
}
t.LUFS = await CalculateLUFSAsync(string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), cancellationToken);
}
_itemRepository.SaveItems(tracks, cancellationToken);
}
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return
[
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(24).Ticks
}
];
}
private string EscapeFilename(string filename)
=> filename;
private async Task<float?> CalculateLUFSAsync(string inputArgs, CancellationToken cancellationToken)
{
var args = $"-hide_banner {inputArgs} -af ebur128=framelog=verbose -f null -";
using (var process = new Process()
{
StartInfo = new ProcessStartInfo
{
FileName = _mediaEncoder.EncoderPath,
Arguments = args,
RedirectStandardOutput = false,
RedirectStandardError = true
},
})
{
try
{
_logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args);
process.Start();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg with arguments: {Arguments}", args);
return null;
}
using var reader = process.StandardError;
var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
MatchCollection split = LUFSRegex().Matches(output);
if (split.Count != 0)
{
return float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
}
_logger.LogError("Failed to find LUFS value in output:\n{Output}", output);
return null;
}
}
}

View File

@ -13,7 +13,6 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;

View File

@ -183,14 +183,13 @@ namespace MediaBrowser.Controller.Entities.Audio
progress.Report(percent * 95);
}
// get album LUFS
LUFS = items.OfType<Audio>().Max(item => item.LUFS);
var parentRefreshOptions = refreshOptions;
if (childUpdateType > ItemUpdateType.None)
{
parentRefreshOptions = new MetadataRefreshOptions(refreshOptions);
parentRefreshOptions.MetadataRefreshMode = MetadataRefreshMode.FullRefresh;
parentRefreshOptions = new MetadataRefreshOptions(refreshOptions)
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh
};
}
// Refresh current item

View File

@ -137,6 +137,13 @@ namespace MediaBrowser.Controller.Entities
[JsonIgnore]
public float? LUFS { get; set; }
/// <summary>
/// Gets or sets the gain required for audio normalization.
/// </summary>
/// <value>The gain required for audio normalization..</value>
[JsonIgnore]
public float? NormalizationGain { get; set; }
/// <summary>
/// Gets or sets the channel identifier.
/// </summary>

View File

@ -782,10 +782,10 @@ namespace MediaBrowser.Model.Dto
public string TimerId { get; set; }
/// <summary>
/// Gets or sets the LUFS value.
/// Gets or sets the gain required for audio normalization.
/// </summary>
/// <value>The LUFS Value.</value>
public float? LUFS { get; set; }
/// <value>The gain required for audio normalization..</value>
public float? NormalizationGain { get; set; }
/// <summary>
/// Gets or sets the current program.

View File

@ -1,9 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
@ -18,7 +15,6 @@ using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using TagLib;
namespace MediaBrowser.Providers.MediaInfo
@ -26,12 +22,8 @@ namespace MediaBrowser.Providers.MediaInfo
/// <summary>
/// Probes audio files for metadata.
/// </summary>
public partial class AudioFileProber
public class AudioFileProber
{
// Default LUFS value for use with the web interface, at -18db gain will be 1(no db gain).
private const float DefaultLUFSValue = -18;
private readonly ILogger<AudioFileProber> _logger;
private readonly IMediaEncoder _mediaEncoder;
private readonly IItemRepository _itemRepo;
private readonly ILibraryManager _libraryManager;
@ -42,7 +34,6 @@ namespace MediaBrowser.Providers.MediaInfo
/// <summary>
/// Initializes a new instance of the <see cref="AudioFileProber"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
@ -50,7 +41,6 @@ namespace MediaBrowser.Providers.MediaInfo
/// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
/// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
public AudioFileProber(
ILogger<AudioFileProber> logger,
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
IItemRepository itemRepo,
@ -58,7 +48,6 @@ namespace MediaBrowser.Providers.MediaInfo
LyricResolver lyricResolver,
ILyricManager lyricManager)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_itemRepo = itemRepo;
_libraryManager = libraryManager;
@ -67,9 +56,6 @@ namespace MediaBrowser.Providers.MediaInfo
_lyricManager = lyricManager;
}
[GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
private static partial Regex LUFSRegex();
/// <summary>
/// Probes the specified item for metadata.
/// </summary>
@ -112,45 +98,6 @@ namespace MediaBrowser.Providers.MediaInfo
await FetchAsync(item, result, options, cancellationToken).ConfigureAwait(false);
}
var libraryOptions = _libraryManager.GetLibraryOptions(item);
if (libraryOptions.EnableLUFSScan && item.LUFS is null)
{
using (var process = new Process()
{
StartInfo = new ProcessStartInfo
{
FileName = _mediaEncoder.EncoderPath,
Arguments = $"-hide_banner -i \"{path}\" -af ebur128=framelog=verbose -f null -",
RedirectStandardOutput = false,
RedirectStandardError = true
},
})
{
try
{
process.Start();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
throw;
}
using var reader = process.StandardError;
var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
MatchCollection split = LUFSRegex().Matches(output);
if (split.Count != 0)
{
item.LUFS = float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
}
}
}
_logger.LogDebug("LUFS for {ItemName} is {LUFS}.", item.Name, item.LUFS);
return ItemUpdateType.MetadataImport;
}
@ -339,7 +286,7 @@ namespace MediaBrowser.Providers.MediaInfo
if (!double.IsNaN(tags.ReplayGainTrackGain))
{
audio.LUFS = DefaultLUFSValue - (float)tags.ReplayGainTrackGain;
audio.NormalizationGain = (float)tags.ReplayGainTrackGain;
}
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))

View File

@ -103,7 +103,6 @@ namespace MediaBrowser.Providers.MediaInfo
_subtitleResolver);
_audioProber = new AudioFileProber(
loggerFactory.CreateLogger<AudioFileProber>(),
mediaSourceManager,
mediaEncoder,
itemRepo,