feat: add SelvaDatasetCompressor node for parallel compression

Mild 2:1-3:1 parallel compression via pedalboard.Compressor to reduce
within-clip loudness variance after LUFS normalization. Blend ratio
keeps transients intact while tightening dynamics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 15:36:27 +02:00
parent 48493a3f0d
commit 15fc5f0793
2 changed files with 80 additions and 0 deletions
+79
View File
@@ -7,6 +7,8 @@ Typical chain:
↓ AUDIO_DATASET
SelvaDatasetLUFSNormalizer (optional)
↓ AUDIO_DATASET
SelvaDatasetCompressor (optional)
↓ AUDIO_DATASET
SelvaDatasetInspector (optional)
↓ AUDIO_DATASET + STRING report
SelvaDatasetItemExtractor → AUDIO (bridges to save/preview nodes)
@@ -201,6 +203,83 @@ class SelvaDatasetLUFSNormalizer:
return (out,)
class SelvaDatasetCompressor:
"""Apply mild parallel compression to reduce within-clip loudness variance.
Uses pedalboard.Compressor (2:13:1 ratio). Parallel (New York) style:
blends compressed signal with dry so transients are preserved while
the dynamic range is gently tightened. Apply after LUFS normalization.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"dataset": (AUDIO_DATASET,),
"threshold_db": ("FLOAT", {
"default": -18.0, "min": -40.0, "max": -6.0, "step": 1.0,
"tooltip": "Compression kicks in above this level. -18 dB is a safe starting point after LUFS normalization.",
}),
"ratio": ("FLOAT", {
"default": 2.5, "min": 1.5, "max": 4.0, "step": 0.5,
"tooltip": "Compression ratio. 2:13:1 is mild; stay below 4:1 to avoid pumping.",
}),
"attack_ms": ("FLOAT", {
"default": 10.0, "min": 1.0, "max": 100.0, "step": 1.0,
"tooltip": "Attack time in ms. Slower attack preserves transients.",
}),
"release_ms": ("FLOAT", {
"default": 100.0, "min": 20.0, "max": 500.0, "step": 10.0,
"tooltip": "Release time in ms.",
}),
"mix": ("FLOAT", {
"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "Parallel blend: 0.0 = dry only, 1.0 = fully compressed. 0.30.5 is typical.",
}),
}
}
RETURN_TYPES = (AUDIO_DATASET,)
RETURN_NAMES = ("dataset",)
FUNCTION = "compress"
CATEGORY = SELVA_CATEGORY
DESCRIPTION = (
"Mild parallel compression to reduce within-clip dynamic range. "
"Blends compressed signal with dry at the given mix ratio. "
"Apply after LUFS normalization."
)
def compress(self, dataset, threshold_db: float, ratio: float,
attack_ms: float, release_ms: float, mix: float):
from pedalboard import Compressor, Pedalboard
board = Pedalboard([Compressor(
threshold_db=threshold_db,
ratio=ratio,
attack_ms=attack_ms,
release_ms=release_ms,
)])
out = []
for item in dataset:
wav = item["waveform"][0] # [C, L]
sr = item["sample_rate"]
# pedalboard expects [C, L] float32 numpy
wav_np = wav.float().numpy() # [C, L]
compressed = board(wav_np, sr) # [C, L]
mixed = (1.0 - mix) * wav_np + mix * compressed
wav_out = torch.from_numpy(mixed).unsqueeze(0) # [1, C, L]
out.append({"waveform": wav_out, "sample_rate": sr, "name": item["name"]})
print(
f"[DatasetCompressor] {len(out)} clips compressed "
f"thr={threshold_db}dB ratio={ratio}:1 mix={mix:.0%}",
flush=True,
)
return (out,)
def _check_hf_shelf(wav: torch.Tensor, sr: int) -> bool:
"""Return True if clip looks codec-compressed (hard HF shelf above 15 kHz).