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
+17 -4
View File
@@ -21,9 +21,21 @@ decision about what the render made obsolete.
loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain
loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but
even a pathological envelope cannot run past the window.
- **The voice chain and master gain are printed; the limiter is not.** The gain multiply in
`bake_render.cpp` carries the argument for the gain, and `bake_reset.h` records where the
printed master stage stops.
- **The whole chain is printed — voice, master gain, then the limiter, in the processor's
own order.** `bake_render.cpp`'s master stage carries the argument. The limiter is printed
only when it is ENGAGED; bypassed, `renderBake` never constructs one and the result is the
pre-limiter render frame for frame. The lookahead is compensated inside the render — the
buffers carry an extra flush window and the capture is read past it — so an engaged bake
under the ceiling is bit-identical to a bypassed one, not the same audio 2 ms late.
- **A printed capture replayed through an engaged limiter is limited TWICE — a NAMED
boundary, not a bug**, and the same shape as the automation-lane limitation below. The
reset is what normally prevents it (`limiterEnabled` is not on the survive list, so a bake
hands the enable back off), and at unity the second pass has nothing to take: every sample
of the printed file is already at or under the ceiling, and the limiter reduces only where
its detector reads ABOVE it — which after a bake means its inter-sample estimate alone. Dial
the enable back on over raised gain, though, and the capture is limited on top of limiting
that is already in its samples. Not detectable from inside the instrument and not corrected
there; the user's remedy is to leave the enable where the bake put it.
- **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses a
collapsed window, a non-positive rate, a window that rounds to no frames, and one past
`kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the
@@ -72,7 +84,8 @@ decision about what the render made obsolete.
render window, the captured slice of it, and the two event frames), `kMaxBakeFrames`, and
`planBake`, the one `ResolvedNote` + rate -> frames resolution, answering a `PlannedBake`.
- `bake_render``BakeAudio` and `renderBake`: the programmed note through the sample's
own voice path, summed into an interleaved buffer at the source's own channel count.
own voice path and then the master stage, summed into an interleaved buffer at the
source's own channel count.
- `bake_reset``BakeReset` and `resetAfterBake`: the ratified reset scope, answered for
both the parameter set and the post-mixer master gain.
+3 -1
View File
@@ -6,9 +6,11 @@ reasampler_pure_library(bake_plan
LINK PUBLIC note_program sampler_core trigger_seam)
reasampler_test(bake_plan LINK bake_plan)
# limiter beside sampler_core, not through it: the render prints the whole master stage, and
# the limiter runs on the summed output rather than inside a voice.
reasampler_pure_library(bake_render
SOURCES bake_render.cpp
LINK PUBLIC bake_plan sampler_core)
LINK PUBLIC bake_plan sampler_core limiter)
reasampler_test(bake_render LINK bake_render)
# No library of its own: the derived window is a PROPERTY of bake_plan + bake_render
+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;
}
+8 -6
View File
@@ -29,11 +29,13 @@ struct BakeAudio {
bool empty() const { return frameCount() == 0; }
};
// Renders `plan` through `sample`'s own voice path, scaled by `masterGainLinear` — the
// post-mixer gain the processor applies after the engine; see the gain multiply in
// bake_render.cpp for why it is printed here rather than left to the processor. The result
// is the plan's captured window: the lead-in frames are rendered and dropped. An unplayable
// sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear);
// Renders `plan` through `sample`'s own voice path and then the master stage the processor
// runs after the engine: `masterGainLinear`, then the limiter when `limiterEnabled` — see
// the master stage in bake_render.cpp for why both are printed here rather than left to the
// processor. Bypassed, the limiter costs the result not one sample: `limiterEnabled` false
// is the pre-limiter render, frame for frame. The result is the plan's captured window: the
// lead-in frames are rendered and dropped. An unplayable sample yields an empty result.
BakeAudio renderBake(SampleData sample, const BakePlan& plan, double masterGainLinear,
bool limiterEnabled);
} // namespace reasampler::instrument::bake
+2 -5
View File
@@ -12,11 +12,8 @@ namespace reasampler::instrument::bake {
// The two surfaces a bake resets. Master gain lives on the processor rather than in the
// parameter set; it is answered here because renderBake prints it into the file (see
// bake_render.cpp's gain multiply) rather than left to the shell.
//
// Gain is the ONLY master-stage control the render prints — the limiter runs in the
// processor's block, off the bake path — so "the bake prints the gain" does not generalize
// to the master stage as a whole, and cannot be used to classify anything else on it.
// bake_render.cpp's master stage) rather than left to the shell. The limiter needs no field
// of its own: its enable rides the parameter set, and the render prints it too.
struct BakeReset {
map::InstrumentParams params;
double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain
+2 -1
View File
@@ -146,7 +146,8 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const instrument::bake::BakePlan& plan = *planned.plan;
const instrument::bake::BakeAudio audio =
renderBake(std::move(*snapshot), plan, processor.masterGainLinear());
renderBake(std::move(*snapshot), plan, processor.masterGainLinear(),
dialed.limiterEnabled);
if (audio.empty()) return fail("the offline pass produced no audio");
// buildFloat32Wav takes doubles and narrows; the narrowing back to float is the bank's