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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+141
-41
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user