implemented 15,12,18 didn't check 18
This commit is contained in:
@ -158,3 +158,125 @@ def detect_silence_ranges(input_path: str, min_silence_ms: int, silence_db: floa
|
||||
silence_db,
|
||||
)
|
||||
return ranges
|
||||
|
||||
|
||||
def normalize_audio(
|
||||
input_path: str,
|
||||
output_path: str = "",
|
||||
target_lufs: float = -14.0,
|
||||
) -> str:
|
||||
"""
|
||||
Normalize audio loudness to a target LUFS level using FFmpeg's loudnorm filter.
|
||||
|
||||
Args:
|
||||
input_path: Path to the input audio/video file.
|
||||
output_path: Path for the normalized output. Auto-generated if empty.
|
||||
target_lufs: Target integrated loudness in LUFS.
|
||||
Common targets: -14 (YouTube), -16 (Spotify), -23 (broadcast).
|
||||
|
||||
Returns: path to the normalized audio file.
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
inp = Path(input_path)
|
||||
if not output_path:
|
||||
output_path = str(inp.with_stem(inp.stem + "_normalized"))
|
||||
|
||||
# Two-pass loudnorm: first pass measures loudness, second pass applies correction.
|
||||
# First pass: measure only (print_format=json)
|
||||
measure_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(inp),
|
||||
"-af", f"loudnorm=I={target_lufs}:LRA=7:TP=-1.5:print_format=json",
|
||||
"-f", "null",
|
||||
"-",
|
||||
]
|
||||
logger.info("Running loudnorm first pass (measurement): %s", " ".join(measure_cmd))
|
||||
measure_result = subprocess.run(measure_cmd, capture_output=True, text=True)
|
||||
|
||||
# Parse measured parameters from stderr (loudnorm outputs JSON to stderr)
|
||||
measured = _parse_loudnorm_measurement(measure_result.stderr)
|
||||
if not measured:
|
||||
logger.warning(
|
||||
"loudnorm measurement failed or produced no output; "
|
||||
"falling back to single-pass normalization"
|
||||
)
|
||||
# Single-pass fallback
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(inp),
|
||||
"-af", f"loudnorm=I={target_lufs}:LRA=7:TP=-1.5",
|
||||
"-c:v", "copy",
|
||||
output_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Audio normalization failed: {result.stderr[-300:]}")
|
||||
logger.info("Single-pass normalized audio saved to %s", output_path)
|
||||
return output_path
|
||||
|
||||
# Second pass: apply normalization using measured values
|
||||
input_i = measured.get("input_i", target_lufs)
|
||||
input_lra = measured.get("input_lra", 7.0)
|
||||
input_tp = measured.get("input_tp", -1.5)
|
||||
input_thresh = measured.get("input_thresh", -30.0)
|
||||
offset = measured.get("target_offset", 0.0)
|
||||
|
||||
apply_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(inp),
|
||||
"-af",
|
||||
(
|
||||
f"loudnorm=I={target_lufs}:LRA=7:TP=-1.5:"
|
||||
f"measured_I={input_i}:measured_LRA={input_lra}:"
|
||||
f"measured_TP={input_tp}:measured_thresh={input_thresh}:"
|
||||
f"offset={offset}:linear=true:print_format=summary"
|
||||
),
|
||||
"-c:v", "copy",
|
||||
output_path,
|
||||
]
|
||||
logger.info("Running loudnorm second pass (apply): %s", " ".join(apply_cmd))
|
||||
result = subprocess.run(apply_cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Audio normalization (apply) failed: {result.stderr[-300:]}")
|
||||
|
||||
logger.info(
|
||||
"Normalized audio saved to %s (target=%s LUFS, measured_I=%s)",
|
||||
output_path,
|
||||
target_lufs,
|
||||
input_i,
|
||||
)
|
||||
return output_path
|
||||
|
||||
|
||||
def _parse_loudnorm_measurement(stderr_output: str) -> dict:
|
||||
"""Parse loudnorm JSON measurement output from FFmpeg stderr."""
|
||||
import json
|
||||
|
||||
# loudnorm prints JSON block between "Parsed_loudnorm" lines
|
||||
lines = stderr_output.split("\n")
|
||||
json_lines = []
|
||||
in_json = False
|
||||
for line in lines:
|
||||
if "Parsed_loudnorm" in line and "}" in line:
|
||||
# Single-line JSON
|
||||
try:
|
||||
start = line.index("{")
|
||||
end = line.rindex("}") + 1
|
||||
return json.loads(line[start:end])
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
if "{" in line and not in_json:
|
||||
in_json = True
|
||||
if in_json:
|
||||
json_lines.append(line)
|
||||
if in_json and "}" in line:
|
||||
in_json = False
|
||||
break
|
||||
|
||||
if json_lines:
|
||||
try:
|
||||
return json.loads("".join(json_lines))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user