Γ-W1-T2: the limiter toggle is a mute, not a crossfade — the ceiling holds across both transitions

The equal-gain dry/wet blend let a peak through at (1-m) of its level. Now the
fade rides only the limited path and the hard edge lands on silence.
This commit is contained in:
2026-08-01 20:53:36 -04:00
parent 0612abbddb
commit 6232851c6b
5 changed files with 219 additions and 65 deletions
+36 -27
View File
@@ -36,7 +36,7 @@ void Limiter::prepare(double sampleRate) {
ceiling_ = static_cast<float>(limiterCeilingLinear());
const double rate = sampleRate > 0.0 ? sampleRate : 48000.0;
releaseCoeff_ = static_cast<float>(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate)));
mixStep_ = static_cast<float>(1.0 / (kLimiterCrossfadeSeconds * rate));
switchStep_ = static_cast<float>(1.0 / (kLimiterMuteSeconds * rate));
// Windowed-sinc polyphase interpolator, built here because it costs transcendentals.
// Phase 0's taps all land on sinc zeros except the centre, so it is an exact delay and is
@@ -77,7 +77,7 @@ void Limiter::clearState() {
void Limiter::reset() {
clearState();
active_ = target_.load(std::memory_order_relaxed);
mix_ = active_ ? 1.f : 0.f;
switchGain_ = active_ ? 1.f : 0.f;
primeRemaining_ = 0;
}
@@ -158,11 +158,13 @@ float Limiter::process(float* left, float* right, int frames) {
const bool want = target_.load(std::memory_order_relaxed);
if (!want && !active_) return 1.f; // settled bypass: not one sample read or written
if (want && !active_) {
// A live engage. Start dry, fill the delay line, then crossfade — so the wet path is
// never silence weighted above zero.
// A live engage. The dry path leaves circuit AT THIS SAMPLE rather than fading out:
// fading it would emit unlimited signal at a partial weight, which is a peak over the
// ceiling. Silence covers the delay line's prime, then the fade-in rides the limited
// path, every sample of which is already under the ceiling.
clearState();
active_ = true;
mix_ = 0.f;
switchGain_ = 0.f;
primeRemaining_ = latency_;
}
@@ -183,33 +185,40 @@ float Limiter::process(float* left, float* right, int frames) {
if (stereo) delayR_[slot] = dryR;
delayPos_ = (delayPos_ + 1 == latency_) ? 0 : delayPos_ + 1;
// The endpoints are branches rather than blend arithmetic so a settled state is exact:
// dry + (wet - dry) * 1.0f is not wet in floating point. At m <= 0 the buffer is left
// untouched, which is the dry sample already in it.
const float m = mix_;
// The reported minimum is the gain actually reaching the output, not the limiter's raw
// target — mid-crossfade only a fraction `m` of the reduction is audible, so the meter
// (whose contract is "smallest gain APPLIED") must blend the same way the signal does:
// unity at m=0, `gain` at m=1, linear between.
const float effectiveGain = 1.f - m + m * gain;
if (effectiveGain < blockMin) blockMin = effectiveGain;
if (m >= 1.f) {
// Settled engaged is a branch rather than `wet * 1.0f` so it is bit-exact.
const float s = switchGain_;
if (s >= 1.f) {
left[i] = wetL;
if (stereo) right[i] = wetR;
} else if (m > 0.f) {
left[i] = dryL + (wetL - dryL) * m;
if (stereo) right[i] = dryR + (wetR - dryR) * m;
}
if (primeRemaining_ > 0) {
--primeRemaining_;
} else if (want) {
mix_ = (mix_ + mixStep_ >= 1.f) ? 1.f : mix_ + mixStep_;
} else if (s > 0.f) {
left[i] = wetL * s;
if (stereo) right[i] = wetR * s;
} else {
mix_ = (mix_ - mixStep_ <= 0.f) ? 0.f : mix_ - mixStep_;
left[i] = 0.f;
if (stereo) right[i] = 0.f;
}
const float effectiveGain = s >= 1.f ? gain : s * gain;
if (effectiveGain < blockMin) blockMin = effectiveGain;
// A disengage is tested FIRST so a toggle-off arriving mid-engage abandons the prime
// instead of waiting it out in silence.
if (!want) {
switchGain_ = s - switchStep_;
if (switchGain_ <= 0.f) {
// The disengage completes HERE, sample-accurately: the delay leaves circuit and
// the rest of the block is the dry buffer, untouched. Resuming from silence is
// the accepted discontinuity; fading the dry path back in instead would put
// unlimited signal at a partial weight, which is the leak the ceiling forbids.
switchGain_ = 0.f;
active_ = false;
break;
}
} else if (primeRemaining_ > 0) {
--primeRemaining_;
} else if (s < 1.f) {
switchGain_ = (s + switchStep_ >= 1.f) ? 1.f : s + switchStep_;
}
}
if (!want && mix_ <= 0.f && primeRemaining_ == 0) active_ = false;
return blockMin;
}