// 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 #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) namespace { constexpr int kRate = 48000; 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 t = Tempo::fromBpm(kBpm); if (!t) { std::printf("FAIL: fixture tempo rejected\n"); ++g_fail; } return t.value_or(Tempo::fromBpm(120.0).value()); } // Flat DC so a level reading is unambiguous: any departure from 0.5 is the envelope, the // filter or a ring-out, never the source's own shape. SampleData dcSample(std::size_t frames) { SampleData s; s.frames.assign(frames, 0.5f); s.sampleRate = kRate; s.rootNote = 60; return s; } double peakAt(const BakeAudio& audio, std::int64_t from, std::int64_t to) { double peak = 0.0; if (from < 0) from = 0; for (std::int64_t f = from; f < to && f < audio.frameCount(); ++f) { const double v = std::fabs(static_cast( audio.interleaved[static_cast(f * audio.channelCount)])); if (v > peak) peak = v; } return peak; } // The derived program, optionally lengthened: `extraMs` widens ONLY the end offset (the same // 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 planOf(const NoteProgram& p) { 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, Division hold = oneBar(), int velocity = 100) { const std::optional 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, Division hold = oneBar()) { const std::optional 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: 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) == 96000 + 4800 + kPad); const BakeAudio wide = bakeWith(s, /*extraMs=*/200.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, 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; CHECK(bakeWindowNeedsHold(s)); // 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 } // --- The Hold predicate is the ENGINE's loop fold, not a reading of the fields ------- { SampleData s = dcSample(48000); s.play.playMode = PlayMode::Gate; 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 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 at half a bar s.play.playMode = PlayMode::Gate; s.play.pitchEngine = PitchEngine::Preserve; s.play.adsr.releaseFrames = 0; 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, 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 envelope the window already holds. Not a tail contributor. { SampleData s = dcSample(96000); s.play.playMode = PlayMode::Gate; s.play.adsr.releaseFrames = 4800; s.play.filter.enabled = true; s.play.filter.settings.cutoffNorm = 0.05f; s.play.filter.settings.resonanceNorm = 1.0f; const BakeAudio wide = bakeWith(s, /*extraMs=*/500.0); CHECK(peakAt(wide, 0, 96000) > kSilence); // the filtered note sounded CHECK(peakAt(wide, 96000, wide.frameCount()) == 0.0); // and nothing rang past it } // ================================ TRIGGER ======================================= // --- Trigger, Varispeed, a constant deep downward pitch offset: UPPER BOUND --------- // The window is scaled by the deepest reachable offset, so a shallower excursion leaves // trailing silence — long, but never short. { SampleData s = dcSample(48000); s.play.playMode = PlayMode::Trigger; s.play.pitchEngine = PitchEngine::Varispeed; s.play.pitchEnv.enabled = true; s.play.pitchEnv.peakSemitones = -24.0; // two octaves down 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 + 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. CHECK(peakAt(derived, 80000, 84000) > 0.4); CHECK(peakAt(derived, 90000, 192000) < kSilence); } // --- 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 + 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, 48000 + kPad, wide.frameCount()) == 0.0); } // --- 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, 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) == 48000 + kPad); // the whole take, not 12000 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); } // ============================== 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; s.play.pitchEngine = PitchEngine::Varispeed; s.play.pitchEnv.enabled = true; s.play.pitchEnv.peakSemitones = -48.0; s.play.pitchEnv.shape.holdFraction = 1.0; 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. s.play.pitchEnv.peakSemitones = -24.0; 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; }