// 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 (Gate's hold to source // exhaustion plus its release, a start point, a Trigger play span, and the Varispeed // read-stretch bound on both branches that take it); // the frame window and both event frames against hand-computed values; a capture opening // BEFORE note-on and one opening AFTER it; the refusals and what each one reports — 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 "../src/core/instrument/engine/voice.h" // kDeclickFrames (the window's tail pad) #include 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 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; // Every derived window carries the voice's terminal declick ramp as trailing silence. const double kPadSeconds = static_cast(kDeclickFrames) / kRate; SampleData dialedSample(std::size_t frames = 96000) { SampleData s; s.frames.assign(frames, 0.5f); s.sampleRate = kRate; s.rootNote = 60; return s; } // The Hold default; read only where the window is underivable, which is nowhere in this file. Division oneBar() { return makeDivision(2, DivisionModifier::Straight); } // `t` is unused by the derivation itself — it takes no tempo — but every caller here resolves // against the same one, so threading it keeps each case's two halves visibly paired. NoteProgram derived(const SampleData& s, Tempo) { return defaultBakeProgram(s, kRate, oneBar(), Velocity{}); } bool near(double a, double b) { return a > b - 1e-6 && a < b + 1e-6; } } // namespace int main() { // --- Gate with no loop: held to source exhaustion, then the DIALED release ---------- { SampleData s = dialedSample(); // 2 s of source == 4 beats s.play.playMode = PlayMode::Gate; s.play.adsr.releaseFrames = kRate * 3 / 2; // 1.5 s — past any fixed tail const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0)); // The note is held to exhaustion — 2 s exactly — instead of the constant quarter note // that released it early. CHECK(near(r.noteOffSeconds, 2.0)); CHECK(r.captureStartSeconds == 0.0); CHECK(near(r.captureEndSeconds, 2.0 + 1.5 + kPadSeconds)); 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(derived(s, at(120.0)), at(120.0)); CHECK(near(shorter.captureEndSeconds, 2.0 + 0.1 + kPadSeconds)); // The length is EXACT, not rounded onto the ladder: 2.5 s is 5 beats, which no rung // hits — the ladder's nearest never-short answer is the 2/1 triplet at 5.33 beats, and // taking it would buy a third of a second of silence for nothing. The tempo is not an // input to a derived length at all, so the same sound derives the same seconds at any. SampleData odd = dialedSample(static_cast(kRate * 5 / 2)); // 2.5 s odd.play.playMode = PlayMode::Gate; CHECK(near(resolveNote(derived(odd, at(120.0)), at(120.0)).noteOffSeconds, 2.5)); CHECK(near(resolveNote(derived(odd, at(97.0)), at(97.0)).noteOffSeconds, 2.5)); } // --- Gate with no loop, a START POINT set: only the post-start span is held ---------- // effectiveStart's whole reason to exist. A start marker means the head begins there, so // holding the note for the WHOLE source would pad the window with silence the voice // already finished; holding it for the source minus the start is exact. { SampleData s = dialedSample(/*frames=*/kRate * 2); // 2 s s.play.playMode = PlayMode::Gate; s.play.adsr.releaseFrames = 0; s.startFrame = kRate / 2; // start half a second in -> 1.5 s of sound left CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 1.5)); // Voice::start's own degenerate rule: a start at or past the end plays from the top, // so the window covers the whole source rather than nothing. s.startFrame = kRate * 2; CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0)); s.startFrame = -1; CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0)); } // --- Gate with no loop under Varispeed: the read-stretch bound applies here too ------- // The Trigger branch has its own coverage below; this pins that the Gate exhaustion length // takes the same bound, which is the branch a downward offset would otherwise truncate. { SampleData s = dialedSample(/*frames=*/kRate); // 1 s s.play.playMode = PlayMode::Gate; s.play.adsr.releaseFrames = 0; s.play.pitchEngine = PitchEngine::Varispeed; s.play.pitchEnv.enabled = true; s.play.pitchEnv.peakSemitones = -12.0; // an octave down == half speed at the peak CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 2.0)); // Preserve reads at the source rate, so the same dial bounds nothing. s.play.pitchEngine = PitchEngine::Preserve; CHECK(near(resolveNote(derived(s, at(120.0)), at(120.0)).noteOffSeconds, 1.0)); } // --- 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(derived(s, at(120.0)), at(120.0)); CHECK(r.captureStartSeconds == 0.0); CHECK(near(r.captureEndSeconds, 1.5 + kPadSeconds)); // A shorter %-length is a shorter window, with no floor under it: the sound is over // when the span is, and note-off means nothing to a Trigger voice. s.play.trigger.lengthFraction = 0.1; // 0.2 s const ResolvedNote brief = resolveNote(derived(s, at(120.0)), at(120.0)); CHECK(!brief.windowCollapsed); CHECK(near(brief.captureEndSeconds, 0.2 + kPadSeconds)); } // --- 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(derived(s, at(120.0)), at(120.0)); // Bounded at the deepest offset: 1 s of source can take up to 2 s to cross. CHECK(near(r.captureEndSeconds, 2.0 + kPadSeconds)); // Preserve decouples pitch from the read rate, so the same dial bounds nothing. s.play.pitchEngine = PitchEngine::Preserve; const ResolvedNote kept = resolveNote(derived(s, at(120.0)), at(120.0)); CHECK(near(kept.captureEndSeconds, 1.0 + kPadSeconds)); } // --- The bake fires at the velocity it is handed, and the Varispeed bound reads it --- { SampleData s = dialedSample(/*frames=*/kRate); s.play.playMode = PlayMode::Trigger; s.play.pitchEngine = PitchEngine::Varispeed; // A bipolar velocity->pitch curve pulling a full octave down at velocity 0 and // nothing at 127: the two velocities must therefore derive different windows. s.play.pitchVelocityCurve = VelocityCurve::fromPoints( {{0.0, -0.5}, {127.0, 0.0}}, reasampler::instrument::engine::CurveDomain::Bipolar); const NoteProgram soft = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(1)); const NoteProgram hard = defaultBakeProgram(s, kRate, oneBar(), Velocity::of(127)); CHECK(soft.velocity.value() == 1); CHECK(hard.velocity.value() == 127); const ResolvedNote softR = resolveNote(soft, at(120.0)); const ResolvedNote hardR = resolveNote(hard, at(120.0)); CHECK(near(hardR.captureEndSeconds, 1.0 + kPadSeconds)); // no offset at 127 CHECK(softR.captureEndSeconds > hardR.captureEndSeconds * 1.9); // ~an octave down // …and the velocity reaches the plan, which is what the render fires. CHECK(planBake(softR, kRate, 60).plan->velocity == 1); } // --- Gate with an ACTIVE sustain loop is the one case that needs Hold --------------- { SampleData s = dialedSample(/*frames=*/kRate); s.play.playMode = PlayMode::Gate; s.loop = SampleLoop{true, 0, kRate / 2}; CHECK(bakeWindowNeedsHold(s)); // The window follows Hold rather than the source, so a longer Hold is a longer file. const ResolvedNote bar = resolveNote( defaultBakeProgram(s, kRate, oneBar(), Velocity{}), at(120.0)); const ResolvedNote twoBars = resolveNote( defaultBakeProgram(s, kRate, makeDivision(3, DivisionModifier::Straight), Velocity{}), at(120.0)); CHECK(near(bar.noteOffSeconds, 2.0)); CHECK(near(twoBars.noteOffSeconds, 4.0)); CHECK(near(twoBars.captureEndSeconds - bar.captureEndSeconds, 2.0)); } // --- 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).plan; 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).plan; 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).plan; 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, and which one each condition reports ----------------------------- { 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).refusal == BakeRefusal::EmptyWindow); } { NoteProgram plain; const ResolvedNote r = resolveNote(plain, at(120.0)); CHECK(planBake(r, 0, 60).refusal == BakeRefusal::EmptyWindow); CHECK(planBake(r, -48000, 60).refusal == BakeRefusal::EmptyWindow); } { // 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).refusal == BakeRefusal::EmptyWindow); } { // 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). This // refusal reads differently to the user — a real sound that will not fit, not an // empty window — so it must be a distinct value, not just a nullopt. const double overSeconds = (static_cast(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).refusal == BakeRefusal::PastFrameCeiling); // 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).refusal == BakeRefusal::PastFrameCeiling); NoteProgram far; far.start = StartOffset(offsetFromMs(-kMaxConvertibleMagnitude)); CHECK(planBake(resolveNote(far, at(120.0)), 48000, 60).refusal == BakeRefusal::PastFrameCeiling); // And just under it still plans, so the ceiling is a bound, not a blanket refusal. NoteProgram fits; fits.end = EndOffset(offsetFromMs( (static_cast(kMaxBakeFrames) / 48000.0 - 1.0) * 1000.0)); const PlannedBake planned = planBake(resolveNote(fits, at(120.0)), 48000, 60); CHECK(planned.plan.has_value()); CHECK(planned.refusal == BakeRefusal::None); CHECK(planned.plan && planned.plan->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).plan; const auto high = planBake(r, 48000, 900).plan; const auto mid = planBake(r, 48000, 60).plan; 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; }