Browse Source

add target loudness to ffmpeg profile (#2727)

* add target loudness to ffmpeg profile

* fix filter
pull/2729/head
Jason Dove 2 weeks ago committed by GitHub
parent
commit
0af81ad839
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs
  3. 5
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs
  4. 1
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs
  5. 5
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs
  6. 1
      ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs
  7. 1
      ErsatzTV.Application/FFmpegProfiles/Mapper.cs
  8. 10
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs
  9. 1
      ErsatzTV.Core/Domain/FFmpegProfile.cs
  10. 6
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  11. 1
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs
  12. 3
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  13. 9
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  14. 23
      ErsatzTV.FFmpeg/Filter/NormalizeLoudnessFilter.cs
  15. 1
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  16. 6
      ErsatzTV.FFmpeg/State/AudioState.cs
  17. 6889
      ErsatzTV.Infrastructure.MySql/Migrations/20251219203119_Add_FFmpegProfile_TargetLoudness.Designer.cs
  18. 28
      ErsatzTV.Infrastructure.MySql/Migrations/20251219203119_Add_FFmpegProfile_TargetLoudness.cs
  19. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  20. 6716
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251219203036_Add_FFmpegProfile_TargetLoudness.Designer.cs
  21. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251219203036_Add_FFmpegProfile_TargetLoudness.cs
  22. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  23. 9
      ErsatzTV/Pages/FFmpegEditor.razor
  24. 27
      ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs

2
CHANGELOG.md

@ -32,6 +32,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds - This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds
- Add `Download Media Sample` button to playback troubleshooting - Add `Download Media Sample` button to playback troubleshooting
- This button will extract up to 30 seconds of the media item and zip it - This button will extract up to 30 seconds of the media item and zip it
- Add `Target Loudness` (LUFS/LKFS) to ffmpeg profile when loudness normalization is enabled
- Default value is `-16`; some sources normalize to a quieter value, e.g. `-24`
### Fixed ### Fixed
- Fix startup on systems unsupported by NvEncSharp - Fix startup on systems unsupported by NvEncSharp

1
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs

@ -26,6 +26,7 @@ public record CreateFFmpegProfile(
int AudioBitrate, int AudioBitrate,
int AudioBufferSize, int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode, NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,

5
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs

@ -70,7 +70,12 @@ public class CreateFFmpegProfileHandler :
AudioFormat = request.AudioFormat, AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate, AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize, AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode, NormalizeLoudnessMode = request.NormalizeLoudnessMode,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? request.TargetLoudness
: null,
AudioChannels = request.AudioChannels, AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate, AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate, NormalizeFramerate = request.NormalizeFramerate,

1
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs

@ -27,6 +27,7 @@ public record UpdateFFmpegProfile(
int AudioBitrate, int AudioBitrate,
int AudioBufferSize, int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode, NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,

5
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs

@ -59,7 +59,12 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.AudioFormat = update.AudioFormat; p.AudioFormat = update.AudioFormat;
p.AudioBitrate = update.AudioBitrate; p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize; p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode; p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
p.TargetLoudness = update.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? update.TargetLoudness
: null;
p.AudioChannels = update.AudioChannels; p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate; p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate; p.NormalizeFramerate = update.NormalizeFramerate;

1
ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs

@ -27,6 +27,7 @@ public record FFmpegProfileViewModel(
int AudioBitrate, int AudioBitrate,
int AudioBufferSize, int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode, NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,

1
ErsatzTV.Application/FFmpegProfiles/Mapper.cs

@ -29,6 +29,7 @@ internal static class Mapper
profile.AudioBitrate, profile.AudioBitrate,
profile.AudioBufferSize, profile.AudioBufferSize,
profile.NormalizeLoudnessMode, profile.NormalizeLoudnessMode,
profile.TargetLoudness,
profile.AudioChannels, profile.AudioChannels,
profile.AudioSampleRate, profile.AudioSampleRate,
profile.NormalizeFramerate, profile.NormalizeFramerate,

10
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs

@ -5,18 +5,14 @@ using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>> public class GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetFFmpegProfileByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<FFmpegProfileViewModel>> Handle( public async Task<Option<FFmpegProfileViewModel>> Handle(
GetFFmpegProfileById request, GetFFmpegProfileById request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FFmpegProfiles return await dbContext.FFmpegProfiles
.Include(p => p.Resolution) .Include(p => p.Resolution)
.SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken) .SelectOneAsync(p => p.Id, p => p.Id == request.Id, cancellationToken)

1
ErsatzTV.Core/Domain/FFmpegProfile.cs

@ -27,6 +27,7 @@ public record FFmpegProfile
public int AudioBitrate { get; set; } public int AudioBitrate { get; set; }
public int AudioBufferSize { get; set; } public int AudioBufferSize { get; set; }
public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; } public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; }
public double? TargetLoudness { get; set; }
public int AudioChannels { get; set; } public int AudioChannels { get; set; }
public int AudioSampleRate { get; set; } public int AudioSampleRate { get; set; }
public bool NormalizeFramerate { get; set; } public bool NormalizeFramerate { get; set; }

6
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -207,7 +207,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{ {
NormalizeLoudnessMode.LoudNorm => AudioFilter.LoudNorm, NormalizeLoudnessMode.LoudNorm => AudioFilter.LoudNorm,
_ => AudioFilter.None _ => AudioFilter.None
}); },
playbackSettings.TargetLoudness);
// don't log generated images, or hls direct, which are expected to have unknown format // don't log generated images, or hls direct, which are expected to have unknown format
bool isUnknownPixelFormatExpected = bool isUnknownPixelFormatExpected =
@ -694,7 +695,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.AudioBufferSize, playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate, playbackSettings.AudioSampleRate,
false, false,
AudioFilter.None); AudioFilter.None,
playbackSettings.TargetLoudness);
string videoFormat = GetVideoFormat(playbackSettings); string videoFormat = GetVideoFormat(playbackSettings);

1
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs

@ -29,5 +29,6 @@ public class FFmpegPlaybackSettings
public bool Deinterlace { get; set; } public bool Deinterlace { get; set; }
public Option<int> VideoTrackTimeScale { get; set; } public Option<int> VideoTrackTimeScale { get; set; }
public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; } public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; }
public Option<double> TargetLoudness { get; set; }
public Option<FrameRate> FrameRate { get; set; } public Option<FrameRate> FrameRate { get; set; }
} }

3
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -164,6 +164,9 @@ public static class FFmpegPlaybackSettingsCalculator
result.AudioSampleRate = ffmpegProfile.AudioSampleRate; result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.PadAudio = true; result.PadAudio = true;
result.NormalizeLoudnessMode = ffmpegProfile.NormalizeLoudnessMode; result.NormalizeLoudnessMode = ffmpegProfile.NormalizeLoudnessMode;
result.TargetLoudness = ffmpegProfile.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? Optional(ffmpegProfile.TargetLoudness)
: Option<double>.None;
result.Deinterlace = ffmpegProfile.DeinterlaceVideo == true; result.Deinterlace = ffmpegProfile.DeinterlaceVideo == true;

9
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -53,7 +53,8 @@ public class PipelineBuilderBaseTests
640, 640,
48, 48,
false, false,
AudioFilter.None)); AudioFilter.None,
Option<double>.None));
var desiredState = new FrameState( var desiredState = new FrameState(
true, true,
@ -153,7 +154,8 @@ public class PipelineBuilderBaseTests
640, 640,
48, 48,
false, false,
AudioFilter.None)); AudioFilter.None,
Option<double>.None));
var desiredState = new FrameState( var desiredState = new FrameState(
true, true,
@ -311,7 +313,8 @@ public class PipelineBuilderBaseTests
None, None,
None, None,
false, false,
AudioFilter.None)); AudioFilter.None,
Option<double>.None));
var desiredState = new FrameState( var desiredState = new FrameState(
true, true,

23
ErsatzTV.FFmpeg/Filter/NormalizeLoudnessFilter.cs

@ -1,25 +1,20 @@
namespace ErsatzTV.FFmpeg.Filter; using System.Globalization;
public class NormalizeLoudnessFilter : BaseFilter namespace ErsatzTV.FFmpeg.Filter;
{
private readonly Option<int> _audioSampleRate;
private readonly AudioFilter _loudnessFilter;
public NormalizeLoudnessFilter(AudioFilter loudnessFilter, Option<int> audioSampleRate)
{
_loudnessFilter = loudnessFilter;
_audioSampleRate = audioSampleRate;
}
public class NormalizeLoudnessFilter(AudioFilter loudnessFilter, Option<double> targetLoudness, Option<int> sampleRate)
: BaseFilter
{
public override string Filter public override string Filter
{ {
get get
{ {
int audioSampleRate = _audioSampleRate.IfNone(48) * 1000; double loudness = targetLoudness.IfNone(-16);
int audioSampleRate = sampleRate.IfNone(48) * 1000;
return _loudnessFilter switch return loudnessFilter switch
{ {
AudioFilter.LoudNorm => $"loudnorm=I=-16:TP=-1.5:LRA=11,aresample={audioSampleRate}", AudioFilter.LoudNorm => $"loudnorm=I={loudness.ToString(NumberFormatInfo.InvariantInfo)}:TP=-1.5:LRA=11,aresample={audioSampleRate}",
_ => string.Empty _ => string.Empty
}; };
} }

1
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -504,6 +504,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
{ {
var filter = new NormalizeLoudnessFilter( var filter = new NormalizeLoudnessFilter(
audioInputFile.DesiredState.NormalizeLoudnessFilter, audioInputFile.DesiredState.NormalizeLoudnessFilter,
audioInputFile.DesiredState.TargetLoudness,
audioInputFile.DesiredState.AudioSampleRate); audioInputFile.DesiredState.AudioSampleRate);
_audioInputFile.Iter(f => f.FilterSteps.Add(filter)); _audioInputFile.Iter(f => f.FilterSteps.Add(filter));

6
ErsatzTV.FFmpeg/State/AudioState.cs

@ -7,7 +7,8 @@ public record AudioState(
Option<int> AudioBufferSize, Option<int> AudioBufferSize,
Option<int> AudioSampleRate, Option<int> AudioSampleRate,
bool PadAudio, bool PadAudio,
AudioFilter NormalizeLoudnessFilter) AudioFilter NormalizeLoudnessFilter,
Option<double> TargetLoudness)
{ {
public static readonly AudioState Copy = new( public static readonly AudioState Copy = new(
Format.AudioFormat.Copy, Format.AudioFormat.Copy,
@ -16,6 +17,7 @@ public record AudioState(
Option<int>.None, Option<int>.None,
Option<int>.None, Option<int>.None,
false, false,
AudioFilter.None AudioFilter.None,
Option<double>.None
); );
} }

6889
ErsatzTV.Infrastructure.MySql/Migrations/20251219203119_Add_FFmpegProfile_TargetLoudness.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.MySql/Migrations/20251219203119_Add_FFmpegProfile_TargetLoudness.cs

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfile_TargetLoudness : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "TargetLoudness",
table: "FFmpegProfile",
type: "double",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TargetLoudness",
table: "FFmpegProfile");
}
}
}

3
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -752,6 +752,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("ScalingBehavior") b.Property<int>("ScalingBehavior")
.HasColumnType("int"); .HasColumnType("int");
b.Property<double?>("TargetLoudness")
.HasColumnType("double");
b.Property<int>("ThreadCount") b.Property<int>("ThreadCount")
.HasColumnType("int"); .HasColumnType("int");

6716
ErsatzTV.Infrastructure.Sqlite/Migrations/20251219203036_Add_FFmpegProfile_TargetLoudness.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20251219203036_Add_FFmpegProfile_TargetLoudness.cs

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfile_TargetLoudness : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "TargetLoudness",
table: "FFmpegProfile",
type: "REAL",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TargetLoudness",
table: "FFmpegProfile");
}
}
}

3
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -721,6 +721,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("ScalingBehavior") b.Property<int>("ScalingBehavior")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<double?>("TargetLoudness")
.HasColumnType("REAL");
b.Property<int>("ThreadCount") b.Property<int>("ThreadCount")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

9
ErsatzTV/Pages/FFmpegEditor.razor

@ -278,6 +278,15 @@
<MudSelectItem Value="@NormalizeLoudnessMode.LoudNorm">loudnorm</MudSelectItem> <MudSelectItem Value="@NormalizeLoudnessMode.LoudNorm">loudnorm</MudSelectItem>
</MudSelect> </MudSelect>
</MudStack> </MudStack>
@if (_model.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Target Loudness</MudText>
</div>
<MudTextField @bind-Value="_model.TargetLoudness" For="@(() => _model.TargetLoudness)" Adornment="Adornment.End" AdornmentText="LUFS"/>
</MudStack>
}
</MudContainer> </MudContainer>
</div> </div>
</MudForm> </MudForm>

27
ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs

@ -8,6 +8,7 @@ namespace ErsatzTV.ViewModels;
public class FFmpegProfileEditViewModel public class FFmpegProfileEditViewModel
{ {
private string _videoProfile; private string _videoProfile;
private NormalizeLoudnessMode _normalizeLoudnessMode;
public FFmpegProfileEditViewModel() public FFmpegProfileEditViewModel()
{ {
@ -21,6 +22,7 @@ public class FFmpegProfileEditViewModel
AudioFormat = viewModel.AudioFormat; AudioFormat = viewModel.AudioFormat;
AudioSampleRate = viewModel.AudioSampleRate; AudioSampleRate = viewModel.AudioSampleRate;
NormalizeLoudnessMode = viewModel.NormalizeLoudnessMode; NormalizeLoudnessMode = viewModel.NormalizeLoudnessMode;
TargetLoudness = viewModel.TargetLoudness;
Id = viewModel.Id; Id = viewModel.Id;
Name = viewModel.Name; Name = viewModel.Name;
NormalizeFramerate = viewModel.NormalizeFramerate; NormalizeFramerate = viewModel.NormalizeFramerate;
@ -48,7 +50,28 @@ public class FFmpegProfileEditViewModel
public int AudioChannels { get; set; } public int AudioChannels { get; set; }
public FFmpegProfileAudioFormat AudioFormat { get; set; } public FFmpegProfileAudioFormat AudioFormat { get; set; }
public int AudioSampleRate { get; set; } public int AudioSampleRate { get; set; }
public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; }
public NormalizeLoudnessMode NormalizeLoudnessMode
{
get => _normalizeLoudnessMode;
set
{
if (_normalizeLoudnessMode != value)
{
_normalizeLoudnessMode = value;
if (_normalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm)
{
TargetLoudness = -16;
}
else
{
TargetLoudness = null;
}
}
}
}
public double? TargetLoudness { get; set; }
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public bool NormalizeFramerate { get; set; } public bool NormalizeFramerate { get; set; }
@ -106,6 +129,7 @@ public class FFmpegProfileEditViewModel
AudioBitrate, AudioBitrate,
AudioBufferSize, AudioBufferSize,
NormalizeLoudnessMode, NormalizeLoudnessMode,
TargetLoudness,
AudioChannels, AudioChannels,
AudioSampleRate, AudioSampleRate,
NormalizeFramerate, NormalizeFramerate,
@ -136,6 +160,7 @@ public class FFmpegProfileEditViewModel
AudioBitrate, AudioBitrate,
AudioBufferSize, AudioBufferSize,
NormalizeLoudnessMode, NormalizeLoudnessMode,
TargetLoudness,
AudioChannels, AudioChannels,
AudioSampleRate, AudioSampleRate,
NormalizeFramerate, NormalizeFramerate,

Loading…
Cancel
Save