From d776e564fd9bfa7c248c57868abfe9dc9c0a5e37 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Thu, 18 Jun 2026 01:34:56 +0900 Subject: [PATCH] Denormals: rely on NIH-plug's FTZ, drop redundant in-code flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated the planned global FTZ for the IIR filters and found NIH-plug already handles it: process_wrapper wraps process()/reset() in a ScopedFtz guard that enables CPU Flush-To-Zero (x86 MXCSR bit 15 / AArch64 FPCR bit 24, via inline asm, restored on drop) on the vst3, clap, and standalone paths. SSE is baseline on x86_64 so FTZ is always active for our build. So adding our own guard would just duplicate the framework. Instead, removed the now-redundant flush_denormal() from compressor.rs (the biquads and envelope/RMS tails already relied on this FTZ) for a single consistent story, and rewrote the README 'Denormal flushing' note to document that the framework handles it (FTZ, not DAZ — sufficient for our feed-forward IIR). No functional change; 10 unit tests still pass. Co-Authored-By: Claude Opus 4.8 --- README.md | 12 +++++++----- src/dsp/compressor.rs | 23 ++++++----------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 03b517d..79c8f4b 100644 --- a/README.md +++ b/README.md @@ -221,11 +221,13 @@ must be pre-allocated in `initialize()`. Use `assert_process_allocs` feature fla development to catch violations. ### Denormal flushing -The compressor flushes its envelope/RMS state to zero in code once it decays below audibility -(`flush_denormal` in `compressor.rs`). The hardware `_MM_SET_FLUSH_ZERO_MODE` intrinsic is now -deprecated and the matching DAZ helper isn't exposed by `std::arch`, so a global hardware FTZ/DAZ -(via inline asm on the audio thread) is deferred until the IIR crossover/limiter filters land, -where it matters more. +Handled by the framework — no plugin code needed. NIH-plug wraps `process()` and `reset()` in +`process_wrapper`, which enables the CPU's **Flush-To-Zero** mode for the duration via its +`ScopedFtz` guard (x86 `MXCSR` bit 15 / AArch64 `FPCR` bit 24, set with inline asm and restored +on drop). FTZ has a fixed threshold at the normal/subnormal boundary (~−759 dB for f32), so the +decaying envelope/RMS tails and all the IIR filter state are flushed to zero automatically, +far below audibility. We therefore do **not** set the register ourselves or flush values in code. +(Note: NIH-plug sets FTZ but not DAZ; for our feed-forward IIR work FTZ on results is sufficient.) ### Parameter smoothing NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise. diff --git a/src/dsp/compressor.rs b/src/dsp/compressor.rs index 515c13d..2565cbb 100644 --- a/src/dsp/compressor.rs +++ b/src/dsp/compressor.rs @@ -30,16 +30,9 @@ const LEVEL_EPS: f32 = 1e-12; /// promoted to a parameter later. const RMS_WINDOW_MS: f32 = 5.0; -/// Flush a decaying envelope value to zero once it is far below audibility, so the -/// exponential tail can't drift into denormal range (which causes CPU spikes). -#[inline] -fn flush_denormal(x: f32) -> f32 { - if x.abs() < 1e-30 { - 0.0 - } else { - x - } -} +// Denormals (the exponentially-decaying envelope/RMS tails and the IIR filter state) are handled +// by the CPU's Flush-To-Zero mode, which NIH-plug enables around `process()`/`reset()` via its +// `ScopedFtz` guard (x86 MXCSR / AArch64 FPCR). So no per-value flushing is needed here. /// Per-block compressor settings. Cheap to copy; rebuilt each process block from params. #[derive(Clone, Copy)] @@ -195,9 +188,7 @@ impl Compressor { } else { // RMS = running mean of the linked squared level over a fixed window. Updated // whenever active (regardless of mode) so switching peak<->RMS is seamless. - self.mean_sq = flush_denormal( - self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak, - ); + self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak; let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak }; let level_db = 20.0 * (detector + LEVEL_EPS).log10(); // Desired attenuation in dB, as a positive quantity. @@ -206,10 +197,8 @@ impl Compressor { // Smooth, decoupled peak detector (Giannoulis eq. 17–18) on the attenuation: // y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold") // yl = attack-smoothed y1 - self.y1 = flush_denormal( - target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target), - ); - self.yl = flush_denormal(set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1); + self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target); + self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1; let total_db = set.makeup_db - self.yl; 10.0f32.powf(total_db / 20.0)