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
+40 -12
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cmath>
#include "core/instrument/engine/limiter.h"
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::instrument::bake {
@@ -18,7 +19,8 @@ constexpr std::int64_t kBlockFrames = 512;
} // namespace
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear) {
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
bool limiterEnabled) {
BakeAudio out;
if (!sample.playable() || plan.totalFrames <= 0 || plan.sampleRate <= 0) return out;
// Each field bounded BEFORE the sum: renderFrames() adds them, and a hand-built plan
@@ -36,9 +38,16 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
sample.live = nullptr;
const int channels = sample.channelCount();
// The limiter delays its output by its lookahead, so the buffers carry that many extra
// frames and the window is read that far in — the file is the same frames it would be
// with the limiter bypassed, not the capture shifted late by 2 ms. The extra input is
// SILENCE rather than more rendered audio: the file ends at the window, so a peak past
// it is not in the capture and must not duck the frames that are.
const auto flushFrames = static_cast<std::size_t>(
limiterEnabled ? engine::limiterLookaheadSamples(plan.sampleRate) : 0);
const auto rendered = static_cast<std::size_t>(plan.renderFrames());
std::vector<AudioSample> left(rendered, 0.f);
std::vector<AudioSample> right(channels == 2 ? rendered : 0u, 0.f);
std::vector<AudioSample> left(rendered + flushFrames, 0.f);
std::vector<AudioSample> right(channels == 2 ? rendered + flushFrames : 0u, 0.f);
// Pre-size the Preserve shifters here, off any audio thread, exactly as the processor
// does for its live engine — a cold shifter would smear the onset.
@@ -69,20 +78,39 @@ BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainL
pos += chunk;
}
// The whole master stage is printed here rather than left for the processor, in the
// processor's own order — gain, then the limiter — because resetAfterBake hands both
// controls back neutral: a render that only summed voices would return every iteration
// shifted by 1/gain and unlimited, and a gain dialed to silence would come back at full
// level. A flat gain multiply, not the processor's per-sample ramp: the gain is constant
// for the whole render, which is exactly what that ramp exists to converge to.
const auto gain = static_cast<AudioSample>(masterGainLinear);
for (AudioSample& s : left) s *= gain;
for (AudioSample& s : right) s *= gain;
if (limiterEnabled) {
engine::Limiter limiter;
// Enabled BEFORE prepare, whose reset snaps to the enable target: that starts the
// render already engaged. Enabling afterwards takes process()'s live-engage path,
// which mutes for the delay-line prime and then fades in — silencing the head of the
// capture. prepare()'s allocation and transcendentals are legal here: the bake runs
// on the UI thread, never in process().
limiter.setEnabled(true);
limiter.prepare(plan.sampleRate);
// One call: kMaxBakeFrames bounds the whole buffer well inside int, and a block
// split would change nothing (the limiter carries its state across calls).
limiter.process(left.data(), channels == 2 ? right.data() : nullptr,
static_cast<int>(left.size()));
}
out.channelCount = channels;
out.sampleRate = plan.sampleRate;
const auto lead = static_cast<std::size_t>(plan.leadInFrames);
const auto lead = static_cast<std::size_t>(plan.leadInFrames) + flushFrames;
const auto total = static_cast<std::size_t>(plan.totalFrames);
out.interleaved.resize(total * static_cast<std::size_t>(channels));
// Printed here rather than left for the processor: resetAfterBake hands master gain
// back to unity, so a render that only summed voices would return every iteration
// shifted by 1/gain, and a gain dialed to silence would come back at full level. A flat
// multiply, not the processor's per-sample ramp: the gain is constant for the whole
// render, which is exactly what that ramp exists to converge to.
const auto gain = static_cast<AudioSample>(masterGainLinear);
for (std::size_t f = 0; f < total; ++f) {
out.interleaved[f * channels] = left[lead + f] * gain;
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f] * gain;
out.interleaved[f * channels] = left[lead + f];
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f];
}
return out;
}