204 lines
6.1 KiB
Python
204 lines
6.1 KiB
Python
# audio_normalize.py
|
|
# Place this file in: ComfyUI/custom_nodes/ComfyUI-AudioNormalize/audio_normalize.py
|
|
|
|
import numpy as np
|
|
import torch
|
|
|
|
class AudioNormalizeLUFS:
|
|
"""
|
|
A ComfyUI node for normalizing audio to a target LUFS level with safe fallbacks.
|
|
If no valid source audio is available for analysis, normalization defaults to
|
|
user-specified target behavior without extreme gain.
|
|
"""
|
|
|
|
MIN_REF_LUFS = -30.0
|
|
MAX_DB_CHANGE = 6.0
|
|
WINDOW_SEC = 3.0
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"audio": ("AUDIO",),
|
|
"target_lufs": ("FLOAT", {
|
|
"default": -20.0,
|
|
"min": -70.0,
|
|
"max": 0.0,
|
|
"step": 0.1,
|
|
"display": "number"
|
|
}),
|
|
"start_time": ("FLOAT", {
|
|
"default": 0.0,
|
|
"min": 0.0,
|
|
"max": 3600.0,
|
|
"step": 0.01,
|
|
"display": "number"
|
|
}),
|
|
"end_time": ("FLOAT", {
|
|
"default": 0.0,
|
|
"min": 0.0,
|
|
"max": 3600.0,
|
|
"step": 0.01,
|
|
"display": "number"
|
|
}),
|
|
"apply_to": (["full_track", "selection_only", "auto_balance"], {
|
|
"default": "full_track"
|
|
})
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("AUDIO",)
|
|
FUNCTION = "normalize_audio"
|
|
CATEGORY = "audio/processing"
|
|
|
|
# -------------------------
|
|
# Utility helpers
|
|
# -------------------------
|
|
|
|
def is_valid_audio(self, audio_np):
|
|
"""
|
|
Returns False if audio is empty or effectively silent.
|
|
"""
|
|
if audio_np.size == 0:
|
|
return False
|
|
if np.max(np.abs(audio_np)) < 1e-6:
|
|
return False
|
|
return True
|
|
|
|
def calculate_lufs(self, audio_data, sample_rate):
|
|
"""
|
|
Calculate approximate integrated LUFS using RMS.
|
|
Returns None for silent or invalid audio.
|
|
"""
|
|
if isinstance(audio_data, torch.Tensor):
|
|
audio_data = audio_data.cpu().numpy()
|
|
|
|
if audio_data.size == 0:
|
|
return None
|
|
|
|
rms = np.sqrt(np.mean(audio_data ** 2))
|
|
|
|
if rms < 1e-10:
|
|
return None
|
|
|
|
# Approximate LUFS (not true K-weighted LUFS)
|
|
lufs = 20 * np.log10(rms) - 0.691
|
|
return lufs
|
|
|
|
# -------------------------
|
|
# Main processing
|
|
# -------------------------
|
|
|
|
def normalize_audio(self, audio, target_lufs, start_time, end_time, apply_to):
|
|
waveform = audio["waveform"]
|
|
sample_rate = audio["sample_rate"]
|
|
|
|
audio_np = waveform.cpu().numpy()
|
|
|
|
num_samples = audio_np.shape[-1]
|
|
duration = num_samples / sample_rate
|
|
|
|
# Clamp time range
|
|
if end_time <= 0 or end_time > duration:
|
|
end_time = duration
|
|
if start_time >= end_time:
|
|
start_time = 0.0
|
|
|
|
start_sample = int(start_time * sample_rate)
|
|
end_sample = int(end_time * sample_rate)
|
|
|
|
audio_normalized = audio_np.copy()
|
|
selection = audio_np[..., start_sample:end_sample]
|
|
|
|
# -------------------------
|
|
# AUTO BALANCE MODE
|
|
# -------------------------
|
|
if apply_to == "auto_balance":
|
|
# Use sliding window before selection
|
|
window_samples = int(self.WINDOW_SEC * sample_rate)
|
|
ref_start = max(0, start_sample - window_samples)
|
|
reference = audio_np[..., ref_start:start_sample]
|
|
|
|
# Calculate LUFS
|
|
ref_lufs = self.calculate_lufs(reference, sample_rate) if self.is_valid_audio(reference) else None
|
|
sel_lufs = self.calculate_lufs(selection, sample_rate) if self.is_valid_audio(selection) else None
|
|
|
|
if sel_lufs is None:
|
|
# Nothing usable → no change
|
|
gain_linear = 1.0
|
|
|
|
elif ref_lufs is not None:
|
|
# Reference-based matching
|
|
ref_lufs = max(ref_lufs, self.MIN_REF_LUFS)
|
|
lufs_diff = ref_lufs - sel_lufs
|
|
|
|
lufs_diff = np.clip(
|
|
lufs_diff,
|
|
-self.MAX_DB_CHANGE,
|
|
self.MAX_DB_CHANGE
|
|
)
|
|
|
|
gain_linear = 10 ** (lufs_diff / 20)
|
|
|
|
else:
|
|
# Fallback: normalize selection to target LUFS
|
|
lufs_diff = target_lufs - sel_lufs
|
|
|
|
lufs_diff = np.clip(
|
|
lufs_diff,
|
|
-self.MAX_DB_CHANGE,
|
|
self.MAX_DB_CHANGE
|
|
)
|
|
|
|
gain_linear = 10 ** (lufs_diff / 20)
|
|
|
|
audio_normalized[..., start_sample:end_sample] *= gain_linear
|
|
|
|
|
|
# -------------------------
|
|
# NORMAL NORMALIZATION
|
|
# -------------------------
|
|
else:
|
|
if self.is_valid_audio(selection):
|
|
current_lufs = self.calculate_lufs(selection, sample_rate)
|
|
else:
|
|
current_lufs = None
|
|
|
|
# Fallback: no valid reference audio
|
|
if current_lufs is None:
|
|
gain_linear = 1.0
|
|
else:
|
|
gain_linear = 10 ** ((target_lufs - current_lufs) / 20)
|
|
|
|
if apply_to == "full_track":
|
|
audio_normalized *= gain_linear
|
|
else: # selection_only
|
|
audio_normalized[..., start_sample:end_sample] *= gain_linear
|
|
|
|
# -------------------------
|
|
# Anti-clipping safeguard
|
|
# -------------------------
|
|
max_val = np.max(np.abs(audio_normalized))
|
|
if max_val > 1.0:
|
|
audio_normalized = audio_normalized / max_val * 0.99
|
|
|
|
normalized_waveform = torch.from_numpy(audio_normalized).to(waveform.device)
|
|
|
|
return ({
|
|
"waveform": normalized_waveform,
|
|
"sample_rate": sample_rate
|
|
},)
|
|
|
|
|
|
# -------------------------
|
|
# Node registration
|
|
# -------------------------
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"AudioNormalizeLUFS": AudioNormalizeLUFS
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"AudioNormalizeLUFS": "Audio Normalize (LUFS)"
|
|
}
|