// See bake_render.h. #include "core/instrument/bake/bake_render.h" #include #include #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( limiterEnabled ? engine::limiterLookaheadSamples(plan.sampleRate) : 0); const auto rendered = static_cast(plan.renderFrames()); std::vector left(rendered + flushFrames, 0.f); std::vector 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( kPreserveWindowMs * static_cast(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(pos); const auto n = static_cast(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(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(left.size())); } out.channelCount = channels; out.sampleRate = plan.sampleRate; const auto lead = static_cast(plan.leadInFrames) + flushFrames; const auto total = static_cast(plan.totalFrames); out.interleaved.resize(total * static_cast(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