From d3894dae6d59d16215e4669abe56285af5403295 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:38:28 -0400 Subject: [PATCH] =?UTF-8?q?test:=20audit=20the=20derived=20bake=20window?= =?UTF-8?q?=20end=20to=20end=20=E2=80=94=20two=20truncations=20pinned,=20G?= =?UTF-8?q?ate's=20missing=20hold=20length=20demonstrated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/instrument/bake/CMakeLists.txt | 4 + tests/test_bake_window.cpp | 271 ++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 tests/test_bake_window.cpp diff --git a/src/core/instrument/bake/CMakeLists.txt b/src/core/instrument/bake/CMakeLists.txt index 4e66418..e0e2ae7 100644 --- a/src/core/instrument/bake/CMakeLists.txt +++ b/src/core/instrument/bake/CMakeLists.txt @@ -10,6 +10,10 @@ reasampler_pure_library(bake_render LINK PUBLIC bake_plan sampler_core) reasampler_test(bake_render LINK bake_render) +# No library of its own: the derived window is a PROPERTY of bake_plan + bake_render +# together, and this measures it end to end rather than either half in isolation. +reasampler_test(bake_window LINK bake_plan bake_render) + # sample_map carries InstrumentParams, which is the whole of what a reset rewrites. reasampler_pure_library(bake_reset SOURCES bake_reset.cpp LINK PUBLIC sample_map) reasampler_test(bake_reset LINK bake_reset) diff --git a/tests/test_bake_window.cpp b/tests/test_bake_window.cpp new file mode 100644 index 0000000..dd34a02 --- /dev/null +++ b/tests/test_bake_window.cpp @@ -0,0 +1,271 @@ +// Standalone audit of the DERIVED bake window: does defaultBakeProgram's window hold the +// whole audible result of the dialed sound, in both play modes? Every case renders through +// the real chain (defaultBakeProgram -> resolveNote -> planBake -> renderBake) and then +// re-renders the SAME sound with a longer window, so "what fell outside" is measured rather +// than argued. Trailing silence is a pass; signal past the derived end is a truncation. + +#include "../src/core/instrument/bake/bake_plan.h" +#include "../src/core/instrument/bake/bake_render.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::bake; +using namespace reasampler::instrument::note; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +namespace { + +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; + +Tempo tempo() { + const std::optional t = Tempo::fromBpm(kBpm); + if (!t) { std::printf("FAIL: fixture tempo rejected\n"); ++g_fail; } + return t.value_or(Tempo::fromBpm(120.0).value()); +} + +// Flat DC so a level reading is unambiguous: any departure from 0.5 is the envelope, the +// filter or a ring-out, never the source's own shape. +SampleData dcSample(std::size_t frames) { + SampleData s; + s.frames.assign(frames, 0.5f); + s.sampleRate = kRate; + s.rootNote = 60; + return s; +} + +double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { + double peak = 0.0; + if (from < 0) from = 0; + for (std::int64_t f = from; f < to && f < audio.frameCount(); ++f) { + const double v = std::fabs(static_cast( + audio.interleaved[static_cast(f * audio.channelCount)])); + if (v > peak) peak = v; + } + return peak; +} + +// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same +// sound, a longer window), `length` overrides the programmed note length. Both leave the +// derivation itself untouched, which is what makes the comparison a measurement of the +// derived end rather than of a second derivation. +NoteProgram derivedProgram(const SampleData& s, double extraMs) { + NoteProgram p = defaultBakeProgram(s, kRate, tempo()); + if (extraMs != 0.0) + p.end = EndOffset(offsetFromMs(offsetMs(p.end.amount(), tempo()) + extraMs)); + return p; +} + +std::optional planOf(const NoteProgram& p) { + return planBake(resolveNote(p, tempo()), kRate, 60); +} + +// The render the shell would produce, plus `extraMs` of extra window. +BakeAudio bakeWith(const SampleData& s, double extraMs) { + const std::optional plan = planOf(derivedProgram(s, extraMs)); + if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; } + return renderBake(s, *plan, kUnity); +} + +std::int64_t derivedFrames(const SampleData& s) { + const std::optional plan = planOf(derivedProgram(s, 0.0)); + return plan ? plan->totalFrames : -1; +} + +} // namespace + +int main() { + // ================================ GATE ========================================== + + // --- Gate, no loop, staged AHDSR: the derived end is EXACT on the release ---------- + // note-off at the quarter note, release_frames after it, and not one frame of signal + // past that — the end offset is the release, so the two must coincide exactly. + { + SampleData s = dcSample(96000); + s.play.playMode = PlayMode::Gate; + s.play.adsr.releaseFrames = 4800; // 100 ms + + CHECK(derivedFrames(s) == 28800); // 24000 (quarter) + 4800 (release) + + const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0); + CHECK(wide.frameCount() == 38400); + // Still releasing on the last ten frames the derived window would have kept… + CHECK(peakAt(wide, 28790, 28800) > kSilence); + // …and EXACTLY silent from there on: nothing was cut. + CHECK(peakAt(wide, 28800, 38400) == 0.0); + } + + // --- Gate with a sustain loop: there is NO intrinsic end to derive ----------------- + // The window holds the whole programmed note, but the note length itself is a constant + // quarter note — hold the same dialed sound longer and it keeps sounding, indefinitely. + // This is the one number a derivation cannot supply. + { + SampleData s = dcSample(48000); + s.loop = SampleLoop{true, 0, 24000}; + s.play.playMode = PlayMode::Gate; + s.play.adsr.releaseFrames = 4800; + + const BakeAudio derived = bakeWith(s, 0.0); + CHECK(derived.frameCount() == 28800); + // The derived window does bound the render: full level while the note is held, and + // down to the release's own floor at the end — the loop is not left cycling. + CHECK(peakAt(derived, 20000, 24000) > 0.4); + CHECK(peakAt(derived, 28790, 28800) < 0.01); + + // The SAME dialed sound held for a whole note instead of a quarter: four times the + // audio, all of it real, none of it reachable from the derivation. + NoteProgram longer = derivedProgram(s, 0.0); + longer.length = makeDivision(2, DivisionModifier::Straight); // 4 beats == 2 s + const std::optional plan = planOf(longer); + CHECK(plan.has_value()); + const BakeAudio held = renderBake(s, *plan, kUnity); + CHECK(held.frameCount() == 100800); // 96000 + 4800 + CHECK(peakAt(held, 28800, 96000) > 0.4); // full level, well past the derived end + } + + // --- Gate: the constant quarter-note hold TRUNCATES a slow attack ------------------ + // The window is correct for the note it programs; the note is what is wrong. A 2 s + // attack never reaches its peak, so the bake prints a sound the user never dialed. + { + SampleData s = dcSample(192000); + s.play.playMode = PlayMode::Gate; + s.play.adsr.attackFrames = 96000; // 2 s, four times the programmed note + s.play.adsr.releaseFrames = 0; + + const BakeAudio derived = bakeWith(s, 0.0); + CHECK(derived.frameCount() == 24000); + // A quarter of the way up the attack at most. + const double derivedPeak = peakAt(derived, 0, 24000); + CHECK(derivedPeak > 0.1 && derivedPeak < 0.14); + + NoteProgram longer = derivedProgram(s, 0.0); + longer.length = makeDivision(3, DivisionModifier::Straight); // 8 beats == 4 s + const std::optional plan = planOf(longer); + CHECK(plan.has_value()); + const BakeAudio held = renderBake(s, *plan, kUnity); + CHECK(peakAt(held, 0, held.frameCount()) > 0.49); // the dialed sound, in full + } + + // --- Gate + Preserve: the terminal ring-out is cut when the source ends AT the window + // Preserve rings its last real output out (~4 ms) instead of hard-cutting it; that ramp + // lands past the derived end whenever the voice's own end lands on it. + { + SampleData s = dcSample(24000); // exhausts exactly at the quarter-note note-off + s.play.playMode = PlayMode::Gate; + s.play.pitchEngine = PitchEngine::Preserve; + s.play.adsr.releaseFrames = 0; + + CHECK(derivedFrames(s) == 24000); + const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0); + CHECK(peakAt(wide, 23990, 24000) > 0.4); // hard cut at full level… + CHECK(peakAt(wide, 24000, 24100) > 0.05); // …with the ring-out outside the window + CHECK(peakAt(wide, 24400, wide.frameCount()) < kSilence); // and it is bounded + } + + // --- The resonant filter cannot ring past the amp gate ------------------------------ + // pitch -> filter -> amp: the amp multiply is last, so a high-Q filter's ring-out is + // gated by the release the window already holds. Not a tail contributor. + { + SampleData s = dcSample(96000); + s.play.playMode = PlayMode::Gate; + s.play.adsr.releaseFrames = 4800; + s.play.filter.enabled = true; + s.play.filter.settings.cutoffNorm = 0.05f; + s.play.filter.settings.resonanceNorm = 1.0f; + + const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0); + CHECK(peakAt(wide, 0, 28800) > kSilence); // the filtered note sounded + CHECK(peakAt(wide, 28800, wide.frameCount()) == 0.0); // and nothing rang past it + } + + // ================================ TRIGGER ======================================= + + // --- Trigger, Varispeed, a constant deep downward pitch offset: UPPER BOUND --------- + // The window is scaled by the deepest reachable offset, so a shallower excursion leaves + // trailing silence — long, but never short. + { + SampleData s = dcSample(48000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.peakSemitones = -24.0; // two octaves down + s.play.pitchEnv.shape.holdFraction = 1.0; // held down for the whole span + + // 1 s of source stretched by 2^(24/12) == 4. + CHECK(derivedFrames(s) == 192000); + const BakeAudio derived = bakeWith(s, 0.0); + // The offset only holds for the envelope's own span, so the read finishes near + // 84000 frames — inside the window, with the balance as trailing silence. + CHECK(peakAt(derived, 80000, 84000) > 0.4); + CHECK(peakAt(derived, 90000, 192000) < kSilence); + } + + // --- Trigger + Preserve (the product-default engine): the ring-out is TRUNCATED ----- + // Preserve's read reaches playEnd on the exact frame the derived window closes, and the + // ~4 ms terminal declick that follows is entirely outside it. The baked file therefore + // ends on a hard cut at full level — reintroducing the click the ramp exists to remove. + { + SampleData s = dcSample(48000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Preserve; + + CHECK(derivedFrames(s) == 48000); + const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0); + CHECK(peakAt(wide, 47990, 48000) > 0.4); // full level on the derived last frame + CHECK(peakAt(wide, 48000, 48100) > 0.05); // real signal past the derived end + // Measured: the ramp opens at the last in-window level (0.500) and reaches exact + // zero 180 frames later — the whole 3.75 ms sits outside the derived window. + CHECK(peakAt(wide, 48000, 48001) > 0.49); + CHECK(peakAt(wide, 48179, 48180) > 0.0); + CHECK(peakAt(wide, 48180, wide.frameCount()) == 0.0); + } + + // --- Trigger + a DRAWN amp EG: the window keeps a %-length the engine IGNORES ------- + // A drawn contour covers the full sample length, so Voice::start forces lengthFraction + // to 1.0 (splineActive, play_params.h) — but defaultBakeProgram reads the stored, + // UI-inert knob straight through triggerPlayLength. The window is a quarter of the take. + { + SampleData s = dcSample(48000); + s.play.playMode = PlayMode::Trigger; + s.play.trigger.lengthFraction = 0.25; // inert in the engine, live in the derivation + s.play.ampSpline.mode = EnvMode::Spline; + s.play.ampSpline.contour = VelocityCurve::flat(); // full level across the sample + + CHECK(derivedFrames(s) == 12000); // 0.25 s of a 1 s take + + const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0); + // The engine really did play the whole sample: full level right up to 48000… + CHECK(peakAt(wide, 12000, 48000) > 0.4); + CHECK(peakAt(wide, 47000, 48000) > 0.4); + // …and stopped at its own span end, so the 36000 lost frames are the take, not tail. + CHECK(peakAt(wide, 48200, wide.frameCount()) < kSilence); + } + + // --- A legitimate dialed sound past the frame ceiling is REFUSED, not cut ----------- + // 1.1e6 source frames stretched by 2^(48/12) == 16 exceeds kMaxBakeFrames. The refusal + // is the safe direction (the shell reports it), but it is a sound that cannot be baked. + { + SampleData s = dcSample(1'100'000); + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + s.play.pitchEnv.enabled = true; + s.play.pitchEnv.peakSemitones = -48.0; + s.play.pitchEnv.shape.holdFraction = 1.0; + CHECK(!planOf(derivedProgram(s, 0.0)).has_value()); + + // One octave shallower is inside the ceiling — the refusal above is the window, not + // the fixture. + s.play.pitchEnv.peakSemitones = -24.0; + CHECK(planOf(derivedProgram(s, 0.0)).has_value()); + } + + if (g_fail == 0) std::printf("bake_window: all tests passed\n"); + return g_fail ? 1 : 0; +}