258 lines
12 KiB
C++
258 lines
12 KiB
C++
// Standalone tests for reasampler::instrument::bake::bake_render — no VST3, no REAPER, no
|
|
// framework. Same fast assert loop as the sibling pure tests.
|
|
//
|
|
// 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).
|
|
|
|
#include "../src/core/instrument/bake/bake_render.h"
|
|
|
|
#include "../src/core/instrument/engine/live_params.h"
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <limits>
|
|
|
|
using namespace reasampler;
|
|
using namespace reasampler::instrument::bake;
|
|
|
|
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;
|
|
|
|
// Distinct per-channel DC so a downmix or a channel duplication is visible in the output
|
|
// rather than hidden behind two identical channels.
|
|
SampleData makeSample(bool stereo, std::size_t frames = 1000) {
|
|
SampleData s;
|
|
s.frames.assign(frames, 0.5f);
|
|
if (stereo) s.framesR.assign(frames, -0.25f);
|
|
s.sampleRate = kRate;
|
|
s.rootNote = 60;
|
|
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;
|
|
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;
|
|
}
|
|
|
|
BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff,
|
|
std::int64_t leadIn = 0) {
|
|
BakePlan p;
|
|
p.totalFrames = total;
|
|
p.leadInFrames = leadIn;
|
|
p.noteOnFrame = noteOn;
|
|
p.noteOffFrame = noteOff;
|
|
p.note = 60;
|
|
p.velocity = 100;
|
|
p.sampleRate = kRate;
|
|
return p;
|
|
}
|
|
|
|
constexpr double kUnity = 1.0;
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
// --- Gate, sustain loop active: the render terminates and the gate really released --
|
|
{
|
|
SampleData s = makeSample(/*stereo=*/false, /*frames=*/200);
|
|
// A 100-frame loop over a 200-frame sample: held past the sample end it would cycle
|
|
// forever, which is exactly the runaway the window has to bound.
|
|
s.loop = SampleLoop{true, 0, 100};
|
|
s.play.playMode = PlayMode::Gate;
|
|
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);
|
|
|
|
CHECK(audio.frameCount() == 9600); // bounded, not a runaway
|
|
CHECK(audio.channelCount == 1);
|
|
// Sounding right up to the release…
|
|
CHECK(peakAt(audio, 4700, 4800) > 0.4);
|
|
// …and silent well after it, which only holds if the note-off was honoured: the
|
|
// loop would otherwise still be cycling at full level here.
|
|
CHECK(peakAt(audio, 6000, 9600) < 1e-6);
|
|
}
|
|
|
|
// --- Trigger: note-off is ignored, the play span ends the sound -------------------
|
|
{
|
|
SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
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);
|
|
|
|
CHECK(audio.frameCount() == 2000);
|
|
// Still sounding past the note-off Trigger ignores…
|
|
CHECK(peakAt(audio, 200, 400) > 0.4);
|
|
// …and finished at its own span end, well before the window closes.
|
|
CHECK(peakAt(audio, 700, 2000) < 1e-6);
|
|
}
|
|
|
|
// --- Channel count preserved; no stereo fold --------------------------------------
|
|
{
|
|
SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
|
|
const BakePlan plan = planOf(/*total=*/500, /*noteOn=*/0, /*noteOff=*/500);
|
|
const BakeAudio audio = renderBake(s, plan, kUnity);
|
|
|
|
CHECK(audio.channelCount == 2);
|
|
CHECK(audio.frameCount() == 500);
|
|
CHECK(audio.interleaved.size() == 1000u);
|
|
// The two channels carry the source's two distinct signals: a downmix would make
|
|
// them equal, a duplication would make R equal L.
|
|
CHECK(audio.interleaved[200] > 0.4f);
|
|
CHECK(audio.interleaved[201] < -0.2f);
|
|
CHECK(audio.interleaved[201] > -0.3f);
|
|
}
|
|
|
|
// --- 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.
|
|
{
|
|
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);
|
|
|
|
CHECK(unity.interleaved.size() == quiet.interleaved.size());
|
|
bool scaled = !unity.interleaved.empty();
|
|
for (std::size_t i = 0; scaled && i < unity.interleaved.size(); ++i) {
|
|
scaled = std::fabs(quiet.interleaved[i] - unity.interleaved[i] * 0.25f) < 1e-6f &&
|
|
std::fabs(loud.interleaved[i] - unity.interleaved[i] * 4.0f) < 1e-5f;
|
|
}
|
|
CHECK(scaled);
|
|
// Both channels, not just the one the peak helper reads.
|
|
CHECK(quiet.interleaved[201] < 0.f && quiet.interleaved[201] > -0.1f);
|
|
// A gain of zero prints silence rather than returning the sound at full level.
|
|
CHECK(peakAt(silent, 0, 500) == 0.0);
|
|
CHECK(peakAt(unity, 0, 500) > 0.4);
|
|
}
|
|
|
|
// --- 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.
|
|
{
|
|
SampleData s = makeSample(/*stereo=*/false, /*frames=*/4000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.trigAhd.attackFrames = 1000; // still climbing when the window opens
|
|
|
|
const BakePlan trimmed = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/2000,
|
|
/*leadIn=*/1000);
|
|
const BakeAudio audio = renderBake(s, trimmed, kUnity);
|
|
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);
|
|
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);
|
|
}
|
|
|
|
// --- Bit-identical repeats ---------------------------------------------------------
|
|
{
|
|
SampleData s = makeSample(/*stereo=*/true, /*frames=*/1000);
|
|
s.loop = SampleLoop{true, 0, 333};
|
|
s.play.adsr.attackFrames = 97; // a shape whose per-frame state must replay exactly
|
|
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);
|
|
|
|
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);
|
|
// 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);
|
|
}
|
|
|
|
// --- The bake is off the audio thread's block, structurally ------------------------
|
|
// The only thing process() and a bake could share is the live-parameter block. This
|
|
// pins that they do not: the caller's SampleData is untouched (renderBake took a
|
|
// copy), and the render ignores what is published in the block — the bake prints the
|
|
// dialed parameter set, not whatever the audio thread is currently observing.
|
|
{
|
|
instrument::engine::LiveParams block;
|
|
SampleData s = makeSample(/*stereo=*/false, /*frames=*/1000);
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.trigAhd.attackFrames = 0; // dialed: instant attack
|
|
|
|
// Publish a MUCH slower attack into the block. A render that observed it would be
|
|
// near-silent at the point the dialed shape is already at full level.
|
|
s.live = █
|
|
{
|
|
PlayParams slow = s.play;
|
|
slow.trigAhd.attackFrames = 900;
|
|
block.publish(instrument::engine::foldLive(slow));
|
|
}
|
|
|
|
const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000);
|
|
const BakeAudio audio = renderBake(s, plan, kUnity);
|
|
|
|
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
|
|
// attack would be barely a ninth of the way up.
|
|
CHECK(peakAt(audio, 90, 110) > 0.4);
|
|
|
|
// 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);
|
|
}
|
|
|
|
// --- Refusals -----------------------------------------------------------------------
|
|
{
|
|
SampleData empty; // nothing decoded
|
|
empty.sampleRate = kRate;
|
|
CHECK(renderBake(empty, planOf(1000, 0, 500), kUnity).empty());
|
|
|
|
SampleData s = makeSample(false);
|
|
CHECK(renderBake(s, planOf(0, 0, 0), kUnity).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());
|
|
// 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)
|
|
.empty());
|
|
}
|
|
|
|
if (g_fail == 0) std::printf("bake_render: all tests passed\n");
|
|
return g_fail ? 1 : 0;
|
|
}
|