releases/src/Paca.Core/Platform/AudioNormalizer.cs
Yun Chan 8677853822
Some checks failed
release / test (push) Failing after 14m5s
release / windows (push) Has been cancelled
release / android (push) Has been cancelled
release / linux (push) Has been cancelled
release / macos (push) Has been cancelled
test(core/browser/platform): 26 extreme RED/GREEN TDD cycles and full regression hardening (3,235 PASS)
2026-09-18 23:32:32 +09:00

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";
}
}