Γ-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
+1 -1
View File
@@ -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 FritschCarlson 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/`
+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;
}
+22 -13
View File
@@ -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
@@ -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 {