// Standalone tests for the SPLINE EG system — no VST3, no REAPER, no framework. One file for // the whole feature because its seams span three modules that only mean something together: the // shared spline (engine), the dual Staged/Spline state and its wire format (map), and the // point-editing grammar (ui). // // The eleven cases below are the spec's own test list, in its order. Each is named for the rule // it pins, so a failure names the behaviour rather than the module. #include "../src/core/instrument/engine/voice_engine.h" #include "../src/core/instrument/map/component_state_io.h" #include "../src/core/instrument/ui/deck_groups.h" #include "../src/core/instrument/ui/spline_edit.h" #include #include #include using namespace reasampler; using namespace reasampler::instrument::engine; using reasampler::instrument::map::ComponentState; using reasampler::instrument::map::InstrumentParams; using reasampler::instrument::map::PlaySeconds; using reasampler::instrument::map::deserializeComponentState; using reasampler::instrument::map::kParamsFormatMarker; using reasampler::instrument::map::kParamsPayloadVersion; using reasampler::instrument::map::resolvePlay; using reasampler::instrument::map::serializeComponentState; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; } // x runs over the curve's canonical span; a spline EG's phase maps onto it linearly. static double xAt(double phase) { return kCurveXMin + phase * (kCurveXMax - kCurveXMin); } // A contour that rises then falls, with a peak at x=64 the caller can make hard or smooth. static VelocityCurve peakContour(bool hardPeak) { VelocityCurve c = VelocityCurve::fromPoints( {{0.0, 0.0}, {32.0, 0.2}, {64.0, 1.0}, {96.0, 0.3}, {127.0, 0.0}}, CurveDomain::Unipolar); if (hardPeak) c.setHard(2, true); return c; } // --- 1. A hard point is a genuine slope discontinuity ------------------------- // The whole hard-point enhancement in one assertion: at a hard knot each side's one-sided slope // is that SEGMENT'S OWN secant — no smoothing was applied on either side — so the two disagree // and the contour has a real corner. The same knot left smooth pins to a shared tangent. static void testHardPointGivesDifferingOneSidedSlopes() { const VelocityCurve hard = peakContour(/*hardPeak=*/true); const double h = 1e-4; const double left = (hard.eval(64.0) - hard.eval(64.0 - h)) / h; const double right = (hard.eval(64.0 + h) - hard.eval(64.0)) / h; // The two adjacent secants: (1.0-0.2)/32 rising, (0.3-1.0)/32 falling. const double secantIn = (1.0 - 0.2) / 32.0; const double secantOut = (0.3 - 1.0) / 32.0; CHECK(near(left, secantIn, 1e-4)); CHECK(near(right, secantOut, 1e-4)); CHECK(left > 0.0 && right < 0.0); // a genuine corner, not a slope that merely changes rate // Neither adjacent segment is straightened by the hard knot — each is still a curve, which // is what "one or more monotone splines joined at their angles" means. A straight segment // would put its midpoint exactly on the chord. CHECK(!near(hard.eval(48.0), (0.2 + 1.0) / 2.0, 1e-6)); CHECK(!near(hard.eval(80.0), (1.0 + 0.3) / 2.0, 1e-6)); // Left smooth, the same knot is a local extremum: Fritsch-Carlson pins the tangent to 0 on // BOTH sides, so the slope is continuous there. const VelocityCurve smooth = peakContour(/*hardPeak=*/false); const double sLeft = (smooth.eval(64.0) - smooth.eval(64.0 - h)) / h; const double sRight = (smooth.eval(64.0 + h) - smooth.eval(64.0)) / h; CHECK(near(sLeft, 0.0, 1e-4)); CHECK(near(sRight, 0.0, 1e-4)); } // --- 2. Per-segment monotonicity, on a contour that is not globally monotone --- static void testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour() { VelocityCurve c = VelocityCurve::fromPoints( {{0.0, 0.2}, {20.0, 0.9}, {50.0, 0.1}, {90.0, 0.85}, {110.0, 0.15}, {127.0, 0.6}}, CurveDomain::Unipolar); c.setHard(2, true); // one hard knot, so the guarantee is asserted across a joint too const std::vector& pts = c.points(); for (std::size_t i = 0; i + 1 < pts.size(); ++i) { const double lo = (std::min)(pts[i].value, pts[i + 1].value); const double hi = (std::max)(pts[i].value, pts[i + 1].value); for (int s = 0; s <= 200; ++s) { const double x = pts[i].velocity + (pts[i + 1].velocity - pts[i].velocity) * (s / 200.0); const double y = c.eval(x); CHECK(y >= lo - 1e-12); CHECK(y <= hi + 1e-12); } } // ...and it genuinely rises AND falls, so the assertion above is not vacuously about a // monotone curve. CHECK(c.eval(20.0) > c.eval(0.0)); CHECK(c.eval(50.0) < c.eval(20.0)); } // --- 3. The 128-point ceiling refuses without disturbing the contour ---------- static void testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical() { VelocityCurve c = VelocityCurve::rampDown(); for (std::size_t i = 0; c.size() < kMaxCurvePoints; ++i) { const double x = 1.0 + static_cast(i); CHECK(c.addPoint(x, 0.5) >= 0); } CHECK(c.size() == kMaxCurvePoints); const std::vector before = c.points(); CHECK(c.addPoint(63.5, 0.25) == -1); const std::vector& after = c.points(); CHECK(after.size() == before.size()); for (std::size_t i = 0; i < before.size(); ++i) { // Bit-identical, not merely close: a refused add must not perturb a drawn shape at all. CHECK(after[i].velocity == before[i].velocity); CHECK(after[i].value == before[i].value); CHECK(after[i].hard == before[i].hard); } } // --- 4. The two full-length endpoints always survive -------------------------- static void testEndpointDeletionIsRefused() { VelocityCurve c = peakContour(false); const std::size_t n = c.size(); CHECK(!c.deletePoint(0)); CHECK(!c.deletePoint(n - 1)); CHECK(c.size() == n); CHECK(c.points().front().velocity == kCurveXMin); CHECK(c.points().back().velocity == kCurveXMax); // An interior point still deletes, so the refusal is about the endpoints and not about // deletion being broken. CHECK(c.deletePoint(2)); CHECK(c.size() == n - 1); } // --- 5. The hard/smooth toggle round-trips ------------------------------------ static void testTogglingHardThenSmoothRestoresTheEvaluatedContour() { VelocityCurve c = peakContour(false); std::vector baseline; for (int i = 0; i <= 127; ++i) baseline.push_back(c.eval(i)); CHECK(c.toggleHard(2)); bool changedSomewhere = false; for (int i = 0; i <= 127; ++i) { if (!near(c.eval(i), baseline[static_cast(i)], 1e-12)) changedSomewhere = true; } CHECK(changedSomewhere); // the toggle must actually do something, or the round trip is empty CHECK(c.points()[2].hard); CHECK(c.toggleHard(2)); CHECK(!c.points()[2].hard); for (int i = 0; i <= 127; ++i) { CHECK(c.eval(i) == baseline[static_cast(i)]); // exact, not approximate } } // --- 6. Both states survive a mode flip, in memory and across save/reload ----- // Distinctive staged values on all three envelopes, so "exactly as left" is checkable rather // than accidentally equal to a default. static InstrumentParams paramsWithBothStates() { InstrumentParams p; p.play.playMode = PlayMode::Gate; p.play.adsr.attackSeconds = 0.37; p.play.adsr.decaySeconds = 0.21; p.play.adsr.sustainLevel = 0.42; p.play.adsr.releaseSeconds = 0.66; p.play.pitchEnv.enabled = true; p.play.pitchEnv.peakSemitones = -7.5; p.play.pitchEnv.shape.attackSeconds = 0.11; p.play.filter.enabled = true; p.play.filter.env.decaySeconds = 0.29; p.play.filter.env.sustainLevel = 0.33; VelocityCurve drawn = peakContour(/*hardPeak=*/true); p.play.ampSpline.mode = EnvMode::Spline; p.play.ampSpline.contour = drawn; p.play.pitchSpline.contour = drawn; // stored, but left Staged: the inactive half p.play.filterSpline.contour = drawn; return p; } static void testStagedAndSplineStatesBothSurviveAFlipAndASaveReload() { InstrumentParams p = paramsWithBothStates(); const VelocityCurve drawn = p.play.ampSpline.contour; // In memory: flipping the amp back to Staged keeps the contour, and forward again keeps the // staged values. Neither converts into the other. p.play.ampSpline.mode = EnvMode::Staged; CHECK(p.play.ampSpline.contour.equals(drawn)); CHECK(p.play.adsr.attackSeconds == 0.37); p.play.ampSpline.mode = EnvMode::Spline; CHECK(p.play.adsr.sustainLevel == 0.42); ComponentState st; st.selectionId = "cap-1"; st.params = p; const ComponentState back = deserializeComponentState(serializeComponentState(st), 48000.0); const PlaySeconds& r = back.params.play; CHECK(r.adsr.attackSeconds == 0.37); CHECK(r.adsr.decaySeconds == 0.21); CHECK(r.adsr.sustainLevel == 0.42); CHECK(r.adsr.releaseSeconds == 0.66); CHECK(r.pitchEnv.peakSemitones == -7.5); CHECK(r.pitchEnv.shape.attackSeconds == 0.11); CHECK(r.filter.env.decaySeconds == 0.29); CHECK(r.filter.env.sustainLevel == 0.33); CHECK(r.ampSpline.mode == EnvMode::Spline); CHECK(r.pitchSpline.mode == EnvMode::Staged); CHECK(r.filterSpline.mode == EnvMode::Staged); // The contour itself, hard flags included, on all three — the inactive ones too. CHECK(r.ampSpline.contour.equals(drawn)); CHECK(r.pitchSpline.contour.equals(drawn)); CHECK(r.filterSpline.contour.equals(drawn)); CHECK(r.ampSpline.contour.points()[2].hard); } // --- 7. A v12 payload still loads --------------------------------------------- // Rewrites the params-payload version field to 12, leaving the v12 prefix byte-identical (v13 // is a strict suffix, so the prefix IS what a v12 writer emitted). The reader is positional and // bounded, so it stops before the appended tail and never sees it. static std::vector asV12Payload(std::vector bytes) { int patched = 0; for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) { const std::uint32_t marker = static_cast(bytes[i]) | (static_cast(bytes[i + 1]) << 8) | (static_cast(bytes[i + 2]) << 16) | (static_cast(bytes[i + 3]) << 24); const std::uint32_t ver = static_cast(bytes[i + 4]) | (static_cast(bytes[i + 5]) << 8) | (static_cast(bytes[i + 6]) << 16) | (static_cast(bytes[i + 7]) << 24); if (marker != kParamsFormatMarker || ver != kParamsPayloadVersion) continue; bytes[i + 4] = 12; ++patched; } CHECK(patched == 1); // exactly one payload header, or the rewrite is meaningless return bytes; } static void testAV12PayloadLoadsWithoutLoss() { InstrumentParams p = paramsWithBothStates(); p.play.playMode = PlayMode::Trigger; // a v12 project can hold any mode p.velocityCurve.addPoint(70.0, 0.4); p.velocityCurve.setHard(1, true); ComponentState st; st.selectionId = "cap-legacy"; st.params = p; const ComponentState back = deserializeComponentState(asV12Payload(serializeComponentState(st)), 48000.0); const PlaySeconds& r = back.params.play; // Everything v12 carried comes through untouched. CHECK(back.selectionId == "cap-legacy"); CHECK(r.playMode == PlayMode::Trigger); CHECK(r.adsr.attackSeconds == 0.37); CHECK(r.adsr.sustainLevel == 0.42); CHECK(r.pitchEnv.peakSemitones == -7.5); CHECK(r.filter.env.decaySeconds == 0.29); CHECK(back.params.velocityCurve.size() == 3); CHECK(near(back.params.velocityCurve.eval(70.0), 0.4)); // Everything v13 added lifts to its default: Staged on all three, the y = 1 - x contour, // and no hard flag anywhere (v12 had nowhere to store one). CHECK(r.ampSpline.mode == EnvMode::Staged); CHECK(r.pitchSpline.mode == EnvMode::Staged); CHECK(r.filterSpline.mode == EnvMode::Staged); CHECK(r.ampSpline.contour.equals(VelocityCurve::rampDown())); CHECK(!back.params.velocityCurve.points()[1].hard); } // --- 8. A stored contour rescales to a different-length sample --------------- static SampleData splineAmpSample(std::size_t frames, const VelocityCurve& contour) { SampleData s; s.frames.assign(frames, 1.0f); // DC: the rendered value IS the envelope s.rootNote = 60; s.sampleRate = 48000; s.play.playMode = PlayMode::Trigger; s.play.ampSpline.mode = EnvMode::Spline; s.play.ampSpline.contour = contour; return s; } static std::vector renderVoice(const SampleData& s, std::size_t frames) { Voice v; v.start(60, 100, s); std::vector out; out.reserve(frames); for (std::size_t i = 0; i < frames; ++i) out.push_back(v.renderFrame()); return out; } static void testAContourReplaysProportionallyOnADifferentLengthSample() { const VelocityCurve contour = peakContour(/*hardPeak=*/true); const std::size_t shortLen = 1000; const std::size_t longLen = 3000; const SampleData s1 = splineAmpSample(shortLen, contour); const SampleData s2 = splineAmpSample(longLen, contour); const std::vector a = renderVoice(s1, shortLen); const std::vector b = renderVoice(s2, longLen); // The rendered value at frame i of the short sample is the contour at phase i/shortLen; the // long sample reaches the SAME phase at frame 3i. Shape preserved, proportionally. for (std::size_t i = 1; i + 1 < shortLen; ++i) { const double phase = static_cast(i) / static_cast(shortLen); CHECK(near(a[i], contour.eval(xAt(phase)), 1e-6)); CHECK(near(b[i * 3], a[i], 1e-6)); } // Not a flat contour, so the agreement above is a real shape match. CHECK(a[shortLen / 2] > a[10] + 0.2); } // --- Regression: a 2-point contour starting at 0 is not a terminus at frame 0 ----------- // // onFinalSegment() (seg_+2==n_) is trivially true for a 2-point contour. Gating the amp // spline's early-free on that alone reads a contour's OWN opening value as the note's end, // so the simplest fade-in (left knot dragged to the box floor) went silent at frame 0. The // fix requires the TERMINAL value (the segment's right endpoint) to be 0, not just the // segment index. static void testTwoPointContourRisingFromZeroSoundsForItsFullSpan() { const VelocityCurve contour = VelocityCurve::fromPoints({{kVelMin, 0.0}, {kVelMax, 1.0}}, CurveDomain::Unipolar); const std::size_t frames = 1000; const SampleData s = splineAmpSample(frames, contour); Voice v; v.start(60, 100, s); CHECK(v.soundingNote()); // fresh note: sounding before anything is rendered const double y0 = v.renderFrame(); CHECK(near(y0, 0.0, 1e-9)); // the contour's own value at phase 0 IS 0 ... CHECK(v.soundingNote()); // ...but the note itself must not be over yet for (std::size_t i = 1; i < frames / 2; ++i) v.renderFrame(); CHECK(v.soundingNote()); // still sounding at the midpoint, rising toward 1 } // The positive direction of the fix above: a contour whose final segment is flat at 0 (here, // the whole two-point span) DOES free the voice early, on its very first tick. Nothing exercises // this without it — a future tightening of the gate (e.g. requiring more than onFinalSegment() + // segmentEndValue()) could silently turn the early-free off, which is a performance regression // (a ringing but silent voice) rather than an audible one, so nothing else would catch it. static void testFlatZeroFinalSegmentStillFreesTheVoiceEarly() { const VelocityCurve contour = VelocityCurve::fromPoints({{kVelMin, 0.0}, {kVelMax, 0.0}}, CurveDomain::Unipolar); const std::size_t frames = 1000; const SampleData s = splineAmpSample(frames, contour); Voice v; v.start(60, 100, s); CHECK(v.soundingNote()); // fresh note: sounding before anything is rendered const double y0 = v.renderFrame(); CHECK(near(y0, 0.0, 1e-9)); CHECK(!v.soundingNote()); // a genuine permanent terminus, not a mid-contour dip } // The timing, not just the fact: the case above is trivially "early" (a wholly-flat contour // frees on frame 0), which can't distinguish "frees early" from "frees at the right frame." This // fixture's final segment starts MID-sample, so the free must land there, not at frame 0 and not // at the sample's natural end. The breakpoint (phase 0.5495) is deliberately off every sampled // frame's exact phase (k/1000), so no sampled frame lands on the segment boundary itself and // which segment "owns" that frame is never ambiguous. static void testFlatZeroFinalSegmentFreesTheVoiceWhereItBeginsNotAtFrameZero() { const double breakpointPhase = 0.5495; const VelocityCurve contour = VelocityCurve::fromPoints( {{xAt(0.0), 1.0}, {xAt(breakpointPhase), 0.0}, {xAt(1.0), 0.0}}, CurveDomain::Unipolar); const std::size_t frames = 1000; const SampleData s = splineAmpSample(frames, contour); Voice v; v.start(60, 100, s); // Frames 0..549 (phase < breakpoint) sit on the declining first segment: still sounding. for (std::size_t i = 0; i < 550; ++i) { v.renderFrame(); CHECK(v.soundingNote()); } // Frame 550 (phase 0.55) is the first sampled frame past the breakpoint, on the flat-zero // final segment — this is where the early-free fires. const double y550 = v.renderFrame(); CHECK(near(y550, 0.0, 1e-9)); CHECK(!v.soundingNote()); } // --- 9. A fresh spline EG opens on the smooth y = 1 - x ---------------------- static void testAFreshSplineEgDefaultsToTheSmoothDownwardSlope() { const SplineEnv fresh; CHECK(fresh.mode == EnvMode::Staged); // drawn is opt-in; the CONTOUR is what defaults here const VelocityCurve& c = fresh.contour; CHECK(c.size() == 2); CHECK(!c.points()[0].hard); CHECK(!c.points()[1].hard); // Two collinear knots reduce the Hermite tangents to the shared secant, so it is an exact // straight line — and a straight line is smooth. for (int i = 0; i <= 127; ++i) { CHECK(near(c.eval(i), 1.0 - static_cast(i) / 127.0, 1e-12)); } } // --- 10. Gate is unavailable while a spline EG is active --------------------- // The rule has one home (splineActive) and one enforcement point on the way to the engine // (resolvePlay). The editor's Gate segment refuses and paints Disabled off the same predicate. // // Pitch and filter additionally gate on their own `enabled` flag, matching Voice::start's // binder (voice.cpp only binds pitchSplineCur_/filterSplineCur_ under that same condition): a // Spline mode flip on a still-disabled envelope produces no modulation, so it must not cost // Gate either — the predicate and the binder must agree on one enable rule. Amp has no such // flag and counts on its mode alone. static void testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards() { PlaySeconds stored; stored.playMode = PlayMode::Gate; CHECK(!splineActive(stored)); CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate); stored.ampSpline.mode = EnvMode::Spline; CHECK(splineActive(stored)); CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger); stored.ampSpline.mode = EnvMode::Staged; CHECK(!splineActive(stored)); CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate); stored.pitchSpline.mode = EnvMode::Spline; CHECK(!splineActive(stored)); // pitchEnv.enabled is still false: no modulation, no cost CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate); stored.pitchEnv.enabled = true; CHECK(splineActive(stored)); CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger); stored.pitchSpline.mode = EnvMode::Staged; stored.pitchEnv.enabled = false; CHECK(!splineActive(stored)); CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate); stored.filterSpline.mode = EnvMode::Spline; CHECK(!splineActive(stored)); // filter.enabled is still false: the filter is fully off CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate); stored.filter.enabled = true; CHECK(splineActive(stored)); CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger); stored.filterSpline.mode = EnvMode::Staged; stored.filter.enabled = false; CHECK(!splineActive(stored)); CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate); // And the staged knobs of a drawn envelope go inert — drawn-but-dead, not removed — while // its depth knob, which scales either shape, stays live. using namespace reasampler::instrument::ui; DeckEnableState gates; gates.pitchEnvEnabled = true; gates.filterEnabled = true; CHECK(!deckKnobInert(DeckParam::kAttack, gates)); gates.ampSpline = true; CHECK(deckKnobInert(DeckParam::kAttack, gates)); CHECK(deckKnobInert(DeckParam::kSustain, gates)); CHECK(deckKnobInert(DeckParam::kTrigDecay, gates)); gates.pitchSpline = true; CHECK(deckKnobInert(DeckParam::kPitchEnvAttack, gates)); CHECK(!deckKnobInert(DeckParam::kPitchEnvDepth, gates)); gates.filterSpline = true; CHECK(deckKnobInert(DeckParam::kFilterEnvRelease, gates)); CHECK(!deckKnobInert(DeckParam::kFilterModAmt, gates)); } // enforceGateUnavailableWhileDrawn (play_params.h) is the ONE enforcement resolvePlay and the // editor's applyControl both call — resolvePlay's own coverage above only exercises it through // the frames mirror; pin it directly over BOTH representations it is shared between, closing the // coverage gap the extraction was for (applyControl has no shell test target of its own). static void testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations() { PlaySeconds seconds; seconds.playMode = PlayMode::Gate; enforceGateUnavailableWhileDrawn(seconds); CHECK(seconds.playMode == PlayMode::Gate); // not splineActive -> untouched seconds.ampSpline.mode = EnvMode::Spline; enforceGateUnavailableWhileDrawn(seconds); CHECK(seconds.playMode == PlayMode::Trigger); PlayParams frames; frames.playMode = PlayMode::Gate; frames.filter.enabled = true; frames.filterSpline.mode = EnvMode::Spline; enforceGateUnavailableWhileDrawn(frames); CHECK(frames.playMode == PlayMode::Trigger); } // --- 11. The velocity->amp curve is the same grammar ------------------------- static void testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete() { using namespace reasampler::instrument::ui; const VelocityCurve::Box box{100, 50, 127, 101}; VelocityCurve amp = VelocityCurve::flat(); CHECK(amp.addPoint(64.0, 0.25) == 1); const auto px = amp.pixelFromPoint(box, amp.points()[1]); // Control-click resolves to the toggle — the identical resolution the spline EG overlay // gets, because it is the identical function. const SplineEdit toggle = resolveSplineEdit(amp, box, SplineGesture::kControlLeft, px.x, px.y); CHECK(toggle.kind == SplineEditKind::kToggleHard); CHECK(toggle.index == 1); const double smoothMid = amp.eval(48.0); CHECK(amp.toggleHard(1)); CHECK(amp.points()[1].hard); CHECK(!near(amp.eval(48.0), smoothMid, 1e-9)); // the hard flag reaches the amp response // Right-click delete is unchanged: it resolves on an interior node and the endpoint guard // still refuses the two ends. const SplineEdit del = resolveSplineEdit(amp, box, SplineGesture::kRight, px.x, px.y); CHECK(del.kind == SplineEditKind::kDelete); CHECK(del.index == 1); CHECK(amp.deletePoint(1)); CHECK(amp.size() == 2); CHECK(!amp.deletePoint(0)); CHECK(!amp.deletePoint(1)); } // --- 12. SplineCursor's binary-search branch agrees with the cold reader ------ // Test 8 only walks a monotone forward read, which never leaves SplineCursor::locate's // select(seg_+1) fast path. A backwards/jumping read forces the actual binary search — and at // a duplicate-X knot (a drawn step) the RT cursor must resolve to the SAME point the cold // VelocityCurve::eval() would, or a backwards read audibly steps to the wrong side of the step. static void testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead() { // A step at x=64: two knots sharing an X but different Y. VelocityCurve c = VelocityCurve::fromPoints( {{0.0, 0.1}, {32.0, 0.3}, {64.0, 0.9}, {64.0, 0.2}, {96.0, 0.6}, {127.0, 0.4}}, CurveDomain::Unipolar); SplineCursor cur; cur.bind(c); // Deliberately out of order, so every eval but the first forces locate()'s binary search // rather than the forward-walk fast path. const double xs[] = {100.0, 10.0, 64.0, 40.0, 64.0, 5.0, 127.0, 20.0, 0.0, 90.0}; for (double x : xs) { const double phase = x / kCurveXMax; CHECK(near(cur.eval(phase), c.eval(x), 1e-6)); } // The duplicate knot itself: both readers resolve to the SAME one (the first, per // VelocityCurve::eval's "first containing segment" rule). CHECK(near(cur.eval(64.0 / kCurveXMax), 0.9, 1e-6)); } int main() { testHardPointGivesDifferingOneSidedSlopes(); testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour(); testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical(); testEndpointDeletionIsRefused(); testTogglingHardThenSmoothRestoresTheEvaluatedContour(); testStagedAndSplineStatesBothSurviveAFlipAndASaveReload(); testAV12PayloadLoadsWithoutLoss(); testAContourReplaysProportionallyOnADifferentLengthSample(); testTwoPointContourRisingFromZeroSoundsForItsFullSpan(); testFlatZeroFinalSegmentStillFreesTheVoiceEarly(); testFlatZeroFinalSegmentFreesTheVoiceWhereItBeginsNotAtFrameZero(); testAFreshSplineEgDefaultsToTheSmoothDownwardSlope(); testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards(); testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations(); testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete(); testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead(); if (g_fail == 0) std::printf("spline_egs: all tests passed\n"); return g_fail == 0 ? 0 : 1; }