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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user