Files
reasampler/tests/test_bake_window.cpp
T

272 lines
12 KiB
C++

// 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;
}