From a47510fef3cab964b419b4bd43befb80a1266fcc Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:56:13 -0500 Subject: [PATCH] add aac (latm) audio format (#2561) * add aac (latm) audio format * update changelog --- CHANGELOG.md | 1 + .../Domain/FFmpegProfileAudioFormat.cs | 1 + .../FFmpeg/FFmpegLibraryProcessService.cs | 51 +++++++++++++++++++ .../PipelineBuilderBaseTests.cs | 4 ++ ErsatzTV.FFmpeg/Decoder/DecoderAacLatm.cs | 24 +++++++++ ErsatzTV.FFmpeg/FFmpegState.cs | 2 + ErsatzTV.FFmpeg/Format/AudioFormat.cs | 1 + ErsatzTV.FFmpeg/InputFile.cs | 2 + .../OutputFormat/OutputFormatHls.cs | 9 +++- .../OutputOption/AudioChannelsOutputOption.cs | 12 +++-- .../Pipeline/PipelineBuilderBase.cs | 7 +++ ErsatzTV/Controllers/IptvController.cs | 2 +- ErsatzTV/Pages/FFmpegEditor.razor | 1 + 13 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 ErsatzTV.FFmpeg/Decoder/DecoderAacLatm.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c94ffdb4a..83736603a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - By default, poster will be added as image with type "poster" and thumbnail will be added as image with type "still" - Poster will continue to be added as icon by default - Add buttons to edit Jellyfin and Emby connection information in **Media Sources** > **Jellyfin** and **Media Sources** > **Emby** +- Add audio format `aac (latm)` for DVB-C compatibility; `aac` uses ADTS by default which is required in most cases ### Fixed - Fix NVIDIA startup errors on arm64 diff --git a/ErsatzTV.Core/Domain/FFmpegProfileAudioFormat.cs b/ErsatzTV.Core/Domain/FFmpegProfileAudioFormat.cs index 143e7f4e1..3d8bdcd3d 100644 --- a/ErsatzTV.Core/Domain/FFmpegProfileAudioFormat.cs +++ b/ErsatzTV.Core/Domain/FFmpegProfileAudioFormat.cs @@ -9,6 +9,7 @@ public enum FFmpegProfileAudioFormat Aac = 1, Ac3 = 2, + AacLatm = 3, Copy = 99 } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 443e72d62..8f58e1d8f 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -171,6 +171,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService string audioFormat = playbackSettings.AudioFormat switch { FFmpegProfileAudioFormat.Aac => AudioFormat.Aac, + FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm, FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3, FFmpegProfileAudioFormat.Copy => AudioFormat.Copy, _ => throw new ArgumentOutOfRangeException($"unexpected audio format {playbackSettings.VideoFormat}") @@ -403,6 +404,27 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService _ => Option.None }; + Option hlsSegmentOptions = Option.None; + if (outputFormat is OutputFormatKind.Hls) + { + string options = string.Empty; + + if (ptsOffset == TimeSpan.Zero) + { + options += "+initial_discontinuity"; + } + + if (audioFormat == AudioFormat.AacLatm) + { + options += "+latm"; + } + + if (!string.IsNullOrWhiteSpace(options)) + { + hlsSegmentOptions = $"mpegts_flags={options}"; + } + } + FrameSize scaledSize = ffmpegVideoStream.SquarePixelFrameSize( new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); @@ -506,6 +528,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService hlsPlaylistPath, hlsSegmentTemplate, hlsInitTemplate, + hlsSegmentOptions, ptsOffset, playbackSettings.ThreadCount, qsvExtraHardwareFrames, @@ -583,6 +606,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService string audioFormat = playbackSettings.AudioFormat switch { FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3, + FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm, _ => AudioFormat.Aac }; @@ -645,6 +669,27 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService _ => Option.None }; + Option hlsSegmentOptions = Option.None; + if (outputFormat is OutputFormatKind.Hls) + { + string options = string.Empty; + + if (ptsOffset == TimeSpan.Zero) + { + options += "+initial_discontinuity"; + } + + if (audioFormat == AudioFormat.AacLatm) + { + options += "+latm"; + } + + if (!string.IsNullOrWhiteSpace(options)) + { + hlsSegmentOptions = $"mpegts_options={options}"; + } + } + string videoPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"); var videoVersion = BackgroundImageMediaVersion.ForPath(videoPath, desiredResolution); @@ -686,6 +731,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService hlsPlaylistPath, hlsSegmentTemplate, hlsInitTemplate, + hlsSegmentOptions, ptsOffset, Option.None, qsvExtraHardwareFrames, @@ -778,6 +824,11 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService $"http://localhost:{Settings.StreamingPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter{accessTokenQuery}", resolution); + if (channel.FFmpegProfile.AudioFormat is FFmpegProfileAudioFormat.AacLatm) + { + concatInputFile.AudioFormat = AudioFormat.AacLatm; + } + IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( HardwareAccelerationMode.None, Option.None, diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index 553c82530..7032c1975 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -91,6 +91,7 @@ public class PipelineBuilderBaseTests Option.None, Option.None, Option.None, + Option.None, TimeSpan.Zero, Option.None, Option.None, @@ -190,6 +191,7 @@ public class PipelineBuilderBaseTests Option.None, Option.None, Option.None, + Option.None, TimeSpan.Zero, Option.None, Option.None, @@ -347,6 +349,7 @@ public class PipelineBuilderBaseTests Option.None, Option.None, Option.None, + Option.None, TimeSpan.Zero, Option.None, Option.None, @@ -440,6 +443,7 @@ public class PipelineBuilderBaseTests Option.None, Option.None, Option.None, + Option.None, TimeSpan.Zero, Option.None, Option.None, diff --git a/ErsatzTV.FFmpeg/Decoder/DecoderAacLatm.cs b/ErsatzTV.FFmpeg/Decoder/DecoderAacLatm.cs new file mode 100644 index 000000000..639cfcce4 --- /dev/null +++ b/ErsatzTV.FFmpeg/Decoder/DecoderAacLatm.cs @@ -0,0 +1,24 @@ +using ErsatzTV.FFmpeg.Environment; +using ErsatzTV.FFmpeg.InputOption; + +namespace ErsatzTV.FFmpeg.Decoder; + +public class DecoderAacLatm : IInputOption +{ + public EnvironmentVariable[] EnvironmentVariables => []; + public string[] GlobalOptions => []; + public string[] FilterOptions => []; + public string[] OutputOptions => []; + + public string[] InputOptions(InputFile inputFile) => ["-c:a", "aac_latm"]; + + public FrameState NextState(FrameState currentState) => currentState; + + public bool AppliesTo(AudioInputFile audioInputFile) => false; + + public bool AppliesTo(VideoInputFile videoInputFile) => false; + + public bool AppliesTo(ConcatInputFile concatInputFile) => true; + + public bool AppliesTo(GraphicsEngineInput graphicsEngineInput) => false; +} diff --git a/ErsatzTV.FFmpeg/FFmpegState.cs b/ErsatzTV.FFmpeg/FFmpegState.cs index 5dd2c73bf..324affa9e 100644 --- a/ErsatzTV.FFmpeg/FFmpegState.cs +++ b/ErsatzTV.FFmpeg/FFmpegState.cs @@ -20,6 +20,7 @@ public record FFmpegState( Option HlsPlaylistPath, Option HlsSegmentTemplate, Option HlsInitTemplate, + Option HlsSegmentOptions, TimeSpan PtsOffset, Option ThreadCount, Option MaybeQsvExtraHardwareFrames, @@ -49,6 +50,7 @@ public record FFmpegState( Option.None, Option.None, Option.None, + Option.None, TimeSpan.Zero, Option.None, Option.None, diff --git a/ErsatzTV.FFmpeg/Format/AudioFormat.cs b/ErsatzTV.FFmpeg/Format/AudioFormat.cs index a22009c0e..79331b9f3 100644 --- a/ErsatzTV.FFmpeg/Format/AudioFormat.cs +++ b/ErsatzTV.FFmpeg/Format/AudioFormat.cs @@ -4,6 +4,7 @@ public static class AudioFormat { public const string Aac = "aac"; public const string Ac3 = "ac3"; + public const string AacLatm = "aac-latm"; public const string Copy = "copy"; } diff --git a/ErsatzTV.FFmpeg/InputFile.cs b/ErsatzTV.FFmpeg/InputFile.cs index b97fd1b1f..2792c48a6 100644 --- a/ErsatzTV.FFmpeg/InputFile.cs +++ b/ErsatzTV.FFmpeg/InputFile.cs @@ -28,6 +28,8 @@ public record ConcatInputFile(string Url, FrameSize Resolution) : InputFile( ScanKind.Unknown) }) { + public Option AudioFormat { get; set; } + public void AddOption(IInputOption option) { if (option.AppliesTo(this)) diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs index 065b0ce8a..9f2df1360 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs @@ -11,6 +11,7 @@ public class OutputFormatHls : IPipelineStep private readonly bool _isTroubleshooting; private readonly Option _mediaFrameRate; private readonly OutputFormatKind _outputFormat; + private readonly Option _segmentOptions; private readonly bool _oneSecondGop; private readonly string _playlistPath; private readonly string _segmentTemplate; @@ -20,6 +21,7 @@ public class OutputFormatHls : IPipelineStep FrameState desiredState, Option mediaFrameRate, OutputFormatKind outputFormat, + Option segmentOptions, string segmentTemplate, Option initTemplate, string playlistPath, @@ -30,6 +32,7 @@ public class OutputFormatHls : IPipelineStep _desiredState = desiredState; _mediaFrameRate = mediaFrameRate; _outputFormat = outputFormat; + _segmentOptions = segmentOptions; _segmentTemplate = segmentTemplate; _initTemplate = initTemplate; _playlistPath = playlistPath; @@ -83,6 +86,11 @@ public class OutputFormatHls : IPipelineStep break; } + foreach (string options in _segmentOptions) + { + result.AddRange("-hls_segment_options", options); + } + string pdt = _isTroubleshooting ? string.Empty : "program_date_time+omit_endlist+"; if (_isFirstTranscode) @@ -108,7 +116,6 @@ public class OutputFormatHls : IPipelineStep result.AddRange( [ "-hls_flags", $"{pdt}append_list+discont_start{independentSegments}", - "-mpegts_flags", "+initial_discontinuity", _playlistPath ]); break; diff --git a/ErsatzTV.FFmpeg/OutputOption/AudioChannelsOutputOption.cs b/ErsatzTV.FFmpeg/OutputOption/AudioChannelsOutputOption.cs index 733dee47f..7e2659354 100644 --- a/ErsatzTV.FFmpeg/OutputOption/AudioChannelsOutputOption.cs +++ b/ErsatzTV.FFmpeg/OutputOption/AudioChannelsOutputOption.cs @@ -20,15 +20,17 @@ public class AudioChannelsOutputOption : OutputOption { get { - if (_sourceChannels != _desiredChannels || _audioFormat == Some(AudioFormat.Aac) && _desiredChannels > 2) + if (_sourceChannels != _desiredChannels || + (_audioFormat == Some(AudioFormat.Aac) || _audioFormat == Some(AudioFormat.AacLatm)) && + _desiredChannels > 2) { - return new[] - { + return + [ "-ac", _desiredChannels.ToString(CultureInfo.InvariantCulture) - }; + ]; } - return Array.Empty(); + return []; } } } diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index b19d68509..79091148b 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -162,6 +162,12 @@ public abstract class PipelineBuilderBase : IPipelineBuilder new EncoderCopyAll() }; + // ffmpeg below 8 doesn't detect this without an explicit decoder + foreach (string _ in concatInputFile.AudioFormat.Where(af => af == AudioFormat.AacLatm)) + { + concatInputFile.AddOption(new DecoderAacLatm()); + } + concatInputFile.AddOption(new ReadrateInputOption(1.0)); SetMetadataServiceProvider(ffmpegState, pipelineSteps); @@ -384,6 +390,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder desiredState, videoStream.FrameRate, ffmpegState.OutputFormat, + ffmpegState.HlsSegmentOptions, segmentTemplate, ffmpegState.HlsInitTemplate, playlistPath, diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 84a49c8b5..882b823fb 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -307,7 +307,7 @@ public class IptvController : StreamingControllerBase string audioCodec = streamingSpecs.AudioFormat switch { FFmpegProfileAudioFormat.Ac3 => "ac-3", - FFmpegProfileAudioFormat.Aac => "mp4a.40.2", + FFmpegProfileAudioFormat.Aac or FFmpegProfileAudioFormat.AacLatm => "mp4a.40.2", _ => string.Empty }; diff --git a/ErsatzTV/Pages/FFmpegEditor.razor b/ErsatzTV/Pages/FFmpegEditor.razor index 0c1834048..a95719ba2 100644 --- a/ErsatzTV/Pages/FFmpegEditor.razor +++ b/ErsatzTV/Pages/FFmpegEditor.razor @@ -242,6 +242,7 @@ aac ac3 + aac (latm)