diff --git a/MusicNodes/MusicNodes.csproj b/MusicNodes/MusicNodes.csproj index 4a713c9d..03e4d670 100644 Binary files a/MusicNodes/MusicNodes.csproj and b/MusicNodes/MusicNodes.csproj differ diff --git a/MusicNodes/MusicNodes.en.json b/MusicNodes/MusicNodes.en.json index 5372f702..2c8610f8 100644 --- a/MusicNodes/MusicNodes.en.json +++ b/MusicNodes/MusicNodes.en.json @@ -7,6 +7,12 @@ "1": "Music file from library" } }, + "AudioFileNormalization": { + "Description": "Normalizes an audio file using two passes of FFMPEGs loudnorm filter", + "Outputs": { + "1": "Audio file normalized and saved to temporary file" + } + }, "ConvertAudio": { "Description": "Convert a music file to the specified audio codec", "Outputs": { diff --git a/MusicNodes/Nodes/AudioFileNormalization.cs b/MusicNodes/Nodes/AudioFileNormalization.cs new file mode 100644 index 00000000..112aad87 --- /dev/null +++ b/MusicNodes/Nodes/AudioFileNormalization.cs @@ -0,0 +1,116 @@ +using FileFlows.Plugin; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace FileFlows.MusicNodes +{ + public class AudioFileNormalization : MusicNode + { + public override int Inputs => 1; + public override int Outputs => 1; + public override FlowElementType Type => FlowElementType.Process; + + public override string Icon => "fas fa-volume-up"; + + + const string LOUDNORM_TARGET = "I=-24:LRA=7:TP=-2.0"; + + public override int Execute(NodeParameters args) + { + try + { + string ffmpegExe = GetFFMpegExe(args); + if (string.IsNullOrEmpty(ffmpegExe)) + return -1; + + MusicInfo musicInfo = GetMusicInfo(args); + if (musicInfo == null) + return -1; + + List ffArgs = new List(); + + + long sampleRate = musicInfo.Frequency > 0 ? musicInfo.Frequency : 48_000; + + string twoPass = DoTwoPass(args, ffmpegExe); + ffArgs.AddRange(new[] { "-i", args.WorkingFile, "-c:a", musicInfo.Codec, "-ar", sampleRate.ToString(), "-af", twoPass }); + + string extension = new FileInfo(args.WorkingFile).Extension; + if (extension.StartsWith(".")) + extension = extension.Substring(1); + + string outputFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + "." + extension); + ffArgs.Add(outputFile); + + var result = args.Execute(new ExecuteArgs + { + Command = ffmpegExe, + ArgumentList = ffArgs.ToArray() + }); + + return result.ExitCode == 0 ? 1 : -1; + } + catch (Exception ex) + { + args.Logger?.ELog("Failed processing AudioFile: " + ex.Message); + return -1; + } + } + + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + public string DoTwoPass(NodeParameters args, string ffmpegExe) + { + //-af loudnorm=I=-24:LRA=7:TP=-2.0" + var result = args.Execute(new ExecuteArgs + { + Command = ffmpegExe, + ArgumentList = new[] + { + "-hide_banner", + "-i", args.WorkingFile, + "-af", "loudnorm=" + LOUDNORM_TARGET + ":print_format=json", + "-f", "null", + "-" + } + }); + if(result.ExitCode != 0) + throw new Exception("Failed to prcoess audio track"); + + string output = result.StandardOutput; + + int index = output.LastIndexOf("{"); + if (index == -1) + throw new Exception("Failed to detected json in output"); + string json = output.Substring(index); + json = json.Substring(0, json.IndexOf("}") + 1); + if (string.IsNullOrEmpty(json)) + throw new Exception("Failed to parse TwoPass json"); + LoudNormStats stats = JsonSerializer.Deserialize(json); + string ar = $"loudnorm=print_format=summary:linear=true:{LOUDNORM_TARGET}:measured_I={stats.input_i}:measured_LRA={stats.input_lra}:measured_tp={stats.input_tp}:measured_thresh={stats.input_thresh}:offset={stats.target_offset}"; + return ar; + } + + private class LoudNormStats + { + /* +{ + "input_i" : "-7.47", + "input_tp" : "12.33", + "input_lra" : "6.70", + "input_thresh" : "-18.13", + "output_i" : "-24.25", + "output_tp" : "-3.60", + "output_lra" : "5.90", + "output_thresh" : "-34.74", + "normalization_type" : "dynamic", + "target_offset" : "0.25" +} + */ + public string input_i { get; set; } + public string input_tp { get; set; } + public string input_lra { get; set; } + public string input_thresh { get; set; } + public string target_offset { get; set; } + } + } +} diff --git a/MusicNodes/Tests/AudioFileNormalizationTests.cs b/MusicNodes/Tests/AudioFileNormalizationTests.cs new file mode 100644 index 00000000..25fda998 --- /dev/null +++ b/MusicNodes/Tests/AudioFileNormalizationTests.cs @@ -0,0 +1,58 @@ +#if(DEBUG) + + +namespace FileFlows.MusicNodes.Tests +{ + using FileFlows.MusicNodes; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + + [TestClass] + public class AudioFileNormalizationTests + { + [TestMethod] + public void AudioFileNormalization_Mp3() + { + + const string file = @"D:\music\unprocessed\01-billy_joel-movin_out.mp3"; + + AudioFileNormalization node = new (); + var logger = new TestLogger(); + var args = new FileFlows.Plugin.NodeParameters(file, logger, false, string.Empty); + args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; + args.TempPath = @"D:\music\temp"; + new MusicFile().Execute(args); // need to read the music info and set it + int output = node.Execute(args); + + string log = logger.ToString(); + + Assert.AreEqual(1, output); + } + [TestMethod] + public void AudioFileNormalization_Bulk() + { + + foreach (var file in Directory.GetFiles(@"d:\music\unprocessed")) + { + + AudioFileNormalization node = new(); + var logger = new TestLogger(); + var args = new FileFlows.Plugin.NodeParameters(file, logger, false, string.Empty); + args.GetToolPathActual = (string tool) => @"C:\utils\ffmpeg\ffmpeg.exe"; + args.TempPath = @"D:\music\temp"; + new MusicFile().Execute(args); // need to read the music info and set it + int output = node.Execute(args); + + string log = logger.ToString(); + + Assert.AreEqual(1, output); + } + } + } +} + +#endif \ No newline at end of file diff --git a/MusicNodes/Tests/TestLogger.cs b/MusicNodes/Tests/TestLogger.cs index 0cc435aa..410935ef 100644 --- a/MusicNodes/Tests/TestLogger.cs +++ b/MusicNodes/Tests/TestLogger.cs @@ -41,6 +41,11 @@ namespace FileFlows.MusicNodes.Tests string log = string.Join(Environment.NewLine, Messages); return log.Contains(message); } + + public override string ToString() + { + return String.Join(Environment.NewLine, this.Messages.ToArray()); + } } }