diff --git a/src/core/instrument/bake/CLAUDE.md b/src/core/instrument/bake/CLAUDE.md index 6c3246a..4656b90 100644 --- a/src/core/instrument/bake/CLAUDE.md +++ b/src/core/instrument/bake/CLAUDE.md @@ -21,8 +21,9 @@ decision about what the render made obsolete. loop runs to `BakePlan::renderFrames()` and stops. That is why a Gate bake with a sustain loop active terminates: the gate is released at `noteOffFrame` so the tail is real, but even a pathological envelope cannot run past the window. -- **The whole signal chain is printed, master gain included** — the gain multiply in - `bake_render.cpp` carries the argument for why. +- **The voice chain and master gain are printed; the limiter is not.** The gain multiply in + `bake_render.cpp` carries the argument for the gain, and `bake_reset.h` records where the + printed master stage stops. - **A degenerate or unholdable window is refused, not rendered.** `planBake` refuses a collapsed window, a non-positive rate, a window that rounds to no frames, and one past `kMaxBakeFrames` — an unbounded window is a `bad_alloc` inside a UI tick, and the @@ -50,6 +51,9 @@ decision about what the render made obsolete. - **Play mode resets to TRIGGER, not to the value struct's Gate default** — the one classification this track made against the ratified rule rather than reading off it. `bake_reset.cpp` carries the argument at the assignment. +- **`kStageTimeMaxSeconds` (the stage-time ceiling `param_taper` owns) is not a reset-list + candidate at all** — it bounds a knob's taper, is never itself a dialed value, and so has + no disposition to classify against the ratified reset rule. ## Modules diff --git a/src/core/instrument/bake/CMakeLists.txt b/src/core/instrument/bake/CMakeLists.txt index cddcf58..7a94710 100644 --- a/src/core/instrument/bake/CMakeLists.txt +++ b/src/core/instrument/bake/CMakeLists.txt @@ -17,4 +17,6 @@ reasampler_test(bake_window LINK bake_plan bake_render) # sample_map carries InstrumentParams, which is the whole of what a reset rewrites. reasampler_pure_library(bake_reset SOURCES bake_reset.cpp LINK PUBLIC sample_map) -reasampler_test(bake_reset LINK bake_reset) +# loop_marks is a TEST-only edge: it defines what a neutral loop looks like on the band, so +# the reset's loop assertions read it rather than restating it. +reasampler_test(bake_reset LINK bake_reset loop_marks) diff --git a/src/core/instrument/bake/bake_reset.h b/src/core/instrument/bake/bake_reset.h index 289db16..df268f6 100644 --- a/src/core/instrument/bake/bake_reset.h +++ b/src/core/instrument/bake/bake_reset.h @@ -13,6 +13,10 @@ namespace reasampler::instrument::bake { // The two surfaces a bake resets. Master gain lives on the processor rather than in the // parameter set; it is answered here because renderBake prints it into the file (see // bake_render.cpp's gain multiply) rather than left to the shell. +// +// Gain is the ONLY master-stage control the render prints — the limiter runs in the +// processor's block, off the bake path — so "the bake prints the gain" does not generalize +// to the master stage as a whole, and cannot be used to classify anything else on it. struct BakeReset { map::InstrumentParams params; double masterGainLinear = 1.0; // unity — renderBake printed the dialed gain diff --git a/tests/test_bake_render.cpp b/tests/test_bake_render.cpp index b06d493..4be2fc1 100644 --- a/tests/test_bake_render.cpp +++ b/tests/test_bake_render.cpp @@ -231,6 +231,45 @@ int main() { CHECK(identical); } + // --- Regression baseline: the neutral render is the source, sample for sample -------- + // A Trigger voice at its own root under Varispeed reads at ratio exactly 1 and hits no + // filter, so every printed frame equals its source frame PROVIDED the amp curve's gain at + // the plan's velocity is exactly 1.0 too (asserted below rather than assumed) — that exact + // value is a property of flat()'s two endpoints cancelling at velocity 100, not a + // guarantee of eval() at an arbitrary velocity. An added stage, a moved default, or a lost + // early-out anywhere in the chain moves a sample here. + { + SampleData s; + s.frames.resize(4000); + // A ramp, not DC: an off-by-one read or a reversed span is invisible in a constant. + for (std::size_t i = 0; i < s.frames.size(); ++i) + s.frames[i] = static_cast(i) / 4000.f - 0.5f; + s.sampleRate = kRate; + s.rootNote = 60; + s.play.playMode = PlayMode::Trigger; + s.play.pitchEngine = PitchEngine::Varispeed; + + // Shorter than the play span, so the window closes before any note-end shaping. + const BakePlan plan = planOf(/*total=*/1000, /*noteOn=*/0, /*noteOff=*/1000); + CHECK(s.velocityCurve.eval(100.0) == 1.0); // names the real cause if this ever fails + const BakeAudio audio = renderBake(s, plan, kUnity); + CHECK(audio.channelCount == 1); + CHECK(audio.frameCount() == 1000); + + bool identity = audio.frameCount() == 1000; + for (std::size_t f = 0; identity && f < 1000; ++f) + identity = (audio.interleaved[f] == s.frames[f]); + CHECK(identity); + + // …and the gain rides that as an exact scalar, which is the only other thing the + // render is permitted to do to the signal. + const BakeAudio halved = renderBake(s, plan, 0.5); + bool scaled = halved.frameCount() == 1000; + for (std::size_t f = 0; scaled && f < 1000; ++f) + scaled = (halved.interleaved[f] == s.frames[f] * 0.5f); + CHECK(scaled); + } + // --- Refusals ----------------------------------------------------------------------- { SampleData empty; // nothing decoded diff --git a/tests/test_bake_reset.cpp b/tests/test_bake_reset.cpp index 2d14513..157b4f4 100644 --- a/tests/test_bake_reset.cpp +++ b/tests/test_bake_reset.cpp @@ -1,23 +1,35 @@ // Standalone tests for reasampler::instrument::bake::bake_reset — no VST3, no REAPER, no // framework. Same fast assert loop as the sibling pure tests. // -// Covers the ratified reset scope PER PARAMETER, in both directions: every control whose -// effect the render printed comes back at its default, and every mapping fact comes back -// untouched. Asserted field by field rather than by struct equality on purpose — a -// whole-struct compare would pass while silently resetting a survivor, or vice versa. +// The ratified reset scope itself is bake/CLAUDE.md's; this sweep checks it field by field +// (never struct equality, which would pass while silently resetting a survivor) over TWO +// independently dialed fixtures, so a pass is a property of the survivors, not of one input. #include "../src/core/instrument/bake/bake_reset.h" +// The loop's neutral is loop_marks' definition of it, not this test's reading of it: what a +// reset loop LOOKS like on the band is the thing worth asserting, and only that module says. +#include "../src/core/instrument/ui/loop_marks.h" + +#include #include +#include using namespace reasampler; using namespace reasampler::instrument::bake; using reasampler::instrument::map::InstrumentParams; using reasampler::instrument::map::PlaySeconds; +using reasampler::instrument::ui::LoopMarks; +using reasampler::instrument::ui::StoredLoop; +using reasampler::instrument::ui::resolveLoopMarks; + +namespace note = reasampler::instrument::note; +namespace filter = reasampler::instrument::engine::filter; static int g_fail = 0; +static const char* g_ctx = ""; #define CHECK(cond) do { if(!(cond)) { \ - std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + std::printf("FAIL [%s] line %d: %s\n", g_ctx, __LINE__, #cond); ++g_fail; } } while(0) namespace { @@ -45,24 +57,118 @@ InstrumentParams dialed() { p.play.trigAhd.decaySeconds = 0.22; p.play.trigAhd.holdFraction = 0.33; p.play.trigAhd.attackCurve = 1.7; + p.play.trigAhd.decayCurve = 2.2; p.play.pitchEngine = PitchEngine::Varispeed; p.play.pitchEnv.enabled = true; p.play.pitchEnv.peakSemitones = -7.0; p.play.pitchEnv.shape.attackSeconds = 0.05; + p.play.pitchEnv.shape.decaySeconds = 0.15; + p.play.pitchEnv.shape.holdFraction = 0.6; + p.play.pitchEnv.shape.attackCurve = 2.1; + p.play.pitchEnv.shape.decayCurve = 0.4; p.play.pitchVelocityCurve = VelocityCurve::linear(); p.play.playRate = 0.5; p.play.pitchOffsetSemitones = -7.5; p.play.filter.enabled = true; + p.play.filter.settings = filter::FilterSettings{0.3f, 0.8f, 0.1f, 0.7f, + filter::MorphLaw::HighNotchLow}; p.play.filter.modAmount = -0.8; p.play.filter.velAmount = 0.6; p.play.filter.keyTrack = 1.5; p.play.filter.env.attackSeconds = 0.7; + p.play.filter.env.holdSeconds = 0.15; + p.play.filter.env.decaySeconds = 0.35; + p.play.filter.env.sustainLevel = 0.25; + p.play.filter.env.releaseSeconds = 0.55; + p.play.filter.env.attackCurve = 2.3; + p.play.filter.env.decayCurve = 0.5; + p.play.filter.env.releaseCurve = 2.8; + p.play.filter.trigEnv.attackSeconds = 0.12; p.play.filter.trigEnv.decaySeconds = 0.8; + p.play.filter.trigEnv.holdFraction = 0.44; + p.play.filter.trigEnv.attackCurve = 1.6; + p.play.filter.trigEnv.decayCurve = 0.6; p.play.filter.velocityCurve = VelocityCurve::linear(); p.play.ampSpline.mode = EnvMode::Spline; p.play.ampSpline.contour = VelocityCurve::linear(); p.play.pitchSpline.mode = EnvMode::Spline; + p.play.pitchSpline.contour = VelocityCurve::linear(); p.play.filterSpline.mode = EnvMode::Spline; + p.play.filterSpline.contour = VelocityCurve::linear(); + p.bakeHold = note::makeDivision(0, note::DivisionModifier::Dotted); + p.limiterEnabled = true; + return p; +} + +// A SECOND dialed instrument, agreeing with the first on the SURVIVORS and disagreeing on +// every other field — including play mode, which is a chosen neutral rather than a survivor. +// Both run the same neutral sweep: if the reset ever inverted into "copy the dialed set, then +// clear a blacklist", a non-survivor would come through and at most one fixture could still +// land on the defaults. The inherent limit this does NOT cover: nothing forces a newly added +// `InstrumentParams` field to be dialled in either fixture at all, let alone asserted. +InstrumentParams dialedOther() { + InstrumentParams p; + p.rootOverride = 43; // survivor — same as dialed() + p.keyTrack = 0.5; // survivor — same as dialed() + p.loopOverride = SampleLoop{true, 7, 909}; + p.startPoint = 12; + p.loopCrossfadeFrames = 64; + p.velocityCurve = VelocityCurve::rampDown(); + + p.play.playMode = PlayMode::Gate; + p.play.adsr.attackSeconds = 1.4; + p.play.adsr.holdSeconds = 1.3; + p.play.adsr.decaySeconds = 1.2; + p.play.adsr.sustainLevel = 0.7; + p.play.adsr.releaseSeconds = 1.9; + p.play.adsr.attackCurve = 0.3; + p.play.adsr.decayCurve = 3.4; + p.play.adsr.releaseCurve = 0.6; + p.play.trigger.lengthFraction = 0.75; + p.play.trigAhd.attackSeconds = 0.91; + p.play.trigAhd.decaySeconds = 0.82; + p.play.trigAhd.holdFraction = 0.13; + p.play.trigAhd.attackCurve = 0.7; + p.play.trigAhd.decayCurve = 0.9; + p.play.pitchEngine = PitchEngine::Preserve; + p.play.pitchEnv.enabled = true; + p.play.pitchEnv.peakSemitones = 11.0; + p.play.pitchEnv.shape.attackSeconds = 0.25; + p.play.pitchEnv.shape.decaySeconds = 0.35; + p.play.pitchEnv.shape.holdFraction = 0.2; + p.play.pitchEnv.shape.attackCurve = 0.5; + p.play.pitchEnv.shape.decayCurve = 2.4; + p.play.pitchVelocityCurve = VelocityCurve::rampDown(); + p.play.playRate = 1.75; + p.play.pitchOffsetSemitones = 3.25; + p.play.filter.enabled = true; + p.play.filter.settings = filter::FilterSettings{0.9f, 0.2f, 0.4f, 0.05f, + filter::MorphLaw::HighNotchLow}; + p.play.filter.modAmount = 0.45; + p.play.filter.velAmount = -0.35; + p.play.filter.keyTrack = -0.9; + p.play.filter.env.attackSeconds = 1.7; + p.play.filter.env.holdSeconds = 0.95; + p.play.filter.env.decaySeconds = 0.75; + p.play.filter.env.sustainLevel = 0.85; + p.play.filter.env.releaseSeconds = 0.15; + p.play.filter.env.attackCurve = 0.4; + p.play.filter.env.decayCurve = 2.9; + p.play.filter.env.releaseCurve = 0.35; + p.play.filter.trigEnv.attackSeconds = 0.62; + p.play.filter.trigEnv.decaySeconds = 1.8; + p.play.filter.trigEnv.holdFraction = 0.77; + p.play.filter.trigEnv.attackCurve = 0.3; + p.play.filter.trigEnv.decayCurve = 2.5; + p.play.filter.velocityCurve = VelocityCurve::rampDown(); + p.play.ampSpline.mode = EnvMode::Spline; + p.play.ampSpline.contour = VelocityCurve::flat(); + p.play.pitchSpline.mode = EnvMode::Spline; + p.play.pitchSpline.contour = VelocityCurve::flat(); + p.play.filterSpline.mode = EnvMode::Spline; + p.play.filterSpline.contour = VelocityCurve::flat(); + p.bakeHold = note::makeDivision(2, note::DivisionModifier::Triplet); + p.limiterEnabled = true; return p; } @@ -75,22 +181,13 @@ bool sameCurve(const VelocityCurve& a, const VelocityCurve& b) { return true; } -} // namespace - -int main() { - const InstrumentParams before = dialed(); - const BakeReset reset = resetAfterBake(before); +// THE reset neutral, field by field. Run against every fixture, so the two can never be +// asserted against two different notions of neutral. +void checkNeutral(const BakeReset& reset) { const InstrumentParams& after = reset.params; const InstrumentParams fresh; // the defaults every reset control must land on const PlaySeconds freshPlay; - // --- SURVIVE: mapping facts, absent from the printed audio ---------------------- - CHECK(after.rootOverride.has_value()); - CHECK(after.rootOverride == before.rootOverride); - CHECK(after.keyTrack == before.keyTrack); - CHECK(after.keyTrack == 0.5); // and it is the dialed value, not the default 1.0 - CHECK(fresh.keyTrack != before.keyTrack); // the fixture really did move it - // --- RESET: loop points, start point, crossfade --------------------------------- CHECK(!after.loopOverride.has_value()); CHECK(!after.startPoint.has_value()); @@ -98,26 +195,17 @@ int main() { // --- RESET: the velocity transfer curves ---------------------------------------- CHECK(sameCurve(after.velocityCurve, VelocityCurve::flat())); - CHECK(!sameCurve(after.velocityCurve, before.velocityCurve)); CHECK(sameCurve(after.play.pitchVelocityCurve, VelocityCurve::zero())); CHECK(sameCurve(after.play.filter.velocityCurve, VelocityCurve::zero())); // --- RESET: play mode, to TRIGGER rather than to the struct's Gate default ------- - // The bake's product is a finished one-shot; Trigger plays it back verbatim, Gate would - // re-gate its printed release tail and each iteration would truncate the last one's. + // bake_reset.cpp carries the argument at the assignment. CHECK(after.play.playMode == PlayMode::Trigger); CHECK(freshPlay.playMode == PlayMode::Gate); // and that really is NOT the default // The Trigger face it lands on plays the whole file flat: full span, unity throughout. CHECK(after.play.trigger.lengthFraction == 1.0); CHECK(after.play.trigAhd.attackSeconds == 0.0); CHECK(after.play.trigAhd.decaySeconds == 0.0); - { - // …and a GATE-dialed instrument lands there too: this is a reset to a chosen - // neutral, not the dialed value surviving. - InstrumentParams gated = dialed(); - gated.play.playMode = PlayMode::Gate; - CHECK(resetAfterBake(gated).params.play.playMode == PlayMode::Trigger); - } // --- RESET: the amp envelope, staged, every stage and every curve exponent ------ CHECK(after.play.adsr.attackSeconds == freshPlay.adsr.attackSeconds); @@ -133,6 +221,7 @@ int main() { CHECK(after.play.trigAhd.decaySeconds == freshPlay.trigAhd.decaySeconds); CHECK(after.play.trigAhd.holdFraction == freshPlay.trigAhd.holdFraction); CHECK(after.play.trigAhd.attackCurve == freshPlay.trigAhd.attackCurve); + CHECK(after.play.trigAhd.decayCurve == freshPlay.trigAhd.decayCurve); // --- RESET: pitch engine + pitch envelope --------------------------------------- CHECK(after.play.pitchEngine == freshPlay.pitchEngine); @@ -140,6 +229,10 @@ int main() { CHECK(!after.play.pitchEnv.enabled); CHECK(after.play.pitchEnv.peakSemitones == 0.0); CHECK(after.play.pitchEnv.shape.attackSeconds == freshPlay.pitchEnv.shape.attackSeconds); + CHECK(after.play.pitchEnv.shape.decaySeconds == freshPlay.pitchEnv.shape.decaySeconds); + CHECK(after.play.pitchEnv.shape.holdFraction == freshPlay.pitchEnv.shape.holdFraction); + CHECK(after.play.pitchEnv.shape.attackCurve == freshPlay.pitchEnv.shape.attackCurve); + CHECK(after.play.pitchEnv.shape.decayCurve == freshPlay.pitchEnv.shape.decayCurve); // --- RESET: Rate and the baseline Pitch offset ----------------------------------- // Both are processing the bake already printed, so the whitelist leaves them at their @@ -150,13 +243,29 @@ int main() { CHECK(after.play.playRate == freshPlay.playRate); CHECK(after.play.pitchOffsetSemitones == freshPlay.pitchOffsetSemitones); - // --- RESET: the filter, including its velocity/key-tracking mod ----------------- + // --- RESET: the filter, its control positions, and its velocity/key-tracking mod -- CHECK(!after.play.filter.enabled); CHECK(after.play.filter.modAmount == 0.0); CHECK(after.play.filter.velAmount == 0.0); CHECK(after.play.filter.keyTrack == 0.0); + CHECK(after.play.filter.settings.cutoffNorm == freshPlay.filter.settings.cutoffNorm); + CHECK(after.play.filter.settings.resonanceNorm == freshPlay.filter.settings.resonanceNorm); + CHECK(after.play.filter.settings.morphNorm == freshPlay.filter.settings.morphNorm); + CHECK(after.play.filter.settings.driveNorm == freshPlay.filter.settings.driveNorm); + CHECK(after.play.filter.settings.morphLaw == freshPlay.filter.settings.morphLaw); CHECK(after.play.filter.env.attackSeconds == freshPlay.filter.env.attackSeconds); + CHECK(after.play.filter.env.holdSeconds == freshPlay.filter.env.holdSeconds); + CHECK(after.play.filter.env.decaySeconds == freshPlay.filter.env.decaySeconds); + CHECK(after.play.filter.env.sustainLevel == freshPlay.filter.env.sustainLevel); + CHECK(after.play.filter.env.releaseSeconds == freshPlay.filter.env.releaseSeconds); + CHECK(after.play.filter.env.attackCurve == freshPlay.filter.env.attackCurve); + CHECK(after.play.filter.env.decayCurve == freshPlay.filter.env.decayCurve); + CHECK(after.play.filter.env.releaseCurve == freshPlay.filter.env.releaseCurve); + CHECK(after.play.filter.trigEnv.attackSeconds == freshPlay.filter.trigEnv.attackSeconds); CHECK(after.play.filter.trigEnv.decaySeconds == freshPlay.filter.trigEnv.decaySeconds); + CHECK(after.play.filter.trigEnv.holdFraction == freshPlay.filter.trigEnv.holdFraction); + CHECK(after.play.filter.trigEnv.attackCurve == freshPlay.filter.trigEnv.attackCurve); + CHECK(after.play.filter.trigEnv.decayCurve == freshPlay.filter.trigEnv.decayCurve); // --- RESET: the three spline contours AND their mode flags ---------------------- // The flag selects which shape ran, so the shape it selected is in the audio; with @@ -165,10 +274,97 @@ int main() { CHECK(after.play.pitchSpline.mode == EnvMode::Staged); CHECK(after.play.filterSpline.mode == EnvMode::Staged); CHECK(sameCurve(after.play.ampSpline.contour, VelocityCurve::rampDown())); - CHECK(!sameCurve(after.play.ampSpline.contour, before.play.ampSpline.contour)); + CHECK(sameCurve(after.play.pitchSpline.contour, VelocityCurve::rampDown())); + CHECK(sameCurve(after.play.filterSpline.contour, VelocityCurve::rampDown())); + + // --- RESET: the bake's own Hold division ----------------------------------------- + // It sizes the render window for the one case that cannot derive one, so the length it + // chose is in the printed file, and the next bake re-derives from that file. + CHECK(after.bakeHold == fresh.bakeHold); + + // --- RESET: the master-bus limiter enable ---------------------------------------- + // Read the asymmetry recorded at BakeReset before reasoning about this one: it is not + // the master stage resetting as a unit. + CHECK(!after.limiterEnabled); + CHECK(!fresh.limiterEnabled); // --- RESET: master gain ---------------------------------------------------------- CHECK(reset.masterGainLinear == 1.0); +} + +} // namespace + +int main() { + const InstrumentParams before = dialed(); + const BakeReset reset = resetAfterBake(before); + const InstrumentParams& after = reset.params; + const InstrumentParams fresh; + + // A sample of the fields checkNeutral asserts, confirming the fixtures actually moved them + // off default — not the whole sweep, but enough spot checks that a fixture regressing to + // the defaults (making the sweep vacuous) would show here first. + CHECK(fresh.keyTrack != before.keyTrack); + CHECK(before.limiterEnabled != fresh.limiterEnabled); + CHECK(before.bakeHold != fresh.bakeHold); + CHECK(dialedOther().bakeHold != fresh.bakeHold); + CHECK(dialedOther().bakeHold != before.bakeHold); + CHECK(!sameCurve(before.velocityCurve, VelocityCurve::flat())); + CHECK(!sameCurve(before.play.ampSpline.contour, VelocityCurve::rampDown())); + CHECK(!sameCurve(dialedOther().play.ampSpline.contour, VelocityCurve::rampDown())); + + // --- SURVIVE: mapping facts, absent from the printed audio ---------------------- + CHECK(after.rootOverride.has_value()); + CHECK(after.rootOverride == before.rootOverride); + CHECK(after.keyTrack == before.keyTrack); + CHECK(after.keyTrack == 0.5); // and it is the dialed value, not the default 1.0 + + // --- The neutral, over both fixtures -------------------------------------------- + g_ctx = "dialed"; + checkNeutral(reset); + g_ctx = "dialedOther"; + { + const BakeReset other = resetAfterBake(dialedOther()); + checkNeutral(other); + // The survivors travel from the OTHER fixture too, so the sweep above is agreeing + // with a reset that read its input rather than one that ignores it wholesale. + CHECK(other.params.rootOverride == before.rootOverride); + CHECK(other.params.keyTrack == before.keyTrack); + } + g_ctx = ""; + + // --- A GATE-dialed instrument lands on Trigger too ------------------------------- + // This is a reset to a chosen neutral, not the dialed value surviving. + { + InstrumentParams gated = dialed(); + gated.play.playMode = PlayMode::Gate; + CHECK(resetAfterBake(gated).params.play.playMode == PlayMode::Trigger); + } + + // --- The loop enable and its points come back together --------------------------- + // What the band shows after a bake is the reset override read against the newly banked + // entry's loop intrinsic — assumed std::nullopt below, which is what the shell lands + // today; if it ever populated one, the enable would have something to fall back to and + // this assumption, not just this test, would need revisiting. + { + constexpr std::int64_t kFrames = 1000; + const LoopMarks dialedMarks = + resolveLoopMarks(StoredLoop{before.loopOverride, std::nullopt, + before.loopCrossfadeFrames, before.startPoint}, + kFrames); + CHECK(dialedMarks.hasLoop); // the fixture really did dial a loop on… + CHECK(!dialedMarks.parked); // …at its own positions + CHECK(dialedMarks.crossfade == 512); + + const LoopMarks neutral = + resolveLoopMarks(StoredLoop{after.loopOverride, std::nullopt, + after.loopCrossfadeFrames, after.startPoint}, + kFrames); + CHECK(!neutral.hasLoop); + CHECK(neutral.parked); // re-offered on the defaults, not left coincident + CHECK(neutral.loopStart < neutral.loopEnd); + CHECK(neutral.crossfade == 0); + CHECK(neutral.start == 0); + } // --- An absent root override stays absent (nothing is invented) ------------------ {