From a45fe89fb6200d5f746d724dde84bafb18db0cb4 Mon Sep 17 00:00:00 2001 From: "Salvador E. Tropea" Date: Tue, 3 Jun 2025 11:23:45 -0300 Subject: [PATCH] First release - 5 nodes - 'Batch Audios' - 'Audio Channel Converter' - 'Audio Channel Conv and Resampler' - 'Audio Resampler' - 'Select Audio from Batch' --- .flake8 | 8 + .gitignore | 9 +- .pre-commit-config.yaml | 43 ++ README.md | 128 ++++- __init__.py | 26 + .../audio_batch_select_example.jpg | Bin 0 -> 9461 bytes .../audio_batch_select_example.json | 1 + example_workflows/resample_force_stereo.jpg | Bin 0 -> 14128 bytes example_workflows/resample_force_stereo.json | 1 + nodes_audio.py | 450 ++++++++++++++++++ pyproject.toml | 13 + requirements.txt | 6 + utils/ansi.py | 113 +++++ utils/logger.py | 84 ++++ utils/misc.py | 7 + 15 files changed, 884 insertions(+), 5 deletions(-) create mode 100644 .flake8 create mode 100644 .pre-commit-config.yaml create mode 100644 __init__.py create mode 100644 example_workflows/audio_batch_select_example.jpg create mode 100644 example_workflows/audio_batch_select_example.json create mode 100644 example_workflows/resample_force_stereo.jpg create mode 100644 example_workflows/resample_force_stereo.json create mode 100644 nodes_audio.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 utils/ansi.py create mode 100644 utils/logger.py create mode 100644 utils/misc.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..10dbcd5 --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +# .flake8 +[flake8] +max-line-length = 127 +# extend-ignore = E203, W503 +# Add plugins if you installed them via additional_dependencies: +# select = C,E,F,W,B,B950 # Example: using flake8-bugbear (B, B950) +# docstring-convention = google +# ... other flake8 or plugin settings diff --git a/.gitignore b/.gitignore index 7b004e5..89d1f24 100644 --- a/.gitignore +++ b/.gitignore @@ -174,9 +174,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the enitre vscode folder # .vscode/ @@ -191,4 +191,7 @@ cython_debug/ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files .cursorignore -.cursorindexingignore \ No newline at end of file +.cursorindexingignore + +*.dst +*.epr diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..dd05e9e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +# .pre-commit-config.yaml + +# Optional: Set a minimum pre-commit version +# min_pre_commit_version: '2.9.0' + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 # Use the latest stable version + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + # - id: check-added-large-files # Optional: good for catching accidental large file commits + +- repo: https://github.com/pycqa/flake8 + rev: 7.0.0 # Use the latest stable version of flake8 + hooks: + - id: flake8 + # Optional: specify arguments for flake8 + # args: ['--max-line-length=88', '--extend-ignore=E203'] + # It's often better to configure flake8 via .flake8, setup.cfg, or pyproject.toml + # so it's consistent whether run via pre-commit or manually. + additional_dependencies: [ + # Add any flake8 plugins you use here, e.g.: + # 'flake8-bugbear', + # 'flake8-comprehensions', + # 'flake8-docstrings', + # 'pep8-naming' + ] + +- repo: https://github.com/codespell-project/codespell + rev: v2.2.6 # Use the latest stable version + hooks: + - id: codespell + args: [ + # "--ignore-words-list=your,custom,words,here", + # Or point to a file: + # "--ignore-words=.codespellignore", + "--skip=*.json,*.lock,*.svg,*.css,*.html", # Skip file types less likely to need it + # "--check-filenames", + # "--check-hidden" + ] + # You can create a .codespellignore file with one word per line for words to ignore. diff --git a/README.md b/README.md index d3866fb..e98e87f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,126 @@ -# ComfyUI-AudioBatch -Audio batch, resampler and channel adjust nodes for ComfyUI +# ComfyUI Audio Batch & Utility Nodes + +This repository provides a set of custom nodes for ComfyUI focused on audio batching and common audio processing tasks like +channel conversion and resampling. These nodes are designed to help manage and prepare audio data within your ComfyUI +workflows, especially when dealing with multiple audio inputs or outputs. + +## Nodes + +### 1. Batch Audios + - **Display Name:** `Batch Audios` + - **Internal Name:** `SET_AudioBatch` + - **Category:** `audio/batch` + - **Description:** Takes two audio inputs (which can themselves be batches) and combines them into a single, larger audio batch. The node handles differences in sample rate, channel count, and length between the two inputs to produce a unified batch. + - **Inputs:** + - `audio1` (AUDIO): The first audio input. Can be a single audio item or a batch. + - `audio2` (AUDIO): The second audio input. Can be a single audio item or a batch. + - **Output:** + - `audio_batch` (AUDIO): A single audio object where the waveforms from `audio1` and `audio2` are concatenated along the batch dimension. + - **Behavior Details:** + - **Sample Rate:** All audio in the output batch will be resampled to match the sample rate of `audio1`. + - **Channels:** + - If both inputs are mono, the output batch will be mono. + - If one input is mono and the other is stereo, the mono input will be converted to "fake stereo" (by duplicating its channel), and the output batch will be stereo. + - If both inputs are stereo, the output batch will be stereo. + - For more complex multi-channel inputs, it defaults to the maximum channel count of the two inputs with a warning, as advanced downmixing is not performed. + - **Length (Samples):** All audio clips in the output batch will be padded with silence at the end to match the length of the longest clip (after any resampling). + - **Input Batch Handling:** If `audio1` has B1 items and `audio2` has B2 items, the output `audio_batch` will contain B1 + B2 items. + +### 2. Select Audio from Batch + - **Display Name:** `Select Audio from Batch` + - **Internal Name:** `SET_SelectAudioFromBatch` + - **Category:** `audio/batch` + - **Description:** Selects a single audio stream from an input audio batch based on a specified index. Provides options for handling out-of-range indices. + - **Inputs:** + - `audio_batch` (AUDIO): An audio batch (e.g., from the "Batch Audios" node). + - `index` (INT): The 0-based index of the audio stream to select from the batch. + - `behavior_out_of_range` (COMBO): What to do if the `index` is out of range: + - `silence_original_length` (default): Output silent audio with the same channel count and duration as items in the original batch. + - `silence_fixed_length`: Output silent audio with a duration specified by `silence_duration_seconds`. + - `error`: Raise an error (which will halt the workflow and display an error in ComfyUI). + - `silence_duration_seconds` (FLOAT): The duration of the silent audio if `behavior_out_of_range` is set to `silence_fixed_length`. + - **Output:** + - `selected_audio` (AUDIO): The selected audio stream (as a batch of 1) or silent audio if the index was out of range (and behavior was not "error"). + +### 3. Audio Channel Converter + - **Display Name:** `Audio Channel Converter` + - **Internal Name:** `SET_AudioChannelConverter` + - **Category:** `audio/conversion` + - **Description:** Converts the channel layout of an input audio (e.g., mono to stereo, stereo to mono). Handles batches. + - **Inputs:** + - `audio` (AUDIO): The input audio. + - `channel_conversion` (COMBO): The desired channel conversion strategy: + - `keep` (default): No changes are made to the channel count. Logs a warning if input has more than 2 channels. + - `stereo_to_mono`: Converts stereo (or multi-channel) audio to mono by averaging all input channels. If already mono, no change. + - `mono_to_stereo`: Converts mono audio to "fake stereo" by duplicating the mono channel. If already stereo, no change. For multi-channel (>2) inputs, it takes the first channel and duplicates it to create stereo. + - `force_mono`: Always converts the input to mono by averaging all channels, regardless of the original channel count. + - `force_stereo`: + - If input is mono, converts to "fake stereo". + - If input is stereo, no change. + - If input has more than 2 channels, it takes the first channel and duplicates it to create stereo. + - **Output:** + - `audio_out` (AUDIO): The audio with the converted channel layout. The batch size and sample rate are preserved. + +### 4. Audio Resampler + - **Display Name:** `Audio Resampler` + - **Internal Name:** `SET_AudioResampler` + - **Category:** `audio/conversion` + - **Description:** Resamples the input audio to a specified target sample rate using `torchaudio.transforms.Resample`. Handles batches. + - **Inputs:** + - `audio` (AUDIO): The input audio. + - `target_sample_rate` (INT): The desired sample rate in Hz (e.g., 44100, 48000, 16000). If set to 0 or if it matches the original sample rate, resampling is skipped. + - **Output:** + - `audio_out` (AUDIO): The resampled audio. The batch size and channel count are preserved. + +### 5. Audio Channel Conv and Resampler + - **Display Name:** `Audio Channel Conv and Resampler` + - **Internal Name:** `SET_AudioChannelConvResampler` + - **Category:** `audio/conversion` + - **Description:** A convenience node that combines channel conversion and resampling into a single step. + - **Inputs:** + - `audio` (AUDIO): The input audio. + - `channel_conversion` (COMBO): Same options as the "Audio Channel Converter" node. + - `target_sample_rate` (INT): Same options as the "Audio Resampler" node. + - **Output:** + - `audio_out` (AUDIO): The audio after both channel conversion and resampling have been applied. + +## Installation + +1. Clone this repository into your `ComfyUI/custom_nodes/` directory: + ```bash + cd ComfyUI/custom_nodes/ + git clone https://github.com/set-soft/ComfyUI-AudioBatch ComfyUI-AudioBatch + ``` +2. Restart ComfyUI. + +The nodes should then appear under the "audio/batch" and "audio/conversion" categories in the "Add Node" menu. + +## Dependencies + +- PyTorch +- Torchaudio (for resampling and potentially other audio operations) +- NumPy (often used with audio data) + +These are typically already present in a standard ComfyUI environment. + +## Usage Notes + +- **AUDIO Type:** These nodes work with ComfyUI's standard "AUDIO" data type, which is a Python dictionary containing: + - `'waveform'`: A `torch.Tensor` of shape `(batch_size, num_channels, num_samples)`. + - `'sample_rate'`: An `int` representing the sample rate in Hz. +- **Logging:** The nodes use Python's `logging` module. Debug messages can be helpful for understanding the transformations being applied. + You can control log verbosity through ComfyUI's startup arguments (e.g., `--preview-method auto --verbose` for more detailed ComfyUI logs + which might also affect custom node loggers if they are configured to inherit levels). The logger name used is "AudioBatch". + You can force debugging level for these nodes defining the `AUDIOBATCH_NODES_DEBUG` environment variable to `1`. + +## Future Improvements / TODO + +- Add more sophisticated downmixing options for multi-channel audio (e.g., 5.1 to stereo). +- Allow user to choose padding value (e.g., silence, edge, reflect) for length matching in "Batch Audios". +- Option in "Batch Audios" to truncate to shortest instead of padding to longest. +- More options for stereo-to-mono conversion (e.g., take left channel, take right channel). +- If you are interested on them, please open an issue. + +## License + +[GPL-3.0](LICENSE) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..19f2aa7 --- /dev/null +++ b/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial +# License: GPL-3.0 +# Project: ComfyUI-AudioBatch +from . import nodes_audio +import inspect +import logging +from .utils.misc import NODES_NAME + +init_logger = logging.getLogger(NODES_NAME + ".__init__") + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +for name, obj in inspect.getmembers(nodes_audio): + if not inspect.isclass(obj) or not hasattr(obj, "INPUT_TYPES"): + continue + assert hasattr(obj, "UNIQUE_NAME"), f"No name for {obj.__name__}" + NODE_CLASS_MAPPINGS[obj.UNIQUE_NAME] = obj + NODE_DISPLAY_NAME_MAPPINGS[obj.UNIQUE_NAME] = obj.DISPLAY_NAME + +init_logger.info(f"Registering {len(NODE_CLASS_MAPPINGS)} node(s).") +init_logger.debug(f"{list(NODE_DISPLAY_NAME_MAPPINGS.values())}") + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/example_workflows/audio_batch_select_example.jpg b/example_workflows/audio_batch_select_example.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c10ceb4dd309ab1c50aec56319c7a9c0a87ad2af GIT binary patch literal 9461 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 literal 0 HcmV?d00001 diff --git a/example_workflows/resample_force_stereo.json b/example_workflows/resample_force_stereo.json new file mode 100644 index 0000000..2769275 --- /dev/null +++ b/example_workflows/resample_force_stereo.json @@ -0,0 +1 @@ +{"id":"f8820f6e-7280-4a6e-858d-4aa7fd65e8aa","revision":0,"last_node_id":10,"last_link_id":9,"nodes":[{"id":3,"type":"SET_AudioResampler","pos":[2450,450],"size":[270,58],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":1},{"localized_name":"target_sample_rate","name":"target_sample_rate","type":"INT","widget":{"name":"target_sample_rate"},"link":null}],"outputs":[{"localized_name":"audio_out","name":"audio_out","type":"AUDIO","links":[4]}],"properties":{"aux_id":"set-soft/ComfyUI-AudioBatch","ver":"65e3da3da285b8593e74f138d114ec0b576a22bd","Node name for S&R":"SET_AudioResampler","widget_ue_connectable":{}},"widgets_values":[44100],"color":"#2a363b","bgcolor":"#3f5159"},{"id":5,"type":"SET_AudioChannelConverter","pos":[2750,450],"size":[270.740234375,58],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":4},{"localized_name":"channel_conversion","name":"channel_conversion","type":"COMBO","widget":{"name":"channel_conversion"},"link":null}],"outputs":[{"localized_name":"audio_out","name":"audio_out","type":"AUDIO","links":[6]}],"properties":{"aux_id":"set-soft/ComfyUI-AudioBatch","ver":"65e3da3da285b8593e74f138d114ec0b576a22bd","Node name for S&R":"SET_AudioChannelConverter","widget_ue_connectable":{}},"widgets_values":["force_stereo"],"color":"#2a363b","bgcolor":"#3f5159"},{"id":1,"type":"LoadAudio","pos":[2050,450],"size":[350,150],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"AUDIO","name":"AUDIO","type":"AUDIO","links":[1]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.34","Node name for S&R":"LoadAudio","widget_ue_connectable":{}},"widgets_values":["Palpatine_1.wav",null,null],"color":"#233","bgcolor":"#355"},{"id":7,"type":"LoadAudio","pos":[2050,850],"size":[350,150],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"AUDIO","name":"AUDIO","type":"AUDIO","links":[7]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.34","Node name for S&R":"LoadAudio","widget_ue_connectable":{}},"widgets_values":["Palpatine_1.wav",null,null],"color":"#233","bgcolor":"#355"},{"id":6,"type":"SaveAudio","pos":[3080,450],"size":[270,112],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":6},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.39","Node name for S&R":"SaveAudio","widget_ue_connectable":{}},"widgets_values":["audio/ComfyUI"],"color":"#233","bgcolor":"#355"},{"id":9,"type":"SaveAudio","pos":[3080,850],"size":[270,112],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":9},{"localized_name":"filename_prefix","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.39","Node name for S&R":"SaveAudio","widget_ue_connectable":{}},"widgets_values":["audio/ComfyUI"],"color":"#233","bgcolor":"#355"},{"id":4,"type":"SET_AudioChannelConvResampler","pos":[2580,850],"size":[327.1470642089844,82],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":7},{"localized_name":"channel_conversion","name":"channel_conversion","type":"COMBO","widget":{"name":"channel_conversion"},"link":null},{"localized_name":"target_sample_rate","name":"target_sample_rate","type":"INT","widget":{"name":"target_sample_rate"},"link":null}],"outputs":[{"localized_name":"audio_out","name":"audio_out","type":"AUDIO","links":[9]}],"properties":{"aux_id":"set-soft/ComfyUI-AudioBatch","ver":"65e3da3da285b8593e74f138d114ec0b576a22bd","Node name for S&R":"SET_AudioChannelConvResampler","widget_ue_connectable":{}},"widgets_values":["force_stereo",44100],"color":"#2a363b","bgcolor":"#3f5159"},{"id":10,"type":"MarkdownNote","pos":[2060,240],"size":[530,130],"flags":{},"order":2,"mode":0,"inputs":[],"outputs":[],"properties":{"widget_ue_connectable":{}},"widgets_values":["# Forcing channels and sample rate\n\n## These two examples will force stereo output sampled at 44100 Hz"],"color":"#432","bgcolor":"#653"}],"links":[[1,1,0,3,0,"AUDIO"],[4,3,0,5,0,"AUDIO"],[6,5,0,6,0,"AUDIO"],[7,7,0,4,0,"AUDIO"],[9,4,0,9,0,"AUDIO"]],"groups":[],"config":{},"extra":{"ue_links":[],"ds":{"scale":1.0313326743041535,"offset":[-1112.3182273496227,-193.07144659156816]},"links_added_by_ue":[]},"version":0.4} diff --git a/nodes_audio.py b/nodes_audio.py new file mode 100644 index 0000000..05b3932 --- /dev/null +++ b/nodes_audio.py @@ -0,0 +1,450 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial +# License: GPL-3.0 +# Project: ComfyUI-AudioBatch +# From code generated by Gemini 2.5 Pro +import torch +import torchaudio.transforms as T +from .utils.logger import main_logger + +logger = main_logger +BASE_CATEGORY = "audio" +BATCH_CATEGORY = "batch" +CONV_CATEGORY = "conversion" + + +def convert_batch_to_stereo_tensor(audio_waveform_mono_batch: torch.Tensor) -> torch.Tensor: + """ Converts a batch of mono audio tensors (B, 1, N) to stereo (B, 2, N). """ + if audio_waveform_mono_batch.ndim != 3 or audio_waveform_mono_batch.shape[1] != 1: + # This could also happen if an input was (N) or (1,N) and wasn't unsqueezed to (B,1,N) yet + raise ValueError("Input for stereo conversion must be a batch of mono audio (B, 1, N), " + f"got {audio_waveform_mono_batch.shape}") + return audio_waveform_mono_batch.repeat(1, 2, 1) # (B, 1, N) -> (B, 2, N) + + +class AudioBatch: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio1": ("AUDIO",), + "audio2": ("AUDIO",), + } + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio_batch",) + FUNCTION = "batch_audio" + CATEGORY = BASE_CATEGORY + "/" + BATCH_CATEGORY + DESCRIPTION = "Audio batch creator" + UNIQUE_NAME = "SET_AudioBatch" + DISPLAY_NAME = "Batch Audios" + + def _preprocess_waveform_batch( + self, + waveform: torch.Tensor, # (B, C_in, N_in) + original_sr: int, + target_sr: int, + target_channels: int, + target_samples: int, + reference_dtype: torch.dtype, + reference_device: torch.device + ) -> torch.Tensor: + """ + Helper function to resample, change channels, and pad a batch of waveforms. + Returns a tensor of shape (B, target_channels, target_samples). + """ + # Ensure correct device and dtype + processed_wf = waveform.to(device=reference_device, dtype=reference_dtype) + current_batch_size, current_channels, current_samples = processed_wf.shape + + # 1. Resample if necessary + if original_sr != target_sr: + logger.debug(f"Resampling batch from {original_sr} to {target_sr}. Input shape: {processed_wf.shape}") + # Resample expects (..., time) + # For (B, C, N), we can reshape to (B*C, N), resample, then reshape back. + # Or, if T.Resample handles batch dims appropriately (some versions might if C=1 or applied per channel). + # Let's reshape for robustness with T.Resample. + resampler = T.Resample(orig_freq=original_sr, new_freq=target_sr, dtype=reference_dtype).to(reference_device) + + if current_channels == 1: + # Reshape (B, 1, N) to (B, N) for resampler, then unsqueeze back + processed_wf_reshaped = processed_wf.squeeze(1) # (B, N) + resampled_wf_reshaped = resampler(processed_wf_reshaped) # (B, N_new) + processed_wf = resampled_wf_reshaped.unsqueeze(1) # (B, 1, N_new) + else: # Multi-channel (e.g., stereo) + # Resample each channel in the batch separately + # This is more complex if T.Resample doesn't broadcast correctly. + # A common way: permute to (C, B, N), reshape to (C*B, N), resample, reshape back. + # Or loop (less efficient for GPU tensors). + # Let's try reshaping to (B*C, N) + original_shape = processed_wf.shape + processed_wf_flat_batch_channel = processed_wf.reshape(-1, current_samples) # (B*C, N) + resampled_wf_flat = resampler(processed_wf_flat_batch_channel) # (B*C, N_new) + # Reshape back to (B, C, N_new) + processed_wf = resampled_wf_flat.reshape(original_shape[0], original_shape[1], -1) + + current_samples = processed_wf.shape[2] # Update sample count + logger.debug(f"Batch after resampling: shape={processed_wf.shape}") + + # 2. Adjust number of channels + if current_channels == 1 and target_channels == 2: + processed_wf = convert_batch_to_stereo_tensor(processed_wf) # (B, 1, N) -> (B, 2, N) + logger.debug(f"Batch converted to stereo: shape={processed_wf.shape}") + elif current_channels == 2 and target_channels == 1: + # Example: Convert stereo to mono by averaging (can make this an option) + processed_wf = processed_wf.mean(dim=1, keepdim=True) # (B, 2, N) -> (B, 1, N) + logger.debug(f"Batch converted to mono (avg): shape={processed_wf.shape}") + elif current_channels != target_channels: + # Fallback or error for other unsupported channel conversions (e.g. 5.1 to stereo) + # For now, if shapes don't match after mono/stereo adjustment, it might error later. + # A more robust node would handle various conversions or error clearly. + logger.warning(f"Unhandled channel conversion from {current_channels} to {target_channels}." + f" Resulting channels: {processed_wf.shape[1]}") + # If, for example, target is 2, and current is 5, how to downmix? + # For this node's purpose (batching existing audio), major downmixing is out of scope. + # We primarily handle mono <-> stereo alignment. + if processed_wf.shape[1] != target_channels: + raise ValueError(f"Cannot align channels: input has {processed_wf.shape[1]} after initial processing, " + f"target is {target_channels}") + + # 3. Pad length if necessary + if current_samples < target_samples: + padding_needed = target_samples - current_samples + # Pad only the last dimension (samples) + processed_wf = torch.nn.functional.pad(processed_wf, (0, padding_needed)) + logger.debug(f"Batch padded: shape={processed_wf.shape}") + elif current_samples > target_samples: # Should not happen if target_samples is max length + logger.warning(f"Waveform has {current_samples} samples, but target is {target_samples}. " + "This indicates an issue in target_samples calculation.") + # Truncate as a fallback, though logic should prevent this. + processed_wf = processed_wf[..., :target_samples] + + # Final check for shape + if processed_wf.shape[1] != target_channels or processed_wf.shape[2] != target_samples: + raise RuntimeError(f"Internal Preprocessing Error: Waveform shape {processed_wf.shape} " + f"does not match target ({current_batch_size}, {target_channels}, {target_samples}).") + + return processed_wf + + def batch_audio(self, audio1: dict, audio2: dict): + waveform1_orig = audio1['waveform'] # (B1, C1, N1) + sr1 = audio1['sample_rate'] + waveform2_orig = audio2['waveform'] # (B2, C2, N2) + sr2 = audio2['sample_rate'] + + logger.debug(f"Audio1 input: shape={waveform1_orig.shape}, sr={sr1}, dtype={waveform1_orig.dtype}, " + f"device={waveform1_orig.device}") + logger.debug(f"Audio2 input: shape={waveform2_orig.shape}, sr={sr2}, dtype={waveform2_orig.dtype}, " + f"device={waveform2_orig.device}") + + # Use properties of audio1 as the reference for the output batch + # (e.g., device, dtype, and target sample rate) + reference_device = waveform1_orig.device + reference_dtype = waveform1_orig.dtype + target_sr = sr1 # All audio will be resampled to sr1 + + # Determine target number of channels for the batch (max of inputs, ensure mono/stereo alignment) + c1 = waveform1_orig.shape[1] + c2 = waveform2_orig.shape[1] + # If one is mono and other is stereo, output will be stereo. Otherwise, max (handles both mono or both stereo). + if (c1 == 1 and c2 == 2) or (c1 == 2 and c2 == 1) or (c1 == 2 and c2 == 2): + target_channels = 2 + elif c1 == 1 and c2 == 1: + target_channels = 1 + else: + # For other multi-channel counts (e.g. 5.1), this simple logic might not be ideal. + # For now, default to max, but this could be an error or require downmixing node. + logger.warning(f"Complex channel counts detected (C1={c1}, C2={c2}). Defaulting to max channels ({max(c1,c2)}) " + "and hoping for downstream compatibility or further processing. " + "Explicit mono/stereo alignment is preferred for this node.") + target_channels = max(c1, c2) + + # Determine target number of samples (length) for the batch + # First, calculate lengths *after* potential resampling + n1_orig = waveform1_orig.shape[2] + n2_orig = waveform2_orig.shape[2] + + n1_after_resample = n1_orig # No resampling for audio1 as it's the target_sr reference + n2_after_resample = n2_orig + if sr2 != target_sr: + n2_after_resample = int(n2_orig * (target_sr / sr2)) + + target_samples = max(n1_after_resample, n2_after_resample) + logger.debug(f"Unified Batch Params: SR={target_sr}, Channels={target_channels}, Samples={target_samples}") + + # Preprocess both waveform batches + wf1_processed = self._preprocess_waveform_batch(waveform1_orig, sr1, target_sr, target_channels, target_samples, + reference_dtype, reference_device) + wf2_processed = self._preprocess_waveform_batch(waveform2_orig, sr2, target_sr, target_channels, target_samples, + reference_dtype, reference_device) + + # wf1_processed is (B1, target_channels, target_samples) + # wf2_processed is (B2, target_channels, target_samples) + + # Concatenate along the batch dimension (dim=0) + try: + batched_waveform_final = torch.cat((wf1_processed, wf2_processed), dim=0) + except RuntimeError as e: + logger.error(f"Error during final torch.cat: {e}") + logger.error(f"Processed Waveform1 shape: {wf1_processed.shape}, dtype: {wf1_processed.dtype}, " + f"device: {wf1_processed.device}") + logger.error(f"Processed Waveform2 shape: {wf2_processed.shape}, dtype: {wf2_processed.dtype}, " + f"device: {wf2_processed.device}") + raise # Re-raise to make error visible + + logger.info(f"Final batched audio: shape={batched_waveform_final.shape}, sr={target_sr}") + + output_audio = { + "waveform": batched_waveform_final, + "sample_rate": target_sr + } + return (output_audio,) + + +class SelectAudioFromBatch: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio_batch": ("AUDIO",), # Expects {'waveform': (B, C, N), 'sample_rate': int} + "index": ("INT", { + "default": 0, + "min": 0, + "max": 0xffffffffffffffff, # Effectively unbounded, but UI might cap + "step": 1, + "display": "number" + }), + "behavior_out_of_range": (["silence_original_length", "silence_fixed_length", "error"], { + "default": "silence_original_length" + }), + "silence_duration_seconds": ("FLOAT", { # Only used if behavior is "silence_fixed_length" + "default": 1.0, + "min": 0.01, + "max": 3600.0, # 1 hour + "step": 0.1, + "display": "number" + }), + } + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("selected_audio",) + FUNCTION = "select_audio" + CATEGORY = BASE_CATEGORY + "/" + BATCH_CATEGORY + DESCRIPTION = "Selects an audio from a batch" + UNIQUE_NAME = "SET_SelectAudioFromBatch" + DISPLAY_NAME = "Select Audio from Batch" + + def select_audio(self, audio_batch: dict, index: int, behavior_out_of_range: str, silence_duration_seconds: float): + + waveform_batch = audio_batch['waveform'] # (B, C, N) + sample_rate = audio_batch['sample_rate'] + + batch_size, num_channels, num_samples_per_item = waveform_batch.shape + + logger.debug(f"Input audio batch: shape={waveform_batch.shape}, sr={sample_rate}, index={index}") + logger.debug(f"Out of range behavior: {behavior_out_of_range}, silence duration: {silence_duration_seconds}s") + + selected_waveform = None + + if 0 <= index < batch_size: + # Valid index, select the audio + # Slicing with index:index+1 keeps the batch dimension, resulting in (1, C, N) + selected_waveform = waveform_batch[index:index+1, :, :] + logger.info(f"Selected audio at index {index}. Shape: {selected_waveform.shape}") + else: + # Index is out of range + msg = f"Index {index} is out of range for batch of size {batch_size}." + logger.warning(msg) + + if behavior_out_of_range == "error": + raise ValueError(msg) + elif behavior_out_of_range == "silence_original_length": + logger.info(f"Outputting silence with original length: {num_samples_per_item} samples, " + f"{num_channels} channels.") + selected_waveform = torch.zeros((1, num_channels if num_channels > 0 else 1, num_samples_per_item), + dtype=waveform_batch.dtype, device=waveform_batch.device) + elif behavior_out_of_range == "silence_fixed_length": + silence_samples = int(silence_duration_seconds * sample_rate) + if silence_samples <= 0: # Ensure positive sample count + silence_samples = 1 + logger.warning(f"Calculated silence samples is <=0 ({silence_samples} from " + f"{silence_duration_seconds}s). Using 1 sample.") + logger.info(f"Outputting silence with fixed length: {silence_samples} samples, {num_channels} channels.") + selected_waveform = torch.zeros((1, num_channels if num_channels > 0 else 1, silence_samples), + dtype=waveform_batch.dtype, device=waveform_batch.device) + + if selected_waveform is None: # Should not happen if logic above is complete + logger.error("Selected waveform is None unexpectedly. Defaulting to silence.") + selected_waveform = torch.zeros((1, num_channels if num_channels > 0 else 1, 1), + dtype=waveform_batch.dtype, device=waveform_batch.device) + + output_audio = { + "waveform": selected_waveform, # Shape (1, C, N_selected) + "sample_rate": sample_rate + } + + return (output_audio,) + + +class AudioChannelConverter: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO",), + "channel_conversion": (["keep", "stereo_to_mono", "mono_to_stereo", "force_mono", "force_stereo"], + {"default": "keep"}), + }, + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio_out",) + FUNCTION = "convert_channels" + CATEGORY = BASE_CATEGORY + "/" + CONV_CATEGORY + DESCRIPTION = "Converts audio channels (mono/stereo/multi-channel handling)." + UNIQUE_NAME = "SET_AudioChannelConverter" + DISPLAY_NAME = "Audio Channel Converter" + + def convert_channels(self, audio: dict, channel_conversion: str): + waveform = audio['waveform'] # (B, C, T) + sample_rate = audio['sample_rate'] + + original_batch_size, original_channels, original_samples = waveform.shape + logger.debug(f"Input audio: {original_batch_size}B, {original_channels}C, {original_samples}T @ {sample_rate}Hz") + logger.debug(f"Channel conversion mode: {channel_conversion}") + + output_waveform = waveform.clone() # Start with a copy + + if channel_conversion == "keep": + # No change needed, but log if > 2 channels + if original_channels > 2: + logger.warning(f"Channel mode 'keep': Input has {original_channels} channels. Outputting all " + f"{original_channels} channels.") + # output_waveform remains as is + + elif channel_conversion == "stereo_to_mono" or channel_conversion == "force_mono": + if original_channels == 1: + logger.debug("Input is already mono. No change for stereo_to_mono/force_mono.") + # output_waveform remains as is + else: # Stereo or Multi-channel to Mono + if original_channels > 2 and channel_conversion == "stereo_to_mono": + logger.warning(f"Channel mode 'stereo_to_mono': Input has {original_channels} channels. " + "Averaging all to mono.") + elif channel_conversion == "force_mono": + logger.info(f"Channel mode 'force_mono': Input has {original_channels} channels. Averaging all to mono.") + # Average across the channel dimension (dim=1) + output_waveform = torch.mean(waveform, dim=1, keepdim=True) + logger.debug(f"Converted to mono. New shape: {output_waveform.shape}") + + elif channel_conversion == "mono_to_stereo" or channel_conversion == "force_stereo": + if original_channels == 2: + logger.debug("Input is already stereo. No change for mono_to_stereo/force_stereo.") + # output_waveform remains as is + elif original_channels == 1: # Mono to Stereo + # Duplicate the mono channel + output_waveform = waveform.repeat(1, 2, 1) + logger.debug(f"Converted mono to stereo by duplication. New shape: {output_waveform.shape}") + else: # Multi-channel (>2) to Stereo + if channel_conversion == "mono_to_stereo": + logger.warning(f"Channel mode 'mono_to_stereo': Input has {original_channels} channels. " + "Taking the first channel and duplicating it to create stereo.") + elif channel_conversion == "force_stereo": + logger.info(f"Channel mode 'force_stereo': Input has {original_channels} channels. " + "Taking the first channel and duplicating it to create stereo.") + output_waveform = waveform[:, 0:1, :].repeat(1, 2, 1) # Take first channel, make it (B,1,T), then repeat + logger.debug(f"Converted {original_channels}ch to stereo. New shape: {output_waveform.shape}") + else: + logger.error(f"Unknown channel_conversion mode: {channel_conversion}. Returning original.") + # output_waveform remains as is (original waveform) + + processed_audio_dict = {"waveform": output_waveform, "sample_rate": sample_rate} + return (processed_audio_dict,) + + +class AudioResampler: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO",), + "target_sample_rate": ("INT", {"default": 0, "min": 0, "max": 192000, "step": 100}), + }, + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio_out",) + FUNCTION = "resample_audio" + CATEGORY = BASE_CATEGORY + "/" + CONV_CATEGORY + DESCRIPTION = "Resamples audio to a target sample rate using torchaudio." + UNIQUE_NAME = "SET_AudioResampler" + DISPLAY_NAME = "Audio Resampler" + + def resample_audio(self, audio: dict, target_sample_rate: int): + waveform = audio['waveform'] # (B, C, T) + original_sample_rate = audio['sample_rate'] + + logger.debug(f"Input audio SR: {original_sample_rate}Hz, Target SR: {target_sample_rate}Hz") + + if target_sample_rate == 0 or target_sample_rate == original_sample_rate: + logger.info(f"Target sample rate ({target_sample_rate}Hz) is 0 or matches original ({original_sample_rate}Hz). " + "Skipping resampling.") + return (audio,) # Return original audio dict + + try: + # Ensure waveform is on the CPU for torchaudio transforms if it might be on GPU + # Though many torchaudio ops support GPU tensors. Resample does. + # For consistency or if issues arise: + # device = waveform.device + # waveform_cpu = waveform.cpu() + # resampler = T.Resample(orig_freq=original_sample_rate, new_freq=target_sample_rate).to(device) + # resampled_waveform = resampler(waveform) + resampler = T.Resample(orig_freq=original_sample_rate, new_freq=target_sample_rate, + dtype=waveform.dtype # Preserve dtype + ).to(waveform.device) # Perform resampling on the tensor's current device + + resampled_waveform = resampler(waveform) + logger.info(f"Resampled audio from {original_sample_rate}Hz to {target_sample_rate}Hz. " + f"Original shape: {waveform.shape}, New shape: {resampled_waveform.shape}") + processed_audio_dict = {"waveform": resampled_waveform, "sample_rate": target_sample_rate} + return (processed_audio_dict,) + + except Exception as e: + logger.error(f"Error during resampling: {e}", exc_info=True) + # Fallback: return original audio if resampling fails + return (audio,) + + +class AudioProcessAdvanced: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO",), + "channel_conversion": (["keep", "stereo_to_mono", "mono_to_stereo", "force_mono", "force_stereo"], + {"default": "keep"}), + "target_sample_rate": ("INT", {"default": 0, "min": 0, "max": 192000, "step": 100}), + }, + } + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio_out",) + FUNCTION = "process_audio" + CATEGORY = BASE_CATEGORY + "/" + CONV_CATEGORY + DESCRIPTION = "Applies channel conversion and resampling to audio." + UNIQUE_NAME = "SET_AudioChannelConvResampler" + DISPLAY_NAME = "Audio Channel Conv and Resampler" + + def process_audio(self, audio: dict, channel_conversion: str, target_sample_rate: int): + # Instantiate helper classes (or move their logic directly here) + channel_converter_node = AudioChannelConverter() + resampler_node = AudioResampler() + + # 1. Channel Conversion + (audio_after_channels,) = channel_converter_node.convert_channels(audio, channel_conversion) + + # 2. Resampling + (audio_after_resample,) = resampler_node.resample_audio(audio_after_channels, target_sample_rate) + + return (audio_after_resample,) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..353d544 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "audio-batch" +description = "Audio batch creation, extraction, resample, mono and stereo conversion." +version = "1.0.0" +license = { file = "LICENSE" } +dependencies = [] + +[project.urls] +Repository = "https://github.com/set-soft/ComfyUI-AudioBatch" + +[tool.comfy] +PublisherId = "set-soft" +DisplayName = "Audio Batch" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..76cf8ed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +torch +torchaudio +numpy + +# non essential dependencies: +colorama diff --git a/utils/ansi.py b/utils/ansi.py new file mode 100644 index 0000000..5e7d0cc --- /dev/null +++ b/utils/ansi.py @@ -0,0 +1,113 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +''' +This module generates ANSI character codes to printing colors to terminals. +See: http://en.wikipedia.org/wiki/ANSI_escape_code +''' +import sys +import os + +CSI = '\033[' +OSC = '\033]' +BEL = '\a' +is_a_tty = sys.stderr.isatty() and os.name == 'posix' + + +def code_to_chars(code): + return CSI + str(code) + 'm' if is_a_tty else '' + + +def set_title(title): + return OSC + '2;' + title + BEL + + +def clear_screen(mode=2): + return CSI + str(mode) + 'J' + + +def clear_line(mode=2): + return CSI + str(mode) + 'K' + + +class AnsiCodes(object): + def __init__(self): + # the subclasses declare class attributes which are numbers. + # Upon instantiation we define instance attributes, which are the same + # as the class attributes but wrapped with the ANSI escape sequence + for name in dir(self): + if not name.startswith('_'): + value = getattr(self, name) + setattr(self, name, code_to_chars(value)) + + +class AnsiCursor(object): + def UP(self, n=1): + return CSI + str(n) + 'A' + + def DOWN(self, n=1): + return CSI + str(n) + 'B' + + def FORWARD(self, n=1): + return CSI + str(n) + 'C' + + def BACK(self, n=1): + return CSI + str(n) + 'D' + + def POS(self, x=1, y=1): + return CSI + str(y) + ';' + str(x) + 'H' + + +class AnsiFore(AnsiCodes): + BLACK = 30 + RED = 31 + GREEN = 32 + YELLOW = 33 + BLUE = 34 + MAGENTA = 35 + CYAN = 36 + WHITE = 37 + RESET = 39 + + # These are fairly well supported, but not part of the standard. + LIGHTBLACK_EX = 90 + LIGHTRED_EX = 91 + LIGHTGREEN_EX = 92 + LIGHTYELLOW_EX = 93 + LIGHTBLUE_EX = 94 + LIGHTMAGENTA_EX = 95 + LIGHTCYAN_EX = 96 + LIGHTWHITE_EX = 97 + + +class AnsiBack(AnsiCodes): + BLACK = 40 + RED = 41 + GREEN = 42 + YELLOW = 43 + BLUE = 44 + MAGENTA = 45 + CYAN = 46 + WHITE = 47 + RESET = 49 + + # These are fairly well supported, but not part of the standard. + LIGHTBLACK_EX = 100 + LIGHTRED_EX = 101 + LIGHTGREEN_EX = 102 + LIGHTYELLOW_EX = 103 + LIGHTBLUE_EX = 104 + LIGHTMAGENTA_EX = 105 + LIGHTCYAN_EX = 106 + LIGHTWHITE_EX = 107 + + +class AnsiStyle(AnsiCodes): + BRIGHT = 1 + DIM = 2 + NORMAL = 22 + RESET_ALL = 0 + + +Fore = AnsiFore() +Back = AnsiBack() +Style = AnsiStyle() +Cursor = AnsiCursor() diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..ce927d5 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,84 @@ +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial +# License: GPL-3.0 +# Project: ComfyUI-AudioBatch +import os +import sys +import logging +from .misc import NODES_NAME, NODES_DEBUG_VAR + +no_colorama = False +try: + from colorama import init as colorama_init, Fore, Back, Style +except ImportError: + no_colorama = True +# If colorama isn't installed use an ANSI basic replacement +if no_colorama: + from .ansi import Fore, Back, Style # noqa: F811 +else: + colorama_init() + + +class CustomFormatter(logging.Formatter): + """Logging Formatter to add colors""" + + def __init__(self): + super(logging.Formatter, self).__init__() + white = Fore.WHITE + Style.BRIGHT + yellow = Fore.YELLOW + Style.BRIGHT + red = Fore.RED + Style.BRIGHT + red_alarm = Fore.RED + Back.WHITE + Style.BRIGHT + cyan = Fore.CYAN + Style.BRIGHT + reset = Style.RESET_ALL + # format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s " + # "(%(filename)s:%(lineno)d)" + format = f"[{NODES_NAME} %(levelname)s] %(message)s (%(name)s - %(filename)s:%(lineno)d)" + format_simple = f"[{NODES_NAME}] %(message)s" + + self.FORMATS = { + logging.DEBUG: cyan + format + reset, + logging.INFO: white + format_simple + reset, + logging.WARNING: yellow + format + reset, + logging.ERROR: red + format + reset, + logging.CRITICAL: red_alarm + format + reset + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + +print("Crea el logger") +# Create a new logger +logger = logging.getLogger(NODES_NAME) +logger.propagate = False + +# Add handler if we don't have one. +if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(CustomFormatter()) + logger.addHandler(handler) + +# ###################### +# Logger setup +# ###################### +# 1. Determine the ComfyUI global log level (influenced by --verbose) +main_logger = logger +comfy_root_logger = logging.getLogger('comfy') +effective_comfy_level = logging.getLogger().getEffectiveLevel() +# 2. Check our custom environment variable for more verbosity +try: + nodes_debug_env = int(os.environ.get(NODES_DEBUG_VAR, "0")) + print(nodes_debug_env) +except ValueError: + nodes_debug_env = 0 +# 3. Set node's logger level +if nodes_debug_env: + main_logger.setLevel(logging.DEBUG - (nodes_debug_env - 1)) + final_level_str = f"DEBUG (due to {NODES_DEBUG_VAR}={nodes_debug_env})" +else: + main_logger.setLevel(effective_comfy_level) + final_level_str = logging.getLevelName(effective_comfy_level) + " (matching ComfyUI global)" +_initial_setup_logger = logging.getLogger(NODES_NAME + ".setup") # A temporary logger for this message +_initial_setup_logger.debug(f"{NODES_NAME} logger level set to: {final_level_str}") diff --git a/utils/misc.py b/utils/misc.py new file mode 100644 index 0000000..4945c26 --- /dev/null +++ b/utils/misc.py @@ -0,0 +1,7 @@ +# Copyright (c) 2025 Salvador E. Tropea +# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial +# License: GPL-3.0 +# Project: ComfyUI-AudioBatch + +NODES_NAME = "AudioBatch" +NODES_DEBUG_VAR = NODES_NAME.upper() + "_NODES_DEBUG"