instrument: spline EGs — hard points on the one shared spline, a drawn contour per envelope beside its staged state, payload v13
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
static void testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards() {
|
||||
PlaySeconds stored;
|
||||
stored.playMode = PlayMode::Gate;
|
||||
CHECK(!splineActive(stored));
|
||||
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Gate);
|
||||
|
||||
for (EnvMode* slot : {&stored.ampSpline.mode, &stored.pitchSpline.mode,
|
||||
&stored.filterSpline.mode}) {
|
||||
*slot = EnvMode::Spline;
|
||||
CHECK(splineActive(stored));
|
||||
CHECK(resolvePlay(stored, 48000).playMode == PlayMode::Trigger);
|
||||
*slot = EnvMode::Staged;
|
||||
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));
|
||||
}
|
||||
|
||||
// --- 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));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testHardPointGivesDifferingOneSidedSlopes();
|
||||
testNoOvershootBetweenAnyAdjacentPairOnARiseAndFallContour();
|
||||
testAddingAtTheCeilingIsRefusedAndLeavesTheContourBitIdentical();
|
||||
testEndpointDeletionIsRefused();
|
||||
testTogglingHardThenSmoothRestoresTheEvaluatedContour();
|
||||
testStagedAndSplineStatesBothSurviveAFlipAndASaveReload();
|
||||
testAV12PayloadLoadsWithoutLoss();
|
||||
testAContourReplaysProportionallyOnADifferentLengthSample();
|
||||
testAFreshSplineEgDefaultsToTheSmoothDownwardSlope();
|
||||
testGateIsUnavailableWhileASplineEgIsActiveAndReturnsAfterwards();
|
||||
testTheVelocityAmpCurveGainsTheToggleAndKeepsItsDelete();
|
||||
if (g_fail == 0) std::printf("spline_egs: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user