From 562afeb2a345e2e0eb6cf29e3a5c55386cb7b209 Mon Sep 17 00:00:00 2001 From: "Salvador E. Tropea" Date: Fri, 18 Jul 2025 10:30:32 -0300 Subject: [PATCH] [Nodes][Added] AudioNormalize and AudioApplyBatchedGain --- README.md | 39 ++++++- example_workflows/normalize_and_undo.jpg | Bin 0 -> 11958 bytes example_workflows/normalize_and_undo.json | 1 + source/nodes/nodes_audio.py | 128 ++++++++++++++++++++++ source/tests/test_audio_normalize.py | 123 +++++++++++++++++++++ 5 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 example_workflows/normalize_and_undo.jpg create mode 100644 example_workflows/normalize_and_undo.json create mode 100644 source/tests/test_audio_normalize.py diff --git a/README.md b/README.md index f33b1cd..b41c2a5 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,14 @@ workflows, especially when dealing with multiple audio inputs or outputs. - [6. Audio Channel Conv and Resampler](#6-audio-channel-conv-and-resampler) - [7. Audio Information](#7-audio-information) - [8. Audio Cut](#8-audio-cut) - - [9. Audio Concatenate](#8-audio-concatenate) - - [10. Audio Blend](#9-audio-blend) - - [11. Audio Test Signal Generator](#10-audio-test-signal-generator) - - [12. Audio Musical Note](#11-audio-musical-note) - - [13. Audio Join 2 Channels](#12-audio-join-2-channels) - - [14. Audio Split 2 Channels](#13-audio-split-2-channels) + - [9. Audio Concatenate](#9-audio-concatenate) + - [10. Audio Blend](#10-audio-blend) + - [11. Audio Test Signal Generator](#11-audio-test-signal-generator) + - [12. Audio Musical Note](#12-audio-musical-note) + - [13. Audio Join 2 Channels](#13-audio-join-2-channels) + - [14. Audio Split 2 Channels](#14-audio-split-2-channels) + - [15. Audio Normalize (Peak)](#15-audio-normalize-peak) + - [16. Audio Apply Batched Gain](#16-audio-apply-batched-gain) - [🚀 Installation](#-installation) - [📦 Dependencies](#-dependencies) - [🖼️ Examples](#️-examples) @@ -259,6 +261,30 @@ workflows, especially when dealing with multiple audio inputs or outputs. - `audio_left` (AUDIO): A mono audio signal containing only the left channel data. - `audio_right` (AUDIO): A mono audio signal containing only the right channel data. +### 15. Audio Normalize (Peak) + - **Display Name:** `Audio Normalize (Peak)` + - **Internal Name:** `SET_AudioNormalize` + - **Category:** `audio/manipulation` + - **Description:** Normalizes the volume of an audio signal so that its loudest point (peak) reaches a specified target level. This is useful for maximizing volume without clipping. + - **Inputs:** + - `audio` (AUDIO): The audio to normalize. Supports batches. + - `peak_level` (FLOAT): The target peak amplitude level. `1.0` is the maximum possible level (0 dBFS). Normalizing to slightly less, like `0.9`, can provide headroom. + - **Outputs:** + - `normalized_audio` (AUDIO): The audio with its volume adjusted. + - `original_peak_level` (FLOAT): The original peak level of the input audio for each item in the batch. **This value can be used with the `Audio Apply Batched Gain` node to revert the normalization and restore the original volume.** + +### 16. Audio Apply Batched Gain + - **Display Name:** `Audio Apply Batched Gain` + - **Internal Name:** `SET_AudioApplyBatchedGain` + - **Category:** `audio/manipulation` + - **Description:** Applies a separate gain (volume) level to each item in an audio batch. This is the perfect companion to the `Audio Normalize` node for reverting normalization. + - **Inputs:** + - `audio` (AUDIO): The audio batch to apply gain to. + - `gain_values` (FLOAT): A batch of gain values. The node expects this to be a 1D tensor of shape `(batch_size,)`, which is the format provided by the `original_peak_level` output of the `Audio Normalize` node. + - **Output:** + - `audio_out` (AUDIO): The audio with the per-item gain applied. + + ## 🚀 Installation You can install the nodes from the ComfyUI nodes manager, the name is *Audio Batch*, or just do it manually: @@ -291,6 +317,7 @@ Once installed the examples are available in the ComfyUI workflow templates, in - [generate_and_blend.json](example_workflows/generate_and_blend.json): Shows how to generate four musical notes and blend them together to create a chord. - [cut_and_concat.json](example_workflows/cut_and_concat.json): Shows how to cut and concatenate audio. +- [normalize_and_undo.json](example_workflows/normalize_and_undo.json): Shows how to normalize audio level and then revert it. ## 📝 Usage Notes diff --git a/example_workflows/normalize_and_undo.jpg b/example_workflows/normalize_and_undo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d93e135fd613f69b5d7ade30a4abad39882721d6 GIT binary patch literal 11958 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 literal 0 HcmV?d00001 diff --git a/example_workflows/normalize_and_undo.json b/example_workflows/normalize_and_undo.json new file mode 100644 index 0000000..13debab --- /dev/null +++ b/example_workflows/normalize_and_undo.json @@ -0,0 +1 @@ +{"id":"e19f3025-8818-483c-a738-6156d8f94f9a","revision":0,"last_node_id":15,"last_link_id":11,"nodes":[{"id":1,"type":"LoadAudio","pos":[1702.73046875,1176.981689453125],"size":[300,140],"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":[2,6]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.34","Node name for S&R":"LoadAudio","widget_ue_connectable":{}},"widgets_values":["aud-sample-vs-1.wav",null,null],"color":"#222","bgcolor":"#000"},{"id":3,"type":"SET_AudioNormalize","pos":[2059.697998046875,1175.7960205078125],"size":[264.7818908691406,78],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":2},{"localized_name":"peak_level","name":"peak_level","type":"FLOAT","widget":{"name":"peak_level"},"link":null}],"outputs":[{"localized_name":"normalized_audio","name":"normalized_audio","type":"AUDIO","links":[1,4,11]},{"localized_name":"original_peak_level","name":"original_peak_level","type":"TORCH_TENSOR","links":[3]}],"properties":{"aux_id":"set-soft/ComfyUI-AudioBatch","ver":"3286ba248c48661cb0461d45b3fca5ff0f40f26a","Node name for S&R":"SET_AudioNormalize"},"widgets_values":[1],"color":"#233","bgcolor":"#355"},{"id":4,"type":"SET_AudioApplyBatchedGain","pos":[2472.402587890625,1352.4998779296875],"size":[203.34042358398438,46],"flags":{},"order":9,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":4},{"localized_name":"gain_values","name":"gain_values","type":"TORCH_TENSOR","link":3}],"outputs":[{"localized_name":"audio_out","name":"audio_out","type":"AUDIO","links":[5,9]}],"properties":{"aux_id":"set-soft/ComfyUI-AudioBatch","ver":"3286ba248c48661cb0461d45b3fca5ff0f40f26a","Node name for S&R":"SET_AudioApplyBatchedGain"},"widgets_values":[],"color":"#233","bgcolor":"#355"},{"id":2,"type":"PreviewAudio","pos":[2419.034912109375,1169.8660888671875],"size":[344.231201171875,95.92967224121094],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":1},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.34","Node name for S&R":"PreviewAudio","widget_ue_connectable":{}},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":11,"type":"easy showAnything","pos":[2608.785400390625,1012.1365356445312],"size":[210,88],"flags":{},"order":11,"mode":0,"inputs":[{"localized_name":"anything","name":"anything","shape":7,"type":"*","link":10}],"outputs":[{"localized_name":"output","name":"output","type":"*","links":null}],"properties":{"cnr_id":"comfyui-easy-use","ver":"1.3.1","Node name for S&R":"easy showAnything"},"widgets_values":["tensor(1.)"],"color":"#222","bgcolor":"#000"},{"id":10,"type":"SET_AudioInfo","pos":[2433.266845703125,957.5831298828125],"size":[149.63046264648438,166],"flags":{"collapsed":false},"order":8,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":11}],"outputs":[{"localized_name":"audio_bypass","name":"audio_bypass","type":"AUDIO","links":null},{"localized_name":"batch_size","name":"batch_size","type":"INT","links":null},{"localized_name":"channels","name":"channels","type":"INT","links":null},{"localized_name":"num_samples","name":"num_samples","type":"INT","links":null},{"localized_name":"sample_rate","name":"sample_rate","type":"INT","links":null},{"localized_name":"mean","name":"mean","type":"TORCH_TENSOR","links":null},{"localized_name":"std","name":"std","type":"TORCH_TENSOR","links":null},{"localized_name":"peak","name":"peak","type":"TORCH_TENSOR","links":[10]}],"properties":{"aux_id":"set-soft/ComfyUI-AudioBatch","ver":"3286ba248c48661cb0461d45b3fca5ff0f40f26a","Node name for S&R":"SET_AudioInfo"},"widgets_values":[],"color":"#2a363b","bgcolor":"#3f5159"},{"id":8,"type":"SET_AudioInfo","pos":[2787.859130859375,1504.3001708984375],"size":[149.63046264648438,166],"flags":{},"order":13,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":9}],"outputs":[{"localized_name":"audio_bypass","name":"audio_bypass","type":"AUDIO","links":null},{"localized_name":"batch_size","name":"batch_size","type":"INT","links":null},{"localized_name":"channels","name":"channels","type":"INT","links":null},{"localized_name":"num_samples","name":"num_samples","type":"INT","links":null},{"localized_name":"sample_rate","name":"sample_rate","type":"INT","links":null},{"localized_name":"mean","name":"mean","type":"TORCH_TENSOR","links":null},{"localized_name":"std","name":"std","type":"TORCH_TENSOR","links":null},{"localized_name":"peak","name":"peak","type":"TORCH_TENSOR","links":[8]}],"properties":{"aux_id":"set-soft/ComfyUI-AudioBatch","ver":"3286ba248c48661cb0461d45b3fca5ff0f40f26a","Node name for S&R":"SET_AudioInfo"},"widgets_values":[],"color":"#2a363b","bgcolor":"#3f5159"},{"id":9,"type":"easy showAnything","pos":[2965.74951171875,1503.114013671875],"size":[210,88],"flags":{},"order":14,"mode":0,"inputs":[{"localized_name":"anything","name":"anything","shape":7,"type":"*","link":8}],"outputs":[{"localized_name":"output","name":"output","type":"*","links":null}],"properties":{"cnr_id":"comfyui-easy-use","ver":"1.3.1","Node name for S&R":"easy showAnything"},"widgets_values":["tensor(0.8055)"],"color":"#222","bgcolor":"#000"},{"id":5,"type":"PreviewAudio","pos":[2767.698974609375,1353.6854248046875],"size":[344.231201171875,95.92967224121094],"flags":{},"order":12,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":5},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.34","Node name for S&R":"PreviewAudio","widget_ue_connectable":{}},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":6,"type":"SET_AudioInfo","pos":[1720.5186767578125,961.1417846679688],"size":[149.63046264648438,166],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"audio","name":"audio","type":"AUDIO","link":6}],"outputs":[{"localized_name":"audio_bypass","name":"audio_bypass","type":"AUDIO","links":null},{"localized_name":"batch_size","name":"batch_size","type":"INT","links":null},{"localized_name":"channels","name":"channels","type":"INT","links":null},{"localized_name":"num_samples","name":"num_samples","type":"INT","links":null},{"localized_name":"sample_rate","name":"sample_rate","type":"INT","links":null},{"localized_name":"mean","name":"mean","type":"TORCH_TENSOR","links":null},{"localized_name":"std","name":"std","type":"TORCH_TENSOR","links":null},{"localized_name":"peak","name":"peak","type":"TORCH_TENSOR","links":[7]}],"properties":{"aux_id":"set-soft/ComfyUI-AudioBatch","ver":"3286ba248c48661cb0461d45b3fca5ff0f40f26a","Node name for S&R":"SET_AudioInfo"},"widgets_values":[],"color":"#2a363b","bgcolor":"#3f5159"},{"id":7,"type":"easy showAnything","pos":[1888.9237060546875,984.860595703125],"size":[210,88],"flags":{},"order":10,"mode":0,"inputs":[{"localized_name":"anything","name":"anything","shape":7,"type":"*","link":7}],"outputs":[{"localized_name":"output","name":"output","type":"*","links":null}],"properties":{"cnr_id":"comfyui-easy-use","ver":"1.3.1","Node name for S&R":"easy showAnything"},"widgets_values":["tensor(0.8055)"],"color":"#222","bgcolor":"#000"},{"id":12,"type":"MarkdownNote","pos":[1705.695556640625,1372.221923828125],"size":[287.8678894042969,88],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# 1. Select an audio \nYou can see the peak value"],"color":"#432","bgcolor":"#653"},{"id":13,"type":"MarkdownNote","pos":[2478.55224609375,802.00537109375],"size":[287.8678894042969,88],"flags":{},"order":2,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# 2. Here you have the audio with peak == 1"],"color":"#432","bgcolor":"#653"},{"id":14,"type":"MarkdownNote","pos":[2817.0810546875,1218.1605224609375],"size":[287.8678894042969,88],"flags":{},"order":3,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# 3. And here the audio is back to its original level"],"color":"#432","bgcolor":"#653"},{"id":15,"type":"MarkdownNote","pos":[2184.859375,1545.54248046875],"size":[428.8094787597656,88],"flags":{},"order":4,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# Show Any is part of \"Easy-Use\" nodes"],"color":"#432","bgcolor":"#653"}],"links":[[1,3,0,2,0,"AUDIO"],[2,1,0,3,0,"AUDIO"],[3,3,1,4,1,"TORCH_TENSOR"],[4,3,0,4,0,"AUDIO"],[5,4,0,5,0,"AUDIO"],[6,1,0,6,0,"AUDIO"],[7,6,7,7,0,"*"],[8,8,7,9,0,"*"],[9,4,0,8,0,"AUDIO"],[10,10,7,11,0,"*"],[11,3,0,10,0,"AUDIO"]],"groups":[],"config":{},"extra":{"ds":{"scale":1.2345539040268603,"offset":[-1579.0805695433937,-1056.3910821825534]}},"version":0.4} diff --git a/source/nodes/nodes_audio.py b/source/nodes/nodes_audio.py index 46cb238..0dbba63 100644 --- a/source/nodes/nodes_audio.py +++ b/source/nodes/nodes_audio.py @@ -904,3 +904,131 @@ class AudioConcatenate: "sample_rate": target_sr } return (output_audio,) + + +class AudioNormalize: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO", {"tooltip": "The audio to normalize."}), + "peak_level": ("FLOAT", { + "default": 1.0, + "min": 0.0, + "max": 10.0, # Allow some headroom if needed + "step": 0.01, + "tooltip": "The target peak amplitude level. 1.0 is 0 dBFS (maximum)." + }) + }, + } + + RETURN_TYPES = ("AUDIO", "TORCH_TENSOR") + RETURN_NAMES = ("normalized_audio", "original_peak_level") + FUNCTION = "normalize_audio" + CATEGORY = BASE_CATEGORY + "/" + MANIPULATION_CATEGORY + DESCRIPTION = "Normalizes audio so its loudest peak reaches a target level." + UNIQUE_NAME = "SET_AudioNormalize" + DISPLAY_NAME = "Audio Normalize (Peak)" + + def normalize_audio(self, audio: dict, peak_level: float): + waveform = audio['waveform'] + sample_rate = audio['sample_rate'] + + logger.debug(f"Normalizing audio of shape {waveform.shape} to target peak: {peak_level}") + + # Find the maximum absolute value for each batch item. + # This is the original peak level we need to return. + original_peak_level, _ = torch.max(torch.abs(waveform), dim=2, keepdim=True) + original_peak_level, _ = torch.max(original_peak_level, dim=1, keepdim=True) + # original_peak_level shape is (B, 1, 1) + + # Define a threshold for what we consider silent to avoid division by zero + silence_threshold = 1e-8 + + # Calculate the gain factor needed to reach the target peak_level. + # Add the threshold to the denominator to prevent division by zero for silent clips. + gain_factor = peak_level / (original_peak_level + silence_threshold) + + # For silent clips (where original_peak_level is below threshold), the gain + # will be huge but will be multiplied by ~0, resulting in ~0 (silence). + # We can explicitly set gain to 0 for silent clips to be safer. + is_silent = original_peak_level <= silence_threshold + gain_factor[is_silent] = 0.0 + + # Apply the gain + normalized_waveform = waveform * gain_factor + + # The value needed to revert is the original peak level. + # Squeeze it to be a more convenient shape for other nodes (e.g., a 1D tensor or a scalar if B=1) + # Squeeze removes all dims of size 1. (B,1,1) -> (B) + revert_value = original_peak_level.squeeze() + # If the original batch size was 1, this will be a 0-dim tensor (scalar). + # Let's ensure it's at least a 1D tensor for consistency. + if revert_value.ndim == 0: + revert_value = revert_value.unsqueeze(0) # Make it (1,) + + logger.info(f"Peak normalization applied. Original avg peak: {original_peak_level.mean():.4f}, " + f"Target peak: {peak_level}") + + output_audio = {"waveform": normalized_waveform, "sample_rate": sample_rate} + + # The revert_value is the original peak level for each item in the batch + return (output_audio, revert_value) + + +class AudioApplyBatchedGain: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "audio": ("AUDIO", {"tooltip": "The audio batch to apply gain to."}), + "gain_values": ("TORCH_TENSOR", { + "tooltip": "A batch of gain values (e.g., from an Audio Normalize node). Expects a 1D tensor." + }), + }, + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio_out",) + FUNCTION = "apply_gain" + CATEGORY = BASE_CATEGORY + "/" + MANIPULATION_CATEGORY + DESCRIPTION = "Applies a different gain (volume) to each item in an audio batch." + UNIQUE_NAME = "SET_AudioApplyBatchedGain" + DISPLAY_NAME = "Audio Apply Batched Gain" + + def apply_gain(self, audio: dict, gain_values: torch.Tensor): + waveform = audio['waveform'] + sample_rate = audio['sample_rate'] + + batch_size = waveform.shape[0] + + # Ensure gain_values is a tensor + if not isinstance(gain_values, torch.Tensor): + # If a simple float is passed, convert it to a tensor + # This makes the node more flexible + gain_values = torch.tensor([gain_values], dtype=torch.float32) + + # Validate dimensions + if gain_values.ndim == 0: # Handle scalar tensor + gain_values = gain_values.unsqueeze(0) + + if gain_values.shape[0] != batch_size: + msg = (f"Batch size of audio ({batch_size}) and gain_values ({gain_values.shape[0]}) must match. " + "Connect the `original_peak_level` from an Audio Normalize node.") + logger.error(msg) + raise ValueError(msg) + + # Reshape gain_values for broadcasting: (B) -> (B, 1, 1) + # This allows multiplying (B, C, N) with (B, 1, 1) + gain_tensor = gain_values.to(waveform.device, waveform.dtype).reshape(batch_size, 1, 1) + + # Apply the per-item gain + adjusted_waveform = waveform * gain_tensor + + logger.info(f"Applied batched gain. Gain range: [{gain_values.min():.4f}, {gain_values.max():.4f}]") + + output_audio = { + "waveform": adjusted_waveform, + "sample_rate": sample_rate + } + return (output_audio,) diff --git a/source/tests/test_audio_normalize.py b/source/tests/test_audio_normalize.py new file mode 100644 index 0000000..a78bacb --- /dev/null +++ b/source/tests/test_audio_normalize.py @@ -0,0 +1,123 @@ +""" +Regression tests for the AudioNormalize and AudioApplyBatchedGain nodes in ComfyUI-AudioBatch. +""" + +import torch +import pytest +import bootstrap # noqa: F401 +from nodes.nodes_audio import AudioNormalize, AudioApplyBatchedGain + + +# Helper function +def create_dummy_audio(batch_size, channels, samples, sr, peak_value=0.5, device='cpu'): + # Create audio that is not normalized to 1.0 to make testing meaningful + waveform = torch.randn(batch_size, channels, samples, device=device) + # Normalize to a known peak value + current_peak, _ = torch.max(torch.abs(waveform), dim=2, keepdim=True) + current_peak, _ = torch.max(current_peak, dim=1, keepdim=True) + waveform = waveform / (current_peak + 1e-9) * peak_value + return {"waveform": waveform, "sample_rate": sr} + + +@pytest.fixture +def normalize_node(): + return AudioNormalize() + + +@pytest.fixture +def apply_gain_node(): + return AudioApplyBatchedGain() + + +# --- Tests for AudioNormalize --- + +def test_normalize_simple(normalize_node): + """Tests if a simple audio clip is normalized to the target peak level.""" + sr = 44100 + original_peak = 0.25 + target_peak = 1.0 + audio = create_dummy_audio(1, 1, sr, sr, peak_value=original_peak) + + (normalized_audio, original_peak_level) = normalize_node.normalize_audio(audio, target_peak) + + # Assertions + new_peak = torch.max(torch.abs(normalized_audio['waveform'])) + assert new_peak.item() == pytest.approx(target_peak, abs=1e-6) + assert original_peak_level.item() == pytest.approx(original_peak, abs=1e-6) + + +def test_normalize_with_batch(normalize_node): + """Tests that each item in a batch is normalized independently.""" + sr = 44100 + # Create a batch with different peak levels + audio1 = create_dummy_audio(1, 2, sr, sr, peak_value=0.5) + audio2 = create_dummy_audio(1, 2, sr, sr, peak_value=0.1) + + batched_waveform = torch.cat((audio1['waveform'], audio2['waveform']), dim=0) + batched_audio = {"waveform": batched_waveform, "sample_rate": sr} + + target_peak = 0.9 + (normalized_audio, original_peak_levels) = normalize_node.normalize_audio(batched_audio, target_peak) + + # Check item 1 + peak1 = torch.max(torch.abs(normalized_audio['waveform'][0])) + assert peak1.item() == pytest.approx(target_peak, abs=1e-6) + assert original_peak_levels[0].item() == pytest.approx(0.5, abs=1e-6) + + # Check item 2 + peak2 = torch.max(torch.abs(normalized_audio['waveform'][1])) + assert peak2.item() == pytest.approx(target_peak, abs=1e-6) + assert original_peak_levels[1].item() == pytest.approx(0.1, abs=1e-6) + + assert original_peak_levels.shape == (2,) + + +def test_normalize_silent_audio(normalize_node): + """Tests that silent audio remains silent and doesn't cause errors.""" + sr = 44100 + silent_audio = {"waveform": torch.zeros(1, 1, sr), "sample_rate": sr} + + (normalized_audio, original_peak_level) = normalize_node.normalize_audio(silent_audio, 1.0) + + # Assert that the output is still silent + assert torch.all(normalized_audio['waveform'] == 0) + # The original peak level should be 0 + assert original_peak_level.item() == 0.0 + + +# --- Tests for AudioApplyBatchedGain --- + +def test_apply_gain_and_revert_normalization(normalize_node, apply_gain_node): + """ + An integration test to verify that applying the `original_peak_level` + from the normalize node correctly reverts the audio to its original state. + """ + sr = 44100 + audio_orig = create_dummy_audio(3, 2, sr, sr, peak_value=0.3) + + # 1. Normalize the audio + (normalized_audio, original_peak_levels) = normalize_node.normalize_audio(audio_orig, 1.0) + + # 2. Apply the original peak levels as gain to the normalized audio + (reverted_audio,) = apply_gain_node.apply_gain(normalized_audio, original_peak_levels) + + # Assertions + # The reverted audio should be almost identical to the original audio + assert reverted_audio['sample_rate'] == audio_orig['sample_rate'] + assert reverted_audio['waveform'].shape == audio_orig['waveform'].shape + # Check that the content is the same (with a small tolerance for float math) + assert torch.allclose(reverted_audio['waveform'], audio_orig['waveform'], atol=1e-6) + + +def test_apply_gain_mismatched_batch_size_error(apply_gain_node): + """ + Tests that an error is raised if the audio batch size and gain values + batch size do not match. + """ + sr = 44100 + audio = create_dummy_audio(3, 1, sr, sr) + # Gain values for only 2 items, but audio has 3 + gain_values = torch.tensor([0.5, 0.5]) + + with pytest.raises(ValueError, match="Batch size of audio .* and gain_values .* must match"): + apply_gain_node.apply_gain(audio, gain_values)