From 0d316b7b9e77e719697a2960a79b6cf91fa98c26 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 20:57:29 -0400 Subject: [PATCH 1/2] 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 --- docs/product/instrument-control-surface.md | 15 +- src/core/instrument/bake/CLAUDE.md | 21 ++- src/core/instrument/bake/CMakeLists.txt | 4 +- src/core/instrument/bake/bake_render.cpp | 52 ++++-- src/core/instrument/bake/bake_render.h | 14 +- src/core/instrument/bake/bake_reset.h | 7 +- src/shell/instrument/instrument_bake.cpp | 3 +- tests/test_bake_render.cpp | 182 ++++++++++++++++----- tests/test_bake_window.cpp | 9 +- 9 files changed, 232 insertions(+), 75 deletions(-) diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index b5c3ebb..c5f3f73 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -713,8 +713,19 @@ reduction is applied.** Phase Ξ-W2's resample reset scope is settled by rule ("reset what the bake baked in"). Derived against that rule — **no new Daniel call**: **rate → reset**, **pitch offset → -reset**, **limiter enabled → reset** (master gain is already on the reset list, so the bake -includes the master stage, so the limiter's effect is in the audio). +reset**, **limiter enabled → reset**. + +**The limiter clause's original reasoning was false, and the code was changed to make its +conclusion true.** It read "master gain is already on the reset list, so the bake includes +the master stage, so the limiter's effect is in the audio" — but the bake printed a flat +gain multiply and nothing else; the limiter ran in the processor's block, off the bake path, +so a capture baked with it engaged came back unlimited and resetting the enable was +resetting a control whose effect was NOT in the file. Daniel ruled the goal rather than the +premise: `renderBake` now prints the whole master stage, gain then limiter, so the +classification stands on the rule it always claimed to. The lookahead is compensated inside +the render, and a bypassed bake is the pre-limiter render frame for frame — +`src/core/instrument/bake/CLAUDE.md` owns both, plus the double-limiting boundary a baked +capture inherits. **This is now a CORRECTION, not a sequencing note.** The original plan required Phase Γ to land before Ξ-W2 so the bake's reset list would be complete on the day it shipped. **That diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 13912a1..4a33025 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -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. diff --git a/src/core/instrument/bake/CMakeLists.txt b/src/core/instrument/bake/CMakeLists.txt index 7a94710..343ca8b 100644 --- a/src/core/instrument/bake/CMakeLists.txt +++ b/src/core/instrument/bake/CMakeLists.txt @@ -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 diff --git a/src/core/instrument/bake/bake_render.cpp b/src/core/instrument/bake/bake_render.cpp index 7c86df7..d0b1696 100644 --- a/src/core/instrument/bake/bake_render.cpp +++ b/src/core/instrument/bake/bake_render.cpp @@ -5,6 +5,7 @@ #include #include +#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( + limiterEnabled ? engine::limiterLookaheadSamples(plan.sampleRate) : 0); const auto rendered = static_cast(plan.renderFrames()); - std::vector left(rendered, 0.f); - std::vector right(channels == 2 ? rendered : 0u, 0.f); + 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. @@ -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(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); + const auto lead = static_cast(plan.leadInFrames) + flushFrames; const auto total = static_cast(plan.totalFrames); out.interleaved.resize(total * static_cast(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(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; } diff --git a/src/core/instrument/bake/bake_render.h b/src/core/instrument/bake/bake_render.h index fa06458..b845e5e 100644 --- a/src/core/instrument/bake/bake_render.h +++ b/src/core/instrument/bake/bake_render.h @@ -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 diff --git a/src/core/instrument/bake/bake_reset.h b/src/core/instrument/bake/bake_reset.h index df268f6..ca2159c 100644 --- a/src/core/instrument/bake/bake_reset.h +++ b/src/core/instrument/bake/bake_reset.h @@ -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 diff --git a/src/shell/instrument/instrument_bake.cpp b/src/shell/instrument/instrument_bake.cpp index 1a0c967..ed351bd 100644 --- a/src/shell/instrument/instrument_bake.cpp +++ b/src/shell/instrument/instrument_bake.cpp @@ -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 diff --git a/tests/test_bake_render.cpp b/tests/test_bake_render.cpp index 915d3e4..7a382be 100644 --- a/tests/test_bake_render.cpp +++ b/tests/test_bake_render.cpp @@ -4,12 +4,13 @@ // Covers: Gate termination WITH a sustain loop active (the render must end at the window, // and the tail must be silent because the gate actually released — not merely because the // buffer ran out); Trigger termination on its own play span; channel-count preservation -// with no stereo fold; the master gain being PRINTED into the output; a lead-in rendered -// and discarded; byte-identical repeats; and the refusals (unplayable sample, empty -// window, a window past the frame ceiling). +// with no stereo fold; the master stage — gain and limiter — being PRINTED into the output; +// a lead-in rendered and discarded; byte-identical repeats; and the refusals (unplayable +// sample, empty window, a window past the frame ceiling). #include "../src/core/instrument/bake/bake_render.h" +#include "../src/core/instrument/engine/limiter.h" #include "../src/core/instrument/engine/live_params.h" #include @@ -38,6 +39,22 @@ SampleData makeSample(bool stereo, std::size_t frames = 1000) { return s; } +// A ramp, not DC: an off-by-one read, a reversed span or an output shifted in time is +// visible in it and invisible in a constant. Trigger at its own root under Varispeed reads +// at ratio exactly 1 and hits no filter, so a neutral render prints the source frame for +// frame — which is what makes this fixture an exact expectation rather than a range. +SampleData makeRamp(std::size_t frames = 4000) { + SampleData s; + s.frames.resize(frames); + for (std::size_t i = 0; i < frames; ++i) + s.frames[i] = static_cast(i) / static_cast(frames) - 0.5f; + s.sampleRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + return s; +} + // Peak magnitude of channel 0 over [from, to) output frames. double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { double peak = 0.0; @@ -50,6 +67,23 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { return peak; } +// Peak magnitude over EVERY channel — a ceiling is a property of the file, not of one leg. +double peakAll(const BakeAudio& audio) { + double peak = 0.0; + for (AudioSample v : audio.interleaved) { + const double m = std::fabs(static_cast(v)); + if (m > peak) peak = m; + } + return peak; +} + +bool sameSamples(const BakeAudio& a, const BakeAudio& b) { + if (a.interleaved.size() != b.interleaved.size() || a.interleaved.empty()) return false; + for (std::size_t i = 0; i < a.interleaved.size(); ++i) + if (a.interleaved[i] != b.interleaved[i]) return false; + return true; +} + BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff, std::int64_t leadIn = 0) { BakePlan p; @@ -64,6 +98,8 @@ BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff, } constexpr double kUnity = 1.0; +constexpr bool kNoLimiter = false; +constexpr bool kLimiter = true; } // namespace @@ -78,7 +114,7 @@ int main() { s.play.adsr.releaseFrames = 480; // 10 ms — short enough to finish inside the tail const BakePlan plan = planOf(/*total=*/9600, /*noteOn=*/0, /*noteOff=*/4800); - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(audio.frameCount() == 9600); // bounded, not a runaway CHECK(audio.channelCount == 1); @@ -96,7 +132,7 @@ int main() { s.play.trigger.lengthFraction = 0.5; // 500 source frames at unity ratio const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/100); - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(audio.frameCount() == 2000); // Still sounding past the note-off Trigger ignores… @@ -111,7 +147,7 @@ int main() { s.play.playMode = PlayMode::Trigger; const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500); - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(audio.channelCount == 2); CHECK(audio.frameCount() == 500); @@ -126,15 +162,17 @@ int main() { // --- The master gain is PRINTED into the file --------------------------------------- // The reset hands the control back at unity, so a render that summed voices alone would // shift every iteration by 1/gain — and a gain dialed to silence would come back loud. + // Every render here is limiter-bypassed, so the exact scaling below is also the guard + // that the limiter never engages on its own: +4x over this DC is far past the ceiling. { SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000); s.play.playMode = PlayMode::Trigger; const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500); - const BakeAudio unity = renderBake(s, plan, kUnity); - const BakeAudio quiet = renderBake(s, plan, 0.25); - const BakeAudio loud = renderBake(s, plan, 4.0); - const BakeAudio silent = renderBake(s, plan, 0.0); + const BakeAudio unity = renderBake(s, plan, kUnity, kNoLimiter); + const BakeAudio quiet = renderBake(s, plan, 0.25, kNoLimiter); + const BakeAudio loud = renderBake(s, plan, 4.0, kNoLimiter); + const BakeAudio silent = renderBake(s, plan, 0.0, kNoLimiter); CHECK(unity.interleaved.size() == quiet.interleaved.size()); bool scaled = !unity.interleaved.empty(); @@ -150,6 +188,76 @@ int main() { CHECK(peakAt(unity, 0, 500) > 0.4); } + // --- The limiter is PRINTED when engaged: the file holds the ceiling ----------------- + // DC at 0.5 through +4x of gain is a constant 2.0 — over twice the ceiling for every + // frame asserted, not a transient that a quiet fixture would let slide. + { + const double ceiling = instrument::engine::limiterCeilingLinear(); + SampleData s = makeSample(/*stereo=*/false, /*frames=*/4000); + s.play.playMode = PlayMode::Trigger; + const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/2000); + + const BakeAudio unlimited = renderBake(s, plan, 4.0, kNoLimiter); + const BakeAudio limited = renderBake(s, plan, 4.0, kLimiter); + + CHECK(unlimited.frameCount() == 2000); + CHECK(limited.frameCount() == 2000); // the lookahead does not shorten the file + // The fixture really drives it: bypassed, the same render sits at twice the ceiling. + CHECK(peakAt(unlimited, 0, 2000) > ceiling * 1.9); + // …and engaged, not one printed sample is over it. + CHECK(peakAll(limited) <= ceiling + 1e-6); + // Held AT the ceiling once settled, not ducked to silence — a limiter that muted + // everything would pass the bound above. + CHECK(peakAt(limited, 1000, 2000) > ceiling * 0.9); + + // Repeat bakes are bit-identical with the limiter engaged too: the render builds its + // own Limiter, and prepare() zeroes every one of its state fields. + CHECK(sameSamples(limited, renderBake(s, plan, 4.0, kLimiter))); + } + + // --- Engaged but below the ceiling: the render is the bypassed one, frame for frame --- + // The limiter delays its output by its lookahead, so this is where a missing or wrong + // compensation shows: an uncompensated render would print ~96 frames of silence at the + // head and shift the whole capture late. Nothing here reaches the ceiling, so the + // limiter's gain is exactly 1 at every sample and the two renders must agree bit for bit + // — which also pins that the engaged render skips the transition mute (it would fade the + // first 10 ms up from silence). + { + SampleData s = makeRamp(); + const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); + + const BakeAudio bypassed = renderBake(s, plan, kUnity, kNoLimiter); + const BakeAudio engaged = renderBake(s, plan, kUnity, kLimiter); + CHECK(sameSamples(bypassed, engaged)); + // And it is the SOURCE they both agree on, so this cannot pass by both being wrong + // the same way. + bool identity = engaged.frameCount() == 1000; + for (std::size_t f = 0; identity && f < 1000; ++f) + identity = (engaged.interleaved[f] == s.frames[f]); + CHECK(identity); + } + + // --- The limiter is stereo-LINKED, and both legs are printed ------------------------- + { + const double ceiling = instrument::engine::limiterCeilingLinear(); + SampleData s = makeSample(/*stereo=*/true, /*frames=*/4000); // L 0.5, R -0.25 + s.play.playMode = PlayMode::Trigger; + const BakePlan plan = planOf(/*total=*/2000, /*noteOn=*/0, /*noteOff=*/2000); + + const BakeAudio limited = renderBake(s, plan, 4.0, kLimiter); + CHECK(limited.channelCount == 2); + CHECK(peakAll(limited) <= ceiling + 1e-6); + // The quieter leg is limited by the louder one's peak rather than by its own, so the + // source's exact 2:1 level ratio survives — one gain, not two. Both are exact: the + // gain multiplies 2.0 and 1.0, and doubling a float is exact. + bool linked = limited.frameCount() == 2000; + for (std::size_t f = 1000; linked && f < 2000; ++f) + linked = (limited.interleaved[f * 2] == -2.f * limited.interleaved[f * 2 + 1]); + CHECK(linked); + // Non-vacuous: the right leg is really sounding, so the ratio is not 0 == -0. + CHECK(std::fabs(static_cast(limited.interleaved[3001])) > 0.1); + } + // --- A lead-in is rendered and then discarded --------------------------------------- // A positive start offset trims the note's head: the frames before the window must be // produced (so the envelope really is mid-flight when the file opens) and dropped. @@ -160,15 +268,20 @@ int main() { const BakePlan trimmed = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/2000, /*leadIn=*/1000); - const BakeAudio audio = renderBake(s, trimmed, kUnity); + const BakeAudio audio = renderBake(s, trimmed, kUnity, kNoLimiter); CHECK(audio.frameCount() == 1000); // the FILE is the window, not the render // Frame 0 of the file is frame 1000 of the render — the attack's end, not its // start. A clamped-away lead-in would put the attack's silent onset here instead. - const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity); + const BakeAudio whole = renderBake(s, planOf(/*total=*/2000, 0, 2000), kUnity, + kNoLimiter); CHECK(peakAt(audio, 0, 1) > peakAt(whole, 0, 1)); CHECK(std::fabs(static_cast(audio.interleaved[0]) - static_cast(whole.interleaved[1000])) < 1e-6); + + // The lead-in and the lookahead are two independent offsets into one buffer: with the + // limiter engaged under the ceiling, the trimmed window is still the same frames. + CHECK(sameSamples(audio, renderBake(s, trimmed, kUnity, kLimiter))); } // --- Bit-identical repeats --------------------------------------------------------- @@ -179,15 +292,12 @@ int main() { s.play.adsr.releaseFrames = 211; const BakePlan plan = planOf(/*total=*/4096, /*noteOn=*/13, /*noteOff=*/2731); - const BakeAudio a = renderBake(s, plan, kUnity); - const BakeAudio b = renderBake(s, plan, kUnity); + const BakeAudio a = renderBake(s, plan, kUnity, kNoLimiter); + const BakeAudio b = renderBake(s, plan, kUnity, kNoLimiter); CHECK(a.interleaved.size() == b.interleaved.size()); CHECK(!a.interleaved.empty()); - bool identical = a.interleaved.size() == b.interleaved.size(); - for (std::size_t i = 0; identical && i < a.interleaved.size(); ++i) - identical = (a.interleaved[i] == b.interleaved[i]); - CHECK(identical); + CHECK(sameSamples(a, b)); // The window opened before the note: those frames must be untouched silence. CHECK(peakAt(a, 0, 13) == 0.0); CHECK(peakAt(a, 200, 400) > 0.0); @@ -214,7 +324,7 @@ int main() { } const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(s.live == &block); // the caller's own snapshot was not detached // At frame 100 the dialed instant attack is at full level; the published 900-frame @@ -224,11 +334,8 @@ int main() { // And it matches a render from a block-free copy exactly. SampleData detached = s; detached.live = nullptr; - const BakeAudio reference = renderBake(detached, plan, kUnity); - bool identical = audio.interleaved.size() == reference.interleaved.size(); - for (std::size_t i = 0; identical && i < audio.interleaved.size(); ++i) - identical = (audio.interleaved[i] == reference.interleaved[i]); - CHECK(identical); + const BakeAudio reference = renderBake(detached, plan, kUnity, kNoLimiter); + CHECK(sameSamples(audio, reference)); } // --- Regression baseline: the neutral render is the source, sample for sample -------- @@ -239,20 +346,12 @@ int main() { // guarantee of eval() at an arbitrary velocity. An added stage, a moved default, or a lost // early-out anywhere in the chain moves a sample here. { - SampleData s; - s.frames.resize(4000); - // A ramp, not DC: an off-by-one read or a reversed span is invisible in a constant. - for (std::size_t i = 0; i < s.frames.size(); ++i) - s.frames[i] = static_cast(i) / 4000.f - 0.5f; - s.sampleRate = kRate; - s.rootNote = 60; - s.play.playMode = PlayMode::Trigger; - s.play.pitchEngine = PitchEngine::Varispeed; + SampleData s = makeRamp(); // Shorter than the play span, so the window closes before any note-end shaping. const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); CHECK(s.velocityCurve.eval(100.0) == 1.0); // names the real cause if this ever fails - const BakeAudio audio = renderBake(s, plan, kUnity); + const BakeAudio audio = renderBake(s, plan, kUnity, kNoLimiter); CHECK(audio.channelCount == 1); CHECK(audio.frameCount() == 1000); @@ -262,8 +361,8 @@ int main() { CHECK(identity); // …and the gain rides that as an exact scalar, which is the only other thing the - // render is permitted to do to the signal. - const BakeAudio halved = renderBake(s, plan, 0.5); + // render is permitted to do to the signal with the limiter bypassed. + const BakeAudio halved = renderBake(s, plan, 0.5, kNoLimiter); bool scaled = halved.frameCount() == 1000; for (std::size_t f = 0; scaled && f < 1000; ++f) scaled = (halved.interleaved[f] == s.frames[f] * 0.5f); @@ -274,20 +373,21 @@ int main() { { SampleData empty; // nothing decoded empty.sampleRate = kRate; - CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity).empty()); + CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity, kNoLimiter).empty()); SampleData s = makeSample(false); - CHECK(renderBake(s, planOf(0, 0, 0), kUnity).empty()); + CHECK(renderBake(s, planOf(0, 0, 0), kUnity, kNoLimiter).empty()); // The ceiling planBake enforces is re-checked here: a hand-built plan must not be // able to walk the render into an allocation it cannot hold. - CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity).empty()); - CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity).empty()); + CHECK(renderBake(s, planOf(kMaxBakeFrames, 0, 0, /*leadIn=*/1), kUnity, kNoLimiter) + .empty()); + CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/-1), kUnity, kNoLimiter).empty()); // A hand-built plan can carry a lead-in near the int64 ceiling; the guard must trip // on that field alone rather than signed-overflowing inside renderFrames()'s sum. CHECK(renderBake(s, planOf(1000, 0, 500, /*leadIn=*/std::numeric_limits::max() - 10), - kUnity) + kUnity, kNoLimiter) .empty()); } diff --git a/tests/test_bake_window.cpp b/tests/test_bake_window.cpp index 8e8945c..ee5671c 100644 --- a/tests/test_bake_window.cpp +++ b/tests/test_bake_window.cpp @@ -26,6 +26,9 @@ constexpr int kRate = 48000; constexpr double kBpm = 120.0; // a quarter note is 0.5 s == 24000 frames constexpr double kUnity = 1.0; constexpr double kSilence = 1e-6; +// The derived window is a property of the voice chain, not of the master stage: every +// measurement here reads the render with the limiter bypassed. +constexpr bool kNoLimiter = false; // The declick pad every derived window carries. Read off the engine's own constants, so a // retuned ramp moves this file's expectations with it rather than against them. @@ -97,7 +100,7 @@ BakeAudio bakeWith(const SampleData& s, double extraMs, Division hold = oneBar() int velocity = 100) { const std::optional plan = planOf(derivedProgram(s, extraMs, hold, velocity)); if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; } - return renderBake(s, *plan, kUnity); + return renderBake(s, *plan, kUnity, kNoLimiter); } std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) { @@ -116,7 +119,7 @@ std::int64_t freeRunningEnd(const SampleData& s, double heldSeconds) { p.end = EndOffset(offsetFromMs(200.0)); const std::optional plan = planOf(p); if (!plan) { std::printf("FAIL: fixture reference window refused\n"); ++g_fail; return -1; } - return lastSoundingFrame(renderBake(s, *plan, kUnity)); + return lastSoundingFrame(renderBake(s, *plan, kUnity, kNoLimiter)); } // The last frame of the file, which is where a hard cut shows up. @@ -190,7 +193,7 @@ int main() { CHECK(plan.has_value()); if (plan) { CHECK(plan->totalFrames == kFrames + kPad); - const BakeAudio whole = renderBake(s, *plan, kUnity); + const BakeAudio whole = renderBake(s, *plan, kUnity, kNoLimiter); // Full level across the two seconds the saturated rung used to cut, and the file // still ends on the declick ramp rather than on a hard edge. CHECK(peakAt(whole, kSlowRate * 48, kFrames) > 0.4); From a3698972db11ffd9b4d712a79fd7e64f005795b2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 2 Aug 2026 21:24:00 -0400 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20close=20three=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20the=20build-shape=20index,=20a=20redundant=20header?= =?UTF-8?q?=20restatement,=20and=20the=20CMake=20guard=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the product doc's track count and list for T3; trim the header's pre-limiter restatement now that bake_render.cpp carries it; add limiter to the extension's not-linked enumeration. --- docs/product/instrument-control-surface.md | 3 ++- src/app/CMakeLists.txt | 6 +++--- src/core/instrument/bake/bake_render.h | 5 ++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/product/instrument-control-surface.md b/docs/product/instrument-control-surface.md index c5f3f73..26cf1b8 100644 --- a/docs/product/instrument-control-surface.md +++ b/docs/product/instrument-control-surface.md @@ -1565,9 +1565,10 @@ Sequenced into `docs/PLAN.md` as **Phase Γ** (worktree slug prefix `pg-`), **fo T1 pitch-rate-deck ................. item A (params + engine + deck descriptor) T2 loop-crossfade-ux ............... item F (waveform painter + pure marker geometry + the chrome-row loop enable) -Γ-W3 The reflow, and the bake correction [2 tracks] +Γ-W3 The reflow, and the bake correction [3 tracks] T1 deck-reflow ..................... item B's ARRANGEMENT half + C's UI half T2 bake-reset-amendment ............ the Phase Ξ correction Γ owns (§3.4) + T3 bake-prints-limiter ............. prints the limiter through the bake's master stage (§3.4) Γ-W4 VST3 parameters [1 track] T1 vst3-parameter-set .............. Ruling 1 (parameter-automation.md §§6-10) ``` diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 75ca5b7..0b72280 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -52,9 +52,9 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp ) target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name) -# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument -# renders its own bake in its own process, which is what keeps the extension's link graph -# free of the voice engine — a link edge to it here means the design drifted. +# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The +# instrument renders its own bake in its own process, which is what keeps the extension's +# link graph free of the voice engine — a link edge to it here means the design drifted. target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) # OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both diff --git a/src/core/instrument/bake/bake_render.h b/src/core/instrument/bake/bake_render.h index b845e5e..c9805fe 100644 --- a/src/core/instrument/bake/bake_render.h +++ b/src/core/instrument/bake/bake_render.h @@ -31,9 +31,8 @@ struct BakeAudio { // 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 +// bake_render.cpp for why both print here rather than in the processor. `limiterEnabled` +// false yields the pre-limiter render. 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);