mirror of
https://github.com/revenz/FileFlowsPlugins.git
synced 2026-05-08 05:20:12 -05:00
FF-256 - added option to normalize converted audio
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
using FileFlows.Plugin;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace FileFlows.AudioNodes;
|
||||
|
||||
public class AudioFileNormalization : AudioNode
|
||||
{
|
||||
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;
|
||||
|
||||
AudioInfo AudioInfo = GetAudioInfo(args);
|
||||
if (AudioInfo == null)
|
||||
return -1;
|
||||
|
||||
List<string> ffArgs = new List<string>();
|
||||
|
||||
|
||||
long sampleRate = AudioInfo.Frequency > 0 ? AudioInfo.Frequency : 48_000;
|
||||
|
||||
string twoPass = DoTwoPass(args, ffmpegExe);
|
||||
ffArgs.AddRange(new[] { "-i", args.WorkingFile, "-c:a", AudioInfo.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 = "<Pending>")]
|
||||
public static 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<LoudNormStats>(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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
namespace FileFlows.AudioNodes
|
||||
{
|
||||
using FileFlows.Plugin;
|
||||
|
||||
public abstract class AudioNode : Node
|
||||
{
|
||||
public override string Icon => "fas fa-music";
|
||||
|
||||
protected string GetFFMpegExe(NodeParameters args)
|
||||
{
|
||||
string ffmpeg = args.GetToolPath("FFMpeg");
|
||||
if (string.IsNullOrEmpty(ffmpeg))
|
||||
{
|
||||
args.Logger.ELog("FFMpeg tool not found.");
|
||||
return "";
|
||||
}
|
||||
var fileInfo = new FileInfo(ffmpeg);
|
||||
if (fileInfo.Exists == false)
|
||||
{
|
||||
args.Logger.ELog("FFMpeg tool configured by ffmpeg file does not exist.");
|
||||
return "";
|
||||
}
|
||||
return fileInfo.FullName;
|
||||
}
|
||||
|
||||
protected string GetFFMpegPath(NodeParameters args)
|
||||
{
|
||||
string ffmpeg = args.GetToolPath("FFMpeg");
|
||||
if (string.IsNullOrEmpty(ffmpeg))
|
||||
{
|
||||
args.Logger.ELog("FFMpeg tool not found.");
|
||||
return "";
|
||||
}
|
||||
var fileInfo = new FileInfo(ffmpeg);
|
||||
if (fileInfo.Exists == false)
|
||||
{
|
||||
args.Logger.ELog("FFMpeg tool configured by ffmpeg file does not exist.");
|
||||
return "";
|
||||
}
|
||||
return fileInfo.DirectoryName;
|
||||
}
|
||||
|
||||
private const string Audio_INFO = "AudioInfo";
|
||||
protected void SetAudioInfo(NodeParameters args, AudioInfo AudioInfo, Dictionary<string, object> variables)
|
||||
{
|
||||
if (args.Parameters.ContainsKey(Audio_INFO))
|
||||
args.Parameters[Audio_INFO] = AudioInfo;
|
||||
else
|
||||
args.Parameters.Add(Audio_INFO, AudioInfo);
|
||||
|
||||
if(AudioInfo.Artist.EndsWith(", The"))
|
||||
variables.AddOrUpdate("audio.Artist", "The " + AudioInfo.Artist.Substring(0, AudioInfo.Artist.Length - ", The".Length).Trim());
|
||||
else
|
||||
variables.AddOrUpdate("audio.Artist", AudioInfo.Artist);
|
||||
|
||||
if(AudioInfo.Artist?.StartsWith("The ") == true)
|
||||
variables.AddOrUpdate("audio.ArtistThe", AudioInfo.Artist.Substring(4).Trim() + ", The");
|
||||
else
|
||||
variables.AddOrUpdate("audio.ArtistThe", AudioInfo.Artist);
|
||||
|
||||
variables.AddOrUpdate("audio.Album", AudioInfo.Album);
|
||||
variables.AddOrUpdate("audio.BitRate", AudioInfo.BitRate);
|
||||
variables.AddOrUpdate("audio.Channels", AudioInfo.Channels);
|
||||
variables.AddOrUpdate("audio.Codec", AudioInfo.Codec);
|
||||
variables.AddOrUpdate("audio.Date", AudioInfo.Date);
|
||||
variables.AddOrUpdate("audio.Year", AudioInfo.Date.Year);
|
||||
variables.AddOrUpdate("audio.Duration", AudioInfo.Duration);
|
||||
variables.AddOrUpdate("audio.Encoder", AudioInfo.Encoder);
|
||||
variables.AddOrUpdate("audio.Frequency", AudioInfo.Frequency);
|
||||
variables.AddOrUpdate("audio.Genres", AudioInfo.Genres);
|
||||
variables.AddOrUpdate("audio.Language", AudioInfo.Language);
|
||||
variables.AddOrUpdate("audio.Title", AudioInfo.Title);
|
||||
variables.AddOrUpdate("audio.Track", AudioInfo.Track);
|
||||
variables.AddOrUpdate("audio.Disc", AudioInfo.Disc < 1 ? 1 : AudioInfo.Disc);
|
||||
variables.AddOrUpdate("audio.TotalDiscs", AudioInfo.TotalDiscs < 1 ? 1 : AudioInfo.TotalDiscs);
|
||||
|
||||
args.UpdateVariables(variables);
|
||||
}
|
||||
|
||||
protected AudioInfo GetAudioInfo(NodeParameters args)
|
||||
{
|
||||
if (args.Parameters.ContainsKey(Audio_INFO) == false)
|
||||
{
|
||||
args.Logger.WLog("No codec information loaded, use a 'Audio File' node first");
|
||||
return null;
|
||||
}
|
||||
var result = args.Parameters[Audio_INFO] as AudioInfo;
|
||||
if (result == null)
|
||||
{
|
||||
args.Logger.WLog("AudioInfo not found for file");
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
protected bool ReadAudioFileInfo(NodeParameters args, string ffmpegExe, string filename)
|
||||
{
|
||||
|
||||
var AudioInfo = new AudioInfoHelper(ffmpegExe, args.Logger).Read(filename);
|
||||
if (AudioInfo.Duration == 0)
|
||||
{
|
||||
args.Logger?.ILog("Failed to load Audio information.");
|
||||
return false;
|
||||
}
|
||||
|
||||
SetAudioInfo(args, AudioInfo, Variables);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using FileFlows.Plugin;
|
||||
using FileFlows.Plugin.Attributes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileFlows.AudioNodes
|
||||
{
|
||||
public class ConvertToMP3 : ConvertNode
|
||||
{
|
||||
protected override string Extension => "mp3";
|
||||
public static List<ListOption> BitrateOptions => ConvertNode.BitrateOptions;
|
||||
protected override List<string> GetArguments()
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"-c:a",
|
||||
"mp3",
|
||||
"-ab",
|
||||
Bitrate + "k"
|
||||
};
|
||||
}
|
||||
}
|
||||
public class ConvertToWAV : ConvertNode
|
||||
{
|
||||
protected override string Extension => "wav";
|
||||
public static List<ListOption> BitrateOptions => ConvertNode.BitrateOptions;
|
||||
protected override List<string> GetArguments()
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
"-ab",
|
||||
Bitrate + "k"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class ConvertToAAC : ConvertNode
|
||||
{
|
||||
protected override string Extension => "aac";
|
||||
public static List<ListOption> BitrateOptions => ConvertNode.BitrateOptions;
|
||||
|
||||
protected override bool SetId3Tags => true;
|
||||
|
||||
protected override List<string> GetArguments()
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-ab",
|
||||
Bitrate + "k"
|
||||
};
|
||||
}
|
||||
}
|
||||
public class ConvertToOGG: ConvertNode
|
||||
{
|
||||
protected override string Extension => "ogg";
|
||||
public static List<ListOption> BitrateOptions => ConvertNode.BitrateOptions;
|
||||
protected override List<string> GetArguments()
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"-c:a",
|
||||
"libvorbis",
|
||||
"-ab",
|
||||
Bitrate + "k"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
//public class ConvertToFLAC : ConvertNode
|
||||
//{
|
||||
// protected override string Extension => "flac";
|
||||
// public static List<ListOption> BitrateOptions => ConvertNode.BitrateOptions;
|
||||
// protected override List<string> GetArguments()
|
||||
// {
|
||||
// return new List<string>
|
||||
// {
|
||||
// "-c:a",
|
||||
// "flac",
|
||||
// "-ab",
|
||||
// Bitrate + "k"
|
||||
// };
|
||||
// }
|
||||
//}
|
||||
|
||||
public class ConvertAudio : ConvertNode
|
||||
{
|
||||
protected override string Extension => Codec;
|
||||
|
||||
public static List<ListOption> BitrateOptions => ConvertNode.BitrateOptions;
|
||||
|
||||
[Select(nameof(CodecOptions), 0)]
|
||||
public string Codec { get; set; }
|
||||
|
||||
[Boolean(4)]
|
||||
[ConditionEquals(nameof(Normalize), true, inverse: true)]
|
||||
public bool SkipIfCodecMatches { get; set; }
|
||||
|
||||
public override int Outputs => 2;
|
||||
|
||||
private static List<ListOption> _CodecOptions;
|
||||
public static List<ListOption> CodecOptions
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_CodecOptions == null)
|
||||
{
|
||||
_CodecOptions = new List<ListOption>
|
||||
{
|
||||
new ListOption { Label = "AAC", Value = "aac"},
|
||||
new ListOption { Label = "MP3", Value = "MP3"},
|
||||
new ListOption { Label = "OGG", Value = "ogg"},
|
||||
new ListOption { Label = "WAV", Value = "wav"},
|
||||
};
|
||||
}
|
||||
return _CodecOptions;
|
||||
}
|
||||
}
|
||||
|
||||
protected override List<string> GetArguments()
|
||||
{
|
||||
string codec = Codec switch
|
||||
{
|
||||
"ogg" => "libvorbis",
|
||||
"wav" => "pcm_s16le",
|
||||
_ => Codec.ToLower()
|
||||
};
|
||||
|
||||
return new List<string>
|
||||
{
|
||||
"-c:a",
|
||||
codec,
|
||||
"-ab",
|
||||
Bitrate + "k"
|
||||
};
|
||||
}
|
||||
|
||||
public override int Execute(NodeParameters args)
|
||||
{
|
||||
AudioInfo AudioInfo = GetAudioInfo(args);
|
||||
if (AudioInfo == null)
|
||||
return -1;
|
||||
|
||||
string ffmpegExe = GetFFMpegExe(args);
|
||||
if (string.IsNullOrEmpty(ffmpegExe))
|
||||
return -1;
|
||||
|
||||
if(Normalize == false && AudioInfo.Codec?.ToLower() == Codec?.ToLower())
|
||||
{
|
||||
if (SkipIfCodecMatches)
|
||||
{
|
||||
args.Logger?.ILog($"Audio file already '{Codec}' at bitrate '{AudioInfo.BitRate}', and set to skip if codec matches");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if(AudioInfo.BitRate <= Bitrate)
|
||||
{
|
||||
args.Logger?.ILog($"Audio file already '{Codec}' at bitrate '{AudioInfo.BitRate}'");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
return base.Execute(args);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class ConvertNode:AudioNode
|
||||
{
|
||||
protected abstract string Extension { get; }
|
||||
|
||||
protected virtual bool SetId3Tags => false;
|
||||
|
||||
public override int Inputs => 1;
|
||||
public override int Outputs => 1;
|
||||
|
||||
protected virtual List<string> GetArguments()
|
||||
{
|
||||
return new List<string>
|
||||
{
|
||||
"-map_metadata",
|
||||
"0:0",
|
||||
"-ab",
|
||||
Bitrate + "k"
|
||||
};
|
||||
}
|
||||
|
||||
public override FlowElementType Type => FlowElementType.Process;
|
||||
|
||||
[Select(nameof(BitrateOptions), 1)]
|
||||
public int Bitrate { get; set; }
|
||||
|
||||
[Boolean(3)]
|
||||
public bool Normalize { get; set; }
|
||||
|
||||
private static List<ListOption> _BitrateOptions;
|
||||
public static List<ListOption> BitrateOptions
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_BitrateOptions == null)
|
||||
{
|
||||
_BitrateOptions = new List<ListOption>
|
||||
{
|
||||
new ListOption { Label = "64 Kbps", Value = 64},
|
||||
new ListOption { Label = "96 Kbps", Value = 96},
|
||||
new ListOption { Label = "128 Kbps", Value = 128},
|
||||
new ListOption { Label = "160 Kbps", Value = 160},
|
||||
new ListOption { Label = "192 Kbps", Value = 192},
|
||||
new ListOption { Label = "224 Kbps", Value = 224},
|
||||
new ListOption { Label = "256 Kbps", Value = 256},
|
||||
new ListOption { Label = "288 Kbps", Value = 288},
|
||||
new ListOption { Label = "320 Kbps", Value = 320},
|
||||
};
|
||||
}
|
||||
return _BitrateOptions;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public override int Execute(NodeParameters args)
|
||||
{
|
||||
string ffmpegExe = GetFFMpegExe(args);
|
||||
if (string.IsNullOrEmpty(ffmpegExe))
|
||||
return -1;
|
||||
|
||||
//AudioInfo AudioInfo = GetAudioInfo(args);
|
||||
//if (AudioInfo == null)
|
||||
// return -1;
|
||||
|
||||
if (Bitrate < 64 || Bitrate > 320)
|
||||
{
|
||||
args.Logger?.ILog("Bitrate not set or invalid, setting to 192kbps");
|
||||
Bitrate = 192;
|
||||
}
|
||||
|
||||
|
||||
|
||||
string outputFile = Path.Combine(args.TempPath, Guid.NewGuid().ToString() + "." + Extension);
|
||||
|
||||
var ffArgs = GetArguments();
|
||||
ffArgs.Insert(0, "-hide_banner");
|
||||
ffArgs.Insert(1, "-y"); // tells ffmpeg to replace the file if already exists, which it shouldnt but just incase
|
||||
ffArgs.Insert(2, "-i");
|
||||
ffArgs.Insert(3, args.WorkingFile);
|
||||
ffArgs.Insert(4, "-vn"); // disables video
|
||||
|
||||
|
||||
if (Normalize)
|
||||
{
|
||||
string twoPass = AudioFileNormalization.DoTwoPass(args, ffmpegExe);
|
||||
ffArgs.Add("-af");
|
||||
ffArgs.Add(twoPass);
|
||||
}
|
||||
|
||||
ffArgs.Add(outputFile);
|
||||
|
||||
args.Logger?.ILog("FFArgs: " + String.Join(" ", ffArgs.Select(x => x.IndexOf(" ") > 0 ? "\"" + x + "\"" : x).ToArray()));
|
||||
|
||||
var result = args.Execute(new ExecuteArgs
|
||||
{
|
||||
Command = ffmpegExe,
|
||||
ArgumentList = ffArgs.ToArray()
|
||||
});
|
||||
|
||||
if(result.ExitCode != 0)
|
||||
{
|
||||
args.Logger?.ELog("Invalid exit code detected: " + result.ExitCode);
|
||||
return -1;
|
||||
}
|
||||
|
||||
//CopyMetaData(outputFile, args.FileName);
|
||||
|
||||
args.SetWorkingFile(outputFile);
|
||||
|
||||
// update the Audio file info
|
||||
if (ReadAudioFileInfo(args, ffmpegExe, args.WorkingFile))
|
||||
return 1;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
//private void CopyMetaData(string outputFile, string originalFile)
|
||||
//{
|
||||
// Track original = new Track(originalFile);
|
||||
// Track dest = new Track(outputFile);
|
||||
|
||||
// dest.Album = original.Album;
|
||||
// dest.AlbumArtist = original.AlbumArtist;
|
||||
// dest.Artist = original.Artist;
|
||||
// dest.Comment = original.Comment;
|
||||
// dest.Composer= original.Composer;
|
||||
// dest.Conductor = original.Conductor;
|
||||
// dest.Copyright = original.Copyright;
|
||||
// dest.Date = original.Date;
|
||||
// dest.Description= original.Description;
|
||||
// dest.DiscNumber= original.DiscNumber;
|
||||
// dest.DiscTotal = original.DiscTotal;
|
||||
// if (original.EmbeddedPictures?.Any() == true)
|
||||
// {
|
||||
// foreach (var pic in original.EmbeddedPictures)
|
||||
// dest.EmbeddedPictures.Add(pic);
|
||||
// }
|
||||
// dest.Genre= original.Genre;
|
||||
// dest.Lyrics= original.Lyrics;
|
||||
// dest.OriginalAlbum= original.OriginalAlbum;
|
||||
// dest.OriginalArtist = original.OriginalArtist;
|
||||
// dest.Popularity= original.Popularity;
|
||||
// dest.Publisher= original.Publisher;
|
||||
// dest.PublishingDate= original.PublishingDate;
|
||||
// dest.Title= original.Title;
|
||||
// dest.TrackNumber= original.TrackNumber;
|
||||
// dest.TrackTotal= original.TrackTotal;
|
||||
// dest.Year= original.Year;
|
||||
// foreach (var key in original.AdditionalFields.Keys)
|
||||
// {
|
||||
// if(dest.AdditionalFields.ContainsKey(key))
|
||||
// dest.AdditionalFields[key] = original.AdditionalFields[key];
|
||||
// else
|
||||
// dest.AdditionalFields.Add(key, original.AdditionalFields[key]);
|
||||
// }
|
||||
|
||||
// dest.Save();
|
||||
//}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user