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); /// /// 29차: EBU R128 2-pass 오디오 라우드니스 정규화 및 무음 트리밍 엔진. /// public sealed class AudioNormalizer { private readonly IProcessRunner _runner; public AudioNormalizer(IProcessRunner runner) { _runner = runner ?? throw new ArgumentNullException(nameof(runner)); } public async Task 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"; } }