|
|
import subprocess |
|
|
import json |
|
|
import re |
|
|
from pathlib import Path |
|
|
from src.logger_config import logger |
|
|
|
|
|
|
|
|
def normalize_loudness( |
|
|
input_path: str, |
|
|
target_lufs: float = -10.0, |
|
|
target_tp: float = -1.0, |
|
|
target_lra: float = 7.0, |
|
|
overwrite: bool = True |
|
|
): |
|
|
""" |
|
|
Two-pass EBU R128 loudness normalization with custom target LUFS and true peak |
|
|
+ Verification pass to print final LUFS/LRA/TP values. |
|
|
|
|
|
Args: |
|
|
target_lufs: Target integrated loudness (-12 to -8 LUFS recommended) |
|
|
target_tp: Target true peak in dBTP (-1.0 dB as per requirement) |
|
|
target_lra: Target loudness range (7 is typical, 11 for dynamic content) |
|
|
""" |
|
|
input_path = Path(input_path) |
|
|
stem = input_path.stem |
|
|
output_path = Path(f"/tmp/{stem}.mp4") |
|
|
|
|
|
if output_path.exists() and overwrite: |
|
|
output_path.unlink() |
|
|
|
|
|
|
|
|
logger.debug("π Step 1: Analyzing loudness...") |
|
|
cmd_analysis = [ |
|
|
"ffmpeg", |
|
|
"-hide_banner", "-y", |
|
|
"-i", str(input_path), |
|
|
"-af", f"loudnorm=I={target_lufs}:TP={target_tp}:LRA={target_lra}:print_format=json", |
|
|
"-f", "null", "-" |
|
|
] |
|
|
|
|
|
result = subprocess.run(cmd_analysis, capture_output=True, text=True) |
|
|
match = re.search(r"\{[\s\S]*\}", result.stderr) |
|
|
if not match: |
|
|
raise RuntimeError("β Failed to parse loudnorm JSON output.") |
|
|
|
|
|
loudnorm_data = json.loads(match.group(0)) |
|
|
logger.debug("π Analysis data:") |
|
|
logger.debug(json.dumps(loudnorm_data, indent=4)) |
|
|
|
|
|
|
|
|
logger.debug("\nποΈ Step 2: Applying normalization...") |
|
|
ln = loudnorm_data |
|
|
loudnorm_filter = ( |
|
|
f"loudnorm=I={target_lufs}:TP={target_tp}:LRA={target_lra}:" |
|
|
f"measured_I={ln['input_i']}:measured_TP={ln['input_tp']}:" |
|
|
f"measured_LRA={ln['input_lra']}:measured_thresh={ln['input_thresh']}:" |
|
|
f"offset={ln['target_offset']}:linear=true:print_format=summary" |
|
|
) |
|
|
|
|
|
cmd_apply = [ |
|
|
"ffmpeg", |
|
|
"-hide_banner", "-y" if overwrite else "-n", |
|
|
"-i", str(input_path), |
|
|
"-af", loudnorm_filter, |
|
|
"-c:v", "copy", |
|
|
"-c:a", "aac", |
|
|
"-b:a", "192k", |
|
|
str(output_path) |
|
|
] |
|
|
subprocess.run(cmd_apply, check=True) |
|
|
logger.debug(f"β
Normalized file saved at: {output_path}") |
|
|
|
|
|
|
|
|
logger.debug("\nπ Step 3: Verifying final loudness...") |
|
|
cmd_verify = [ |
|
|
"ffmpeg", |
|
|
"-hide_banner", |
|
|
"-i", str(output_path), |
|
|
"-af", f"loudnorm=I={target_lufs}:TP={target_tp}:LRA={target_lra}:print_format=json", |
|
|
"-f", "null", "-" |
|
|
] |
|
|
|
|
|
result_verify = subprocess.run(cmd_verify, capture_output=True, text=True) |
|
|
match_verify = re.search(r"\{[\s\S]*\}", result_verify.stderr) |
|
|
if match_verify: |
|
|
verify_data = json.loads(match_verify.group(0)) |
|
|
logger.debug("\nβ
Final Loudness Stats:") |
|
|
logger.debug(f"Integrated Loudness (LUFS): {verify_data['input_i']}") |
|
|
logger.debug(f"True Peak (dBTP): {verify_data['input_tp']}") |
|
|
logger.debug(f"Loudness Range (LRA): {verify_data['input_lra']}") |
|
|
else: |
|
|
logger.warning("β οΈ Could not extract final loudness stats.") |
|
|
|
|
|
return output_path |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
|
|
|
|
|
|
|
|
normalize_loudness("output_normalized.mp4") |