107 lines
5.2 KiB
C#
107 lines
5.2 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
|
|
namespace Paca.Core.Platform;
|
|
|
|
public sealed record LoudnessSpec(double TargetLufs = -14.0, double TruePeak = -1.0, double LoudnessRange = 11.0)
|
|
{
|
|
public static LoudnessSpec WebStandard => new(-14.0, -1.0, 11.0);
|
|
public static LoudnessSpec EbuR128 => new(-23.0, -1.0, 7.0);
|
|
}
|
|
|
|
public sealed record MeasuredLoudness(double MeasuredI, double MeasuredTp, double MeasuredLra, double MeasuredThresh);
|
|
|
|
/// <summary>
|
|
/// 29차: EBU R128 2-pass 오디오 라우드니스 정규화 및 무음 트리밍 엔진.
|
|
/// </summary>
|
|
public sealed class AudioNormalizer
|
|
{
|
|
private readonly IProcessRunner _runner;
|
|
|
|
public AudioNormalizer(IProcessRunner runner)
|
|
{
|
|
_runner = runner ?? throw new ArgumentNullException(nameof(runner));
|
|
}
|
|
|
|
public async Task<string> NormalizeAudioAsync(string ffmpeg, string inputFile, string outputFile, LoudnessSpec spec, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentException("입력 파일 경로가 유효하지 않습니다.", nameof(inputFile));
|
|
if (string.IsNullOrWhiteSpace(outputFile)) throw new ArgumentException("출력 파일 경로가 유효하지 않습니다.", nameof(outputFile));
|
|
if (string.Equals(Path.GetFullPath(inputFile), Path.GetFullPath(outputFile), StringComparison.OrdinalIgnoreCase))
|
|
throw new ArgumentException("입력 파일과 출력 파일 경로가 동일할 수 없습니다. 원본 손상을 방지하기 위해 별도의 출력 경로를 지정해야 합니다.");
|
|
|
|
// Pass 1: Measure loudness
|
|
var iStr = spec.TargetLufs.ToString("F1", CultureInfo.InvariantCulture);
|
|
var tpStr = spec.TruePeak.ToString("F1", CultureInfo.InvariantCulture);
|
|
var lraStr = spec.LoudnessRange.ToString("F1", CultureInfo.InvariantCulture);
|
|
|
|
var pass1Filter = $"loudnorm=I={iStr}:TP={tpStr}:LRA={lraStr}:print_format=json";
|
|
var pass1Args = new[]
|
|
{
|
|
"-hide_banner", "-i", inputFile,
|
|
"-af", pass1Filter,
|
|
"-f", "null", "-"
|
|
};
|
|
|
|
var pass1Res = await _runner.RunAsync(new ProcessSpec(ffmpeg, pass1Args), null, ct);
|
|
if (pass1Res.ExitCode != 0)
|
|
throw new InvalidOperationException($"오디오 라우드니스 1-pass 측정 실패 (ExitCode: {pass1Res.ExitCode}): {pass1Res.Stderr}");
|
|
var stats = ParseLoudnessStats(pass1Res.Stdout.Length > 0 ? pass1Res.Stdout : pass1Res.Stderr);
|
|
|
|
// Pass 2: Apply normalized audio
|
|
var pass2Filter = stats != null
|
|
? $"loudnorm=I={iStr}:TP={tpStr}:LRA={lraStr}:measured_I={stats.MeasuredI.ToString("F2", CultureInfo.InvariantCulture)}:measured_TP={stats.MeasuredTp.ToString("F2", CultureInfo.InvariantCulture)}:measured_LRA={stats.MeasuredLra.ToString("F2", CultureInfo.InvariantCulture)}:measured_thresh={stats.MeasuredThresh.ToString("F2", CultureInfo.InvariantCulture)}:linear=true"
|
|
: $"loudnorm=I={iStr}:TP={tpStr}:LRA={lraStr}";
|
|
|
|
var pass2Args = new[]
|
|
{
|
|
"-y", "-hide_banner", "-loglevel", "error",
|
|
"-i", inputFile,
|
|
"-af", pass2Filter,
|
|
"-c:v", "copy",
|
|
"-c:a", "aac", "-b:a", "192k",
|
|
outputFile
|
|
};
|
|
|
|
var pass2Res = await _runner.RunAsync(new ProcessSpec(ffmpeg, pass2Args), null, ct);
|
|
if (pass2Res.ExitCode != 0)
|
|
throw new InvalidOperationException($"오디오 정규화 실패: {pass2Res.Stderr}");
|
|
|
|
return outputFile;
|
|
}
|
|
|
|
public static MeasuredLoudness? ParseLoudnessStats(string text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
|
|
|
var startIdx = text.IndexOf('{');
|
|
var endIdx = text.LastIndexOf('}');
|
|
if (startIdx < 0 || endIdx <= startIdx) return null;
|
|
|
|
var json = text.Substring(startIdx, endIdx - startIdx + 1);
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
var root = doc.RootElement;
|
|
|
|
double i = 0, tp = 0, lra = 0, thresh = 0;
|
|
if (root.TryGetProperty("input_i", out var iEl)) double.TryParse(iEl.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out i);
|
|
if (root.TryGetProperty("input_tp", out var tpEl)) double.TryParse(tpEl.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out tp);
|
|
if (root.TryGetProperty("input_lra", out var lraEl)) double.TryParse(lraEl.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out lra);
|
|
if (root.TryGetProperty("input_thresh", out var thEl)) double.TryParse(thEl.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out thresh);
|
|
|
|
return new MeasuredLoudness(i, tp, lra, thresh);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public static string BuildSilenceRemoveFilter(double thresholdDb = -50.0, double minDurationSec = 0.5)
|
|
{
|
|
var thStr = thresholdDb.ToString("0.##", CultureInfo.InvariantCulture);
|
|
var durStr = minDurationSec.ToString("0.##", CultureInfo.InvariantCulture);
|
|
return $"silenceremove=start_periods=1:start_duration={durStr}:start_threshold={thStr}dB:stop_periods=1:stop_duration={durStr}:stop_threshold={thStr}dB";
|
|
}
|
|
}
|