Γ-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:
+159
-23
@@ -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<std::size_t>(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<int>(
|
||||
@@ -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<float> x(48000);
|
||||
for (std::size_t i = 0; i < x.size(); ++i) {
|
||||
x[i] = static_cast<float>(
|
||||
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<float> in(48000);
|
||||
for (std::size_t i = 0; i < in.size(); ++i) {
|
||||
in[i] = static_cast<float>(
|
||||
amp * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast<double>(i) / kRate));
|
||||
}
|
||||
std::vector<float> y = in;
|
||||
const float naturalStep =
|
||||
static_cast<float>(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<float>(amp / (kLimiterMuteSeconds * kRate)) * 1.01f;
|
||||
CHECK(std::fabs(in[static_cast<std::size_t>(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<std::size_t>(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<std::size_t>(block)) {
|
||||
if (static_cast<int>(i) >= engageAt && !lim.enabled()) lim.setEnabled(true);
|
||||
if (static_cast<int>(i) >= disengageAt && lim.enabled()) lim.setEnabled(false);
|
||||
const int n = static_cast<int>(
|
||||
std::min(static_cast<std::size_t>(block), x.size() - i));
|
||||
lim.process(x.data() + i, nullptr, n);
|
||||
std::min(static_cast<std::size_t>(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<std::size_t>(engageAt) - 1] == in[static_cast<std::size_t>(engageAt) - 1]);
|
||||
CHECK(y[static_cast<std::size_t>(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<int>(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<std::size_t>(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<float>(limiterCeilingLinear());
|
||||
// +12 dB over the ceiling for the WHOLE run, so the transition windows are driven, not
|
||||
// merely crossed while quiet.
|
||||
const std::vector<float> in = pattern(48000, ceiling * 3.98f);
|
||||
std::vector<float> y = in;
|
||||
|
||||
Limiter lim;
|
||||
lim.prepare(kRate);
|
||||
const int block = 64;
|
||||
for (std::size_t i = 0; i < y.size(); i += static_cast<std::size_t>(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<int>(
|
||||
std::min(static_cast<std::size_t>(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<float>(limiterCeilingLinear());
|
||||
const int latency = limiterLookaheadSamples(kRate);
|
||||
const int fade = static_cast<int>(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<float> in(
|
||||
static_cast<std::size_t>(toggleAt + 2 * fade + 8 * latency), 0.f);
|
||||
in[static_cast<std::size_t>(toggleAt + offset)] = ceiling * 8.f;
|
||||
std::vector<float> 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<std::size_t>(block)) {
|
||||
if (static_cast<int>(i) >= toggleAt) lim.setEnabled(engaging);
|
||||
const int n = static_cast<int>(
|
||||
std::min(static_cast<std::size_t>(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<int>(kLimiterCrossfadeSeconds * kRate) + latency + 64;
|
||||
// The engage costs a `latency`-sample prime, then the fade, then the delay itself.
|
||||
const int settle =
|
||||
static_cast<int>(kLimiterMuteSeconds * kRate) + 2 * latency + 64;
|
||||
const std::vector<float> src = pattern(4 * settle, 0.3f); // under the ceiling throughout
|
||||
|
||||
std::vector<float> y = src;
|
||||
@@ -302,8 +436,10 @@ int main() {
|
||||
testEngagedHoldsTheCeilingOnProgramTwelveDbOver();
|
||||
testTruePeakDetectionEngagesWhereSamplePeakWouldNot();
|
||||
testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle();
|
||||
testToggleEmitsNoStepLargerThanTheSignalsOwn();
|
||||
testCrossfadeSettlesToTheExactEngagedAndBypassedPaths();
|
||||
testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence();
|
||||
testUnlimitedSignalIsNeverEmittedAtAPartialWeight();
|
||||
testALoudTransientInFlightAtTheToggleCannotSpike();
|
||||
testTransitionSettlesToTheExactEngagedAndBypassedPaths();
|
||||
testGainNeverRisesAboveUnity();
|
||||
testAlignmentIdentityHoldsAtTheExactWindowEdge();
|
||||
testBakedConstants();
|
||||
|
||||
Reference in New Issue
Block a user