Denormals: rely on NIH-plug's FTZ, drop redundant in-code flush

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 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-18 01:34:56 +09:00
parent 1d939535be
commit d776e564fd
2 changed files with 13 additions and 22 deletions
+7 -5
View File
@@ -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.
+6 -17
View File
@@ -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. 1718) 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)