Γ-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.
|
||||
- `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/`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user