Print the limiter through the bake's master stage, compensating its lookahead so an engaged bake is the approved sound and a bypassed one is unchanged

This commit is contained in:
2026-08-02 20:57:29 -04:00
parent a9c166b8c2
commit 0d316b7b9e
9 changed files with 232 additions and 75 deletions
+141 -41
View File
@@ -4,12 +4,13 @@
// Covers: Gate termination WITH a sustain loop active (the render must end at the window,
// and the tail must be silent because the gate actually released — not merely because the
// buffer ran out); Trigger termination on its own play span; channel-count preservation
// with no stereo fold; the master gain being PRINTED into the output; a lead-in rendered
// and discarded; byte-identical repeats; and the refusals (unplayable sample, empty
// window, a window past the frame ceiling).
// with no stereo fold; the master stage — gain and limiter — being PRINTED into the output;
// a lead-in rendered and discarded; byte-identical repeats; and the refusals (unplayable
// sample, empty window, a window past the frame ceiling).
#include "../src/core/instrument/bake/bake_render.h"
#include "../src/core/instrument/engine/limiter.h"
#include "../src/core/instrument/engine/live_params.h"
#include <cmath>
@@ -38,6 +39,22 @@ SampleData makeSample(bool stereo, std::size_t frames = 1000) {
return s;
}
// A ramp, not DC: an off-by-one read, a reversed span or an output shifted in time is
// visible in it and invisible in a constant. Trigger at its own root under Varispeed reads
// at ratio exactly 1 and hits no filter, so a neutral render prints the source frame for
// frame — which is what makes this fixture an exact expectation rather than a range.
SampleData makeRamp(std::size_t frames = 4000) {
SampleData s;
s.frames.resize(frames);
for (std::size_t i = 0; i < frames; ++i)
s.frames[i] = static_cast<float>(i) / static_cast<float>(frames) - 0.5f;
s.sampleRate = kRate;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
return s;
}
// Peak magnitude of channel 0 over [from, to) output frames.
double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
double peak = 0.0;
@@ -50,6 +67,23 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
return peak;
}
// Peak magnitude over EVERY channel — a ceiling is a property of the file, not of one leg.
double peakAll(const BakeAudio& audio) {
double peak = 0.0;
for (AudioSample v : audio.interleaved) {
const double m = std::fabs(static_cast<double>(v));
if (m > peak) peak = m;
}
return peak;
}
bool sameSamples(const BakeAudio& a, const BakeAudio& b) {
if (a.interleaved.size() != b.interleaved.size() || a.interleaved.empty()) return false;
for (std::size_t i = 0; i < a.interleaved.size(); ++i)
if (a.interleaved[i] != b.interleaved[i]) return false;
return true;
}
BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff,
std::int64_t leadIn = 0) {
BakePlan p;
@@ -64,6 +98,8 @@ BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff,
}
constexpr double kUnity = 1.0;
constexpr bool kNoLimiter = false;
constexpr bool kLimiter = true;
} // namespace
@@ -78,7 +114,7 @@ int main() {
s.play.adsr.releaseFrames = 480; // 10 ms — short enough to finish inside the tail
const BakePlan plan = planOf(/*total=*/9600, /*noteOn=*/0, /*noteOff=*/4800);
const BakeAudio audio = renderBake(s, plan, kUnity);
const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
CHECK(audio.frameCount() == 9600); // bounded, not a runaway
CHECK(audio.channelCount == 1);
@@ -96,7 +132,7 @@ int main() {
s.play.trigger.lengthFraction = 0.5; // 500 source frames at unity ratio
const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/100);
const BakeAudio audio = renderBake(s, plan, kUnity);
const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
CHECK(audio.frameCount() == 2000);
// Still sounding past the note-off Trigger ignores…
@@ -111,7 +147,7 @@ int main() {
s.play.playMode = PlayMode::Trigger;
const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);
const BakeAudio audio = renderBake(s, plan, kUnity);
const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
CHECK(audio.channelCount == 2);
CHECK(audio.frameCount() == 500);
@@ -126,15 +162,17 @@ int main() {
// --- The master gain is PRINTED into the file ---------------------------------------
// The reset hands the control back at unity, so a render that summed voices alone would
// shift every iteration by 1/gain — and a gain dialed to silence would come back loud.
// Every render here is limiter-bypassed, so the exact scaling below is also the guard
// that the limiter never engages on its own: +4x over this DC is far past the ceiling.
{
SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
s.play.playMode = PlayMode::Trigger;
const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);
const BakeAudio unity = renderBake(s, plan, kUnity);
const BakeAudio quiet = renderBake(s, plan, 0.25);
const BakeAudio loud = renderBake(s, plan, 4.0);
const BakeAudio silent = renderBake(s, plan, 0.0);
const BakeAudio unity = renderBake(s, plan, kUnity, kNoLimiter);
const BakeAudio quiet = renderBake(s, plan, 0.25, kNoLimiter);
const BakeAudio loud = renderBake(s, plan, 4.0, kNoLimiter);
const BakeAudio silent = renderBake(s, plan, 0.0, kNoLimiter);
CHECK(unity.interleaved.size() == quiet.interleaved.size());
bool scaled = !unity.interleaved.empty();
@@ -150,6 +188,76 @@ int main() {
CHECK(peakAt(unity, 0, 500) > 0.4);
}
// --- The limiter is PRINTED when engaged: the file holds the ceiling -----------------
// DC at 0.5 through +4x of gain is a constant 2.0 — over twice the ceiling for every
// frame asserted, not a transient that a quiet fixture would let slide.
{
const double ceiling = instrument::engine::limiterCeilingLinear();
SampleData s = makeSample(/*stereo=*/false, /*frames=*/4000);
s.play.playMode = PlayMode::Trigger;
const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/2000);
const BakeAudio unlimited = renderBake(s, plan, 4.0, kNoLimiter);
const BakeAudio limited = renderBake(s, plan, 4.0, kLimiter);
CHECK(unlimited.frameCount() == 2000);
CHECK(limited.frameCount() == 2000); // the lookahead does not shorten the file
// The fixture really drives it: bypassed, the same render sits at twice the ceiling.
CHECK(peakAt(unlimited, 0, 2000) > ceiling * 1.9);
// …and engaged, not one printed sample is over it.
CHECK(peakAll(limited) <= ceiling + 1e-6);
// Held AT the ceiling once settled, not ducked to silence — a limiter that muted
// everything would pass the bound above.
CHECK(peakAt(limited, 1000, 2000) > ceiling * 0.9);
// Repeat bakes are bit-identical with the limiter engaged too: the render builds its
// own Limiter, and prepare() zeroes every one of its state fields.
CHECK(sameSamples(limited, renderBake(s, plan, 4.0, kLimiter)));
}
// --- Engaged but below the ceiling: the render is the bypassed one, frame for frame ---
// The limiter delays its output by its lookahead, so this is where a missing or wrong
// compensation shows: an uncompensated render would print ~96 frames of silence at the
// head and shift the whole capture late. Nothing here reaches the ceiling, so the
// limiter's gain is exactly 1 at every sample and the two renders must agree bit for bit
// — which also pins that the engaged render skips the transition mute (it would fade the
// first 10 ms up from silence).
{
SampleData s = makeRamp();
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
const BakeAudio bypassed = renderBake(s, plan, kUnity, kNoLimiter);
const BakeAudio engaged = renderBake(s, plan, kUnity, kLimiter);
CHECK(sameSamples(bypassed, engaged));
// And it is the SOURCE they both agree on, so this cannot pass by both being wrong
// the same way.
bool identity = engaged.frameCount() == 1000;
for (std::size_t f = 0; identity && f < 1000; ++f)
identity = (engaged.interleaved[f] == s.frames[f]);
CHECK(identity);
}
// --- The limiter is stereo-LINKED, and both legs are printed -------------------------
{
const double ceiling = instrument::engine::limiterCeilingLinear();
SampleData s = makeSample(/*stereo=*/true, /*frames=*/4000); // L 0.5, R -0.25
s.play.playMode = PlayMode::Trigger;
const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/2000);
const BakeAudio limited = renderBake(s, plan, 4.0, kLimiter);
CHECK(limited.channelCount == 2);
CHECK(peakAll(limited) <= ceiling + 1e-6);
// The quieter leg is limited by the louder one's peak rather than by its own, so the
// source's exact 2:1 level ratio survives — one gain, not two. Both are exact: the
// gain multiplies 2.0 and 1.0, and doubling a float is exact.
bool linked = limited.frameCount() == 2000;
for (std::size_t f = 1000; linked && f < 2000; ++f)
linked = (limited.interleaved[f * 2] == -2.f * limited.interleaved[f * 2 + 1]);
CHECK(linked);
// Non-vacuous: the right leg is really sounding, so the ratio is not 0 == -0.
CHECK(std::fabs(static_cast<double>(limited.interleaved[3001])) > 0.1);
}
// --- A lead-in is rendered and then discarded ---------------------------------------
// A positive start offset trims the note's head: the frames before the window must be
// produced (so the envelope really is mid-flight when the file opens) and dropped.
@@ -160,15 +268,20 @@ int main() {
const BakePlan trimmed = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/2000,
/*leadIn=*/1000);
const BakeAudio audio = renderBake(s, trimmed, kUnity);
const BakeAudio audio = renderBake(s, trimmed, kUnity, kNoLimiter);
CHECK(audio.frameCount() == 1000); // the FILE is the window, not the render
// Frame 0 of the file is frame 1000 of the render — the attack's end, not its
// start. A clamped-away lead-in would put the attack's silent onset here instead.
const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity);
const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity,
kNoLimiter);
CHECK(peakAt(audio, 0, 1) > peakAt(whole, 0, 1));
CHECK(std::fabs(static_cast<double>(audio.interleaved[0]) -
static_cast<double>(whole.interleaved[1000])) < 1e-6);
// The lead-in and the lookahead are two independent offsets into one buffer: with the
// limiter engaged under the ceiling, the trimmed window is still the same frames.
CHECK(sameSamples(audio, renderBake(s, trimmed, kUnity, kLimiter)));
}
// --- Bit-identical repeats ---------------------------------------------------------
@@ -179,15 +292,12 @@ int main() {
s.play.adsr.releaseFrames = 211;
const BakePlan plan = planOf(/*total=*/4096, /*noteOn=*/13, /*noteOff=*/2731);
const BakeAudio a = renderBake(s, plan, kUnity);
const BakeAudio b = renderBake(s, plan, kUnity);
const BakeAudio a = renderBake(s, plan, kUnity, kNoLimiter);
const BakeAudio b = renderBake(s, plan, kUnity, kNoLimiter);
CHECK(a.interleaved.size() == b.interleaved.size());
CHECK(!a.interleaved.empty());
bool identical = a.interleaved.size() == b.interleaved.size();
for (std::size_t i = 0; identical && i < a.interleaved.size(); ++i)
identical = (a.interleaved[i] == b.interleaved[i]);
CHECK(identical);
CHECK(sameSamples(a, b));
// The window opened before the note: those frames must be untouched silence.
CHECK(peakAt(a, 0, 13) == 0.0);
CHECK(peakAt(a, 200, 400) > 0.0);
@@ -214,7 +324,7 @@ int main() {
}
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
const BakeAudio audio = renderBake(s, plan, kUnity);
const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
CHECK(s.live == &block); // the caller's own snapshot was not detached
// At frame 100 the dialed instant attack is at full level; the published 900-frame
@@ -224,11 +334,8 @@ int main() {
// And it matches a render from a block-free copy exactly.
SampleData detached = s;
detached.live = nullptr;
const BakeAudio reference = renderBake(detached, plan, kUnity);
bool identical = audio.interleaved.size() == reference.interleaved.size();
for (std::size_t i = 0; identical && i < audio.interleaved.size(); ++i)
identical = (audio.interleaved[i] == reference.interleaved[i]);
CHECK(identical);
const BakeAudio reference = renderBake(detached, plan, kUnity, kNoLimiter);
CHECK(sameSamples(audio, reference));
}
// --- Regression baseline: the neutral render is the source, sample for sample --------
@@ -239,20 +346,12 @@ int main() {
// guarantee of eval() at an arbitrary velocity. An added stage, a moved default, or a lost
// early-out anywhere in the chain moves a sample here.
{
SampleData s;
s.frames.resize(4000);
// A ramp, not DC: an off-by-one read or a reversed span is invisible in a constant.
for (std::size_t i = 0; i < s.frames.size(); ++i)
s.frames[i] = static_cast<float>(i) / 4000.f - 0.5f;
s.sampleRate = kRate;
s.rootNote = 60;
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Varispeed;
SampleData s = makeRamp();
// Shorter than the play span, so the window closes before any note-end shaping.
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
CHECK(s.velocityCurve.eval(100.0) == 1.0); // names the real cause if this ever fails
const BakeAudio audio = renderBake(s, plan, kUnity);
const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter);
CHECK(audio.channelCount == 1);
CHECK(audio.frameCount() == 1000);
@@ -262,8 +361,8 @@ int main() {
CHECK(identity);
// …and the gain rides that as an exact scalar, which is the only other thing the
// render is permitted to do to the signal.
const BakeAudio halved = renderBake(s, plan, 0.5);
// render is permitted to do to the signal with the limiter bypassed.
const BakeAudio halved = renderBake(s, plan, 0.5, kNoLimiter);
bool scaled = halved.frameCount() == 1000;
for (std::size_t f = 0; scaled && f < 1000; ++f)
scaled = (halved.interleaved[f] == s.frames[f] * 0.5f);
@@ -274,20 +373,21 @@ int main() {
{
SampleData empty; // nothing decoded
empty.sampleRate = kRate;
CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity).empty());
CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity, kNoLimiter).empty());
SampleData s = makeSample(false);
CHECK(renderBake(s, planOf(0, 0, 0), kUnity).empty());
CHECK(renderBake(s, planOf(0, 0, 0), kUnity, kNoLimiter).empty());
// The ceiling planBake enforces is re-checked here: a hand-built plan must not be
// able to walk the render into an allocation it cannot hold.
CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity).empty());
CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity).empty());
CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity, kNoLimiter)
.empty());
CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity, kNoLimiter).empty());
// A hand-built plan can carry a lead-in near the int64 ceiling; the guard must trip
// on that field alone rather than signed-overflowing inside renderFrames()'s sum.
CHECK(renderBake(s,
planOf(1000, 0, 500,
/*leadIn=*/std::numeric_limits<std::int64_t>::max() - 10),
kUnity)
kUnity, kNoLimiter)
.empty());
}