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:
+163
-102
@@ -1,11 +1,12 @@
|
||||
// Standalone audit of the DERIVED bake window: does defaultBakeProgram's window hold the
|
||||
// whole audible result of the dialed sound, in both play modes? Every case renders through
|
||||
// the real chain (defaultBakeProgram -> resolveNote -> planBake -> renderBake) and then
|
||||
// re-renders the SAME sound with a longer window, so "what fell outside" is measured rather
|
||||
// than argued. Trailing silence is a pass; signal past the derived end is a truncation.
|
||||
// The DERIVED bake window, end to end: does defaultBakeProgram's window hold the whole
|
||||
// audible result of the dialed sound? Every case renders through the real chain
|
||||
// (defaultBakeProgram -> resolveNote -> planBake -> renderBake) and then re-renders the SAME
|
||||
// sound with a longer window, so "what fell outside" is measured rather than argued.
|
||||
// Trailing silence is a pass; signal past the derived end is a truncation.
|
||||
|
||||
#include "../src/core/instrument/bake/bake_plan.h"
|
||||
#include "../src/core/instrument/bake/bake_render.h"
|
||||
#include "../src/core/instrument/engine/voice.h" // kDeclickFrames (the pad under test)
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
@@ -25,6 +26,14 @@ constexpr double kBpm = 120.0; // a quarter note is 0.5 s == 24000 frames
|
||||
constexpr double kUnity = 1.0;
|
||||
constexpr double kSilence = 1e-6;
|
||||
|
||||
// The declick pad every derived window carries. Read off the engine's own constants, so a
|
||||
// retuned ramp moves this file's expectations with it rather than against them.
|
||||
constexpr std::int64_t kPad = kDeclickFrames;
|
||||
|
||||
// One bar at 120 BPM == 2 s == 96000 frames. The Hold default, spelled out so the
|
||||
// expectations below read as arithmetic rather than as magic.
|
||||
Division oneBar() { return makeDivision(2, DivisionModifier::Straight); }
|
||||
|
||||
Tempo tempo() {
|
||||
const std::optional<Tempo> t = Tempo::fromBpm(kBpm);
|
||||
if (!t) { std::printf("FAIL: fixture tempo rejected\n"); ++g_fail; }
|
||||
@@ -53,125 +62,143 @@ double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) {
|
||||
}
|
||||
|
||||
// The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same
|
||||
// sound, a longer window), `length` overrides the programmed note length. Both leave the
|
||||
// derivation itself untouched, which is what makes the comparison a measurement of the
|
||||
// derived end rather than of a second derivation.
|
||||
NoteProgram derivedProgram(const SampleData& s, double extraMs) {
|
||||
NoteProgram p = defaultBakeProgram(s, kRate, tempo());
|
||||
// sound, a longer window). It leaves the derivation itself untouched, which is what makes the
|
||||
// comparison a measurement of the derived end rather than of a second derivation.
|
||||
NoteProgram derivedProgram(const SampleData& s, double extraMs, Division hold = oneBar(),
|
||||
int velocity = 100) {
|
||||
NoteProgram p = defaultBakeProgram(s, kRate, tempo(), hold, Velocity::of(velocity));
|
||||
if (extraMs != 0.0)
|
||||
p.end = EndOffset(offsetFromMs(offsetMs(p.end.amount(), tempo()) + extraMs));
|
||||
return p;
|
||||
}
|
||||
|
||||
std::optional<BakePlan> planOf(const NoteProgram& p) {
|
||||
return planBake(resolveNote(p, tempo()), kRate, 60);
|
||||
return planBake(resolveNote(p, tempo()), kRate, 60).plan;
|
||||
}
|
||||
|
||||
// The render the shell would produce, plus `extraMs` of extra window.
|
||||
BakeAudio bakeWith(const SampleData& s, double extraMs) {
|
||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, extraMs));
|
||||
BakeAudio bakeWith(const SampleData& s, double extraMs, Division hold = oneBar(),
|
||||
int velocity = 100) {
|
||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, extraMs, hold, velocity));
|
||||
if (!plan) { std::printf("FAIL: fixture window refused\n"); ++g_fail; return BakeAudio{}; }
|
||||
return renderBake(s, *plan, kUnity);
|
||||
}
|
||||
|
||||
std::int64_t derivedFrames(const SampleData& s) {
|
||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0));
|
||||
std::int64_t derivedFrames(const SampleData& s, Division hold = oneBar()) {
|
||||
const std::optional<BakePlan> plan = planOf(derivedProgram(s, 0.0, hold));
|
||||
return plan ? plan->totalFrames : -1;
|
||||
}
|
||||
|
||||
// The last frame of the file, which is where a hard cut shows up.
|
||||
double lastFrameLevel(const BakeAudio& audio) {
|
||||
return audio.frameCount() > 0 ? peakAt(audio, audio.frameCount() - 1, audio.frameCount())
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// ================================ GATE ==========================================
|
||||
|
||||
// --- Gate, no loop, staged AHDSR: the derived end is EXACT on the release ----------
|
||||
// note-off at the quarter note, release_frames after it, and not one frame of signal
|
||||
// past that — the end offset is the release, so the two must coincide exactly.
|
||||
// --- Gate, no loop: the note is held until the SOURCE runs out ---------------------
|
||||
// The read head frees the voice at exhaustion whether or not the gate is still down, so
|
||||
// the maximal sound is the whole take. 2 s of source == 4 beats, exactly one bar.
|
||||
{
|
||||
SampleData s = dcSample(96000);
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.adsr.releaseFrames = 4800; // 100 ms
|
||||
|
||||
CHECK(derivedFrames(s) == 28800); // 24000 (quarter) + 4800 (release)
|
||||
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
|
||||
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/200.0);
|
||||
CHECK(wide.frameCount() == 38400);
|
||||
// Still releasing on the last ten frames the derived window would have kept…
|
||||
CHECK(peakAt(wide, 28790, 28800) > kSilence);
|
||||
// …and EXACTLY silent from there on: nothing was cut.
|
||||
CHECK(peakAt(wide, 28800, 38400) == 0.0);
|
||||
// Full level right up to exhaustion, and nothing at all past it — the note outlived
|
||||
// its own release, so the release window is trailing silence, not a truncated tail.
|
||||
CHECK(peakAt(wide, 95000, 96000) > 0.4);
|
||||
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0);
|
||||
}
|
||||
|
||||
// --- Gate with a sustain loop: there is NO intrinsic end to derive -----------------
|
||||
// The window holds the whole programmed note, but the note length itself is a constant
|
||||
// quarter note — hold the same dialed sound longer and it keeps sounding, indefinitely.
|
||||
// This is the one number a derivation cannot supply.
|
||||
// --- Gate, no loop, a SLOW ATTACK: the derived note reaches the dialed peak ---------
|
||||
// The window used to be a constant quarter note, which released a 2 s attack a quarter of
|
||||
// the way up. Deriving the hold from source exhaustion is what closes that.
|
||||
{
|
||||
SampleData s = dcSample(192000); // 4 s
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.adsr.attackFrames = 96000; // 2 s
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
|
||||
CHECK(derivedFrames(s) == 192000 + kPad);
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
// The whole attack, at full level — under the old constant quarter note this peaked
|
||||
// between 0.10 and 0.14.
|
||||
CHECK(peakAt(derived, 0, derived.frameCount()) > 0.49);
|
||||
// …and a longer window adds nothing: the derivation already held everything.
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
|
||||
CHECK(peakAt(wide, 192000, wide.frameCount()) == 0.0);
|
||||
}
|
||||
|
||||
// --- Gate WITH a sustain loop: Hold is the note length, and the ONLY user input ------
|
||||
// A looped Gate voice sounds for as long as it is held, so no derivation supplies a
|
||||
// duration — this is the one case the predicate names, and the window follows Hold.
|
||||
{
|
||||
SampleData s = dcSample(48000);
|
||||
s.loop = SampleLoop{true, 0, 24000};
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.adsr.releaseFrames = 4800;
|
||||
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
CHECK(derived.frameCount() == 28800);
|
||||
// The derived window does bound the render: full level while the note is held, and
|
||||
// down to the release's own floor at the end — the loop is not left cycling.
|
||||
CHECK(peakAt(derived, 20000, 24000) > 0.4);
|
||||
CHECK(peakAt(derived, 28790, 28800) < 0.01);
|
||||
CHECK(bakeWindowNeedsHold(s));
|
||||
|
||||
// The SAME dialed sound held for a whole note instead of a quarter: four times the
|
||||
// audio, all of it real, none of it reachable from the derivation.
|
||||
NoteProgram longer = derivedProgram(s, 0.0);
|
||||
longer.length = makeDivision(2, DivisionModifier::Straight); // 4 beats == 2 s
|
||||
const std::optional<BakePlan> plan = planOf(longer);
|
||||
CHECK(plan.has_value());
|
||||
const BakeAudio held = renderBake(s, *plan, kUnity);
|
||||
CHECK(held.frameCount() == 100800); // 96000 + 4800
|
||||
CHECK(peakAt(held, 28800, 96000) > 0.4); // full level, well past the derived end
|
||||
// One bar at 120 BPM == 96000 frames, plus the release, plus the pad.
|
||||
CHECK(derivedFrames(s) == 96000 + 4800 + kPad);
|
||||
const BakeAudio bar = bakeWith(s, 0.0);
|
||||
CHECK(peakAt(bar, 90000, 96000) > 0.4); // still cycling at note-off
|
||||
CHECK(peakAt(bar, 96000 + 4790, 96000 + 4800) < 0.01); // released to its own floor
|
||||
|
||||
// A different Hold is a different window, in the same proportion — the knob really is
|
||||
// what the derivation reads here.
|
||||
const Division twoBars = makeDivision(3, DivisionModifier::Straight);
|
||||
CHECK(derivedFrames(s, twoBars) == 192000 + 4800 + kPad);
|
||||
const BakeAudio held = bakeWith(s, 0.0, twoBars);
|
||||
CHECK(peakAt(held, 96000, 192000) > 0.4); // full level well past one bar
|
||||
}
|
||||
|
||||
// --- Gate: the constant quarter-note hold TRUNCATES a slow attack ------------------
|
||||
// The window is correct for the note it programs; the note is what is wrong. A 2 s
|
||||
// attack never reaches its peak, so the bake prints a sound the user never dialed.
|
||||
// --- The Hold predicate is the ENGINE's loop fold, not a reading of the fields -------
|
||||
{
|
||||
SampleData s = dcSample(192000);
|
||||
SampleData s = dcSample(48000);
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.adsr.attackFrames = 96000; // 2 s, four times the programmed note
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
CHECK(derived.frameCount() == 24000);
|
||||
// A quarter of the way up the attack at most.
|
||||
const double derivedPeak = peakAt(derived, 0, 24000);
|
||||
CHECK(derivedPeak > 0.1 && derivedPeak < 0.14);
|
||||
|
||||
NoteProgram longer = derivedProgram(s, 0.0);
|
||||
longer.length = makeDivision(3, DivisionModifier::Straight); // 8 beats == 4 s
|
||||
const std::optional<BakePlan> plan = planOf(longer);
|
||||
CHECK(plan.has_value());
|
||||
const BakeAudio held = renderBake(s, *plan, kUnity);
|
||||
CHECK(peakAt(held, 0, held.frameCount()) > 0.49); // the dialed sound, in full
|
||||
CHECK(!bakeWindowNeedsHold(s)); // no loop at all
|
||||
s.loop = SampleLoop{true, 0, 24000};
|
||||
CHECK(bakeWindowNeedsHold(s));
|
||||
s.loop = SampleLoop{true, 0, 48001}; // reaches past the PCM
|
||||
CHECK(!bakeWindowNeedsHold(s)); // …which resolveLoop refuses
|
||||
s.loop = SampleLoop{true, 24000, 12000}; // inverted
|
||||
CHECK(!bakeWindowNeedsHold(s));
|
||||
s.loop = SampleLoop{true, 0, 24000};
|
||||
s.play.playMode = PlayMode::Trigger; // Trigger has no sustain loop
|
||||
CHECK(!bakeWindowNeedsHold(s));
|
||||
}
|
||||
|
||||
// --- Gate + Preserve: the terminal ring-out is cut when the source ends AT the window
|
||||
// Preserve rings its last real output out (~4 ms) instead of hard-cutting it; that ramp
|
||||
// lands past the derived end whenever the voice's own end lands on it.
|
||||
// --- Gate + Preserve: the terminal ring-out is INSIDE the window ---------------------
|
||||
// Preserve rings its last real output out instead of hard-cutting it; the derived window
|
||||
// is padded by exactly that ramp, so the file ends at silence.
|
||||
{
|
||||
SampleData s = dcSample(24000); // exhausts exactly at the quarter-note note-off
|
||||
SampleData s = dcSample(24000); // exhausts at half a bar
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
s.play.adsr.releaseFrames = 0;
|
||||
|
||||
CHECK(derivedFrames(s) == 24000);
|
||||
CHECK(derivedFrames(s) == 24000 + kPad);
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
CHECK(peakAt(derived, 23990, 24000) > 0.4); // full level while the source lasts
|
||||
CHECK(peakAt(derived, 24000, 24000 + kPad) > 0.05); // the ramp, inside the file
|
||||
CHECK(lastFrameLevel(derived) < 1e-3); // and the file ends at silence
|
||||
// Nothing at all past the pad: the window is not merely long, it is exactly enough.
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
|
||||
CHECK(peakAt(wide, 23990, 24000) > 0.4); // hard cut at full level…
|
||||
CHECK(peakAt(wide, 24000, 24100) > 0.05); // …with the ring-out outside the window
|
||||
CHECK(peakAt(wide, 24400, wide.frameCount()) < kSilence); // and it is bounded
|
||||
CHECK(peakAt(wide, 24000 + kPad, wide.frameCount()) == 0.0);
|
||||
}
|
||||
|
||||
// --- The resonant filter cannot ring past the amp gate ------------------------------
|
||||
// pitch -> filter -> amp: the amp multiply is last, so a high-Q filter's ring-out is
|
||||
// gated by the release the window already holds. Not a tail contributor.
|
||||
// gated by the envelope the window already holds. Not a tail contributor.
|
||||
{
|
||||
SampleData s = dcSample(96000);
|
||||
s.play.playMode = PlayMode::Gate;
|
||||
@@ -181,8 +208,8 @@ int main() {
|
||||
s.play.filter.settings.resonanceNorm = 1.0f;
|
||||
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0);
|
||||
CHECK(peakAt(wide, 0, 28800) > kSilence); // the filtered note sounded
|
||||
CHECK(peakAt(wide, 28800, wide.frameCount()) == 0.0); // and nothing rang past it
|
||||
CHECK(peakAt(wide, 0, 96000) > kSilence); // the filtered note sounded
|
||||
CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0); // and nothing rang past it
|
||||
}
|
||||
|
||||
// ================================ TRIGGER =======================================
|
||||
@@ -199,7 +226,7 @@ int main() {
|
||||
s.play.pitchEnv.shape.holdFraction = 1.0; // held down for the whole span
|
||||
|
||||
// 1 s of source stretched by 2^(24/12) == 4.
|
||||
CHECK(derivedFrames(s) == 192000);
|
||||
CHECK(derivedFrames(s) == 192000 + kPad);
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
// The offset only holds for the envelope's own span, so the read finishes near
|
||||
// 84000 frames — inside the window, with the balance as trailing silence.
|
||||
@@ -207,50 +234,72 @@ int main() {
|
||||
CHECK(peakAt(derived, 90000, 192000) < kSilence);
|
||||
}
|
||||
|
||||
// --- Trigger + Preserve (the product-default engine): the ring-out is TRUNCATED -----
|
||||
// Preserve's read reaches playEnd on the exact frame the derived window closes, and the
|
||||
// ~4 ms terminal declick that follows is entirely outside it. The baked file therefore
|
||||
// ends on a hard cut at full level — reintroducing the click the ramp exists to remove.
|
||||
// --- Trigger + Preserve (the product-default engine): the ring-out is HELD -----------
|
||||
// Preserve's read reaches playEnd where the un-padded window used to close, leaving the
|
||||
// whole terminal declick outside it — a hard cut at full level, the very click the ramp
|
||||
// exists to remove. The pad is what closes that.
|
||||
{
|
||||
SampleData s = dcSample(48000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.pitchEngine = PitchEngine::Preserve;
|
||||
|
||||
CHECK(derivedFrames(s) == 48000);
|
||||
CHECK(derivedFrames(s) == 48000 + kPad);
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
CHECK(peakAt(derived, 47990, 48000) > 0.4); // full level at the source's own end
|
||||
CHECK(peakAt(derived, 48000, 48001) > 0.49); // the ramp opens at that same level…
|
||||
CHECK(lastFrameLevel(derived) < 1e-3); // …and the file ends at silence
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/50.0);
|
||||
CHECK(peakAt(wide, 47990, 48000) > 0.4); // full level on the derived last frame
|
||||
CHECK(peakAt(wide, 48000, 48100) > 0.05); // real signal past the derived end
|
||||
// Measured: the ramp opens at the last in-window level (0.500) and reaches exact
|
||||
// zero 180 frames later — the whole 3.75 ms sits outside the derived window.
|
||||
CHECK(peakAt(wide, 48000, 48001) > 0.49);
|
||||
CHECK(peakAt(wide, 48179, 48180) > 0.0);
|
||||
CHECK(peakAt(wide, 48180, wide.frameCount()) == 0.0);
|
||||
CHECK(peakAt(wide, 48000 + kPad, wide.frameCount()) == 0.0);
|
||||
}
|
||||
|
||||
// --- Trigger + a DRAWN amp EG: the window keeps a %-length the engine IGNORES -------
|
||||
// A drawn contour covers the full sample length, so Voice::start forces lengthFraction
|
||||
// to 1.0 (splineActive, play_params.h) — but defaultBakeProgram reads the stored,
|
||||
// UI-inert knob straight through triggerPlayLength. The window is a quarter of the take.
|
||||
// --- Trigger + a DRAWN amp EG: the window holds the WHOLE take -----------------------
|
||||
// A drawn contour covers the full sample length, so the engine folds lengthFraction to
|
||||
// 1.0 (effectiveLengthFraction, trigger_seam.h). The stored %-knob is inert but still
|
||||
// saved, and reading it raw here cut this window to a quarter of the take.
|
||||
{
|
||||
SampleData s = dcSample(48000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.play.trigger.lengthFraction = 0.25; // inert in the engine, live in the derivation
|
||||
s.play.trigger.lengthFraction = 0.25; // inert in the engine, and now in the window
|
||||
s.play.ampSpline.mode = EnvMode::Spline;
|
||||
s.play.ampSpline.contour = VelocityCurve::flat(); // full level across the sample
|
||||
|
||||
CHECK(derivedFrames(s) == 12000); // 0.25 s of a 1 s take
|
||||
CHECK(derivedFrames(s) == 48000 + kPad); // the whole take, not 12000
|
||||
|
||||
const BakeAudio wide = bakeWith(s, /*extraMs=*/1000.0);
|
||||
// The engine really did play the whole sample: full level right up to 48000…
|
||||
CHECK(peakAt(wide, 12000, 48000) > 0.4);
|
||||
CHECK(peakAt(wide, 47000, 48000) > 0.4);
|
||||
// …and stopped at its own span end, so the 36000 lost frames are the take, not tail.
|
||||
CHECK(peakAt(wide, 48200, wide.frameCount()) < kSilence);
|
||||
const BakeAudio derived = bakeWith(s, 0.0);
|
||||
// Full level across the 36000 frames a raw read of the stored knob used to cut. Both
|
||||
// spans start past the old 12000-frame end, so a truncated window reads 0 here.
|
||||
CHECK(peakAt(derived, 36000, 40000) > 0.4);
|
||||
CHECK(peakAt(derived, 47000, 48000) > 0.4);
|
||||
// A %-knob that IS live still shortens the window — the fold is conditional, not a
|
||||
// blanket ignore.
|
||||
SampleData staged = s;
|
||||
staged.play.ampSpline.mode = EnvMode::Staged;
|
||||
CHECK(derivedFrames(staged) == 12000 + kPad);
|
||||
}
|
||||
|
||||
// --- A legitimate dialed sound past the frame ceiling is REFUSED, not cut -----------
|
||||
// 1.1e6 source frames stretched by 2^(48/12) == 16 exceeds kMaxBakeFrames. The refusal
|
||||
// is the safe direction (the shell reports it), but it is a sound that cannot be baked.
|
||||
// ============================== VELOCITY ========================================
|
||||
|
||||
// --- The bake renders at the velocity it is handed ----------------------------------
|
||||
// Three velocity curves are live, so a sound auditioned at 120 does not bake as one
|
||||
// auditioned at 40. linear() maps velocity/127 onto amp gain.
|
||||
{
|
||||
SampleData s = dcSample(24000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
s.velocityCurve = VelocityCurve::linear();
|
||||
|
||||
const BakeAudio soft = bakeWith(s, 0.0, oneBar(), /*velocity=*/40);
|
||||
const BakeAudio hard = bakeWith(s, 0.0, oneBar(), /*velocity=*/120);
|
||||
const double softPeak = peakAt(soft, 0, 24000);
|
||||
const double hardPeak = peakAt(hard, 0, 24000);
|
||||
// 0.5 * 40/127 == 0.157, 0.5 * 120/127 == 0.472.
|
||||
CHECK(softPeak > 0.15 && softPeak < 0.17);
|
||||
CHECK(hardPeak > 0.46 && hardPeak < 0.48);
|
||||
CHECK(hardPeak > softPeak * 2.0);
|
||||
}
|
||||
|
||||
// ============================== REFUSALS ========================================
|
||||
|
||||
// --- A legitimate dialed sound past the frame ceiling is REFUSED, and says so --------
|
||||
{
|
||||
SampleData s = dcSample(1'100'000);
|
||||
s.play.playMode = PlayMode::Trigger;
|
||||
@@ -258,7 +307,10 @@ int main() {
|
||||
s.play.pitchEnv.enabled = true;
|
||||
s.play.pitchEnv.peakSemitones = -48.0;
|
||||
s.play.pitchEnv.shape.holdFraction = 1.0;
|
||||
CHECK(!planOf(derivedProgram(s, 0.0)).has_value());
|
||||
const PlannedBake tooLong =
|
||||
planBake(resolveNote(derivedProgram(s, 0.0), tempo()), kRate, 60);
|
||||
CHECK(!tooLong.plan.has_value());
|
||||
CHECK(tooLong.refusal == BakeRefusal::PastFrameCeiling);
|
||||
|
||||
// One octave shallower is inside the ceiling — the refusal above is the window, not
|
||||
// the fixture.
|
||||
@@ -266,6 +318,15 @@ int main() {
|
||||
CHECK(planOf(derivedProgram(s, 0.0)).has_value());
|
||||
}
|
||||
|
||||
// --- An empty window is a DIFFERENT refusal, so the shell can say a different thing ---
|
||||
{
|
||||
NoteProgram p;
|
||||
p.end = EndOffset(offsetFromMs(-5000.0)); // ends long before note-off: collapsed
|
||||
const PlannedBake empty = planBake(resolveNote(p, tempo()), kRate, 60);
|
||||
CHECK(!empty.plan.has_value());
|
||||
CHECK(empty.refusal == BakeRefusal::EmptyWindow);
|
||||
}
|
||||
|
||||
if (g_fail == 0) std::printf("bake_window: all tests passed\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user