224 lines
10 KiB
C++
224 lines
10 KiB
C++
// Standalone tests for reasampler::instrument::bake::bake_plan — no VST3, no REAPER, no
|
|
// framework. Same fast assert loop as the sibling pure tests.
|
|
//
|
|
// Covers: the default program's window derived from the dialed sound (a Gate release, a
|
|
// Trigger play span, and the Varispeed read-stretch bound); the frame window and both event
|
|
// frames against hand-computed values; a capture opening BEFORE note-on and one opening
|
|
// AFTER it; the refusals — a collapsed window, a non-positive rate, a window that rounds to
|
|
// nothing, and one past the frame ceiling; and root/velocity clamping.
|
|
|
|
#include "../src/core/instrument/bake/bake_plan.h"
|
|
|
|
#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)
|
|
|
|
static Tempo at(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());
|
|
}
|
|
|
|
namespace {
|
|
|
|
constexpr int kRate = 48000;
|
|
|
|
SampleData dialedSample(std::size_t frames = 96000) {
|
|
SampleData s;
|
|
s.frames.assign(frames, 0.5f);
|
|
s.sampleRate = kRate;
|
|
s.rootNote = 60;
|
|
return s;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
// --- The default program's window comes from the DIALED release, not a constant -----
|
|
{
|
|
SampleData s = dialedSample();
|
|
s.play.playMode = PlayMode::Gate;
|
|
s.play.adsr.releaseFrames = kRate * 3 / 2; // 1.5 s — past any fixed tail
|
|
const NoteProgram p = defaultBakeProgram(s, kRate, at(120.0));
|
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
|
// A quarter note at 120 BPM is 0.5 s; the window must hold the whole 1.5 s release.
|
|
CHECK(r.noteOffSeconds > 0.499 && r.noteOffSeconds < 0.501);
|
|
CHECK(r.captureStartSeconds == 0.0);
|
|
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
|
|
CHECK(!r.windowCollapsed);
|
|
|
|
// A shorter release yields a shorter window — the derivation really reads the knob.
|
|
s.play.adsr.releaseFrames = kRate / 10; // 0.1 s
|
|
const ResolvedNote shorter = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
|
at(120.0));
|
|
CHECK(shorter.captureEndSeconds > 0.599 && shorter.captureEndSeconds < 0.601);
|
|
}
|
|
|
|
// --- Trigger: the window is the play span, which ignores the note's length ----------
|
|
{
|
|
SampleData s = dialedSample(/*frames=*/kRate * 2); // 2 s of source
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.trigger.lengthFraction = 0.75; // 1.5 s of it
|
|
const ResolvedNote r = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
|
at(120.0));
|
|
CHECK(r.captureStartSeconds == 0.0);
|
|
CHECK(r.captureEndSeconds > 1.49 && r.captureEndSeconds < 1.51);
|
|
|
|
// A span SHORTER than the quarter note closes the window early rather than padding
|
|
// it out to note-off — the sound is over, and a negative end offset is legal.
|
|
s.play.trigger.lengthFraction = 0.1; // 0.2 s
|
|
const ResolvedNote brief = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
|
at(120.0));
|
|
CHECK(!brief.windowCollapsed);
|
|
CHECK(brief.captureEndSeconds > 0.199 && brief.captureEndSeconds < 0.201);
|
|
}
|
|
|
|
// --- Trigger under Varispeed: a downward pitch offset stretches the read -----------
|
|
{
|
|
SampleData s = dialedSample(/*frames=*/kRate); // 1 s, played whole
|
|
s.play.playMode = PlayMode::Trigger;
|
|
s.play.pitchEngine = PitchEngine::Varispeed;
|
|
s.play.pitchEnv.enabled = true;
|
|
s.play.pitchEnv.peakSemitones = -12.0; // an octave down = half speed at the peak
|
|
const ResolvedNote r = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
|
at(120.0));
|
|
// Bounded at the deepest offset: 1 s of source can take up to 2 s to cross.
|
|
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
|
|
|
|
// Preserve decouples pitch from the read rate, so the same dial bounds nothing.
|
|
s.play.pitchEngine = PitchEngine::Preserve;
|
|
const ResolvedNote kept = resolveNote(defaultBakeProgram(s, kRate, at(120.0)),
|
|
at(120.0));
|
|
CHECK(kept.captureEndSeconds > 0.99 && kept.captureEndSeconds < 1.01);
|
|
}
|
|
|
|
// --- The frame window and both event frames ----------------------------------
|
|
{
|
|
NoteProgram p; // 1/4 straight, velocity 100
|
|
p.end = EndOffset(offsetFromMs(250.0));
|
|
const ResolvedNote r = resolveNote(p, at(120.0)); // note-off 0.5 s, end 0.75 s
|
|
const auto plan = planBake(r, 48000, 60);
|
|
CHECK(plan.has_value());
|
|
CHECK(plan->totalFrames == 36000); // 0.75 s * 48 kHz
|
|
CHECK(plan->leadInFrames == 0);
|
|
CHECK(plan->renderFrames() == 36000);
|
|
CHECK(plan->noteOnFrame == 0);
|
|
CHECK(plan->noteOffFrame == 24000); // 0.5 s * 48 kHz
|
|
CHECK(plan->sampleRate == 48000);
|
|
CHECK(plan->note == 60);
|
|
CHECK(plan->velocity == 100);
|
|
}
|
|
|
|
// --- A capture that opens BEFORE note-on -------------------------------------
|
|
{
|
|
NoteProgram p;
|
|
p.start = StartOffset(offsetFromMs(-100.0)); // negative = earlier
|
|
p.end = EndOffset(offsetFromMs(100.0));
|
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
|
const auto plan = planBake(r, 44100, 60);
|
|
CHECK(plan.has_value());
|
|
// Window is [-0.1, 0.6] s = 0.7 s; note-on sits 0.1 s in, note-off 0.5 s after it.
|
|
CHECK(plan->totalFrames == 30870);
|
|
CHECK(plan->leadInFrames == 0); // nothing to discard: the file opens first
|
|
CHECK(plan->noteOnFrame == 4410);
|
|
CHECK(plan->noteOffFrame == 26460);
|
|
CHECK(plan->noteOffFrame - plan->noteOnFrame == 22050); // the note's own length
|
|
}
|
|
|
|
// --- A capture that opens AFTER note-on (a positive start trims the attack) ---
|
|
{
|
|
NoteProgram p;
|
|
p.start = StartOffset(offsetFromMs(100.0)); // positive = later: the head is cut
|
|
p.end = EndOffset(offsetFromMs(100.0));
|
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
|
const auto plan = planBake(r, 48000, 60);
|
|
CHECK(plan.has_value());
|
|
// Window is [0.1, 0.6] s = 0.5 s of FILE, but the note starts 0.1 s before it, so
|
|
// the render must produce that head and throw it away rather than shift the note.
|
|
CHECK(plan->totalFrames == 24000);
|
|
CHECK(plan->leadInFrames == 4800);
|
|
CHECK(plan->renderFrames() == 28800);
|
|
CHECK(plan->noteOnFrame == 0); // the note is at the START of the render
|
|
CHECK(plan->noteOffFrame == 24000); // still its full 0.5 s length
|
|
CHECK(plan->noteOffFrame - plan->noteOnFrame == 24000);
|
|
}
|
|
|
|
// --- Refusals -----------------------------------------------------------------
|
|
{
|
|
NoteProgram p;
|
|
// An end offset more negative than the note length inverts the window.
|
|
p.end = EndOffset(offsetFromMs(-10000.0));
|
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
|
CHECK(r.windowCollapsed);
|
|
CHECK(!planBake(r, 48000, 60).has_value());
|
|
}
|
|
{
|
|
NoteProgram plain;
|
|
const ResolvedNote r = resolveNote(plain, at(120.0));
|
|
CHECK(!planBake(r, 0, 60).has_value());
|
|
CHECK(!planBake(r, -48000, 60).has_value());
|
|
}
|
|
{
|
|
// A legal but sub-frame window rounds to nothing and is refused rather than
|
|
// rendered as a degenerate buffer.
|
|
NoteProgram p;
|
|
p.end = EndOffset(offsetFromMs(-500.0)); // exactly cancels the 0.5 s note
|
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
|
CHECK(!r.windowCollapsed);
|
|
CHECK(r.captureLengthSeconds() == 0.0);
|
|
CHECK(!planBake(r, 48000, 60).has_value());
|
|
}
|
|
{
|
|
// A legal offset magnitude reaches days: refused at the ceiling, not attempted as
|
|
// an allocation (and never narrowed out of int64's range on the way there).
|
|
const double overSeconds =
|
|
(static_cast<double>(kMaxBakeFrames) / 48000.0) + 1.0;
|
|
NoteProgram p;
|
|
p.end = EndOffset(offsetFromMs(overSeconds * 1000.0));
|
|
const ResolvedNote big = resolveNote(p, at(120.0));
|
|
CHECK(!big.windowCollapsed);
|
|
CHECK(!planBake(big, 48000, 60).has_value());
|
|
|
|
// The extreme a legal OffsetAmount can hold, in both directions.
|
|
NoteProgram huge;
|
|
huge.end = EndOffset(offsetFromMs(kMaxConvertibleMagnitude));
|
|
CHECK(!planBake(resolveNote(huge, at(120.0)), 48000, 60).has_value());
|
|
NoteProgram far;
|
|
far.start = StartOffset(offsetFromMs(-kMaxConvertibleMagnitude));
|
|
CHECK(!planBake(resolveNote(far, at(120.0)), 48000, 60).has_value());
|
|
|
|
// And just under it still plans, so the ceiling is a bound, not a blanket refusal.
|
|
NoteProgram fits;
|
|
fits.end = EndOffset(offsetFromMs(
|
|
(static_cast<double>(kMaxBakeFrames) / 48000.0 - 1.0) * 1000.0));
|
|
const auto planned = planBake(resolveNote(fits, at(120.0)), 48000, 60);
|
|
CHECK(planned.has_value());
|
|
CHECK(planned && planned->renderFrames() <= kMaxBakeFrames);
|
|
}
|
|
|
|
// --- Domain clamps -------------------------------------------------------------
|
|
{
|
|
NoteProgram p;
|
|
p.end = EndOffset(offsetFromMs(100.0));
|
|
const ResolvedNote r = resolveNote(p, at(120.0));
|
|
const auto low = planBake(r, 48000, -5);
|
|
const auto high = planBake(r, 48000, 900);
|
|
const auto mid = planBake(r, 48000, 60);
|
|
CHECK(low.has_value() && high.has_value() && mid.has_value());
|
|
if (low && high && mid) {
|
|
CHECK(low->note == 0);
|
|
CHECK(high->note == 127);
|
|
CHECK(mid->note == 60);
|
|
}
|
|
}
|
|
|
|
if (g_fail == 0) std::printf("bake_plan: all tests passed\n");
|
|
return g_fail ? 1 : 0;
|
|
}
|