From 6232851c6bfb071c737795b220ed7e731da15ab6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 20:53:36 -0400 Subject: [PATCH] =?UTF-8?q?=CE=93-W1-T2:=20the=20limiter=20toggle=20is=20a?= =?UTF-8?q?=20mute,=20not=20a=20crossfade=20=E2=80=94=20the=20ceiling=20ho?= =?UTF-8?q?lds=20across=20both=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/instrument/CLAUDE.md | 2 +- src/core/instrument/engine/limiter.cpp | 63 +++--- src/core/instrument/engine/limiter.h | 35 ++-- src/shell/instrument/reasampler_processor.cpp | 2 +- tests/test_limiter.cpp | 182 +++++++++++++++--- 5 files changed, 219 insertions(+), 65 deletions(-) diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index c9fa2ee..055c9e4 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -292,7 +292,7 @@ anything for a trigger shape. - `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header. - `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE Fritsch–Carlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [−1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`. - `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift. -- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental; the engage/disengage crossfade is the codebase's standing ramp-every-gain-path-change rule applied to a limiter switching in. +- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked −0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental. **Switching is a MUTE, never a blend:** unlimited signal is emitted at weight 1 (the untouched bypass buffer) or at weight 0 and never in between, because a fraction of an unlimited signal is a peak over the ceiling — so the fade always rides the limited path and the hard edge always lands on the bypassed side, against silence. Do not reintroduce an equal-gain dry/wet crossfade over the toggle. - `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over −60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws. ### `map/` diff --git a/src/core/instrument/engine/limiter.cpp b/src/core/instrument/engine/limiter.cpp index 5837ca6..7cd86b2 100644 --- a/src/core/instrument/engine/limiter.cpp +++ b/src/core/instrument/engine/limiter.cpp @@ -36,7 +36,7 @@ void Limiter::prepare(double sampleRate) { ceiling_ = static_cast(limiterCeilingLinear()); const double rate = sampleRate > 0.0 ? sampleRate : 48000.0; releaseCoeff_ = static_cast(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate))); - mixStep_ = static_cast(1.0 / (kLimiterCrossfadeSeconds * rate)); + switchStep_ = static_cast(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; } diff --git a/src/core/instrument/engine/limiter.h b/src/core/instrument/engine/limiter.h index 346907d..41aad09 100644 --- a/src/core/instrument/engine/limiter.h +++ b/src/core/instrument/engine/limiter.h @@ -28,10 +28,11 @@ inline constexpr double kLimiterLookaheadSeconds = 0.002; // no-overshoot bound survives it unchanged. inline constexpr double kLimiterReleaseSeconds = 0.100; -// The engage/disengage crossfade. A limiter engaging is a gain-path change and this codebase -// ramps every gain-path change; it also covers the window before the host acts on the latency -// change, which is the plugin's to keep clean because the host schedules that, not us. -inline constexpr double kLimiterCrossfadeSeconds = 0.010; +// The transition mute. Long enough that the fade is not itself an edge and that it dwarfs the +// 2 ms delay-line prime it covers; short enough that the whole muted window (prime + fade) is +// ~12 ms rather than a gap. Linear in amplitude, not equal-power: this fades ONE leg to +// silence, it does not cross two. +inline constexpr double kLimiterMuteSeconds = 0.010; // 4x true-peak oversampling (ITU-R BS.1770's floor at 48 kHz) over an 8-tap-per-phase // polyphase interpolator. The 33-tap prototype's centre tap makes phase 0 an exact 4-sample @@ -53,6 +54,15 @@ int limiterLookaheadSamples(double sampleRate); // then a MOVING AVERAGE of the same width. Every term of that average is a minimum whose own // window contains the sample being gained, so the smoothed gain is <= the target gain at every // sample by construction — the ceiling is held structurally rather than by a tuned attack. +// +// SWITCHING IS A MUTE, NOT A BLEND. Unlimited signal is emitted at weight 1 (settled bypass, +// which is the untouched buffer) or at weight 0, never in between — a fraction of an unlimited +// signal is a peak above the ceiling, which is exactly the leak this design forbids. So the +// FADE always rides the limited path (any weight of it is already under the ceiling, since the +// mute only scales down) and the HARD EDGE always lands on the bypassed side, against silence: +// engaging mutes at once, holds while the delay line primes, then fades the limited path in; +// disengaging fades the limited path out and resumes the dry buffer from silence. That +// discontinuity is accepted; a spike is not. class Limiter { public: // Sizes the delay line, the detector and the smoothers, and snaps to the current enable @@ -60,7 +70,7 @@ public: void prepare(double sampleRate); // Clears the delay line and the detector and snaps to the current enable state, skipping - // the engage crossfade — an activation has nothing sounding to be continuous with. + // the transition mute — an activation has nothing sounding to be continuous with. // Main/UI thread only (the host guarantees process() is stopped at both call sites). void reset(); @@ -70,10 +80,9 @@ public: // Applies the limiter in place over `frames` of `left` (and `right`, which may be null for // a mono buffer). Returns the SMALLEST gain actually applied to the output this block — 1.0 - // for none (a settled bypass, or wherever the engage/disengage crossfade sits at dry). Mid - // crossfade this is the target gain blended by the same fraction `mix_` blends the signal, - // not the limiter's raw target — the two must agree, or the meter over-reports reduction - // that is only partially audible. + // for a settled bypass, 0.0 anywhere the transition mute is at silence. The transition mute + // counts because the contract is the gain that REACHED the output: the reported value and + // the signal are scaled by the same factor, or the meter and the bus disagree. float process(float* left, float* right, int frames); private: @@ -91,7 +100,7 @@ private: int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1 float ceiling_ = 1.f; float releaseCoeff_ = 1.f; - float mixStep_ = 1.f; + float switchStep_ = 1.f; float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay) // --- audio-thread state --- @@ -110,9 +119,9 @@ private: double avgSum_ = 0.0; // double: the running sum is added to and subtracted from forever int avgPos_ = 0; float releaseGain_ = 1.f; - bool active_ = false; // the limiter path is running (engaged, or mid-crossfade) - float mix_ = 0.f; // 0 = dry, 1 = limited - int primeRemaining_ = 0; // samples the crossfade waits on while the delay line fills + bool active_ = false; // the limited path is in circuit (engaged, or still fading out) + float switchGain_ = 0.f; // the transition mute; only ever scales the LIMITED path + int primeRemaining_ = 0; // samples held at silence while the delay line fills }; } // namespace reasampler::instrument::engine diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index 7c173d3..89c65c4 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -97,7 +97,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { reloadInstrument(); // The host performs this deactivate/reactivate whenever it acts on a kLatencyChanged // request, so the limiter starts each activation with an empty delay line and snapped - // to its persisted state — no crossfade, because there is nothing sounding to be + // to its persisted state — no transition mute, because there is nothing sounding to be // continuous with once the block above has destroyed every voice. limiter_.reset(); } else { diff --git a/tests/test_limiter.cpp b/tests/test_limiter.cpp index ed65cd3..44fe03e 100644 --- a/tests/test_limiter.cpp +++ b/tests/test_limiter.cpp @@ -10,7 +10,11 @@ // * the detection is TRUE-peak: a signal whose SAMPLES all clear the ceiling but whose // inter-sample peak does not still engages; // * the gain is stereo-linked, so a dual-mono signal stays centered across a full toggle; -// * the engage/disengage crossfade leaves no step larger than the signal's own. +// * across a toggle in EITHER direction, every output sample is under the ceiling or exactly +// the unlimited input — never a fraction of the unlimited input, which is the leak the +// retired equal-gain crossfade admitted; +// * the transition's only two discontinuities are the hard edges against silence, one per +// direction. #include "../src/core/instrument/engine/limiter.h" @@ -146,7 +150,7 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { bool centered = true; for (std::size_t i = 0; i < l.size(); i += static_cast(block)) { // Toggle on a quarter in and off three quarters in, so the run covers bypassed, - // the engage crossfade, fully engaged, the disengage crossfade, and bypassed again. + // the engage mute, fully engaged, the disengage fade, and bypassed again. if (i >= l.size() / 4 && !lim.enabled()) lim.setEnabled(true); if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); const int n = static_cast( @@ -167,42 +171,172 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { CHECK(worstEngaged > 0.f); } -static void testToggleEmitsNoStepLargerThanTheSignalsOwn() { - // A steady sine: the crossfade blends it with a copy of itself delayed by the lookahead, - // which at 440 Hz is nearly half a cycle out — switching hard instead of fading would step - // by up to twice the amplitude, so this assertion has real teeth. - const double freq = 440.0; - const double amp = 0.5; // under the ceiling: this measures the TRANSITION, not limiting - std::vector x(48000); - for (std::size_t i = 0; i < x.size(); ++i) { - x[i] = static_cast( +static void testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence() { + // Replaces the retired crossfade's "no step larger than the signal's own", which no longer + // describes the design: the mute has exactly ONE hard edge per direction, both against + // silence, and everything between them is continuous. A steady sine well under the + // ceiling, so this measures the TRANSITION and not limiting. 375 Hz is one cycle per 128 + // samples, so a block-aligned toggle lands on a phase the test can state rather than + // inherit — at a zero crossing the engage edge would be small for a reason that has + // nothing to do with the design. + const double freq = 375.0; // kRate / 128 + const double amp = 0.5; + const int block = 32; + const int engageAt = 12064; // block-aligned AND one sample past the sine's peak + const int disengageAt = 36064; + std::vector in(48000); + for (std::size_t i = 0; i < in.size(); ++i) { + in[i] = static_cast( amp * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast(i) / kRate)); } + std::vector y = in; const float naturalStep = static_cast(amp * 2.0 * 3.14159265358979323846 * freq / kRate); + // One fade step's worth of signal: the disengage's last emitted sample sits at most this + // far above zero, because the fade is stepped AFTER the sample it weighted. + const float silenceFloor = + static_cast(amp / (kLimiterMuteSeconds * kRate)) * 1.01f; + CHECK(std::fabs(in[static_cast(engageAt) - 1]) > 0.4f); // the edge has teeth Limiter lim; lim.prepare(kRate); - const int block = 32; - for (std::size_t i = 0; i < x.size(); i += static_cast(block)) { - if (i >= x.size() / 4 && !lim.enabled()) lim.setEnabled(true); - if (i >= (x.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); + for (std::size_t i = 0; i < y.size(); i += static_cast(block)) { + if (static_cast(i) >= engageAt && !lim.enabled()) lim.setEnabled(true); + if (static_cast(i) >= disengageAt && lim.enabled()) lim.setEnabled(false); const int n = static_cast( - std::min(static_cast(block), x.size() - i)); - lim.process(x.data() + i, nullptr, n); + std::min(static_cast(block), y.size() - i)); + lim.process(y.data() + i, nullptr, n); } + + // Engage: the dry path leaves circuit AT the toggle sample, in one step to silence — the + // sample before it is still the untouched dry buffer, never a partial weight of it. + CHECK(y[static_cast(engageAt) - 1] == in[static_cast(engageAt) - 1]); + CHECK(y[static_cast(engageAt)] == 0.f); + + // Disengage: one resume edge, out of near-silence straight into the untouched dry buffer, + // and nothing written after it. + std::size_t lastTouched = 0; + for (std::size_t i = 0; i < y.size(); ++i) { + if (y[i] != in[i]) lastTouched = i; + } + CHECK(static_cast(lastTouched) > disengageAt); + CHECK(std::fabs(y[lastTouched]) <= silenceFloor); + bool dryAfterResume = true; + for (std::size_t i = lastTouched + 1; i < y.size(); ++i) { + if (y[i] != in[i]) { dryAfterResume = false; break; } + } + CHECK(dryAfterResume); + + // Everything BETWEEN the two edges is continuous — both fades and the settled middle. float worstStep = 0.f; - for (std::size_t i = 1; i < x.size(); ++i) { - worstStep = std::max(worstStep, std::fabs(x[i] - x[i - 1])); + for (std::size_t i = static_cast(engageAt) + 1; i <= lastTouched; ++i) { + worstStep = std::max(worstStep, std::fabs(y[i] - y[i - 1])); } CHECK(worstStep <= naturalStep * 1.2f); + // And the run really was muted, so the continuity above is not an untouched buffer's. + bool sawSilenceOverSignal = false; + for (std::size_t i = 0; i < y.size(); ++i) { + if (y[i] == 0.f && std::fabs(in[i]) > 0.4f) { sawSilenceOverSignal = true; break; } + } + CHECK(sawSilenceOverSignal); } -static void testCrossfadeSettlesToTheExactEngagedAndBypassedPaths() { +// The one rule the transition encodes: every output sample is EITHER under the ceiling OR +// exactly the unlimited input. A fraction of the unlimited input is neither, which is why the +// retired equal-gain crossfade could pass a peak over the ceiling mid-transition. +static bool underCeilingOrExactlyDry(float y, float x, float ceiling) { + return std::fabs(y) <= ceiling * (1.f + 1e-6f) || y == x; +} + +static void testUnlimitedSignalIsNeverEmittedAtAPartialWeight() { + const float ceiling = static_cast(limiterCeilingLinear()); + // +12 dB over the ceiling for the WHOLE run, so the transition windows are driven, not + // merely crossed while quiet. + const std::vector in = pattern(48000, ceiling * 3.98f); + std::vector y = in; + + Limiter lim; + lim.prepare(kRate); + const int block = 64; + for (std::size_t i = 0; i < y.size(); i += static_cast(block)) { + if (i >= y.size() / 4 && !lim.enabled()) lim.setEnabled(true); // engage + if (i >= (y.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); // disengage + const int n = static_cast( + std::min(static_cast(block), y.size() - i)); + lim.process(y.data() + i, nullptr, n); + } + + bool held = true; + bool sawLimited = false, sawMuted = false, sawDry = false; + for (std::size_t i = 0; i < y.size(); ++i) { + if (!underCeilingOrExactlyDry(y[i], in[i], ceiling)) { held = false; break; } + if (y[i] != in[i] && y[i] != 0.f) sawLimited = true; + if (y[i] == 0.f && std::fabs(in[i]) > ceiling) sawMuted = true; + if (y[i] == in[i] && std::fabs(in[i]) > ceiling) sawDry = true; + } + CHECK(held); + // Each of the three states the rule distinguishes actually occurred, so `held` is not + // satisfied by a buffer that was only ever passed through. + CHECK(sawLimited); + CHECK(sawMuted); + CHECK(sawDry); +} + +static void testALoudTransientInFlightAtTheToggleCannotSpike() { + // The toggle flipped while a transient 18 dB over the ceiling is in flight, swept across + // the whole transition window (the 2 ms prime, the 10 ms fade, and past both) in each + // direction. Nothing anywhere may land between silence and the unlimited input. + const float ceiling = static_cast(limiterCeilingLinear()); + const int latency = limiterLookaheadSamples(kRate); + const int fade = static_cast(kLimiterMuteSeconds * kRate); + const int block = 32; + const int toggleAt = 3200; // a block boundary + const int offsets[] = {0, 1, latency - 1, latency, latency + 1, fade / 2, + fade, fade + latency, fade + 4 * latency}; + + for (bool engaging : {true, false}) { + for (int offset : offsets) { + std::vector in( + static_cast(toggleAt + 2 * fade + 8 * latency), 0.f); + in[static_cast(toggleAt + offset)] = ceiling * 8.f; + std::vector y = in; + + Limiter lim; + lim.setEnabled(!engaging); + lim.prepare(kRate); // prepare snaps to the target: the run starts settled + for (std::size_t i = 0; i < y.size(); i += static_cast(block)) { + if (static_cast(i) >= toggleAt) lim.setEnabled(engaging); + const int n = static_cast( + std::min(static_cast(block), y.size() - i)); + lim.process(y.data() + i, nullptr, n); + } + + bool held = true; + float loudestLimited = 0.f; + for (std::size_t i = 0; i < y.size(); ++i) { + if (!underCeilingOrExactlyDry(y[i], in[i], ceiling)) { held = false; break; } + if (y[i] != in[i]) loudestLimited = std::max(loudestLimited, std::fabs(y[i])); + } + CHECK(held); + // The transient reached the LIMITED path rather than being muted away entirely, + // so `held` above is not satisfied by silence. The qualifying offset differs by + // direction because the fade opens at the end of an engage and closes at the + // start of a disengage. + if (engaging && offset >= fade + latency) { + CHECK(loudestLimited > ceiling * 0.9f); + } + if (!engaging && offset == 0) CHECK(loudestLimited > ceiling * 0.5f); + } + } +} + +static void testTransitionSettlesToTheExactEngagedAndBypassedPaths() { Limiter lim; lim.prepare(kRate); const int latency = limiterLookaheadSamples(kRate); - const int settle = static_cast(kLimiterCrossfadeSeconds * kRate) + latency + 64; + // The engage costs a `latency`-sample prime, then the fade, then the delay itself. + const int settle = + static_cast(kLimiterMuteSeconds * kRate) + 2 * latency + 64; const std::vector src = pattern(4 * settle, 0.3f); // under the ceiling throughout std::vector y = src; @@ -302,8 +436,10 @@ int main() { testEngagedHoldsTheCeilingOnProgramTwelveDbOver(); testTruePeakDetectionEngagesWhereSamplePeakWouldNot(); testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle(); - testToggleEmitsNoStepLargerThanTheSignalsOwn(); - testCrossfadeSettlesToTheExactEngagedAndBypassedPaths(); + testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence(); + testUnlimitedSignalIsNeverEmittedAtAPartialWeight(); + testALoudTransientInFlightAtTheToggleCannotSpike(); + testTransitionSettlesToTheExactEngagedAndBypassedPaths(); testGainNeverRisesAboveUnity(); testAlignmentIdentityHoldsAtTheExactWindowEdge(); testBakedConstants();