286 lines
12 KiB
C++
286 lines
12 KiB
C++
// Standalone tests for reasampler::instrument::engine::Limiter — no VST3, no REAPER, no
|
|
// framework. The properties the master bus depends on, asserted rather than judged by ear:
|
|
//
|
|
// * bypassed and settled, process() does not touch one byte of the buffers (the byte-identical
|
|
// at-rest path) and reports no reduction;
|
|
// * engaged below the ceiling, the output is the input DELAYED and bit-exact — nothing is
|
|
// louder, quieter or altered at rest, and there is no makeup gain to find;
|
|
// * engaged on program +12 dB over, no output sample passes the ceiling; bypassed, the same
|
|
// program still passes 0 dBFS, so the toggle is doing the work;
|
|
// * 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.
|
|
|
|
#include "../src/core/instrument/engine/limiter.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
using namespace reasampler::instrument::engine;
|
|
|
|
static int g_fail = 0;
|
|
#define CHECK(cond) do { if(!(cond)) { \
|
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
|
|
|
static constexpr double kRate = 48000.0;
|
|
|
|
// A deterministic non-repeating pattern, so an untouched-buffer check cannot pass by accident.
|
|
static std::vector<float> pattern(int n, float scale = 1.f) {
|
|
std::vector<float> v(static_cast<std::size_t>(n));
|
|
std::uint32_t s = 0x1234567u;
|
|
for (int i = 0; i < n; ++i) {
|
|
s = s * 1664525u + 1013904223u;
|
|
v[static_cast<std::size_t>(i)] =
|
|
scale * (static_cast<float>(static_cast<int>(s >> 8) % 20001 - 10000) / 10000.f);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
// Runs `in` through `lim` in blocks of `block`, returning the output and the smallest gain
|
|
// reported across the whole run.
|
|
static std::vector<float> runMono(Limiter& lim, const std::vector<float>& in, int block,
|
|
float* minGainOut = nullptr) {
|
|
std::vector<float> out = in;
|
|
float lowest = 1.f;
|
|
for (std::size_t i = 0; i < out.size(); i += static_cast<std::size_t>(block)) {
|
|
const int n = static_cast<int>(
|
|
std::min(static_cast<std::size_t>(block), out.size() - i));
|
|
const float g = lim.process(out.data() + i, nullptr, n);
|
|
if (g < lowest) lowest = g;
|
|
}
|
|
if (minGainOut) *minGainOut = lowest;
|
|
return out;
|
|
}
|
|
|
|
static void testBypassedLeavesEveryByteUntouched() {
|
|
Limiter lim;
|
|
lim.prepare(kRate);
|
|
CHECK(!lim.enabled());
|
|
const std::vector<float> in = pattern(2048, 1.8f); // well over full scale
|
|
float minGain = 0.f;
|
|
const std::vector<float> out = runMono(lim, in, 512, &minGain);
|
|
bool identical = true;
|
|
for (std::size_t i = 0; i < in.size(); ++i) {
|
|
if (out[i] != in[i]) { identical = false; break; }
|
|
}
|
|
CHECK(identical);
|
|
CHECK(minGain == 1.f);
|
|
// And that untouched signal still passes 0 dBFS — the toggle, not the meter, is what
|
|
// stops it.
|
|
float peak = 0.f;
|
|
for (float v : out) peak = std::max(peak, std::fabs(v));
|
|
CHECK(peak > 1.f);
|
|
}
|
|
|
|
static void testEngagedBelowThresholdIsTheInputDelayedBitExactly() {
|
|
Limiter lim;
|
|
lim.setEnabled(true);
|
|
lim.prepare(kRate); // prepare snaps to the target: no crossfade, no priming
|
|
const int latency = limiterLookaheadSamples(kRate);
|
|
// Comfortably under the ceiling at every sample AND between samples.
|
|
const std::vector<float> in = pattern(4096, 0.4f);
|
|
float minGain = 0.f;
|
|
const std::vector<float> out = runMono(lim, in, 256, &minGain);
|
|
CHECK(minGain == 1.f); // exactly unity: there is no makeup gain and no residual trim
|
|
bool exact = true;
|
|
for (std::size_t i = static_cast<std::size_t>(latency); i < in.size(); ++i) {
|
|
if (out[i] != in[i - static_cast<std::size_t>(latency)]) { exact = false; break; }
|
|
}
|
|
CHECK(exact);
|
|
}
|
|
|
|
static void testEngagedHoldsTheCeilingOnProgramTwelveDbOver() {
|
|
Limiter lim;
|
|
lim.setEnabled(true);
|
|
lim.prepare(kRate);
|
|
const int latency = limiterLookaheadSamples(kRate);
|
|
const float ceiling = static_cast<float>(limiterCeilingLinear());
|
|
// +12 dB over the ceiling, sustained, with the transient content the pattern gives.
|
|
std::vector<float> in = pattern(24000, ceiling * 3.98f);
|
|
float minGain = 0.f;
|
|
const std::vector<float> out = runMono(lim, in, 128, &minGain);
|
|
CHECK(minGain < 0.4f); // it really did pull the gain down
|
|
float worst = 0.f;
|
|
for (std::size_t i = static_cast<std::size_t>(latency); i < out.size(); ++i) {
|
|
worst = std::max(worst, std::fabs(out[i]));
|
|
}
|
|
// Sample peak, so the true-peak ceiling is the bound with room to spare for float rounding.
|
|
CHECK(worst <= ceiling * 1.0001f);
|
|
}
|
|
|
|
static void testTruePeakDetectionEngagesWhereSamplePeakWouldNot() {
|
|
// fs/4 at 45 degrees: every SAMPLE sits at A/sqrt(2) while the waveform reaches A between
|
|
// them. A sample-peak detector would pass this through untouched.
|
|
const double amp = 1.2;
|
|
const float ceiling = static_cast<float>(limiterCeilingLinear());
|
|
std::vector<float> in(8000);
|
|
for (std::size_t i = 0; i < in.size(); ++i) {
|
|
in[i] = static_cast<float>(
|
|
amp * std::cos(3.14159265358979323846 * (0.5 * static_cast<double>(i) + 0.25)));
|
|
}
|
|
float samplePeak = 0.f;
|
|
for (float v : in) samplePeak = std::max(samplePeak, std::fabs(v));
|
|
CHECK(samplePeak < ceiling); // the premise: no SAMPLE is over
|
|
|
|
Limiter lim;
|
|
lim.setEnabled(true);
|
|
lim.prepare(kRate);
|
|
float minGain = 0.f;
|
|
runMono(lim, in, 256, &minGain);
|
|
CHECK(minGain < 1.f);
|
|
}
|
|
|
|
static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() {
|
|
Limiter lim;
|
|
lim.prepare(kRate);
|
|
const float ceiling = static_cast<float>(limiterCeilingLinear());
|
|
const std::vector<float> src = pattern(48000, ceiling * 2.5f);
|
|
std::vector<float> l = src, r = src; // dual mono: L and R are the same signal
|
|
const int block = 64;
|
|
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.
|
|
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>(
|
|
std::min(static_cast<std::size_t>(block), l.size() - i));
|
|
lim.process(l.data() + i, r.data() + i, n);
|
|
}
|
|
for (std::size_t i = 0; i < l.size(); ++i) {
|
|
if (l[i] != r[i]) { centered = false; break; }
|
|
}
|
|
CHECK(centered);
|
|
// And the engaged stretch really was limited, so the equality above is not equality on an
|
|
// untouched buffer.
|
|
float worstEngaged = 0.f;
|
|
for (std::size_t i = l.size() / 2; i < (l.size() * 3) / 4; ++i) {
|
|
worstEngaged = std::max(worstEngaged, std::fabs(l[i]));
|
|
}
|
|
CHECK(worstEngaged <= ceiling * 1.0001f);
|
|
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>(
|
|
amp * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast<double>(i) / kRate));
|
|
}
|
|
const float naturalStep =
|
|
static_cast<float>(amp * 2.0 * 3.14159265358979323846 * freq / kRate);
|
|
|
|
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);
|
|
const int n = static_cast<int>(
|
|
std::min(static_cast<std::size_t>(block), x.size() - i));
|
|
lim.process(x.data() + i, nullptr, n);
|
|
}
|
|
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]));
|
|
}
|
|
CHECK(worstStep <= naturalStep * 1.2f);
|
|
}
|
|
|
|
static void testCrossfadeSettlesToTheExactEngagedAndBypassedPaths() {
|
|
Limiter lim;
|
|
lim.prepare(kRate);
|
|
const int latency = limiterLookaheadSamples(kRate);
|
|
const int settle = static_cast<int>(kLimiterCrossfadeSeconds * kRate) + latency + 64;
|
|
const std::vector<float> src = pattern(4 * settle, 0.3f); // under the ceiling throughout
|
|
|
|
std::vector<float> y = src;
|
|
lim.setEnabled(true);
|
|
lim.process(y.data(), nullptr, static_cast<int>(y.size()));
|
|
// Past the crossfade the engaged path is exactly the delayed input again.
|
|
bool exact = true;
|
|
for (std::size_t i = static_cast<std::size_t>(settle); i < y.size(); ++i) {
|
|
if (y[i] != src[i - static_cast<std::size_t>(latency)]) { exact = false; break; }
|
|
}
|
|
CHECK(exact);
|
|
|
|
std::vector<float> z = src;
|
|
lim.setEnabled(false);
|
|
lim.process(z.data(), nullptr, static_cast<int>(z.size()));
|
|
bool passthrough = true;
|
|
for (std::size_t i = static_cast<std::size_t>(settle); i < z.size(); ++i) {
|
|
if (z[i] != src[i]) { passthrough = false; break; }
|
|
}
|
|
CHECK(passthrough);
|
|
// And once settled bypassed, the next block is untouched again.
|
|
std::vector<float> w = pattern(512, 1.5f);
|
|
const std::vector<float> before = w;
|
|
CHECK(lim.process(w.data(), nullptr, static_cast<int>(w.size())) == 1.f);
|
|
bool untouched = true;
|
|
for (std::size_t i = 0; i < w.size(); ++i) {
|
|
if (w[i] != before[i]) { untouched = false; break; }
|
|
}
|
|
CHECK(untouched);
|
|
}
|
|
|
|
static void testGainNeverRisesAboveUnity() {
|
|
// "No makeup gain, ever, of any kind" as a property rather than an absence: across quiet,
|
|
// loud and silent material the applied gain is never above 1 and the output magnitude is
|
|
// never above the input's own.
|
|
Limiter lim;
|
|
lim.setEnabled(true);
|
|
lim.prepare(kRate);
|
|
std::vector<float> in = pattern(16000, 2.0f);
|
|
for (std::size_t i = 4000; i < 8000; ++i) in[i] = 0.f; // a silent stretch
|
|
for (std::size_t i = 8000; i < 12000; ++i) in[i] *= 0.001f; // and a very quiet one
|
|
float minGain = 0.f;
|
|
const std::vector<float> out = runMono(lim, in, 200, &minGain);
|
|
CHECK(minGain <= 1.f);
|
|
float inPeak = 0.f, outPeak = 0.f;
|
|
for (std::size_t i = 0; i < in.size(); ++i) {
|
|
inPeak = std::max(inPeak, std::fabs(in[i]));
|
|
outPeak = std::max(outPeak, std::fabs(out[i]));
|
|
}
|
|
CHECK(outPeak <= inPeak);
|
|
}
|
|
|
|
static void testBakedConstants() {
|
|
CHECK(kLimiterCeilingDbTp == -0.3);
|
|
CHECK(std::fabs(limiterCeilingLinear() - std::pow(10.0, -0.3 / 20.0)) < 1e-12);
|
|
CHECK(limiterCeilingLinear() < 1.0);
|
|
// 2 ms at the common rates, and never below the detector's own group delay.
|
|
CHECK(limiterLookaheadSamples(48000.0) == 96);
|
|
CHECK(limiterLookaheadSamples(44100.0) == 88);
|
|
CHECK(limiterLookaheadSamples(96000.0) == 192);
|
|
CHECK(limiterLookaheadSamples(0.0) == 0);
|
|
CHECK(limiterLookaheadSamples(-1.0) == 0);
|
|
CHECK(limiterLookaheadSamples(100.0) > kLimiterOsDelay);
|
|
}
|
|
|
|
int main() {
|
|
testBypassedLeavesEveryByteUntouched();
|
|
testEngagedBelowThresholdIsTheInputDelayedBitExactly();
|
|
testEngagedHoldsTheCeilingOnProgramTwelveDbOver();
|
|
testTruePeakDetectionEngagesWhereSamplePeakWouldNot();
|
|
testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle();
|
|
testToggleEmitsNoStepLargerThanTheSignalsOwn();
|
|
testCrossfadeSettlesToTheExactEngagedAndBypassedPaths();
|
|
testGainNeverRisesAboveUnity();
|
|
testBakedConstants();
|
|
if (g_fail) {
|
|
std::printf("%d FAILURE(S)\n", g_fail);
|
|
return 1;
|
|
}
|
|
std::printf("limiter tests passed\n");
|
|
return 0;
|
|
}
|