Γ-W1-T2: the master bus — a true-peak limiter whose ceiling is a theorem, the meter's published half, and the plugin's first PDC report
This commit is contained in:
@@ -66,3 +66,11 @@ add_library(time_stretch INTERFACE)
|
||||
target_include_directories(time_stretch INTERFACE ${REASAMPLER_SRC_DIR})
|
||||
target_link_libraries(time_stretch INTERFACE loop_span)
|
||||
reasampler_test(time_stretch LINK time_stretch)
|
||||
|
||||
# The master bus's two pure halves. Neither links the engine: the limiter runs on the summed
|
||||
# output, and the ballistics run on what the audio thread published about it.
|
||||
reasampler_pure_library(limiter SOURCES limiter.cpp)
|
||||
reasampler_test(limiter LINK limiter)
|
||||
|
||||
reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp)
|
||||
reasampler_test(meter_ballistics LINK meter_ballistics)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// limiter.cpp — see limiter.h.
|
||||
|
||||
#include "core/instrument/engine/limiter.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kProtoLen = kLimiterOversample * kLimiterOsTaps + 1; // 33: odd, so phase 0 is exact
|
||||
|
||||
double sincPi(double x) {
|
||||
if (x == 0.0) return 1.0;
|
||||
const double a = 3.14159265358979323846 * x;
|
||||
return std::sin(a) / a;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
double limiterCeilingLinear() { return std::pow(10.0, kLimiterCeilingDbTp / 20.0); }
|
||||
|
||||
int limiterLookaheadSamples(double sampleRate) {
|
||||
if (!(sampleRate > 0.0)) return 0;
|
||||
const int n = static_cast<int>(kLimiterLookaheadSeconds * sampleRate + 0.5);
|
||||
// One sample above the detector's group delay is the floor: the smoothing window must have
|
||||
// at least one entry of its own for the no-overshoot bound to say anything.
|
||||
return n > kLimiterOsDelay ? n : kLimiterOsDelay + 1;
|
||||
}
|
||||
|
||||
void Limiter::prepare(double sampleRate) {
|
||||
latency_ = limiterLookaheadSamples(sampleRate);
|
||||
if (latency_ <= 0) latency_ = kLimiterOsDelay + 1;
|
||||
window_ = latency_ - kLimiterOsDelay + 1;
|
||||
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));
|
||||
|
||||
// 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
|
||||
// read straight out of the history instead of being convolved.
|
||||
for (int p = 0; p < kLimiterOversample; ++p) {
|
||||
for (int k = 0; k < kLimiterOsTaps; ++k) {
|
||||
const int i = kLimiterOversample * k + p;
|
||||
const double centred = static_cast<double>(i) - (kProtoLen - 1) / 2.0;
|
||||
const double hann =
|
||||
0.5 - 0.5 * std::cos(2.0 * 3.14159265358979323846 * i / (kProtoLen - 1));
|
||||
osTaps_[p][k] = static_cast<float>(sincPi(centred / kLimiterOversample) * hann);
|
||||
}
|
||||
}
|
||||
|
||||
delayL_.assign(static_cast<std::size_t>(latency_), 0.f);
|
||||
delayR_.assign(static_cast<std::size_t>(latency_), 0.f);
|
||||
wedgeVal_.assign(static_cast<std::size_t>(window_), 1.f);
|
||||
wedgeIdx_.assign(static_cast<std::size_t>(window_), 0);
|
||||
avgRing_.assign(static_cast<std::size_t>(window_), 1.f);
|
||||
reset();
|
||||
}
|
||||
|
||||
void Limiter::clearState() {
|
||||
std::fill(delayL_.begin(), delayL_.end(), 0.f);
|
||||
std::fill(delayR_.begin(), delayR_.end(), 0.f);
|
||||
delayPos_ = 0;
|
||||
for (int i = 0; i < kLimiterOsTaps; ++i) { histL_[i] = 0.f; histR_[i] = 0.f; }
|
||||
histPos_ = 0;
|
||||
wedgeHead_ = 0;
|
||||
wedgeCount_ = 0;
|
||||
pushIndex_ = 0;
|
||||
std::fill(avgRing_.begin(), avgRing_.end(), 1.f);
|
||||
avgSum_ = static_cast<double>(window_);
|
||||
avgPos_ = 0;
|
||||
releaseGain_ = 1.f;
|
||||
}
|
||||
|
||||
void Limiter::reset() {
|
||||
clearState();
|
||||
active_ = target_.load(std::memory_order_relaxed);
|
||||
mix_ = active_ ? 1.f : 0.f;
|
||||
primeRemaining_ = 0;
|
||||
}
|
||||
|
||||
void Limiter::setEnabled(bool on) { target_.store(on, std::memory_order_relaxed); }
|
||||
|
||||
float Limiter::detectTruePeak(float xl, float xr, bool stereo) {
|
||||
histPos_ = (histPos_ + 1) & (kLimiterOsTaps - 1);
|
||||
histL_[histPos_] = xl;
|
||||
if (stereo) histR_[histPos_] = xr;
|
||||
|
||||
// Phase 0 is the exact delay, so the sample under test is read, not convolved.
|
||||
const int base = (histPos_ - kLimiterOsDelay + kLimiterOsTaps) & (kLimiterOsTaps - 1);
|
||||
float peak = std::fabs(histL_[base]);
|
||||
if (stereo) {
|
||||
const float r0 = std::fabs(histR_[base]);
|
||||
if (r0 > peak) peak = r0;
|
||||
}
|
||||
for (int p = 1; p < kLimiterOversample; ++p) {
|
||||
float accL = 0.f, accR = 0.f;
|
||||
for (int k = 0; k < kLimiterOsTaps; ++k) {
|
||||
const int idx = (histPos_ - k + kLimiterOsTaps) & (kLimiterOsTaps - 1);
|
||||
accL += osTaps_[p][k] * histL_[idx];
|
||||
if (stereo) accR += osTaps_[p][k] * histR_[idx];
|
||||
}
|
||||
const float al = std::fabs(accL);
|
||||
if (al > peak) peak = al;
|
||||
if (stereo) {
|
||||
const float ar = std::fabs(accR);
|
||||
if (ar > peak) peak = ar;
|
||||
}
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
float Limiter::smoothGain(float target) {
|
||||
// Sliding minimum over `window_` via a monotonic wedge. Expiring the front BEFORE the push
|
||||
// is what bounds the wedge to `window_` entries — pushing first can lap the ring.
|
||||
while (wedgeCount_ > 0 &&
|
||||
wedgeIdx_[static_cast<std::size_t>(wedgeHead_)] <= pushIndex_ - window_) {
|
||||
wedgeHead_ = (wedgeHead_ + 1) % window_;
|
||||
--wedgeCount_;
|
||||
}
|
||||
while (wedgeCount_ > 0) {
|
||||
const int back = (wedgeHead_ + wedgeCount_ - 1) % window_;
|
||||
if (wedgeVal_[static_cast<std::size_t>(back)] < target) break;
|
||||
--wedgeCount_;
|
||||
}
|
||||
const int slot = (wedgeHead_ + wedgeCount_) % window_;
|
||||
wedgeVal_[static_cast<std::size_t>(slot)] = target;
|
||||
wedgeIdx_[static_cast<std::size_t>(slot)] = pushIndex_;
|
||||
++wedgeCount_;
|
||||
++pushIndex_;
|
||||
const float windowMin = wedgeVal_[static_cast<std::size_t>(wedgeHead_)];
|
||||
|
||||
// Moving average of the same width over those minima.
|
||||
avgSum_ += static_cast<double>(windowMin) - static_cast<double>(avgRing_[static_cast<std::size_t>(avgPos_)]);
|
||||
avgRing_[static_cast<std::size_t>(avgPos_)] = windowMin;
|
||||
avgPos_ = (avgPos_ + 1 == window_) ? 0 : avgPos_ + 1;
|
||||
float smoothed = static_cast<float>(avgSum_ / window_);
|
||||
// Never above unity — the structural form of "no makeup gain, ever", and what makes the
|
||||
// at-rest gain land on EXACTLY 1.0f after the running sum has been added to and subtracted
|
||||
// from for hours.
|
||||
if (!(smoothed < 1.f)) smoothed = 1.f;
|
||||
|
||||
// Release: falls with the smoother, rises no faster than the one-pole. Staying at or below
|
||||
// `smoothed` is what preserves the no-overshoot bound.
|
||||
if (smoothed < releaseGain_) releaseGain_ = smoothed;
|
||||
else releaseGain_ += (smoothed - releaseGain_) * releaseCoeff_;
|
||||
return releaseGain_;
|
||||
}
|
||||
|
||||
float Limiter::process(float* left, float* right, int frames) {
|
||||
if (!left || frames <= 0 || latency_ <= 0) return 1.f;
|
||||
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.
|
||||
clearState();
|
||||
active_ = true;
|
||||
mix_ = 0.f;
|
||||
primeRemaining_ = latency_;
|
||||
}
|
||||
|
||||
const bool stereo = (right != nullptr);
|
||||
float blockMin = 1.f;
|
||||
for (int i = 0; i < frames; ++i) {
|
||||
const float dryL = left[i];
|
||||
const float dryR = stereo ? right[i] : 0.f;
|
||||
|
||||
const float peak = detectTruePeak(dryL, dryR, stereo);
|
||||
const float targetGain = peak > ceiling_ ? ceiling_ / peak : 1.f;
|
||||
const float gain = smoothGain(targetGain);
|
||||
if (gain < blockMin) blockMin = gain;
|
||||
|
||||
const std::size_t slot = static_cast<std::size_t>(delayPos_);
|
||||
const float wetL = delayL_[slot] * gain;
|
||||
const float wetR = stereo ? delayR_[slot] * gain : 0.f;
|
||||
delayL_[slot] = dryL;
|
||||
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_;
|
||||
if (m >= 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 {
|
||||
mix_ = (mix_ - mixStep_ <= 0.f) ? 0.f : mix_ - mixStep_;
|
||||
}
|
||||
}
|
||||
if (!want && mix_ <= 0.f && primeRemaining_ == 0) active_ = false;
|
||||
return blockMin;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,113 @@
|
||||
// limiter.h — the master bus's lookahead brickwall limiter: true-peak sidechain detection,
|
||||
// stereo-linked gain, and NO makeup gain of any kind. RT: process() allocates nothing, takes
|
||||
// no lock and evaluates no transcendental; prepare() owns every allocation and every exp/pow.
|
||||
// Bypassed and settled, process() returns without touching a sample — that untouched buffer
|
||||
// is what makes the master bus byte-identical to the bare ramped multiply with the limiter off.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The BAKED ceiling. A safety device with no configurable controls, so this is not a
|
||||
// parameter. dBTP is a TRUE-peak target, which is why the detector oversamples and the
|
||||
// signal path never does.
|
||||
inline constexpr double kLimiterCeilingDbTp = -0.3;
|
||||
|
||||
// The total delay the limiter imposes while engaged, and therefore the plugin's whole reported
|
||||
// PDC latency. The detector's own group delay is inside this budget, not on top of it.
|
||||
inline constexpr double kLimiterLookaheadSeconds = 0.002;
|
||||
|
||||
// Gain recovery. The min-then-average smoother releases in one lookahead window on its own,
|
||||
// which distorts low frequencies; this one-pole only ever slows the RISE, so the smoother's
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// delay, and that delay is the detector's group delay.
|
||||
inline constexpr int kLimiterOversample = 4;
|
||||
inline constexpr int kLimiterOsTaps = 8;
|
||||
inline constexpr int kLimiterOsDelay = 4;
|
||||
|
||||
// kLimiterCeilingDbTp as a linear magnitude.
|
||||
double limiterCeilingLinear();
|
||||
|
||||
// The delay the limiter imposes while engaged, in samples at `sampleRate` — what the plugin
|
||||
// reports to the host's PDC. 0 at a non-positive rate; never below the detector's own delay.
|
||||
int limiterLookaheadSamples(double sampleRate);
|
||||
|
||||
// The master-bus limiter. One instance per plugin instance; prepare() before the first block.
|
||||
//
|
||||
// The gain law is a sliding MINIMUM of the per-sample target gain over the lookahead window,
|
||||
// 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.
|
||||
class Limiter {
|
||||
public:
|
||||
// Sizes the delay line, the detector and the smoothers, and snaps to the current enable
|
||||
// state. Allocates and evaluates transcendentals: main/UI thread only, never in process().
|
||||
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.
|
||||
// Main/UI thread only (the host guarantees process() is stopped at both call sites).
|
||||
void reset();
|
||||
|
||||
// The enable target. Set on the UI thread, observed by process() at block start.
|
||||
void setEnabled(bool on);
|
||||
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 applied in this block — 1.0 for none, and the
|
||||
// value a settled bypass returns.
|
||||
float process(float* left, float* right, int frames);
|
||||
|
||||
private:
|
||||
void clearState();
|
||||
// The detector's true-peak estimate for the sample kLimiterOsDelay back, given the newest
|
||||
// input frame. Advances the FIR history.
|
||||
float detectTruePeak(float xl, float xr, bool stereo);
|
||||
// Pushes one target gain through the sliding minimum and the moving average.
|
||||
float smoothGain(float target);
|
||||
|
||||
std::atomic<bool> target_{false};
|
||||
|
||||
// --- prepared geometry ---
|
||||
int latency_ = 0; // total delay; also the delay ring's length
|
||||
int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1
|
||||
float ceiling_ = 1.f;
|
||||
float releaseCoeff_ = 1.f;
|
||||
float mixStep_ = 1.f;
|
||||
float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay)
|
||||
|
||||
// --- audio-thread state ---
|
||||
std::vector<float> delayL_, delayR_;
|
||||
int delayPos_ = 0;
|
||||
float histL_[kLimiterOsTaps] = {};
|
||||
float histR_[kLimiterOsTaps] = {};
|
||||
int histPos_ = 0;
|
||||
// Monotonic wedge over the target gain: values ascending from the front, so the front is
|
||||
// the window minimum. Amortized O(1) per sample, bounded by 2 ops per push over a block.
|
||||
std::vector<float> wedgeVal_;
|
||||
std::vector<std::int64_t> wedgeIdx_;
|
||||
int wedgeHead_ = 0, wedgeCount_ = 0;
|
||||
std::int64_t pushIndex_ = 0;
|
||||
std::vector<float> avgRing_;
|
||||
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
|
||||
};
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,57 @@
|
||||
// meter_ballistics.cpp — see meter_ballistics.h.
|
||||
|
||||
#include "core/instrument/engine/meter_ballistics.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
double meterDbFromLinear(double linear) {
|
||||
if (!(linear > 0.0)) return kMeterFloorDb; // also catches NaN
|
||||
const double db = 20.0 * std::log10(linear);
|
||||
return db < kMeterFloorDb ? kMeterFloorDb : db;
|
||||
}
|
||||
|
||||
double meterNormFromDb(double db) {
|
||||
if (!(db > kMeterFloorDb)) return 0.0; // also catches NaN
|
||||
if (db >= kMeterTopDb) return 1.0;
|
||||
return (db - kMeterFloorDb) / (kMeterTopDb - kMeterFloorDb);
|
||||
}
|
||||
|
||||
MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds) {
|
||||
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
|
||||
const double fall = kMeterFallDbPerSecond * dt;
|
||||
const double peakDb = meterDbFromLinear(blockPeakLinear);
|
||||
|
||||
MeterState next = prev;
|
||||
// Instantaneous rise, timed fall — one expression, because a fall can never take the bar
|
||||
// below the peak this very block carried.
|
||||
const double fallen = prev.levelDb - fall;
|
||||
next.levelDb = fallen > peakDb ? fallen : peakDb;
|
||||
|
||||
if (next.levelDb >= next.holdDb) {
|
||||
next.holdDb = next.levelDb;
|
||||
next.holdRemainingSeconds = kMeterPeakHoldSeconds;
|
||||
} else {
|
||||
next.holdRemainingSeconds = prev.holdRemainingSeconds - dt;
|
||||
if (next.holdRemainingSeconds < 0.0) {
|
||||
// Spend the overshoot as fall time so the tick's release does not quantize to the
|
||||
// UI frame it happened to expire on.
|
||||
const double held = kMeterFallDbPerSecond * -next.holdRemainingSeconds;
|
||||
const double dropped = next.holdDb - held;
|
||||
next.holdDb = dropped > next.levelDb ? dropped : next.levelDb;
|
||||
next.holdRemainingSeconds = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
if (blockPeakLinear >= 1.0) next.clip = true;
|
||||
return next;
|
||||
}
|
||||
|
||||
MeterState clearMeterClip(MeterState prev) {
|
||||
MeterState next = prev;
|
||||
next.clip = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
@@ -0,0 +1,40 @@
|
||||
// meter_ballistics.h — the output meter's ballistics and its dB scale: peak fall, peak hold,
|
||||
// clip latch, and the dB -> normalized map the bar draws against. UI-thread math ONLY: the
|
||||
// audio thread publishes raw block peaks per block and converts, holds and decays nothing.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The scale is LINEAR IN dB across this span. Above 0 dBFS is shown because that is exactly
|
||||
// what the limiter-off case has to make visible.
|
||||
inline constexpr double kMeterFloorDb = -60.0;
|
||||
inline constexpr double kMeterTopDb = 6.0;
|
||||
|
||||
// A peak meter must not smooth its attack or it under-reports, so the rise is instantaneous
|
||||
// and only the fall is timed. 20 dB/s is close to the IEC 60268-18 PPM fallback.
|
||||
inline constexpr double kMeterFallDbPerSecond = 20.0;
|
||||
inline constexpr double kMeterPeakHoldSeconds = 1.5;
|
||||
|
||||
// Linear magnitude -> dBFS, floored at kMeterFloorDb — a silent block reads the floor rather
|
||||
// than -inf, so the state stays a finite number the ballistics can subtract from.
|
||||
double meterDbFromLinear(double linear);
|
||||
|
||||
// dBFS -> [0,1] up the meter, clamped at both ends.
|
||||
double meterNormFromDb(double db);
|
||||
|
||||
struct MeterState {
|
||||
double levelDb = kMeterFloorDb;
|
||||
double holdDb = kMeterFloorDb;
|
||||
double holdRemainingSeconds = 0.0;
|
||||
bool clip = false; // latched; only clearMeterClip lowers it
|
||||
};
|
||||
|
||||
// One UI frame of ballistics against the block peak the audio thread published and the time
|
||||
// since the previous frame. Clip latches at a block peak >= 0 dBFS and is never cleared here.
|
||||
MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds);
|
||||
|
||||
// The click-to-clear on the meter's clip cap.
|
||||
MeterState clearMeterClip(MeterState prev);
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
Reference in New Issue
Block a user