diff --git a/src/core/instrument/engine/limiter.cpp b/src/core/instrument/engine/limiter.cpp index 7cd86b2..0a1dd27 100644 --- a/src/core/instrument/engine/limiter.cpp +++ b/src/core/instrument/engine/limiter.cpp @@ -197,8 +197,10 @@ float Limiter::process(float* left, float* right, int frames) { left[i] = 0.f; if (stereo) right[i] = 0.f; } - const float effectiveGain = s >= 1.f ? gain : s * gain; - if (effectiveGain < blockMin) blockMin = effectiveGain; + // `gain` is the limiter's own reduction, computed from the real input this sample + // whether or not the mute is currently scaling it toward silence — publishing it + // unscaled is what lets the meter show "really limiting" and not "just muting". + if (gain < blockMin) blockMin = gain; // A disengage is tested FIRST so a toggle-off arriving mid-engage abandons the prime // instead of waiting it out in silence. diff --git a/src/core/instrument/engine/limiter.h b/src/core/instrument/engine/limiter.h index 41aad09..4bbabbe 100644 --- a/src/core/instrument/engine/limiter.h +++ b/src/core/instrument/engine/limiter.h @@ -79,10 +79,12 @@ public: bool enabled() const { return target_.load(std::memory_order_relaxed); } // 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 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. + // a mono buffer). Returns the SMALLEST gain the LIMITER ITSELF computed this block — + // smoothGain's output against the real input, at every sample including a muted one — NOT + // scaled by the transition mute. The mute is a switch, not limiting: scaling by it would + // report 0.0 (full reduction) on every toggle regardless of program content, which is a + // meter defect, not a fact about the bus. 1.0 means no detected peak exceeded the ceiling, + // whether settled bypassed or mid-mute over quiet material. float process(float* left, float* right, int frames); private: diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index c29ac2d..1154e8e 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -40,7 +40,10 @@ class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInte struct MasterBusMeter { float peakL = 0.f; // max |x| this block float peakR = 0.f; - float minGain = 1.f; // smallest limiter gain applied this block; 1 = no reduction + // Smallest gain the LIMITER computed this block (Limiter::process) — deliberately NOT + // scaled by the transition mute, so a toggle over quiet material reads 1 (no reduction) + // rather than the mute's own weight. 1 = no reduction. + float minGain = 1.f; bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it }; diff --git a/tests/test_limiter.cpp b/tests/test_limiter.cpp index 44fe03e..7628a30 100644 --- a/tests/test_limiter.cpp +++ b/tests/test_limiter.cpp @@ -14,7 +14,9 @@ // 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. +// direction; +// * the published minimum tracks the limiter's own reduction, not the mute weight: a toggle +// over content that never crosses the ceiling publishes exactly 1.0 all the way through. #include "../src/core/instrument/engine/limiter.h" @@ -143,11 +145,19 @@ static void testTruePeakDetectionEngagesWhereSamplePeakWouldNot() { static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { Limiter lim; lim.prepare(kRate); + const int latency = limiterLookaheadSamples(kRate); const float ceiling = static_cast(limiterCeilingLinear()); const std::vector src = pattern(48000, ceiling * 2.5f); std::vector l = src, r = src; // dual mono: L and R are the same signal const int block = 64; bool centered = true; + // The prime+fade window right after the engage point: the published minimum here is the + // case Daniel named — it must read the limiter's own reduction on this loud program, not + // the mute weight (which would read exactly 0 through the prime, old contract). + const std::size_t engageAt = l.size() / 4; + const std::size_t muteWindowEnd = engageAt + static_cast(latency) + + static_cast(kLimiterMuteSeconds * kRate); + float minGainDuringMute = 1.f; 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 mute, fully engaged, the disengage fade, and bypassed again. @@ -155,7 +165,8 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); const int n = static_cast( std::min(static_cast(block), l.size() - i)); - lim.process(l.data() + i, r.data() + i, n); + const float g = lim.process(l.data() + i, r.data() + i, n); + if (i >= engageAt && i < muteWindowEnd && g < minGainDuringMute) minGainDuringMute = g; } for (std::size_t i = 0; i < l.size(); ++i) { if (l[i] != r[i]) { centered = false; break; } @@ -169,6 +180,37 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { } CHECK(worstEngaged <= ceiling * (1.f + 1e-6f)); CHECK(worstEngaged > 0.f); + CHECK(minGainDuringMute > 0.f); // never the mute's own zero weight + CHECK(minGainDuringMute < 1.f); // and it really is reduction, not a no-op read +} + +static void testToggleWithNothingOverCeilingPublishesNoReduction() { + // Daniel's ruling: only show GR when it's really limiting, not just muting. Content that + // never exceeds the ceiling must publish exactly 1.0 through the WHOLE transition — the + // prime, both fades, and the settled stretches — because the old effectiveGain contract + // read 0.0 through the mute regardless of content. + Limiter lim; + lim.prepare(kRate); + const float ceiling = static_cast(limiterCeilingLinear()); + const std::vector src = pattern(48000, ceiling * 0.5f); // comfortably under, always + std::vector y = src; + const int block = 64; + float minGain = 1.f; + for (std::size_t i = 0; i < y.size(); i += static_cast(block)) { + if (i >= y.size() / 4 && !lim.enabled()) lim.setEnabled(true); + if (i >= (y.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); + const int n = static_cast( + std::min(static_cast(block), y.size() - i)); + const float g = lim.process(y.data() + i, nullptr, n); + if (g < minGain) minGain = g; + } + CHECK(minGain == 1.f); + // And the run really did mute, so `minGain == 1.f` is not vacuous over an untouched buffer. + bool sawSilenceOverSignal = false; + for (std::size_t i = 0; i < y.size(); ++i) { + if (y[i] == 0.f && std::fabs(src[i]) > 0.1f) { sawSilenceOverSignal = true; break; } + } + CHECK(sawSilenceOverSignal); } static void testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence() { @@ -436,6 +478,7 @@ int main() { testEngagedHoldsTheCeilingOnProgramTwelveDbOver(); testTruePeakDetectionEngagesWhereSamplePeakWouldNot(); testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle(); + testToggleWithNothingOverCeilingPublishesNoReduction(); testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence(); testUnlimitedSignalIsNeverEmittedAtAPartialWeight(); testALoudTransientInFlightAtTheToggleCannotSpike();