Files
reasampler/src/core/instrument/bake/bake_render.cpp
T

119 lines
6.1 KiB
C++

// See bake_render.h.
#include "core/instrument/bake/bake_render.h"
#include <algorithm>
#include <cmath>
#include "core/instrument/engine/limiter.h"
#include "core/instrument/engine/voice_engine.h"
namespace reasampler::instrument::bake {
namespace {
// A fixed render block rather than the host's. A block boundary is where the engine
// re-observes live state, and the detach below leaves it nothing to observe — so this is
// defence in depth against a future block-boundary read, not the reason two bakes agree.
constexpr std::int64_t kBlockFrames = 512;
} // namespace
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
// (planBake already bounds both — bake_plan.cpp) could otherwise carry leadInFrames
// near INT64_MAX and signed-overflow inside the guard meant to catch exactly that.
if (plan.leadInFrames < 0 || plan.leadInFrames > kMaxBakeFrames ||
plan.totalFrames > kMaxBakeFrames) {
return out;
}
if (plan.renderFrames() > kMaxBakeFrames) return out;
// The live block is the audio thread's moving target; a render that observed it would
// depend on what the user happened to be dragging. The dialed values are already in
// this SampleData's own play params, which is what the bake is meant to print.
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 + 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.
std::int64_t preserveWindow = static_cast<std::int64_t>(
kPreserveWindowMs * static_cast<double>(plan.sampleRate) / 1000.0 + 0.5);
if (preserveWindow < 2) preserveWindow = 2;
VoiceEngine engine(/*maxVoices=*/1, sample, /*preserveVoiceCap=*/0, preserveWindow,
VoiceMode::Poly, MonoTrigger::Retrigger, /*takeoverDeclick=*/false);
for (std::int64_t pos = 0; pos < plan.renderFrames();) {
if (pos == plan.noteOnFrame) engine.noteOn(plan.note, plan.velocity);
// Trigger ignores note-off by design; in Gate this is the release the programmed
// note length bounds.
if (pos == plan.noteOffFrame) engine.noteOff(plan.note);
// Stop the block at the next event frame so both land sample-accurately. An event
// past the window (a capture that closes before the note) never bounds anything.
std::int64_t limit = plan.renderFrames();
if (pos < plan.noteOnFrame) limit = (std::min)(limit, plan.noteOnFrame);
else if (pos < plan.noteOffFrame) limit = (std::min)(limit, plan.noteOffFrame);
const std::int64_t chunk = (std::min)(limit - pos, kBlockFrames);
if (chunk <= 0) break; // unreachable while limit > pos; a guard, not a path
const auto at = static_cast<std::size_t>(pos);
const auto n = static_cast<std::size_t>(chunk);
if (channels == 2) engine.render(left.data() + at, right.data() + at, n);
else engine.render(left.data() + at, n);
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) + flushFrames;
const auto total = static_cast<std::size_t>(plan.totalFrames);
out.interleaved.resize(total * static_cast<std::size_t>(channels));
for (std::size_t f = 0; f < total; ++f) {
out.interleaved[f * channels] = left[lead + f];
if (channels == 2) out.interleaved[f * channels + 1] = right[lead + f];
}
return out;
}
} // namespace reasampler::instrument::bake