Γ-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:
@@ -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.
|
- `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`.
|
- `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.
|
- `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.
|
- `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/`
|
### `map/`
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ void Limiter::prepare(double sampleRate) {
|
|||||||
ceiling_ = static_cast<float>(limiterCeilingLinear());
|
ceiling_ = static_cast<float>(limiterCeilingLinear());
|
||||||
const double rate = sampleRate > 0.0 ? sampleRate : 48000.0;
|
const double rate = sampleRate > 0.0 ? sampleRate : 48000.0;
|
||||||
releaseCoeff_ = static_cast<float>(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate)));
|
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.
|
// 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
|
// 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() {
|
void Limiter::reset() {
|
||||||
clearState();
|
clearState();
|
||||||
active_ = target_.load(std::memory_order_relaxed);
|
active_ = target_.load(std::memory_order_relaxed);
|
||||||
mix_ = active_ ? 1.f : 0.f;
|
switchGain_ = active_ ? 1.f : 0.f;
|
||||||
primeRemaining_ = 0;
|
primeRemaining_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,11 +158,13 @@ float Limiter::process(float* left, float* right, int frames) {
|
|||||||
const bool want = target_.load(std::memory_order_relaxed);
|
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_) return 1.f; // settled bypass: not one sample read or written
|
||||||
if (want && !active_) {
|
if (want && !active_) {
|
||||||
// A live engage. Start dry, fill the delay line, then crossfade — so the wet path is
|
// A live engage. The dry path leaves circuit AT THIS SAMPLE rather than fading out:
|
||||||
// never silence weighted above zero.
|
// 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();
|
clearState();
|
||||||
active_ = true;
|
active_ = true;
|
||||||
mix_ = 0.f;
|
switchGain_ = 0.f;
|
||||||
primeRemaining_ = latency_;
|
primeRemaining_ = latency_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,33 +185,40 @@ float Limiter::process(float* left, float* right, int frames) {
|
|||||||
if (stereo) delayR_[slot] = dryR;
|
if (stereo) delayR_[slot] = dryR;
|
||||||
delayPos_ = (delayPos_ + 1 == latency_) ? 0 : delayPos_ + 1;
|
delayPos_ = (delayPos_ + 1 == latency_) ? 0 : delayPos_ + 1;
|
||||||
|
|
||||||
// The endpoints are branches rather than blend arithmetic so a settled state is exact:
|
// Settled engaged is a branch rather than `wet * 1.0f` so it is bit-exact.
|
||||||
// dry + (wet - dry) * 1.0f is not wet in floating point. At m <= 0 the buffer is left
|
const float s = switchGain_;
|
||||||
// untouched, which is the dry sample already in it.
|
if (s >= 1.f) {
|
||||||
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) {
|
|
||||||
left[i] = wetL;
|
left[i] = wetL;
|
||||||
if (stereo) right[i] = wetR;
|
if (stereo) right[i] = wetR;
|
||||||
} else if (m > 0.f) {
|
} else if (s > 0.f) {
|
||||||
left[i] = dryL + (wetL - dryL) * m;
|
left[i] = wetL * s;
|
||||||
if (stereo) right[i] = dryR + (wetR - dryR) * m;
|
if (stereo) right[i] = wetR * s;
|
||||||
}
|
|
||||||
|
|
||||||
if (primeRemaining_ > 0) {
|
|
||||||
--primeRemaining_;
|
|
||||||
} else if (want) {
|
|
||||||
mix_ = (mix_ + mixStep_ >= 1.f) ? 1.f : mix_ + mixStep_;
|
|
||||||
} else {
|
} 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;
|
return blockMin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ inline constexpr double kLimiterLookaheadSeconds = 0.002;
|
|||||||
// no-overshoot bound survives it unchanged.
|
// no-overshoot bound survives it unchanged.
|
||||||
inline constexpr double kLimiterReleaseSeconds = 0.100;
|
inline constexpr double kLimiterReleaseSeconds = 0.100;
|
||||||
|
|
||||||
// The engage/disengage crossfade. A limiter engaging is a gain-path change and this codebase
|
// The transition mute. Long enough that the fade is not itself an edge and that it dwarfs the
|
||||||
// ramps every gain-path change; it also covers the window before the host acts on the latency
|
// 2 ms delay-line prime it covers; short enough that the whole muted window (prime + fade) is
|
||||||
// change, which is the plugin's to keep clean because the host schedules that, not us.
|
// ~12 ms rather than a gap. Linear in amplitude, not equal-power: this fades ONE leg to
|
||||||
inline constexpr double kLimiterCrossfadeSeconds = 0.010;
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
class Limiter {
|
||||||
public:
|
public:
|
||||||
// Sizes the delay line, the detector and the smoothers, and snaps to the current enable
|
// Sizes the delay line, the detector and the smoothers, and snaps to the current enable
|
||||||
@@ -60,7 +70,7 @@ public:
|
|||||||
void prepare(double sampleRate);
|
void prepare(double sampleRate);
|
||||||
|
|
||||||
// Clears the delay line and the detector and snaps to the current enable state, skipping
|
// 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).
|
// Main/UI thread only (the host guarantees process() is stopped at both call sites).
|
||||||
void reset();
|
void reset();
|
||||||
|
|
||||||
@@ -70,10 +80,9 @@ public:
|
|||||||
|
|
||||||
// Applies the limiter in place over `frames` of `left` (and `right`, which may be null for
|
// 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
|
// 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
|
// for a settled bypass, 0.0 anywhere the transition mute is at silence. The transition mute
|
||||||
// crossfade this is the target gain blended by the same fraction `mix_` blends the signal,
|
// counts because the contract is the gain that REACHED the output: the reported value and
|
||||||
// not the limiter's raw target — the two must agree, or the meter over-reports reduction
|
// the signal are scaled by the same factor, or the meter and the bus disagree.
|
||||||
// that is only partially audible.
|
|
||||||
float process(float* left, float* right, int frames);
|
float process(float* left, float* right, int frames);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -91,7 +100,7 @@ private:
|
|||||||
int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1
|
int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1
|
||||||
float ceiling_ = 1.f;
|
float ceiling_ = 1.f;
|
||||||
float releaseCoeff_ = 1.f;
|
float releaseCoeff_ = 1.f;
|
||||||
float mixStep_ = 1.f;
|
float switchStep_ = 1.f;
|
||||||
float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay)
|
float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay)
|
||||||
|
|
||||||
// --- audio-thread state ---
|
// --- audio-thread state ---
|
||||||
@@ -110,9 +119,9 @@ private:
|
|||||||
double avgSum_ = 0.0; // double: the running sum is added to and subtracted from forever
|
double avgSum_ = 0.0; // double: the running sum is added to and subtracted from forever
|
||||||
int avgPos_ = 0;
|
int avgPos_ = 0;
|
||||||
float releaseGain_ = 1.f;
|
float releaseGain_ = 1.f;
|
||||||
bool active_ = false; // the limiter path is running (engaged, or mid-crossfade)
|
bool active_ = false; // the limited path is in circuit (engaged, or still fading out)
|
||||||
float mix_ = 0.f; // 0 = dry, 1 = limited
|
float switchGain_ = 0.f; // the transition mute; only ever scales the LIMITED path
|
||||||
int primeRemaining_ = 0; // samples the crossfade waits on while the delay line fills
|
int primeRemaining_ = 0; // samples held at silence while the delay line fills
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace reasampler::instrument::engine
|
} // namespace reasampler::instrument::engine
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
|||||||
reloadInstrument();
|
reloadInstrument();
|
||||||
// The host performs this deactivate/reactivate whenever it acts on a kLatencyChanged
|
// 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
|
// 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.
|
// continuous with once the block above has destroyed every voice.
|
||||||
limiter_.reset();
|
limiter_.reset();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+159
-23
@@ -10,7 +10,11 @@
|
|||||||
// * the detection is TRUE-peak: a signal whose SAMPLES all clear the ceiling but whose
|
// * the detection is TRUE-peak: a signal whose SAMPLES all clear the ceiling but whose
|
||||||
// inter-sample peak does not still engages;
|
// inter-sample peak does not still engages;
|
||||||
// * the gain is stereo-linked, so a dual-mono signal stays centered across a full toggle;
|
// * 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"
|
#include "../src/core/instrument/engine/limiter.h"
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() {
|
|||||||
bool centered = true;
|
bool centered = true;
|
||||||
for (std::size_t i = 0; i < l.size(); i += static_cast<std::size_t>(block)) {
|
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,
|
// 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() / 4 && !lim.enabled()) lim.setEnabled(true);
|
||||||
if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false);
|
if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false);
|
||||||
const int n = static_cast<int>(
|
const int n = static_cast<int>(
|
||||||
@@ -167,42 +171,172 @@ static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() {
|
|||||||
CHECK(worstEngaged > 0.f);
|
CHECK(worstEngaged > 0.f);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testToggleEmitsNoStepLargerThanTheSignalsOwn() {
|
static void testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence() {
|
||||||
// A steady sine: the crossfade blends it with a copy of itself delayed by the lookahead,
|
// Replaces the retired crossfade's "no step larger than the signal's own", which no longer
|
||||||
// which at 440 Hz is nearly half a cycle out — switching hard instead of fading would step
|
// describes the design: the mute has exactly ONE hard edge per direction, both against
|
||||||
// by up to twice the amplitude, so this assertion has real teeth.
|
// silence, and everything between them is continuous. A steady sine well under the
|
||||||
const double freq = 440.0;
|
// ceiling, so this measures the TRANSITION and not limiting. 375 Hz is one cycle per 128
|
||||||
const double amp = 0.5; // under the ceiling: this measures the TRANSITION, not limiting
|
// samples, so a block-aligned toggle lands on a phase the test can state rather than
|
||||||
std::vector<float> x(48000);
|
// inherit — at a zero crossing the engage edge would be small for a reason that has
|
||||||
for (std::size_t i = 0; i < x.size(); ++i) {
|
// nothing to do with the design.
|
||||||
x[i] = static_cast<float>(
|
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));
|
amp * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast<double>(i) / kRate));
|
||||||
}
|
}
|
||||||
|
std::vector<float> y = in;
|
||||||
const float naturalStep =
|
const float naturalStep =
|
||||||
static_cast<float>(amp * 2.0 * 3.14159265358979323846 * freq / kRate);
|
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;
|
Limiter lim;
|
||||||
lim.prepare(kRate);
|
lim.prepare(kRate);
|
||||||
const int block = 32;
|
for (std::size_t i = 0; i < y.size(); i += static_cast<std::size_t>(block)) {
|
||||||
for (std::size_t i = 0; i < x.size(); i += static_cast<std::size_t>(block)) {
|
if (static_cast<int>(i) >= engageAt && !lim.enabled()) lim.setEnabled(true);
|
||||||
if (i >= x.size() / 4 && !lim.enabled()) lim.setEnabled(true);
|
if (static_cast<int>(i) >= disengageAt && lim.enabled()) lim.setEnabled(false);
|
||||||
if (i >= (x.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false);
|
|
||||||
const int n = static_cast<int>(
|
const int n = static_cast<int>(
|
||||||
std::min(static_cast<std::size_t>(block), x.size() - i));
|
std::min(static_cast<std::size_t>(block), y.size() - i));
|
||||||
lim.process(x.data() + i, nullptr, n);
|
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;
|
float worstStep = 0.f;
|
||||||
for (std::size_t i = 1; i < x.size(); ++i) {
|
for (std::size_t i = static_cast<std::size_t>(engageAt) + 1; i <= lastTouched; ++i) {
|
||||||
worstStep = std::max(worstStep, std::fabs(x[i] - x[i - 1]));
|
worstStep = std::max(worstStep, std::fabs(y[i] - y[i - 1]));
|
||||||
}
|
}
|
||||||
CHECK(worstStep <= naturalStep * 1.2f);
|
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;
|
Limiter lim;
|
||||||
lim.prepare(kRate);
|
lim.prepare(kRate);
|
||||||
const int latency = limiterLookaheadSamples(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
|
const std::vector<float> src = pattern(4 * settle, 0.3f); // under the ceiling throughout
|
||||||
|
|
||||||
std::vector<float> y = src;
|
std::vector<float> y = src;
|
||||||
@@ -302,8 +436,10 @@ int main() {
|
|||||||
testEngagedHoldsTheCeilingOnProgramTwelveDbOver();
|
testEngagedHoldsTheCeilingOnProgramTwelveDbOver();
|
||||||
testTruePeakDetectionEngagesWhereSamplePeakWouldNot();
|
testTruePeakDetectionEngagesWhereSamplePeakWouldNot();
|
||||||
testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle();
|
testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle();
|
||||||
testToggleEmitsNoStepLargerThanTheSignalsOwn();
|
testTheTransitionsOnlyEdgesAreTheTwoAgainstSilence();
|
||||||
testCrossfadeSettlesToTheExactEngagedAndBypassedPaths();
|
testUnlimitedSignalIsNeverEmittedAtAPartialWeight();
|
||||||
|
testALoudTransientInFlightAtTheToggleCannotSpike();
|
||||||
|
testTransitionSettlesToTheExactEngagedAndBypassedPaths();
|
||||||
testGainNeverRisesAboveUnity();
|
testGainNeverRisesAboveUnity();
|
||||||
testAlignmentIdentityHoldsAtTheExactWindowEdge();
|
testAlignmentIdentityHoldsAtTheExactWindowEdge();
|
||||||
testBakedConstants();
|
testBakedConstants();
|
||||||
|
|||||||
Reference in New Issue
Block a user