add some audio process nodes

This commit is contained in:
LeonGu
2025-05-06 11:05:30 +08:00
parent 32825a7263
commit 33aa83b141
4 changed files with 427 additions and 12 deletions
+26 -1
View File
@@ -1,5 +1,30 @@
from .videochat.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# from .videochat.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
from .videochat.nodes import ModelLoader, VideoSummary, LoadVideoUpload
from .nodes.audio import ResampleAudio, ConcatAudio, SplitAudio, JoinAudio, AudioCombine, ConvertAudioChannels
NODE_CLASS_MAPPINGS = {
"VCW_ModelLoader": ModelLoader,
"VCW_VideoSummary": VideoSummary,
"VCW_LoadVideo": LoadVideoUpload,
"ResampleAudio": ResampleAudio,
"ConcatAudio": ConcatAudio,
"SplitAudio": SplitAudio,
"JoinAudio": JoinAudio,
"AudioCombine": AudioCombine,
"ConvertAudioChannels": ConvertAudioChannels,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VCW_ModelLoader": "Load Model (VideoChat)",
"VCW_VideoSummary": "Video Summary (VideoChat)",
"VCW_LoadVideo": "Load Video (Upload) (VideoChat) ",
"ResampleAudio": "Resample Audio",
"ConcatAudio": "Concat Audio",
"SplitAudio": "Split Audio",
"JoinAudio": "Join Audio",
"AudioCombine": "Audio Combine",
"ConvertAudioChannels": "Convert Audio Channels",
}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+375
View File
@@ -0,0 +1,375 @@
import sys
import warnings
from typing import Any, Dict
import torch
import torchaudio
from torchaudio.transforms import Resample
from typing import TypedDict
from typing import Tuple
from torch import Tensor
import comfy.model_management
# pip install git+https://git@github.com/facebookresearch/audiocraft#egg=audiocraft
# from audiocraft.data.audio_utils import convert_audio
# from .node_def import BASE_NODE_CATEGORY, AudioData
BASE_NODE_CATEGORY = "jida"
AudioData = Dict[str, Any]
NODE_CATEGORY = BASE_NODE_CATEGORY + "/audio"
class AUDIO(TypedDict):
"""
Required Fields:
waveform (torch.Tensor): A tensor containing the audio data. Shape: [Batch, Channels, frames].
sample_rate (int): The sample rate of the audio data.
"""
waveform: Tensor
sample_rate: int
def convert_audio_channels(wav: torch.Tensor, channels: int = 2) -> torch.Tensor:
"""Convert audio to the given number of channels.
Args:
wav (torch.Tensor): Audio wave of shape [B, C, T].
channels (int): Expected number of channels as output.
Returns:
torch.Tensor: Downmixed or unchanged audio wave [B, C, T].
"""
*shape, src_channels, length = wav.shape
if src_channels == channels:
pass
elif channels == 1:
# Case 1:
# The caller asked 1-channel audio, and the stream has multiple
# channels, downmix all channels.
wav = wav.mean(dim=-2, keepdim=True)
elif src_channels == 1:
# Case 2:
# The caller asked for multiple channels, but the input file has
# a single channel, replicate the audio over all channels.
wav = wav.expand(*shape, channels, length)
elif src_channels >= channels:
# Case 3:
# The caller asked for multiple channels, and the input file has
# more channels than requested. In that case return the first channels.
wav = wav[..., :channels, :]
else:
# Case 4: What is a reasonable choice here?
raise ValueError('The audio file has less channels than requested but is not mono.')
return wav
# def convert_audio(wav: torch.Tensor, from_rate: float,
# to_rate: float, to_channels: int) -> torch.Tensor:
# """Convert audio to new sample rate and number of audio channels."""
# wav = julius.resample_frac(wav, int(from_rate), int(to_rate))
# wav = convert_audio_channels(wav, to_channels)
# return wav
# https://github.com/christian-byrne/audio-separation-nodes-comfyui/blob/master/src/combine_video_with_audio.py
# https://github.com/kale4eat/ComfyUI-speech-dataset-toolkit/blob/main/edit_nodes.py
# https://github.com/eigenpunk/ComfyUI-audio/blob/main/util_nodes.py
# https://github.com/facebookresearch/audiocraft/blob/main/audiocraft/data/audio_utils.py
class ConvertAudioChannels:
"""
convert an AUDIO's number of channels
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
"to_channels": ("INT", {"default": 1, "min": 1, "max": 2, "step": 1}),
}
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "convert"
CATEGORY = NODE_CATEGORY
DESCRIPTION = "Convert an AUDIO's number of channels."
def convert(self, audio, to_channels):
# converted = []
clip = audio["waveform"]
expand_dim = len(clip.shape) == 2
if expand_dim:
clip = clip.unsqueeze(0)
# conv_clip = convert_audio(clip, from_rate, to_rate, to_channels)
conv_clip = convert_audio_channels(clip, to_channels)
conv_clip = conv_clip.squeeze(0) if expand_dim else conv_clip
audio_data = {"waveform": conv_clip, "sample_rate": audio["sample_rate"]}
return (audio_data,)
# combine two audio tracks(mono or stereo) by overlaying their waveforms
class AudioCombine:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio_1": ("AUDIO",),
"audio_2": ("AUDIO",),
},
"optional": {
"method": (
["add", "mean", "subtract", "multiply", "divide"],
{
"default": "add",
"tooltip": "The method used to combine the audio waveforms.",
},
)
},
}
FUNCTION = "main"
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
CATEGORY = NODE_CATEGORY
DESCRIPTION = "Combine two audio tracks by overlaying their waveforms."
def main(
self,
audio_1: AUDIO,
audio_2: AUDIO,
method: str = "add",
) -> Tuple[AUDIO]:
waveform_1: torch.Tensor = audio_1["waveform"]
input_sample_rate_1: int = audio_1["sample_rate"]
waveform_2: torch.Tensor = audio_2["waveform"]
input_sample_rate_2: int = audio_2["sample_rate"]
# Resample the audio if the sample rates are different
if input_sample_rate_1 != input_sample_rate_2:
device: torch.device = comfy.model_management.get_torch_device()
if input_sample_rate_1 < input_sample_rate_2:
resample = Resample(input_sample_rate_1, input_sample_rate_2).to(device)
waveform_1: torch.Tensor = resample(waveform_1.to(device))
waveform_1.to("cpu")
output_sample_rate = input_sample_rate_2
else:
resample = Resample(input_sample_rate_2, input_sample_rate_1).to(device)
waveform_2: torch.Tensor = resample(waveform_2.to(device))
waveform_2.to("cpu")
output_sample_rate = input_sample_rate_1
else:
output_sample_rate = input_sample_rate_1
# Ensure the audio is the same length
min_length = min(waveform_1.shape[-1], waveform_2.shape[-1])
if waveform_1.shape[-1] != min_length:
waveform_1 = waveform_1[..., :min_length]
if waveform_2.shape[-1] != min_length:
waveform_2 = waveform_2[..., :min_length]
match method:
case "add":
waveform = waveform_1 + waveform_2
case "subtract":
waveform = waveform_1 - waveform_2
case "multiply":
waveform = waveform_1 * waveform_2
case "divide":
waveform = waveform_1 / waveform_2
case "mean":
waveform = (waveform_1 + waveform_2) / 2
return (
{
"waveform": waveform,
"sample_rate": output_sample_rate,
},
)
# join/combine multiple audio tracks into a single audio track
class JoinAudio:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audios": ("AUDIO",),
"silent_interval": ("FLOAT", {"default": 0.0, "step": 0.001}),
}
}
CATEGORY = NODE_CATEGORY
INPUT_IS_LIST = True
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "join_audio"
DESCRIPTION = "Join/combine multiple audio tracks into a single audio track."
def join_audio(self, audios: list[AudioData], silent_interval: float):
if len(audios) == 0:
raise ValueError("Audios size is zero.")
if not all(
[audio["sample_rate"] == audios[0]["sample_rate"] for audio in audios]
):
raise ValueError("sample_rate must be the same.")
if not all(
[
audio["waveform"].size(1) == audios[0]["waveform"].size(1)
for audio in audios
]
):
raise ValueError("All audio must be either stereo or monaural.")
samples = int(silent_interval * audios[0]["sample_rate"])
interval = torch.zeros((audios[0]["waveform"].size(1), samples))
new_tensors = []
for audio in audios:
new_tensors.append(audio)
new_tensors.append(interval)
new_tensors.pop()
new_waveforms = torch.concat(new_tensors, dim=-1)
new_audio = {"waveform": new_waveforms, "sample_rate": audios[0]["sample_rate"]}
return (new_audio,)
# split an audio track into two audio tracks(left and right)
class SplitAudio:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
"second": ("FLOAT", {"default": 0.0, "step": 0.001}),
}
}
CATEGORY = NODE_CATEGORY
RETURN_TYPES = ("AUDIO", "AUDIO")
RETURN_NAMES = ("audio1", "audio2")
FUNCTION = "split_audio"
DESCRIPTION = "Split an audio track into two audio tracks(left and right)."
def split_audio(self, audio: AudioData, second: float):
sample = max(0, int(second * audio["sample_rate"]) - 1)
view1 = audio["waveform"][..., :sample]
view2 = audio["waveform"][..., sample:]
audio1 = {
"waveform": view1.detach().clone(),
"sample_rate": audio["sample_rate"],
}
audio2 = {
"waveform": view2.detach().clone(),
"sample_rate": audio["sample_rate"],
}
return (audio1, audio2)
# concatenate two audio tracks
class ConcatAudio:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio1": ("AUDIO",),
"audio2": ("AUDIO",),
"silent_interval": ("FLOAT", {"default": 0.0, "step": 0.001}),
}
}
CATEGORY = NODE_CATEGORY
DESCRIPTION = "Concatenate two audio tracks."
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "concat_audio"
def concat_audio(
self, audio1: AudioData, audio2: AudioData, silent_interval: float
):
if audio1["sample_rate"] != audio2["sample_rate"]:
raise ValueError(
f"sample_rate is different.\naudio1: {0}\naudio2: {1}".format(
audio1["sample_rate"], audio2["sample_rate"]
)
)
audio1_ch = "stereo" if audio1["waveform"].size(1) > 1 else "monaural"
audio2_ch = "stereo" if audio2["waveform"].size(1) > 1 else "monaural"
if not audio1_ch == audio2_ch:
raise ValueError(f"naudio1 is {audio1_ch} but audio2 is {audio2_ch}")
samples = int(silent_interval * audio1["sample_rate"])
interval = torch.zeros(
(audio1["waveform"].size(0), audio1["waveform"].size(1), samples)
)
new_waveform = torch.concat(
[audio1["waveform"], interval, audio2["waveform"]], dim=-1
)
new_audio = {"waveform": new_waveform, "sample_rate": audio1["sample_rate"]}
return (new_audio,)
# resample an audio track
class ResampleAudio:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
"new_freq": ("INT", {"default": 32000, "min": 1, "max": 2 ** 32}),
"resampling_method": (["sinc_interp_hann", "sinc_interp_kaiser"],),
"lowpass_filter_width": (
"INT",
{"default": 6, "min": 0, "max": 2**32},
),
"rolloff": (
"FLOAT",
{
"default": 0.99,
"min": 0,
"max": sys.float_info.max,
},
),
},
"optional": {
"beta": (
"FLOAT",
{
"default": 14.769656459379492,
"min": 0.0,
"max": sys.float_info.max,
},
),
},
}
CATEGORY = NODE_CATEGORY
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "resample"
DESCRIPTION = "Resample an audio track."
def resample(
self,
audio: AudioData,
new_freq: int,
resampling_method: str,
lowpass_filter_width: int,
rolloff: float,
beta=None,
):
transform = torchaudio.transforms.Resample(
orig_freq=audio["sample_rate"],
new_freq=new_freq,
resampling_method=resampling_method,
lowpass_filter_width=lowpass_filter_width,
rolloff=rolloff,
beta=beta,
)
new_audio = {"waveform": transform(audio["waveform"]), "sample_rate": new_freq}
return (new_audio,)
+26
View File
@@ -0,0 +1,26 @@
import wave
def is_stereo(wav_file_path):
"""
Checks if a WAV file is stereo or mono.
Args:
wav_file_path: Path to the WAV file.
Returns:
True if the WAV file is stereo, False if it's mono.
"""
try:
with wave.open(wav_file_path, 'rb') as wf:
num_channels = wf.getnchannels()
return num_channels == 2
except wave.Error as e:
print(f"Error processing {wav_file_path}: {e}")
return False
# Example usage
file_path = 'path/to/your/audio.wav'
if is_stereo(file_path):
print(f"{file_path} is a stereo WAV file.")
else:
print(f"{file_path} is a mono WAV file.")
-11
View File
@@ -173,14 +173,3 @@ class LoadVideoUpload:
return True
NODE_CLASS_MAPPINGS = {
"VCW_ModelLoader": ModelLoader,
"VCW_VideoSummary": VideoSummary,
"VCW_LoadVideo": LoadVideoUpload,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VCW_ModelLoader": "Load Model (VideoChat)",
"VCW_VideoSummary": "Video Summary (VideoChat)",
"VCW_LoadVideo": "Load Video (Upload) (VideoChat) ",
}