201 lines
6.1 KiB
Python
201 lines
6.1 KiB
Python
import torch
|
|
|
|
|
|
class NormalizeAudio:
|
|
"""Normalize audio volume using peak or RMS normalization."""
|
|
|
|
CATEGORY = "audio"
|
|
FUNCTION = "normalize"
|
|
|
|
RETURN_TYPES = ("AUDIO",)
|
|
RETURN_NAMES = ("audio",)
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"audio": ("AUDIO",),
|
|
"method": (("peak", "rms"), {"default": "peak"}),
|
|
"target_dB": (
|
|
"FLOAT",
|
|
{
|
|
"default": -1.0,
|
|
"min": -60.0,
|
|
"max": 0.0,
|
|
"step": 0.1,
|
|
"tooltip": (
|
|
"Target level in dBFS. "
|
|
"0.0 = maximum digital level, "
|
|
"-1.0 = slight headroom (recommended for peak), "
|
|
"-14.0 = typical broadcast loudness (recommended for RMS)."
|
|
),
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
def normalize(self, audio, method="peak", target_dB=-1.0):
|
|
waveform = audio["waveform"] # [batch, channels, samples]
|
|
sample_rate = audio["sample_rate"]
|
|
|
|
target_linear = 10.0 ** (target_dB / 20.0)
|
|
|
|
if method == "peak":
|
|
waveform = self._peak_normalize(waveform, target_linear)
|
|
else:
|
|
waveform = self._rms_normalize(waveform, target_linear)
|
|
|
|
return ({"waveform": waveform, "sample_rate": sample_rate},)
|
|
|
|
@staticmethod
|
|
def _peak_normalize(waveform: torch.Tensor, target: float) -> torch.Tensor:
|
|
# Normalize per batch item so each clip is treated independently
|
|
peak = waveform.abs().amax(dim=(-2, -1), keepdim=True).clamp(min=1e-8)
|
|
return waveform * (target / peak)
|
|
|
|
@staticmethod
|
|
def _rms_normalize(waveform: torch.Tensor, target: float) -> torch.Tensor:
|
|
rms = waveform.pow(2).mean(dim=(-2, -1), keepdim=True).sqrt().clamp(min=1e-8)
|
|
scaled = waveform * (target / rms)
|
|
# Clamp to prevent clipping after RMS scaling
|
|
peak = scaled.abs().amax(dim=(-2, -1), keepdim=True)
|
|
if (peak > 1.0).any():
|
|
scaled = scaled / peak.clamp(min=1e-8)
|
|
return scaled
|
|
|
|
|
|
class AudioProperties:
|
|
"""Get audio properties: sample count, duration, sample rate, channel count.
|
|
|
|
If the audio input is None, isNone is True and numeric outputs are -1.
|
|
"""
|
|
|
|
CATEGORY = "audio"
|
|
FUNCTION = "get_properties"
|
|
|
|
RETURN_TYPES = ("INT", "FLOAT", "INT", "INT", "BOOLEAN")
|
|
RETURN_NAMES = ("samples", "duration", "sample_rate", "num_channels", "isNone")
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"optional": {
|
|
"audio": ("AUDIO",),
|
|
},
|
|
}
|
|
|
|
def get_properties(self, audio=None):
|
|
if audio is None:
|
|
return (-1, -1.0, -1, -1, True)
|
|
|
|
waveform = audio["waveform"] # [batch, channels, samples]
|
|
sample_rate = int(audio["sample_rate"])
|
|
samples = int(waveform.shape[-1])
|
|
num_channels = int(waveform.shape[-2])
|
|
duration = samples / sample_rate if sample_rate > 0 else 0.0
|
|
return (samples, float(duration), sample_rate, num_channels, False)
|
|
|
|
|
|
class SplitAudioChannels:
|
|
"""Split a multi-channel audio into individual mono channels.
|
|
|
|
Returns the first two channels as (left, right). For mono inputs both
|
|
outputs are identical. For >2 channels, only the first two are returned.
|
|
"""
|
|
|
|
CATEGORY = "audio"
|
|
FUNCTION = "split"
|
|
|
|
RETURN_TYPES = ("AUDIO", "AUDIO")
|
|
RETURN_NAMES = ("left", "right")
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"audio": ("AUDIO",),
|
|
},
|
|
}
|
|
|
|
def split(self, audio):
|
|
waveform = audio["waveform"] # [batch, channels, samples]
|
|
sample_rate = audio["sample_rate"]
|
|
num_channels = waveform.shape[-2]
|
|
|
|
left = waveform[..., 0:1, :]
|
|
if num_channels >= 2:
|
|
right = waveform[..., 1:2, :]
|
|
else:
|
|
right = left.clone()
|
|
|
|
return (
|
|
{"waveform": left.contiguous(), "sample_rate": sample_rate},
|
|
{"waveform": right.contiguous(), "sample_rate": sample_rate},
|
|
)
|
|
|
|
|
|
class MergeAudioChannels:
|
|
"""Merge two mono audio inputs into a single stereo audio.
|
|
|
|
Both inputs must share the same sample rate. If lengths differ, the
|
|
shorter one is zero-padded to match the longer.
|
|
"""
|
|
|
|
CATEGORY = "audio"
|
|
FUNCTION = "merge"
|
|
|
|
RETURN_TYPES = ("AUDIO",)
|
|
RETURN_NAMES = ("audio",)
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"left": ("AUDIO",),
|
|
"right": ("AUDIO",),
|
|
},
|
|
}
|
|
|
|
def merge(self, left, right):
|
|
lw = left["waveform"]
|
|
rw = right["waveform"]
|
|
lsr = int(left["sample_rate"])
|
|
rsr = int(right["sample_rate"])
|
|
|
|
if lsr != rsr:
|
|
raise ValueError(
|
|
f"MergeAudioChannels: sample rates differ ({lsr} vs {rsr})."
|
|
)
|
|
|
|
# Use first channel only from each input
|
|
lw = lw[..., 0:1, :]
|
|
rw = rw[..., 0:1, :]
|
|
|
|
# Pad shorter to match longer along the sample dimension
|
|
ll = lw.shape[-1]
|
|
rl = rw.shape[-1]
|
|
if ll < rl:
|
|
pad = torch.zeros(*lw.shape[:-1], rl - ll, dtype=lw.dtype, device=lw.device)
|
|
lw = torch.cat([lw, pad], dim=-1)
|
|
elif rl < ll:
|
|
pad = torch.zeros(*rw.shape[:-1], ll - rl, dtype=rw.dtype, device=rw.device)
|
|
rw = torch.cat([rw, pad], dim=-1)
|
|
|
|
merged = torch.cat([lw, rw], dim=-2)
|
|
return ({"waveform": merged.contiguous(), "sample_rate": lsr},)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"NormalizeAudio": NormalizeAudio,
|
|
"AudioProperties": AudioProperties,
|
|
"SplitAudioChannels": SplitAudioChannels,
|
|
"MergeAudioChannels": MergeAudioChannels,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"NormalizeAudio": "Normalize Audio",
|
|
"AudioProperties": "Audio Properties",
|
|
"SplitAudioChannels": "Split Audio Channels",
|
|
"MergeAudioChannels": "Merge Audio Channels",
|
|
}
|