diff --git a/FFMpegCore.Test/ArgumentBuilderTest.cs b/FFMpegCore.Test/ArgumentBuilderTest.cs index 082d9bf..a06a951 100644 --- a/FFMpegCore.Test/ArgumentBuilderTest.cs +++ b/FFMpegCore.Test/ArgumentBuilderTest.cs @@ -414,5 +414,60 @@ public void Builder_BuildString_ForcePixelFormat() .OutputToFile("output.mp4", false, opt => opt.ForcePixelFormat("yuv444p")).Arguments; Assert.AreEqual("-i \"input.mp4\" -pix_fmt yuv444p \"output.mp4\"", str); } + + [TestMethod] + public void Builder_BuildString_PanAudioFilterChannelNumber() + { + var str = FFMpegArguments.FromFileInput("input.mp4") + .OutputToFile("output.mp4", false, + opt => opt.WithAudioFilters(filterOptions => filterOptions.Pan(2, "c0=c1", "c1=c1"))) + .Arguments; + + Assert.AreEqual("-i \"input.mp4\" -af \"pan=2c|c0=c1|c1=c1\" \"output.mp4\"", str); + } + + [TestMethod] + public void Builder_BuildString_PanAudioFilterChannelLayout() + { + var str = FFMpegArguments.FromFileInput("input.mp4") + .OutputToFile("output.mp4", false, + opt => opt.WithAudioFilters(filterOptions => filterOptions.Pan("stereo", "c0=c0", "c1=c1"))) + .Arguments; + + Assert.AreEqual("-i \"input.mp4\" -af \"pan=stereo|c0=c0|c1=c1\" \"output.mp4\"", str); + } + + [TestMethod] + public void Builder_BuildString_PanAudioFilterChannelNoOutputDefinition() + { + var str = FFMpegArguments.FromFileInput("input.mp4") + .OutputToFile("output.mp4", false, + opt => opt.WithAudioFilters(filterOptions => filterOptions.Pan("stereo"))) + .Arguments; + + Assert.AreEqual("-i \"input.mp4\" -af \"pan=stereo\" \"output.mp4\"", str); + } + + [TestMethod] + public void Builder_BuildString_DynamicAudioNormalizerDefaultFormat() + { + var str = FFMpegArguments.FromFileInput("input.mp4") + .OutputToFile("output.mp4", false, + opt => opt.WithAudioFilters(filterOptions => filterOptions.DynamicNormalizer())) + .Arguments; + + Assert.AreEqual("-i \"input.mp4\" -af \"dynaudnorm=f=500:g=31:p=0.95:m=10.0:r=0.0:n=1:c=0:b=0:s=0.0\" \"output.mp4\"", str); + } + + [TestMethod] + public void Builder_BuildString_DynamicAudioNormalizerWithValuesFormat() + { + var str = FFMpegArguments.FromFileInput("input.mp4") + .OutputToFile("output.mp4", false, + opt => opt.WithAudioFilters(filterOptions => filterOptions.DynamicNormalizer(125, 13, 0.9215, 5.124, 0.5458,false,true,true, 0.3333333))) + .Arguments; + + Assert.AreEqual("-i \"input.mp4\" -af \"dynaudnorm=f=125:g=13:p=0.92:m=5.1:r=0.5:n=0:c=1:b=1:s=0.3\" \"output.mp4\"", str); + } } } \ No newline at end of file diff --git a/FFMpegCore.Test/AudioTest.cs b/FFMpegCore.Test/AudioTest.cs index b6fde77..795fedf 100644 --- a/FFMpegCore.Test/AudioTest.cs +++ b/FFMpegCore.Test/AudioTest.cs @@ -1,5 +1,6 @@ using FFMpegCore.Enums; using FFMpegCore.Exceptions; +using FFMpegCore.Extend; using FFMpegCore.Pipes; using FFMpegCore.Test.Resources; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -8,7 +9,6 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using FFMpegCore.Extend; namespace FFMpegCore.Test { @@ -223,5 +223,111 @@ public void Audio_ToAAC_Args_Pipe_InvalidSampleRate() .WithAudioCodec(AudioCodec.Aac)) .ProcessSynchronously()); } + + [TestMethod, Timeout(10000)] + public void Audio_Pan_ToMono() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var success = FFMpegArguments.FromFileInput(TestResources.Mp3Audio) + .OutputToFile(outputFile, true, + argumentOptions => argumentOptions + .WithAudioFilters(filter => filter.Pan(1, "c0 < 0.9 * c0 + 0.1 * c1"))) + .ProcessSynchronously(); + + var mediaAnalysis = FFProbe.Analyse(outputFile); + + Assert.IsTrue(success); + Assert.AreEqual(1, mediaAnalysis.AudioStreams.Count); + Assert.AreEqual("mono", mediaAnalysis.PrimaryAudioStream.ChannelLayout); + } + + [TestMethod, Timeout(10000)] + public void Audio_Pan_ToMonoNoDefinitions() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var success = FFMpegArguments.FromFileInput(TestResources.Mp3Audio) + .OutputToFile(outputFile, true, + argumentOptions => argumentOptions + .WithAudioFilters(filter => filter.Pan(1))) + .ProcessSynchronously(); + + var mediaAnalysis = FFProbe.Analyse(outputFile); + + Assert.IsTrue(success); + Assert.AreEqual(1, mediaAnalysis.AudioStreams.Count); + Assert.AreEqual("mono", mediaAnalysis.PrimaryAudioStream.ChannelLayout); + } + + [TestMethod, Timeout(10000)] + public void Audio_Pan_ToMonoChannelsToOutputDefinitionsMismatch() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var ex = Assert.ThrowsException(() => FFMpegArguments.FromFileInput(TestResources.Mp3Audio) + .OutputToFile(outputFile, true, + argumentOptions => argumentOptions + .WithAudioFilters(filter => filter.Pan(1, "c0=c0", "c1=c1"))) + .ProcessSynchronously()); + } + + [TestMethod, Timeout(10000)] + public void Audio_Pan_ToMonoChannelsLayoutToOutputDefinitionsMismatch() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var ex = Assert.ThrowsException(() => FFMpegArguments.FromFileInput(TestResources.Mp3Audio) + .OutputToFile(outputFile, true, + argumentOptions => argumentOptions + .WithAudioFilters(filter => filter.Pan("mono", "c0=c0", "c1=c1"))) + .ProcessSynchronously()); + } + + [TestMethod, Timeout(10000)] + public void Audio_DynamicNormalizer_WithDefaultValues() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var success = FFMpegArguments.FromFileInput(TestResources.Mp3Audio) + .OutputToFile(outputFile, true, + argumentOptions => argumentOptions + .WithAudioFilters(filter => filter.DynamicNormalizer())) + .ProcessSynchronously(); + + Assert.IsTrue(success); + } + + [TestMethod, Timeout(10000)] + public void Audio_DynamicNormalizer_WithNonDefaultValues() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var success = FFMpegArguments.FromFileInput(TestResources.Mp3Audio) + .OutputToFile(outputFile, true, + argumentOptions => argumentOptions + .WithAudioFilters( + filter => filter.DynamicNormalizer(250, 7, 0.9, 2, 1, false, true, true, 0.5))) + .ProcessSynchronously(); + + Assert.IsTrue(success); + } + + [DataTestMethod, Timeout(10000)] + [DataRow(2)] + [DataRow(32)] + [DataRow(8)] + public void Audio_DynamicNormalizer_FilterWindow(int filterWindow) + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var ex = Assert.ThrowsException(() => FFMpegArguments + .FromFileInput(TestResources.Mp3Audio) + .OutputToFile(outputFile, true, + argumentOptions => argumentOptions + .WithAudioFilters( + filter => filter.DynamicNormalizer(filterWindow: filterWindow))) + .ProcessSynchronously()); + } } } \ No newline at end of file diff --git a/FFMpegCore.Test/FFMpegCore.Test.csproj b/FFMpegCore.Test/FFMpegCore.Test.csproj index 2505545..e6831e6 100644 --- a/FFMpegCore.Test/FFMpegCore.Test.csproj +++ b/FFMpegCore.Test/FFMpegCore.Test.csproj @@ -40,9 +40,9 @@ - - - + + + diff --git a/FFMpegCore/FFMpeg/Arguments/AudioFiltersArgument.cs b/FFMpegCore/FFMpeg/Arguments/AudioFiltersArgument.cs new file mode 100644 index 0000000..50b26b3 --- /dev/null +++ b/FFMpegCore/FFMpeg/Arguments/AudioFiltersArgument.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Linq; +using FFMpegCore.Exceptions; + +namespace FFMpegCore.Arguments +{ + public class AudioFiltersArgument : IArgument + { + public readonly AudioFilterOptions Options; + + public AudioFiltersArgument(AudioFilterOptions options) + { + Options = options; + } + + public string Text => GetText(); + + private string GetText() + { + if (!Options.Arguments.Any()) + throw new FFMpegArgumentException("No audio-filter arguments provided"); + + var arguments = Options.Arguments + .Where(arg => !string.IsNullOrEmpty(arg.Value)) + .Select(arg => + { + var escapedValue = arg.Value.Replace(",", "\\,"); + return string.IsNullOrEmpty(arg.Key) ? escapedValue : $"{arg.Key}={escapedValue}"; + }); + + return $"-af \"{string.Join(", ", arguments)}\""; + } + } + + public interface IAudioFilterArgument + { + public string Key { get; } + public string Value { get; } + } + + public class AudioFilterOptions + { + public List Arguments { get; } = new List(); + + public AudioFilterOptions Pan(string channelLayout, params string[] outputDefinitions) => WithArgument(new PanArgument(channelLayout, outputDefinitions)); + public AudioFilterOptions Pan(int channels, params string[] outputDefinitions) => WithArgument(new PanArgument(channels, outputDefinitions)); + public AudioFilterOptions DynamicNormalizer(int frameLength = 500, int filterWindow = 31, double targetPeak = 0.95, + double gainFactor = 10.0, double targetRms = 0.0, bool channelCoupling = true, + bool enableDcBiasCorrection = false, bool enableAlternativeBoundary = false, + double compressorFactor = 0.0) => WithArgument(new DynamicNormalizerArgument(frameLength, filterWindow, + targetPeak, gainFactor, targetRms, channelCoupling, enableDcBiasCorrection, enableAlternativeBoundary, + compressorFactor)); + + private AudioFilterOptions WithArgument(IAudioFilterArgument argument) + { + Arguments.Add(argument); + return this; + } + } +} \ No newline at end of file diff --git a/FFMpegCore/FFMpeg/Arguments/DynamicNormalizerArgument.cs b/FFMpegCore/FFMpeg/Arguments/DynamicNormalizerArgument.cs new file mode 100644 index 0000000..d1c948c --- /dev/null +++ b/FFMpegCore/FFMpeg/Arguments/DynamicNormalizerArgument.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace FFMpegCore.Arguments +{ + public class DynamicNormalizerArgument : IAudioFilterArgument + { + private readonly Dictionary _arguments = new Dictionary(); + + /// + /// Dynamic Audio Normalizer. + /// + /// Set the frame length in milliseconds. Must be between 10 to 8000. The default value is 500 + /// Set the Gaussian filter window size. In range from 3 to 301, must be odd number. The default value is 31 + /// Set the target peak value. The default value is 0.95 + /// Set the maximum gain factor. In range from 1.0 to 100.0. Default is 10.0. + /// Set the target RMS. In range from 0.0 to 1.0. Default to 0.0 (disabled) + /// Enable channels coupling. By default is enabled. + /// Enable DC bias correction. By default is disabled. + /// Enable alternative boundary mode. By default is disabled. + /// Set the compress factor. In range from 0.0 to 30.0. Default is 0.0 (disabled). + public DynamicNormalizerArgument(int frameLength = 500, int filterWindow = 31, double targetPeak = 0.95, double gainFactor = 10.0, double targetRms = 0.0, bool channelCoupling = true, bool enableDcBiasCorrection = false, bool enableAlternativeBoundary = false, double compressorFactor = 0.0) + { + if (frameLength < 10 || frameLength > 8000) throw new ArgumentOutOfRangeException(nameof(frameLength),"Frame length must be between 10 to 8000"); + if (filterWindow < 3 || filterWindow > 31) throw new ArgumentOutOfRangeException(nameof(filterWindow), "Gaussian filter window size must be between 3 to 31"); + if (filterWindow % 2 == 0) throw new ArgumentOutOfRangeException(nameof(filterWindow), "Gaussian filter window size must be an odd number"); + if (targetPeak <= 0 || targetPeak > 1) throw new ArgumentOutOfRangeException(nameof(targetPeak)); + if (gainFactor < 1 || gainFactor > 100) throw new ArgumentOutOfRangeException(nameof(gainFactor), "Gain factor must be between 1.0 to 100.0"); + if (targetRms < 0 || targetRms > 1) throw new ArgumentOutOfRangeException(nameof(targetRms), "Target RMS must be between 0.0 and 1.0"); + if (compressorFactor < 0 || compressorFactor > 30) throw new ArgumentOutOfRangeException(nameof(compressorFactor), "Compressor factor must be between 0.0 and 30.0"); + + _arguments.Add("f", frameLength.ToString()); + _arguments.Add("g", filterWindow.ToString()); + _arguments.Add("p", targetPeak.ToString("0.00", CultureInfo.InvariantCulture)); + _arguments.Add("m", gainFactor.ToString("0.0", CultureInfo.InvariantCulture)); + _arguments.Add("r", targetRms.ToString("0.0", CultureInfo.InvariantCulture)); + _arguments.Add("n", (channelCoupling ? 1 : 0).ToString()); + _arguments.Add("c", (enableDcBiasCorrection ? 1 : 0).ToString()); + _arguments.Add("b", (enableAlternativeBoundary ? 1 : 0).ToString()); + _arguments.Add("s", compressorFactor.ToString("0.0", CultureInfo.InvariantCulture)); + } + + public string Key { get; } = "dynaudnorm"; + + public string Value => string.Join(":", _arguments.Select(pair => $"{pair.Key}={pair.Value}")); + } +} \ No newline at end of file diff --git a/FFMpegCore/FFMpeg/Arguments/PanArgument.cs b/FFMpegCore/FFMpeg/Arguments/PanArgument.cs new file mode 100644 index 0000000..013fbf6 --- /dev/null +++ b/FFMpegCore/FFMpeg/Arguments/PanArgument.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; + +namespace FFMpegCore.Arguments +{ + /// + /// Mix channels with specific gain levels. + /// + public class PanArgument : IAudioFilterArgument + { + public readonly string ChannelLayout; + private readonly string[] _outputDefinitions; + + /// + /// Mix channels with specific gain levels + /// + /// + /// Represent the output channel layout. Like "stereo", "mono", "2.1", "5.1" + /// + /// + /// Output channel specification, of the form: "out_name=[gain*]in_name[(+-)[gain*]in_name...]" + /// + public PanArgument(string channelLayout, params string[] outputDefinitions) + { + if (string.IsNullOrWhiteSpace(channelLayout)) + { + throw new ArgumentException("The channel layout must be set" ,nameof(channelLayout)); + } + + ChannelLayout = channelLayout; + + _outputDefinitions = outputDefinitions; + } + + /// + /// Mix channels with specific gain levels + /// + /// Number of channels in output file + /// + /// Output channel specification, of the form: "out_name=[gain*]in_name[(+-)[gain*]in_name...]" + /// + public PanArgument(int channels, params string[] outputDefinitions) + { + if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels)); + + if (outputDefinitions.Length > channels) + throw new ArgumentException("The number of output definitions must be equal or lower than number of channels", nameof(outputDefinitions)); + + ChannelLayout = $"{channels}c"; + + _outputDefinitions = outputDefinitions; + } + + public string Key { get; } = "pan"; + + public string Value => + string.Join("|", Enumerable.Empty().Append(ChannelLayout).Concat(_outputDefinitions)); + } +} diff --git a/FFMpegCore/FFMpeg/FFMpegArgumentOptions.cs b/FFMpegCore/FFMpeg/FFMpegArgumentOptions.cs index 41ac38c..ca6628a 100644 --- a/FFMpegCore/FFMpeg/FFMpegArgumentOptions.cs +++ b/FFMpegCore/FFMpeg/FFMpegArgumentOptions.cs @@ -43,6 +43,13 @@ public FFMpegArgumentOptions WithVideoFilters(Action videoFi return WithArgument(new VideoFiltersArgument(videoFilterOptionsObj)); } + public FFMpegArgumentOptions WithAudioFilters(Action audioFilterOptions) + { + var audioFilterOptionsObj = new AudioFilterOptions(); + audioFilterOptions(audioFilterOptionsObj); + return WithArgument(new AudioFiltersArgument(audioFilterOptionsObj)); + } + public FFMpegArgumentOptions WithFramerate(double framerate) => WithArgument(new FrameRateArgument(framerate)); public FFMpegArgumentOptions WithoutMetadata() => WithArgument(new RemoveMetadataArgument()); public FFMpegArgumentOptions WithSpeedPreset(Speed speed) => WithArgument(new SpeedPresetArgument(speed)); diff --git a/FFMpegCore/FFProbe/FFProbe.cs b/FFMpegCore/FFProbe/FFProbe.cs index 09dde4b..89f905d 100644 --- a/FFMpegCore/FFProbe/FFProbe.cs +++ b/FFMpegCore/FFProbe/FFProbe.cs @@ -75,7 +75,10 @@ public static async Task AnalyseAsync(string filePath, int outpu throw new FFMpegException(FFMpegExceptionType.File, $"No file found at '{filePath}'"); using var instance = PrepareStreamAnalysisInstance(filePath, outputCapacity, ffOptions ?? GlobalFFOptions.Current); - await instance.FinishedRunning().ConfigureAwait(false); + var exitCode = await instance.FinishedRunning().ConfigureAwait(false); + if (exitCode != 0) + throw new FFMpegException(FFMpegExceptionType.Process, $"ffprobe exited with non-zero exit-code ({exitCode} - {string.Join("\n", instance.ErrorData)})", null, string.Join("\n", instance.ErrorData)); + return ParseOutput(instance); } @@ -91,7 +94,10 @@ public static async Task GetFramesAsync(string filePath, int outp public static async Task AnalyseAsync(Uri uri, int outputCapacity = int.MaxValue, FFOptions? ffOptions = null) { using var instance = PrepareStreamAnalysisInstance(uri.AbsoluteUri, outputCapacity, ffOptions ?? GlobalFFOptions.Current); - await instance.FinishedRunning().ConfigureAwait(false); + var exitCode = await instance.FinishedRunning().ConfigureAwait(false); + if (exitCode != 0) + throw new FFMpegException(FFMpegExceptionType.Process, $"ffprobe exited with non-zero exit-code ({exitCode} - {string.Join("\n", instance.ErrorData)})", null, string.Join("\n", instance.ErrorData)); + return ParseOutput(instance); } public static async Task AnalyseAsync(Stream stream, int outputCapacity = int.MaxValue, FFOptions? ffOptions = null)