Files
reasampler/tests/test_bake_window.cpp
T
daniel 65f6070348 Bake window: derived note lengths carry exact durations, not ladder rungs — a long take is no longer cut at 384 beats
Hold keeps its picker. Also: one home for the %-fold, duration-ordered Hold travel, and a corrupt tail degrades to absent rather than fabricating one.
2026-08-01 21:10:38 -04:00

393 lines
19 KiB
C++

// The DERIVED bake window, end to end: does defaultBakeProgram's window hold the whole
// audible result of the dialed sound? 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 "../src/core/instrument/engine/voice.h" // kDeclickFrames (the pad under test)
#include <cmath>
#include <cstdio>
#include <optional>
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;
// 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.
constexpr std::int64_t kPad = kDeclickFrames;
// One bar at 120 BPM == 2 s == 96000 frames. The Hold default, spelled out so the
// expectations below read as arithmetic rather than as magic.
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
Tempo tempoOf(double bpm) {
const std::optional<Tempo> t = Tempo::fromBpm(bpm);
if (!t) { std::printf("FAIL: fixture tempo %f rejected\n", bpm); ++g_fail; }
return t.value_or(Tempo::fromBpm(120.0).value());
}
Tempo tempo() { return tempoOf(kBpm); }
// 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). It leaves 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, Division hold = oneBar(),
int velocity = 100) {
NoteProgram p = defaultBakeProgram(s, kRate, hold, Velocity::of(velocity));
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).plan;
}
// The render the shell would produce, plus `extraMs` of extra window.
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);
}
std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0, hold));
return plan ? plan->totalFrames : -1;
}
// The last frame of the file, which is where a hard cut shows up.
double lastFrameLevel(const BakeAudio& audio) {
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
: 0.0;
}
} // namespace
int main() {
// ================================ GATE ==========================================
// --- Gate, no loop: the note is held until the SOURCE runs out ---------------------
// The read head frees the voice at exhaustion whether or not the gate is still down, so
// the maximal sound is the whole take. 2 s of source == 4 beats, exactly one bar.
{
SampleData s = dcSample(96000);
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 4800; // 100 ms
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
// Full level right up to exhaustion, and nothing at all past it — the note outlived
// its own release, so the release window is trailing silence, not a truncated tail.
CHECK(peakAt(wide, 95000, 96000) > 0.4);
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0);
}
// --- Gate, no loop, a SLOW ATTACK: the derived note reaches the dialed peak ---------
// The window used to be a constant quarter note, which released a 2 s attack a quarter of
// the way up. Deriving the hold from source exhaustion is what closes that.
{
SampleData s = dcSample(192000); // 4 s
s.play.playMode = PlayMode::Gate;
s.play.adsr.attackFrames = 96000; // 2 s
s.play.adsr.releaseFrames = 0;
CHECK(derivedFrames(s) == 192000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
// The whole attack, at full level — under the old constant quarter note this peaked
// between 0.10 and 0.14.
CHECK(peakAt(derived, 0, derived.frameCount()) > 0.49);
// …and a longer window adds nothing: the derivation already held everything.
const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
CHECK(peakAt(wide, 192000, wide.frameCount()) == 0.0);
}
// --- Gate, no loop, a source LONGER than the note-length ladder ---------------------
// The regression the exact-duration seam exists for. A derived length used to be rounded
// onto the musical-division ladder, whose longest rung is kMaxDivisionBeats (384 beats):
// a source past that took the top rung, so note-off — and the window with it — landed
// INSIDE the sound. Rendered at a low rate and a fast tempo so the case is 400 beats long
// without being twenty million frames; nothing here depends on either number but the
// beats it puts the source at.
{
constexpr int kSlowRate = 8000;
const Tempo fast = tempoOf(480.0); // 384 beats == 48 s at this tempo
constexpr std::int64_t kFrames = kSlowRate * 50; // 50 s == 400 beats: past the ladder
SampleData s = dcSample(static_cast<std::size_t>(kFrames));
s.sampleRate = kSlowRate;
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
const NoteProgram p =
defaultBakeProgram(s, kSlowRate, oneBar(), Velocity::of(100));
const ResolvedNote r = resolveNote(p, fast);
CHECK(r.noteOffSeconds > 49.9 && r.noteOffSeconds < 50.1); // 50 s, not the 48 s rung
const std::optional<BakePlan> plan = planBake(r, kSlowRate, 60).plan;
CHECK(plan.has_value());
if (plan) {
CHECK(plan->totalFrames == kFrames + kPad);
const BakeAudio whole = renderBake(s, *plan, kUnity);
// 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);
CHECK(lastFrameLevel(whole) < 1e-3);
}
}
// --- Gate, no loop, a START POINT under Varispeed: both terms of the exhaustion ------
// The two derivation terms this branch has that the cases above do not exercise: the
// window covers frameCount MINUS the start, and that remainder is stretched by the
// deepest downward offset. Getting either wrong shortens the window.
{
SampleData s = dcSample(48000); // 1 s of source
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 0;
s.startFrame = 24000; // half of it left to play
s.play.pitchEngine = PitchEngine::Varispeed;
// A flat velocity->pitch curve at half depth: an octave down (the range is 24
// semitones) for the note's whole lifetime, so the read really does run at half rate
// to the end rather than for one envelope stage.
s.play.pitchVelocityCurve = VelocityCurve::fromPoints(
{{0.0, -0.5}, {127.0, -0.5}}, reasampler::instrument::engine::CurveDomain::Bipolar);
// (48000 - 24000) source frames at half rate == 48000 output frames. Reading either
// term wrong halves or doubles this.
CHECK(derivedFrames(s) == 48000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
CHECK(peakAt(derived, 47000, 48000) > 0.4); // still sounding at the derived end
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0); // and nothing past it
}
// --- Gate WITH a sustain loop: Hold is the note length, and the ONLY user input ------
// A looped Gate voice sounds for as long as it is held, so no derivation supplies a
// duration — this is the one case the predicate names, and the window follows Hold.
{
SampleData s = dcSample(48000);
s.loop = SampleLoop{true, 0, 24000};
s.play.playMode = PlayMode::Gate;
s.play.adsr.releaseFrames = 4800;
CHECK(bakeWindowNeedsHold(s));
// One bar at 120 BPM == 96000 frames, plus the release, plus the pad.
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
const BakeAudio bar = bakeWith(s, 0.0);
CHECK(peakAt(bar, 90000, 96000) > 0.4); // still cycling at note-off
CHECK(peakAt(bar, 96000 + 4790, 96000 + 4800) < 0.01); // released to its own floor
// A different Hold is a different window, in the same proportion — the knob really is
// what the derivation reads here.
const Division twoBars = makeDivision(3, DivisionModifier::Straight);
CHECK(derivedFrames(s, twoBars) == 192000 + 4800 + kPad);
const BakeAudio held = bakeWith(s, 0.0, twoBars);
CHECK(peakAt(held, 96000, 192000) > 0.4); // full level well past one bar
}
// --- The Hold predicate is the ENGINE's loop fold, not a reading of the fields -------
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Gate;
CHECK(!bakeWindowNeedsHold(s)); // no loop at all
s.loop = SampleLoop{true, 0, 24000};
CHECK(bakeWindowNeedsHold(s));
s.loop = SampleLoop{true, 0, 48001}; // reaches past the PCM
CHECK(!bakeWindowNeedsHold(s)); // …which resolveLoop refuses
s.loop = SampleLoop{true, 24000, 12000}; // inverted
CHECK(!bakeWindowNeedsHold(s));
s.loop = SampleLoop{true, 0, 24000};
s.play.playMode = PlayMode::Trigger; // Trigger has no sustain loop
CHECK(!bakeWindowNeedsHold(s));
}
// --- Gate + Preserve: the terminal ring-out is INSIDE the window ---------------------
// Preserve rings its last real output out instead of hard-cutting it; the derived window
// is padded by exactly that ramp, so the file ends at silence.
{
SampleData s = dcSample(24000); // exhausts at half a bar
s.play.playMode = PlayMode::Gate;
s.play.pitchEngine = PitchEngine::Preserve;
s.play.adsr.releaseFrames = 0;
CHECK(derivedFrames(s) == 24000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
CHECK(peakAt(derived, 23990, 24000) > 0.4); // full level while the source lasts
CHECK(peakAt(derived, 24000, 24000 + kPad) > 0.05); // the ramp, inside the file
CHECK(lastFrameLevel(derived) < 1e-3); // and the file ends at silence
// Nothing at all past the pad: the window is not merely long, it is exactly enough.
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
CHECK(peakAt(wide, 24000 + kPad, wide.frameCount()) == 0.0);
}
// --- 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 envelope 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, 96000) > kSilence); // the filtered note sounded
CHECK(peakAt(wide, 96000, 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 + kPad);
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 HELD -----------
// Preserve's read reaches playEnd where the un-padded window used to close, leaving the
// whole terminal declick outside it — a hard cut at full level, the very click the ramp
// exists to remove. The pad is what closes that.
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.pitchEngine = PitchEngine::Preserve;
CHECK(derivedFrames(s) == 48000 + kPad);
const BakeAudio derived = bakeWith(s, 0.0);
CHECK(peakAt(derived, 47990, 48000) > 0.4); // full level at the source's own end
CHECK(peakAt(derived, 48000, 48001) > 0.49); // the ramp opens at that same level…
CHECK(lastFrameLevel(derived) < 1e-3); // …and the file ends at silence
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0);
}
// --- Trigger + a DRAWN amp EG: the window holds the WHOLE take -----------------------
// A drawn contour covers the full sample length, so the engine folds lengthFraction to
// 1.0 (effectiveLengthFraction, play_params.h). The stored %-knob is inert but still
// saved, and reading it raw here cut this window to a quarter of the take.
{
SampleData s = dcSample(48000);
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 0.25; // inert in the engine, and now in the window
s.play.ampSpline.mode = EnvMode::Spline;
s.play.ampSpline.contour = VelocityCurve::flat(); // full level across the sample
CHECK(derivedFrames(s) == 48000 + kPad); // the whole take, not 12000
const BakeAudio derived = bakeWith(s, 0.0);
// Full level across the 36000 frames a raw read of the stored knob used to cut. Both
// spans start past the old 12000-frame end, so a truncated window reads 0 here.
CHECK(peakAt(derived, 36000, 40000) > 0.4);
CHECK(peakAt(derived, 47000, 48000) > 0.4);
// A %-knob that IS live still shortens the window — the fold is conditional, not a
// blanket ignore.
SampleData staged = s;
staged.play.ampSpline.mode = EnvMode::Staged;
CHECK(derivedFrames(staged) == 12000 + kPad);
}
// ============================== VELOCITY ========================================
// --- The bake renders at the velocity it is handed ----------------------------------
// Three velocity curves are live, so a sound auditioned at 120 does not bake as one
// auditioned at 40. linear() maps velocity/127 onto amp gain.
{
SampleData s = dcSample(24000);
s.play.playMode = PlayMode::Trigger;
s.velocityCurve = VelocityCurve::linear();
const BakeAudio soft = bakeWith(s, 0.0, oneBar(), /*velocity=*/40);
const BakeAudio hard = bakeWith(s, 0.0, oneBar(), /*velocity=*/120);
const double softPeak = peakAt(soft, 0, 24000);
const double hardPeak = peakAt(hard, 0, 24000);
// 0.5 * 40/127 == 0.157, 0.5 * 120/127 == 0.472.
CHECK(softPeak > 0.15 && softPeak < 0.17);
CHECK(hardPeak > 0.46 && hardPeak < 0.48);
CHECK(hardPeak > softPeak * 2.0);
}
// ============================== REFUSALS ========================================
// --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so --------
{
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;
const PlannedBake tooLong =
planBake(resolveNote(derivedProgram(s, 0.0), tempo()), kRate, 60);
CHECK(!tooLong.plan.has_value());
CHECK(tooLong.refusal == BakeRefusal::PastFrameCeiling);
// 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());
}
// --- An empty window is a DIFFERENT refusal, so the shell can say a different thing ---
{
NoteProgram p;
p.end = EndOffset(offsetFromMs(-5000.0)); // ends long before note-off: collapsed
const PlannedBake empty = planBake(resolveNote(p, tempo()), kRate, 60);
CHECK(!empty.plan.has_value());
CHECK(empty.refusal == BakeRefusal::EmptyWindow);
}
if (g_fail == 0) std::printf("bake_window: all tests passed\n");
return g_fail ? 1 : 0;
}