[Added][Signal generator] A new node to generate simple waveforms

This commit is contained in:
Salvador E. Tropea
2025-07-16 07:24:30 -03:00
parent 6dc0502582
commit 0915bd1492
3 changed files with 502 additions and 0 deletions
+25
View File
@@ -16,6 +16,7 @@ workflows, especially when dealing with multiple audio inputs or outputs.
- [7. Audio Information](#7-audio-information)
- [8. Audio Cut](#8-audio-cut)
- [9. Audio Blend](#9-audio-blend)
- [10. Audio Test Signal Generator](#10-audio-test-signal-generator)
- [🚀 Installation](#-installation)
- [📦 Dependencies](#-dependencies)
- [🖼️ Examples](#️-examples)
@@ -158,6 +159,30 @@ workflows, especially when dealing with multiple audio inputs or outputs.
- **Output:**
- `audio_out` (AUDIO): The blended audio.
### 10. Audio Test Signal Generator
- **Display Name:** `Audio Test Signal Generator`
- **Internal Name:** `SET_AudioTestSignalGenerator`
- **Category:** `audio/generation`
- **Description:** Generates various standard audio test signals. This node is perfect for creating predictable, clean audio to test or debug other audio processing nodes in your workflow without needing external files.
- **Inputs:**
- `waveform_type` (COMBO): The type of signal to generate.
- **Periodic:** `sine`, `square`, `sawtooth`, `triangle`.
- **Sweep:** `sweep` (a linear frequency chirp from `frequency` to `frequency_end`).
- **Noise:** `white_noise`, `pink_noise`, `brownian_noise`.
- **Special:** `impulse` (a single sample at full amplitude), `silence` (a signal of all zeros, to which a DC offset can be added).
- `frequency` (FLOAT): The fundamental frequency in Hz for periodic waveforms, or the starting frequency for a sweep.
- `frequency_end` (FLOAT): The ending frequency in Hz, used only for the `sweep` waveform type.
- `amplitude` (FLOAT): The peak amplitude of the signal, from 0.0 to 1.0. This is ignored for `silence`.
- `dc_offset` (FLOAT): A constant value to add to the entire signal, shifting it up or down. For the `silence` type, this directly sets the signal's value.
- `phase` (FLOAT): A phase offset in degrees (0-360) for periodic waveforms.
- `duration` (STRING): The total duration of the audio. Supports seconds (`"5.5"`) or time format (`"HH:MM:SS.ss"`).
- `sample_rate` (INT): The sample rate for the output audio (e.g., 44100).
- `batch_size` (INT): The number of identical audio clips to generate in the output batch.
- `channels` (INT): The number of audio channels (1 for mono, 2 for stereo, etc.).
- `seed` (INT, optional): A seed for random number generation, ensuring that noise waveforms are reproducible.
- **Output:**
- `audio_out` (AUDIO): The generated test signal.
## 🚀 Installation
You can install the nodes from the ComfyUI nodes manager, the name is *Audio Batch*, or just do it manually:
+162
View File
@@ -5,6 +5,7 @@
# Project: ComfyUI-AudioBatch
# From code generated by Gemini 2.5 Pro
import torch
import torchaudio
import torchaudio.transforms as T
from .utils.aligner import AudioBatchAligner
from .utils.logger import main_logger
@@ -15,6 +16,7 @@ BASE_CATEGORY = "audio"
BATCH_CATEGORY = "batch"
CONV_CATEGORY = "conversion"
MANIPULATION_CATEGORY = "manipulation"
GEN_CATEGORY = "generation"
class AudioBatch:
@@ -457,3 +459,163 @@ class AudioBlend:
output_audio = {"waveform": blended_waveform, "sample_rate": target_sr}
return (output_audio,)
class AudioTestSignalGenerator:
WAVEFORM_TYPES = ["sine", "square", "sawtooth", "triangle", "sweep",
"white_noise", "pink_noise", "brownian_noise",
"impulse", "silence"]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"waveform_type": (cls.WAVEFORM_TYPES, {"default": "sine"}),
"frequency": ("FLOAT", {"default": 440.0, "min": 0.0, "max": 22050.0, "step": 1.0,
"tooltip": "Frequency in Hz for periodic waveforms (or start frequency for sweep)."}),
"frequency_end": ("FLOAT", {"default": 880.0, "min": 0.0, "max": 22050.0, "step": 1.0,
"tooltip": "End frequency in Hz for 'sweep' waveform type."}),
"amplitude": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "Amplitude of the signal (0.0 to 1.0)."}),
"dc_offset": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01,
"tooltip": "DC offset to add to the signal."}),
"phase": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0,
"tooltip": "Phase offset in degrees (0-360)."}),
# "duration_seconds": ("FLOAT", {"default": 3.0, "min": 0.01, "max": 3600.0, "step": 0.1}),
"duration": ("STRING", {
"default": "3.0",
"tooltip": "Duration time in HH:MM:SS.ss format, or just a float for seconds."
}),
"sample_rate": ("INT", {"default": 44100, "min": 1, "max": 192000, "step": 100}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1}),
"channels": ("INT", {"default": 1, "min": 1, "max": 8, "step": 1,
"tooltip": "Number of audio channels (1=mono, 2=stereo, etc.)."}),
},
"optional": { # To allow for deterministic noise generation
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
}
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio_out",)
FUNCTION = "generate_signal"
CATEGORY = BASE_CATEGORY + "/" + GEN_CATEGORY
DESCRIPTION = "Generates various audio test signals like sine waves, noise, and sweeps."
UNIQUE_NAME = "SET_AudioTestSignalGenerator"
DISPLAY_NAME = "Audio Test Signal Generator"
def generate_signal(self, waveform_type: str, frequency: float, frequency_end: float,
amplitude: float, dc_offset: float, phase: float,
duration: str, sample_rate: int,
batch_size: int, channels: int, seed: int = 0):
duration_seconds = parse_time_to_seconds(duration)
num_samples = int(duration_seconds * sample_rate)
if num_samples <= 0:
logger.warning("Duration and sample rate result in 0 or negative samples. Outputting empty audio.")
waveform = torch.empty((batch_size, channels, 0), dtype=torch.float32)
return ({"waveform": waveform, "sample_rate": sample_rate},)
# Create the time vector `t` for one channel
t = torch.linspace(0., duration_seconds, num_samples)
# Convert phase from degrees to radians
phase_rad = torch.deg2rad(torch.tensor(phase))
waveform_1d = None
waveform_multichannel = None
if waveform_type == "sine":
waveform_1d = torch.sin(2 * torch.pi * frequency * t + phase_rad)
elif waveform_type == "square":
# A square wave is the sign of a sine wave
waveform_1d = torch.sign(torch.sin(2 * torch.pi * frequency * t + phase_rad))
# Avoid pure 0s for a cleaner square wave if sine hits exactly 0
waveform_1d[waveform_1d == 0] = 1.0
elif waveform_type == "sawtooth":
# A sawtooth wave is related to the modulo of the time component
waveform_1d = 2 * (t * frequency - torch.floor(0.5 + t * frequency))
elif waveform_type == "triangle":
# A triangle wave can be derived from the absolute value of a sawtooth
sawtooth = 2 * (t * frequency - torch.floor(0.5 + t * frequency))
waveform_1d = 2 * torch.abs(sawtooth) - 1
elif waveform_type == "sweep":
# Linear frequency sweep (chirp) from `frequency` to `frequency_end`
logger.debug(f"Generating linear sweep from {frequency}Hz to {frequency_end}Hz.")
# Instantaneous frequency: f(t) = f_start + (f_end - f_start) * (t / duration)
# Phase is the integral of instantaneous frequency
f_start = frequency
f_end = frequency_end
k = (f_end - f_start) / duration_seconds # Sweep rate
# Phase = 2 * pi * (f_start * t + (k/2) * t^2)
phase_sweep = 2 * torch.pi * (f_start * t + (k / 2.0) * (t ** 2))
waveform_1d = torch.sin(phase_sweep + phase_rad)
elif "noise" in waveform_type:
generator = torch.Generator().manual_seed(seed)
noise_shape = (batch_size, channels, num_samples)
# Use torchaudio's colored_noise if available for pink/brownian
if waveform_type != "white_noise" and hasattr(torchaudio.functional, 'colored_noise'):
try:
exponent = -1 if waveform_type == "pink_noise" else -2
# Reshape for torchaudio function and then back
flat_noise = torchaudio.functional.colored_noise(
size=(batch_size * channels, num_samples),
exponent=exponent,
generator=generator
)
waveform_multichannel = flat_noise.reshape(noise_shape)
except Exception as e:
logger.warning(f"torchaudio.functional.colored_noise failed ({e}). Falling back to simpler methods.")
waveform_multichannel = None # Signal fallback
if waveform_multichannel is None: # Fallback or white_noise
white_noise = torch.rand(noise_shape, generator=generator) * 2 - 1
if waveform_type == "brownian_noise":
brownian_unscaled = torch.cumsum(white_noise, dim=2)
max_abs, _ = torch.max(torch.abs(brownian_unscaled), dim=2, keepdim=True)
waveform_multichannel = brownian_unscaled / (max_abs + 1e-9)
else: # For white_noise or as a fallback for pink_noise
if waveform_type == "pink_noise":
logger.warning("Using white noise as a fallback for pink noise generation.")
waveform_multichannel = white_noise
elif waveform_type == "impulse":
waveform_1d = torch.zeros(num_samples)
if num_samples > 0:
waveform_1d[0] = 1.0 # Single sample impulse at the beginning
elif waveform_type == "silence":
# The amplitude and dc_offset will be applied correctly later.
waveform_1d = torch.zeros(num_samples)
else:
logger.error(f"Unknown waveform type: {waveform_type}. Defaulting to silence.")
waveform_1d = torch.zeros(num_samples)
# Apply amplitude and DC offset to periodic/single-channel waveforms
if waveform_1d is not None:
waveform_1d = waveform_1d * amplitude + dc_offset
# Expand to the correct number of channels and batch size
# (1, num_samples) -> (1, 1, num_samples) -> (batch_size, channels, num_samples)
waveform_multichannel = waveform_1d.unsqueeze(0).unsqueeze(0).repeat(batch_size, channels, 1)
elif 'noise' not in waveform_type: # if waveform_multichannel was not created in noise block
logger.error("Waveform generation failed. Outputting silence.")
waveform_multichannel = torch.zeros((batch_size, channels, num_samples))
# Final clip to ensure [-1, 1] range, as some operations might exceed it slightly
final_waveform = torch.clamp(waveform_multichannel, -1.0, 1.0)
logger.info(f"Generated '{waveform_type}' signal. Shape: {final_waveform.shape}, SR: {sample_rate}Hz")
output_audio = {
"waveform": final_waveform,
"sample_rate": sample_rate
}
return (output_audio,)
@@ -0,0 +1,315 @@
"""
Functional and regression tests for the AudioTestSignalGenerator node.
"""
import bootstrap # noqa: F401
import logging
import pytest
import torch
import torchaudio.transforms as T
# Import the class and utility function to be tested
from nodes.nodes_audio import AudioTestSignalGenerator, logger as node_logger
# from utils.misc import parse_time_to_seconds
# Configure logging for tests to see output from the node
node_logger.setLevel(logging.DEBUG)
# --- Pytest Fixtures (Reusable Setup) ---
@pytest.fixture
def signal_gen_node():
"""Provides an instance of the AudioTestSignalGenerator node for tests."""
return AudioTestSignalGenerator()
# --- Helper Functions for Strict Testing ---
def get_peak_frequency(waveform: torch.Tensor, sample_rate: int) -> float:
"""Helper to find the dominant frequency in a waveform using FFT."""
if waveform.ndim > 1:
# Use the first channel and first batch item for analysis
waveform = waveform.squeeze()
if waveform.ndim > 1:
waveform = waveform[0]
# Perform Real Fast Fourier Transform
fft_result = torch.fft.rfft(waveform)
# Get frequency bins for the FFT result
freq_bins = torch.fft.rfftfreq(n=waveform.size(-1), d=1.0 / sample_rate)
# Find the index of the maximum magnitude in the FFT
peak_index = torch.argmax(torch.abs(fft_result))
# Get the frequency corresponding to that peak index
peak_freq = freq_bins[peak_index].item()
return peak_freq
# --- Test Cases ---
def test_generator_basic_sine_shape_and_type(signal_gen_node):
"""Tests basic generation of a sine wave, checking output structure."""
(result_audio,) = signal_gen_node.generate_signal(
waveform_type="sine", frequency=440.0, frequency_end=880.0,
amplitude=0.8, dc_offset=0.0, phase=0.0, duration="2.0",
sample_rate=44100, batch_size=2, channels=2, seed=0
)
assert isinstance(result_audio, dict)
assert "waveform" in result_audio
assert "sample_rate" in result_audio
waveform = result_audio['waveform']
sr = result_audio['sample_rate']
assert isinstance(waveform, torch.Tensor)
assert sr == 44100
expected_samples = int(2.0 * sr)
assert waveform.shape == (2, 2, expected_samples) # B, C, N
assert waveform.dtype == torch.float32
@pytest.mark.parametrize("duration_str, expected_seconds", [
("5", 5.0),
("2.5", 2.5),
("0:10", 10.0),
("0:01.5", 1.5),
("1:02:03", 3723.0)
])
def test_generator_duration_parsing(signal_gen_node, duration_str, expected_seconds):
"""Tests if the duration string is parsed correctly to produce the right length."""
sr = 16000
(result_audio,) = signal_gen_node.generate_signal(
waveform_type="sine", frequency=100.0, frequency_end=200.0,
amplitude=1.0, dc_offset=0.0, phase=0.0, duration=duration_str,
sample_rate=sr, batch_size=1, channels=1, seed=0
)
expected_samples = int(expected_seconds * sr)
actual_samples = result_audio['waveform'].shape[2]
assert actual_samples == expected_samples
def test_generator_fft_verification_sine(signal_gen_node):
"""
Strict Test: Verifies that a generated sine wave has the correct peak frequency.
"""
sr = 44100
target_freq = 1000.0
(result_audio,) = signal_gen_node.generate_signal(
waveform_type="sine", frequency=target_freq, frequency_end=0.0,
amplitude=0.9, dc_offset=0.0, phase=0.0, duration="1.0",
sample_rate=sr, batch_size=1, channels=1, seed=0
)
peak_freq = get_peak_frequency(result_audio['waveform'], result_audio['sample_rate'])
print(f"Target Frequency: {target_freq} Hz, Detected Peak Frequency: {peak_freq:.2f} Hz")
# Assert that the detected frequency is very close to the target.
# The tolerance accounts for the resolution of the FFT (bin width).
assert peak_freq == pytest.approx(target_freq, abs=1.0)
def test_generator_amplitude_and_offset(signal_gen_node):
"""
Strict Test: Verifies that amplitude and DC offset are correctly applied.
"""
amplitude = 0.7
offset = -0.1
(result_audio,) = signal_gen_node.generate_signal(
waveform_type="sine", frequency=100.0, frequency_end=0.0,
amplitude=amplitude, dc_offset=offset, phase=90.0, # Phase 90 ensures we hit the max value at t=0
duration="1.0", sample_rate=44100, batch_size=1, channels=1, seed=0
)
waveform = result_audio['waveform']
expected_max = amplitude + offset
expected_min = -amplitude + offset
# Check that the max and min values of the waveform are close to expected
assert torch.max(waveform).item() == pytest.approx(expected_max, abs=1e-5)
assert torch.min(waveform).item() == pytest.approx(expected_min, abs=1e-5)
def test_generator_noise_reproducibility_with_seed(signal_gen_node):
"""
Strict Test: Verifies that using the same seed produces identical noise,
and different seeds produce different noise.
"""
params = {
"waveform_type": "white_noise", "frequency": 0.0, "frequency_end": 0.0,
"amplitude": 1.0, "dc_offset": 0.0, "phase": 0.0, "duration": "0.5",
"sample_rate": 16000, "batch_size": 1, "channels": 1
}
# Generate twice with the same seed
(result1,) = signal_gen_node.generate_signal(**params, seed=123)
(result2,) = signal_gen_node.generate_signal(**params, seed=123)
# Generate once with a different seed
(result3,) = signal_gen_node.generate_signal(**params, seed=456)
# Assertions
assert torch.equal(result1['waveform'], result2['waveform'])
assert not torch.equal(result1['waveform'], result3['waveform'])
def test_generator_impulse_content(signal_gen_node):
"""
Strict Test: Verifies that an impulse waveform is correct.
"""
amplitude = 0.9
offset = 0.05
(result_audio,) = signal_gen_node.generate_signal(
waveform_type="impulse", frequency=0.0, frequency_end=0.0,
amplitude=amplitude, dc_offset=offset, phase=0.0, duration="0.1",
sample_rate=100, batch_size=1, channels=1, seed=0
)
waveform = result_audio['waveform'].squeeze() # Get 1D tensor
# The first sample should be the impulse
expected_impulse_value = amplitude + offset
assert waveform[0].item() == pytest.approx(expected_impulse_value)
# All other samples should be just the DC offset
assert torch.all(torch.isclose(waveform[1:], torch.tensor(offset)))
def linear_regression(x, y):
"""
Performs a simple linear regression and returns the slope and intercept.
Uses torch for calculations.
"""
x_mean = torch.mean(x)
y_mean = torch.mean(y)
numerator = torch.sum((x - x_mean) * (y - y_mean))
denominator = torch.sum((x - x_mean) ** 2)
if denominator == 0:
return 0.0, y_mean.item() # No slope if x is constant
slope = numerator / denominator
intercept = y_mean - slope * x_mean
return slope.item(), intercept.item()
def test_generator_sweep_frequency_trend(signal_gen_node):
"""
Strict Test (Corrected Again): Verifies a linear sweep has a constant rate of
frequency change using STFT and linear regression.
"""
sr = 44100
f_start = 500.0
f_end = 8000.0
duration_seconds = 2.0
(result_audio,) = signal_gen_node.generate_signal(
waveform_type="sweep", frequency=f_start, frequency_end=f_end,
amplitude=1.0, dc_offset=0.0, phase=0.0, duration=str(duration_seconds),
sample_rate=sr, batch_size=1, channels=1, seed=0
)
waveform = result_audio['waveform'].squeeze()
# --- Use STFT to analyze the frequency over time ---
n_fft = 2048
hop_length = 512
spectrogram_transform = T.Spectrogram(n_fft=n_fft, hop_length=hop_length, power=2.0)
spectrogram = spectrogram_transform(waveform) # (freq_bins, time_frames)
# Get the frequency and time axes for the spectrogram
fft_freqs = torch.fft.rfftfreq(n=n_fft, d=1.0 / sr)
time_bins = torch.linspace(0, duration_seconds, steps=spectrogram.shape[1])
# For each time slice, find the index of the peak frequency
peak_freq_indices = torch.argmax(torch.abs(spectrogram), dim=0)
# Convert these indices to actual frequencies
detected_peak_freqs = fft_freqs[peak_freq_indices]
# We might get some noise at the very beginning/end, so let's analyze the stable middle part
start_idx_for_fit = 10
end_idx_for_fit = -10
if len(time_bins) <= start_idx_for_fit * 2: # Handle very short signals
start_idx_for_fit = 0
end_idx_for_fit = len(time_bins)
time_for_fit = time_bins[start_idx_for_fit:end_idx_for_fit]
freqs_for_fit = detected_peak_freqs[start_idx_for_fit:end_idx_for_fit]
# --- Perform Linear Regression ---
# We are fitting a line: frequency = slope * time + intercept
detected_slope, detected_intercept = linear_regression(time_for_fit, freqs_for_fit)
# --- Calculate Theoretical Values ---
theoretical_slope = (f_end - f_start) / duration_seconds
theoretical_intercept = f_start
logging.info("\n--- Sweep Linear Regression Test ---")
logging.info(f"Theoretical Slope: {theoretical_slope:.2f} Hz/s")
logging.info(f"Detected Slope: {detected_slope:.2f} Hz/s")
logging.info(f"Theoretical Intercept (Start Freq): {theoretical_intercept:.2f} Hz")
logging.info(f"Detected Intercept: {detected_intercept:.2f} Hz")
# Assertions:
# 1. The detected slope should be very close to the theoretical rate of change.
assert detected_slope == pytest.approx(theoretical_slope, rel=0.05) # 5% tolerance is reasonable
# 2. The detected intercept should be close to the starting frequency.
# This is still subject to some error from the first few windows, so give it some tolerance.
assert detected_intercept == pytest.approx(theoretical_intercept, rel=0.1) # 10% tolerance
def test_generator_silence_and_dc_offset(signal_gen_node):
"""
Strict Test: Verifies the 'silence' waveform type and that it correctly
applies the dc_offset.
"""
sample_rate = 1000 # Use a low SR for a small tensor
duration = "0.5" # 0.5 seconds
num_samples = int(0.5 * sample_rate)
target_offset = 0.25
# Generate a "silence" waveform with a non-zero DC offset.
# Amplitude should have no effect.
(result_audio,) = signal_gen_node.generate_signal(
waveform_type="silence",
frequency=100.0, # Should be ignored
frequency_end=800.0, # Should be ignored
amplitude=0.8, # Should be ignored
dc_offset=target_offset,
phase=0.0,
duration=duration,
sample_rate=sample_rate,
batch_size=2,
channels=2,
seed=0
)
waveform = result_audio['waveform']
# 1. Check shape and SR
assert waveform.shape == (2, 2, num_samples)
assert result_audio['sample_rate'] == sample_rate
# 2. Check content
# The entire tensor should be filled with the dc_offset value.
expected_waveform = torch.full_like(waveform, fill_value=target_offset)
assert torch.allclose(waveform, expected_waveform)
# 3. Test pure silence (dc_offset = 0)
(result_pure_silence,) = signal_gen_node.generate_signal(
waveform_type="silence",
dc_offset=0.0,
# ... other params ...
frequency=100.0, frequency_end=800.0, amplitude=0.8, phase=0.0, duration=duration,
sample_rate=sample_rate, batch_size=1, channels=1, seed=0
)
assert torch.all(result_pure_silence['waveform'] == 0.0)