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:
2026-08-01 20:26:04 -04:00
parent d3894dae6d
commit 19aeb92775
36 changed files with 1040 additions and 274 deletions
+163 -102
View File
@@ -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;
}