Bake window derives itself: %-knob fold, declick pad, Gate held to exhaustion, preview velocity; Hold is the one knob a loop needs
This commit is contained in:
+111
-46
@@ -1,13 +1,15 @@
|
||||
// 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.
|
||||
// Covers: the default program's window derived from the dialed sound (Gate's hold to source
|
||||
// exhaustion plus its 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 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 <cstdio>
|
||||
|
||||
@@ -29,6 +31,9 @@ namespace {
|
||||
|
||||
constexpr int kRate = 48000;
|
||||
|
||||
// Every derived window carries the voice's terminal declick ramp as trailing silence.
|
||||
const double kPadSeconds = static_cast<double>(kDeclickFrames) / kRate;
|
||||
|
||||
SampleData dialedSample(std::size_t frames = 96000) {
|
||||
SampleData s;
|
||||
s.frames.assign(frames, 0.5f);
|
||||
@@ -37,27 +42,44 @@ SampleData dialedSample(std::size_t frames = 96000) {
|
||||
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); }
|
||||
|
||||
NoteProgram derived(const SampleData& s, Tempo t) {
|
||||
return defaultBakeProgram(s, kRate, t, oneBar(), Velocity{});
|
||||
}
|
||||
|
||||
bool near(double a, double b) { return a > b - 1e-6 && a < b + 1e-6; }
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// --- The default program's window comes from the DIALED release, not a constant -----
|
||||
// --- Gate with no loop: held to source exhaustion, then the DIALED release ----------
|
||||
{
|
||||
SampleData s = dialedSample();
|
||||
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 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);
|
||||
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 rounded up to the shortest length outlasting the source — 2 s exactly,
|
||||
// one bar at 120 BPM — instead of the constant quarter note that released it early.
|
||||
CHECK(near(r.noteOffSeconds, 2.0));
|
||||
CHECK(r.captureStartSeconds == 0.0);
|
||||
CHECK(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
|
||||
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(defaultBakeProgram(s, kRate, at(120.0)),
|
||||
at(120.0));
|
||||
CHECK(shorter.captureEndSeconds > 0.599 && shorter.captureEndSeconds < 0.601);
|
||||
const ResolvedNote shorter = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||
CHECK(near(shorter.captureEndSeconds, 2.0 + 0.1 + kPadSeconds));
|
||||
|
||||
// A source that does not land on a rung rounds UP. 2.5 s is 5 beats, and the shortest
|
||||
// rung at or above that is the 2/1 triplet (8 * 2/3 == 5.33 beats) — NOT the dotted
|
||||
// half above it, which is why picker order is not length order.
|
||||
SampleData odd = dialedSample(static_cast<std::size_t>(kRate * 5 / 2)); // 2.5 s
|
||||
odd.play.playMode = PlayMode::Gate;
|
||||
const ResolvedNote up = resolveNote(derived(odd, at(120.0)), at(120.0));
|
||||
CHECK(up.noteOffSeconds >= 2.5); // the property that matters: never short
|
||||
CHECK(near(up.noteOffSeconds, at(120.0).beatsToSeconds(8.0 * 2.0 / 3.0)));
|
||||
}
|
||||
|
||||
// --- Trigger: the window is the play span, which ignores the note's length ----------
|
||||
@@ -65,18 +87,16 @@ int main() {
|
||||
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));
|
||||
const ResolvedNote r = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||
CHECK(r.captureStartSeconds == 0.0);
|
||||
CHECK(r.captureEndSeconds > 1.49 && r.captureEndSeconds < 1.51);
|
||||
CHECK(near(r.captureEndSeconds, 1.5 + kPadSeconds));
|
||||
|
||||
// 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));
|
||||
const ResolvedNote brief = resolveNote(derived(s, at(120.0)), at(120.0));
|
||||
CHECK(!brief.windowCollapsed);
|
||||
CHECK(brief.captureEndSeconds > 0.199 && brief.captureEndSeconds < 0.201);
|
||||
CHECK(near(brief.captureEndSeconds, 0.2 + kPadSeconds));
|
||||
}
|
||||
|
||||
// --- Trigger under Varispeed: a downward pitch offset stretches the read -----------
|
||||
@@ -86,16 +106,56 @@ int main() {
|
||||
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));
|
||||
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(r.captureEndSeconds > 1.99 && r.captureEndSeconds < 2.01);
|
||||
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(defaultBakeProgram(s, kRate, at(120.0)),
|
||||
at(120.0));
|
||||
CHECK(kept.captureEndSeconds > 0.99 && kept.captureEndSeconds < 1.01);
|
||||
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, at(120.0), oneBar(), Velocity::of(1));
|
||||
const NoteProgram hard =
|
||||
defaultBakeProgram(s, kRate, at(120.0), 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, at(120.0), oneBar(), Velocity{}), at(120.0));
|
||||
const ResolvedNote twoBars = resolveNote(
|
||||
defaultBakeProgram(s, kRate, at(120.0),
|
||||
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 ----------------------------------
|
||||
@@ -103,7 +163,7 @@ int main() {
|
||||
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);
|
||||
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);
|
||||
@@ -121,7 +181,7 @@ int main() {
|
||||
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);
|
||||
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);
|
||||
@@ -137,7 +197,7 @@ int main() {
|
||||
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);
|
||||
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.
|
||||
@@ -149,20 +209,20 @@ int main() {
|
||||
CHECK(plan->noteOffFrame - plan->noteOnFrame == 24000);
|
||||
}
|
||||
|
||||
// --- Refusals -----------------------------------------------------------------
|
||||
// --- 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).has_value());
|
||||
CHECK(planBake(r, 48000, 60).refusal == BakeRefusal::EmptyWindow);
|
||||
}
|
||||
{
|
||||
NoteProgram plain;
|
||||
const ResolvedNote r = resolveNote(plain, at(120.0));
|
||||
CHECK(!planBake(r, 0, 60).has_value());
|
||||
CHECK(!planBake(r, -48000, 60).has_value());
|
||||
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
|
||||
@@ -172,34 +232,39 @@ int main() {
|
||||
const ResolvedNote r = resolveNote(p, at(120.0));
|
||||
CHECK(!r.windowCollapsed);
|
||||
CHECK(r.captureLengthSeconds() == 0.0);
|
||||
CHECK(!planBake(r, 48000, 60).has_value());
|
||||
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).
|
||||
// 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<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());
|
||||
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).has_value());
|
||||
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).has_value());
|
||||
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<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);
|
||||
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 -------------------------------------------------------------
|
||||
@@ -207,9 +272,9 @@ int main() {
|
||||
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);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user