Merge Γ-W3-T3: the bake prints the limiter through its master stage, with the lookahead compensated so an engaged bake returns the sound the user approved and a bypassed one is byte-for-byte unchanged

This commit is contained in:
2026-08-02 21:24:13 -04:00
10 changed files with 236 additions and 79 deletions
+15 -3
View File
@@ -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
@@ -1554,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)
```
+3 -3
View File
@@ -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
+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;
}
+7 -6
View File
@@ -29,11 +29,12 @@ 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
// 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);
} // 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
+141 -41
View File
@@ -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 <cmath>
@@ -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<float>(i) / static_cast<float>(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<double>(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<double>(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<double>(audio.interleaved[0]) -
static_cast<double>(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<float>(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<std::int64_t>::max() - 10),
kUnity)
kUnity, kNoLimiter)
.empty());
}
+6 -3
View File
@@ -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<BakePlan> 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<BakePlan> 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);