// 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; byte-identical repeats; and the refusals (unplayable sample, empty // window). #include "../src/core/instrument/bake/bake_render.h" #include "../src/core/instrument/engine/live_params.h" #include #include 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(audio.interleaved[static_cast( f * audio.channelCount)])); if (v > peak) peak = v; } return peak; } BakePlan planOf(std::int64_t total, std::int64_t noteOn, std::int64_t noteOff) { BakePlan p; p.totalFrames = total; p.noteOnFrame = noteOn; p.noteOffFrame = noteOff; p.note = 60; p.velocity = 100; p.sampleRate = kRate; return p; } } // 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); 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); 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); 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); } // --- 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); const BakeAudio b = renderBake(s, plan); 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); 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); 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)).empty()); SampleData s = makeSample(false); CHECK(renderBake(s, planOf(0, 0, 0)).empty()); } if (g_fail == 0) std::printf("bake_render: all tests passed\n"); return g_fail ? 1 : 0; }