test: audit the derived bake window end to end — two truncations pinned, Gate's missing hold length demonstrated
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
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<Tempo> 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<double>(
|
||||
audio.interleaved[static_cast<std::size_t>(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<BakePlan> 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<BakePlan> 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<BakePlan> 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<BakePlan> 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<BakePlan> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user