Files
reasampler/tests/test_spline_egs.cpp
T
daniel 757e1585d6 fix: close round-3 review findings — smallest-target-first, residue test fix, extraction
Waveform overlay now resolves node/tab/marker click collisions by target area instead of check order; residue test now uses a distinguishing fixture; Gate-unavailable-while-drawn logic extracted to one pure helper shared by resolvePlay and applyControl.
2026-07-31 23:44:05 -04:00

543 lines
25 KiB
C++

// 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 <cmath>
#include <cstdio>
#include <vector>
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<VelocityPoint>& 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<double>(i);
CHECK(c.addPoint(x, 0.5) >= 0);
}
CHECK(c.size() == kMaxCurvePoints);
const std::vector<VelocityPoint> before = c.points();
CHECK(c.addPoint(63.5, 0.25) == -1);
const std::vector<VelocityPoint>& 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<double> 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<std::size_t>(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<std::size_t>(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<std::uint8_t> asV12Payload(std::vector<std::uint8_t> bytes) {
int patched = 0;
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
const std::uint32_t marker = static_cast<std::uint32_t>(bytes[i]) |
(static_cast<std::uint32_t>(bytes[i + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[i + 3]) << 24);
const std::uint32_t ver = static_cast<std::uint32_t>(bytes[i + 4]) |
(static_cast<std::uint32_t>(bytes[i + 5]) << 8) |
(static_cast<std::uint32_t>(bytes[i + 6]) << 16) |
(static_cast<std::uint32_t>(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<double> renderVoice(const SampleData& s, std::size_t frames) {
Voice v;
v.start(60, 100, s);
std::vector<double> 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<double> a = renderVoice(s1, shortLen);
const std::vector<double> 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<double>(i) / static_cast<double>(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
}
// --- 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<double>(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();
testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
testEnforceGateUnavailableWhileDrawnForcesTriggerOnBothRepresentations();
testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();
testSplineCursorBinarySearchAgreesWithTheColdReaderOnAJumpingRead();
if (g_fail == 0) std::printf("spline_egs: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}