// Standalone tests for reasampler::instrument::ui::curve_tessellate — no VST3, no REAPER, no // framework. Same fast assert loop as the sibling pure tests. // // Every assertion is expressed RELATIVE to the vertices buildEnvelopePolyline returns, never // against an absolute pixel literal, so a re-scaled overlay axis leaves this file untouched. // Covers: segmentCurve's node->exponent rule; the neutral exponent emitting today's straight // vertex list unchanged; knots excluded and node vertices preserved exactly; THE GATE — the // knot's centre within 1 px of the trace at every exponent, on all three sloped stages of both // layout policies; the mid-segment level matching curve_law's own curveMidLevel; curvature // direction; no overshoot past a segment's own endpoint levels; per-pixel-column density that // scales with the canvas; the x clamp; and the degenerate/empty cases. // // Both layout policies here ARE all three envelopes and both play modes: the editor's // packEnvelope collapses amp/filter/pitch x Gate/Trigger onto exactly these two EnvKinds, and // paintEnvelopeOverlay is the single paint path over them. #include "../src/core/instrument/ui/curve_tessellate.h" #include #include #include #include "../src/core/util/curve_law.h" using namespace reasampler; using namespace reasampler::instrument::ui; using reasampler::ui::StrokePoint; 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 OverlayArea overlayOf(const Rect& r) { return OverlayArea{r}; } // Offset so left/top != 0 (catches origin bugs). Tall enough that a 1 px tolerance is a small // fraction of the level span, which is what makes the level assertions discriminating. static Rect wideArea() { return Rect::ltrb(20, 10, 1020, 210); } // width 1000, height 200 static StageEnvelope ahdsr(double a, double h, double d, double sus, double r) { StageEnvelope e; e.kind = EnvKind::Ahdsr; e.attackSeconds = a; e.holdSeconds = h; e.decaySeconds = d; e.sustainLevel = sus; e.releaseSeconds = r; return e; } static StageEnvelope ahd(double a, double d, double frac, double span) { StageEnvelope e; e.kind = EnvKind::Ahd; e.attackSeconds = a; e.decaySeconds = d; e.holdFraction = frac; e.originSeconds = 0.0; e.spanSeconds = span; return e; } static bool findNode(const std::vector& poly, EnvNode node, EnvVertex& out) { for (const EnvVertex& v : poly) { if (v.node == node) { out = v; return true; } } return false; } // The polyline's y where it crosses `x` — the trace as STROKED, not merely its samples, so a // knot between two samples is still measured against the line the user sees. static bool traceYAtX(const std::vector& t, double x, double& y) { for (std::size_t i = 1; i < t.size(); ++i) { const double x0 = t[i - 1].x, x1 = t[i].x; if (x1 == x0) { if (x == x0) { y = t[i].y; return true; } continue; } const double lo = x0 < x1 ? x0 : x1, hi = x0 < x1 ? x1 : x0; if (x < lo || x > hi) continue; const double u = (x - x0) / (x1 - x0); y = t[i - 1].y + (t[i].y - t[i - 1].y) * u; return true; } return false; } // The three sloped stages, each named by the knot that rides it and the two nodes it runs // between — the same pairing gatePolyline/ahdPolyline place the knot from. struct Stage { EnvNode knot; EnvNode from; EnvNode to; }; static const Stage kStages[3] = { {EnvNode::AttackCurve, EnvNode::Origin, EnvNode::AttackEnd}, {EnvNode::DecayCurve, EnvNode::HoldEnd, EnvNode::DecayEnd}, {EnvNode::ReleaseCurve, EnvNode::ReleaseStart, EnvNode::ReleaseEnd}, }; static const double kExponents[7] = {util::kCurveMin, 0.25, 0.5, util::kCurveNeutral, 2.0, 4.0, util::kCurveMax}; // --------------------------------------------------------------------------------------- static void testSegmentCurve() { StageEnvelope e = ahdsr(1.0, 0.0, 1.0, 0.5, 1.0); e.attackCurve = 0.3; e.decayCurve = 3.0; e.releaseCurve = 7.0; CHECK(segmentCurve(e, EnvNode::AttackEnd) == e.attackCurve); CHECK(segmentCurve(e, EnvNode::DecayEnd) == e.decayCurve); CHECK(segmentCurve(e, EnvNode::ReleaseEnd) == e.releaseCurve); // The plateau-ending nodes carry no exponent of their own. CHECK(segmentCurve(e, EnvNode::HoldEnd) == util::kCurveNeutral); CHECK(segmentCurve(e, EnvNode::ReleaseStart) == util::kCurveNeutral); CHECK(segmentCurve(e, EnvNode::Origin) == util::kCurveNeutral); } // The regression guard: at the neutral exponent the trace IS the non-knot vertex list, one // point per vertex, at the same integer coordinates — the straight stroke drawn before curves. static void testNeutralIsTodaysStraightLine() { const Rect area = wideArea(); const StageEnvelope envs[2] = {ahdsr(2.0, 0.0, 2.0, 0.4, 2.0), ahd(1.2, 1.6, 0.5, 4.0)}; for (const StageEnvelope& env : envs) { const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); std::size_t nodes = 0; for (const EnvVertex& v : poly) if (!v.knot) ++nodes; CHECK(trace.size() == nodes); std::size_t i = 0; for (const EnvVertex& v : poly) { if (v.knot) continue; CHECK(trace[i].x == static_cast(v.x)); CHECK(trace[i].y == static_cast(v.y)); ++i; } } } // Knots are handles, not line vertices. A knot sits mid-canvas but is appended AFTER the last // node, so admitting one would break the trace's x ordering — which is what this catches. static void testKnotsExcludedAndNodesPreserved() { const Rect area = wideArea(); StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); env.attackCurve = 0.3; env.decayCurve = 4.0; env.releaseCurve = 0.4; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); std::size_t knots = 0; for (const EnvVertex& v : poly) if (v.knot) ++knots; CHECK(knots == 3); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); for (std::size_t i = 1; i < trace.size(); ++i) CHECK(trace[i].x >= trace[i - 1].x); // Every node vertex still appears at its exact integer position: the handles are drawn // there, and a trace that missed one would sit off its own handle. for (const EnvVertex& v : poly) { if (v.knot) continue; bool found = false; for (const StrokePoint& p : trace) { if (p.x == static_cast(v.x) && p.y == static_cast(v.y)) found = true; } CHECK(found); } } // THE GATE (plan acceptance criterion 1): at every exponent the knot's centre lies on the // trace within 1 px. Swept over all three sloped stages of the AHDSR schematic. static void testKnotLiesOnTraceAhdsr() { const Rect area = wideArea(); for (const Stage& st : kStages) { for (double pw : kExponents) { StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw; else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw; else env.releaseCurve = pw; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); EnvVertex knot; CHECK(findNode(poly, st.knot, knot)); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); double y = 0.0; CHECK(traceYAtX(trace, static_cast(knot.x), y)); CHECK(std::fabs(y - static_cast(knot.y)) <= 1.0); } } } // The same gate on the AHD policy — its two sloped stages, its own 1:1 time axis. static void testKnotLiesOnTraceAhd() { const Rect area = wideArea(); for (int stage = 0; stage < 2; ++stage) { for (double pw : kExponents) { StageEnvelope env = ahd(1.2, 1.6, 0.5, 4.0); if (stage == 0) env.attackCurve = pw; else env.decayCurve = pw; const EnvNode knotNode = stage == 0 ? EnvNode::AttackCurve : EnvNode::DecayCurve; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); EnvVertex knot; CHECK(findNode(poly, knotNode, knot)); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); double y = 0.0; CHECK(traceYAtX(trace, static_cast(knot.x), y)); CHECK(std::fabs(y - static_cast(knot.y)) <= 1.0); } } } // The trace is the SAME law the audio evaluates: at a segment's midpoint its level is // curve_law's curveMidLevel of that stage's exponent, composed onto the endpoints the trace // itself returned. static void testMidSegmentLevelMatchesTheLaw() { const Rect area = wideArea(); for (const Stage& st : kStages) { for (double pw : kExponents) { StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw; else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw; else env.releaseCurve = pw; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); EnvVertex a, b; CHECK(findNode(poly, st.from, a)); CHECK(findNode(poly, st.to, b)); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); const double xm = 0.5 * (static_cast(a.x) + static_cast(b.x)); double y = 0.0; CHECK(traceYAtX(trace, xm, y)); const double want = static_cast(a.y) + (static_cast(b.y) - static_cast(a.y)) * util::curveMidLevel(pw); CHECK(std::fabs(y - want) <= 1.0); } } } // A curve must bend, and in the direction the exponent names: phi^p with p > 1 holds the level // LOW for longer, p < 1 lifts it early. Stated against the chord between the two endpoints the // trace returned, so it holds whichever way the segment slopes. static void testCurvatureDirection() { const Rect area = wideArea(); for (const Stage& st : kStages) { for (double pw : {0.25, 4.0}) { StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw; else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw; else env.releaseCurve = pw; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); EnvVertex a, b; CHECK(findNode(poly, st.from, a)); CHECK(findNode(poly, st.to, b)); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); const double xm = 0.5 * (static_cast(a.x) + static_cast(b.x)); double y = 0.0; CHECK(traceYAtX(trace, xm, y)); const double chord = 0.5 * (static_cast(a.y) + static_cast(b.y)); // Normalized level at the midpoint, 0 at the start node, 1 at the end node. const double dy = static_cast(b.y) - static_cast(a.y); const double u = (y - static_cast(a.y)) / dy; CHECK(std::fabs(y - chord) > 1.0); // it actually left the straight line if (pw > util::kCurveNeutral) CHECK(u < 0.5); else CHECK(u > 0.5); } } } // curveMap maps 0->0 and 1->1 at every positive exponent, so no sample may pass either of its // own segment's endpoint levels. static void testNoOvershoot() { const Rect area = wideArea(); for (const Stage& st : kStages) { for (double pw : kExponents) { StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); if (st.knot == EnvNode::AttackCurve) env.attackCurve = pw; else if (st.knot == EnvNode::DecayCurve) env.decayCurve = pw; else env.releaseCurve = pw; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); EnvVertex a, b; CHECK(findNode(poly, st.from, a)); CHECK(findNode(poly, st.to, b)); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); const double lo = a.y < b.y ? a.y : b.y; const double hi = a.y < b.y ? b.y : a.y; for (const StrokePoint& p : trace) { if (p.x < static_cast(a.x) || p.x > static_cast(b.x)) continue; CHECK(p.y >= static_cast(lo) - 0.001f); CHECK(p.y <= static_cast(hi) + 0.001f); } } } } // Density follows the canvas: one sample per pixel column, so a wider canvas gets proportionally // more of them. A fixed count would fail the second half. Uses the AHD policy, whose x axis is // the waveform's own and so is independent of the AHDSR schematic's scale. static void testDensityFollowsWidth() { std::size_t counts[2] = {0, 0}; const int widths[2] = {1000, 4000}; for (int w = 0; w < 2; ++w) { const Rect area = Rect::ltrb(20, 10, 20 + widths[w], 210); StageEnvelope env = ahd(1.2, 1.6, 0.5, 4.0); env.attackCurve = 4.0; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); EnvVertex a, b; CHECK(findNode(poly, EnvNode::Origin, a)); CHECK(findNode(poly, EnvNode::AttackEnd, b)); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); std::size_t n = 0; float prevX = 0.0f; for (const StrokePoint& p : trace) { if (p.x < static_cast(a.x) || p.x > static_cast(b.x)) continue; if (n > 0) CHECK(p.x - prevX <= 1.0f); // no gap wider than one column prevX = p.x; ++n; } CHECK(n > 1); counts[w] = n; } // 4x the canvas, ~4x the samples across the same stage. CHECK(counts[1] > 3 * counts[0]); } // A plateau is straight because its two endpoints share a level, so a stray exponent on the // node that ends it must not curve it. static void testFlatSegmentEmitsNoInterior() { const Rect area = wideArea(); StageEnvelope env = ahdsr(2.0, 0.0, 0.0, 1.0, 2.0); // sustain == 1: the decay span is flat env.attackCurve = util::kCurveNeutral; env.decayCurve = 5.0; env.releaseCurve = util::kCurveNeutral; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); std::vector trace; buildEnvelopeTrace(poly, env, area.x, area.right() - 1, trace); std::size_t nodes = 0; for (const EnvVertex& v : poly) if (!v.knot) ++nodes; CHECK(trace.size() == nodes); } static void testClampAndDegenerate() { const Rect area = wideArea(); StageEnvelope env = ahdsr(2.0, 0.0, 2.0, 0.4, 2.0); env.attackCurve = 0.25; env.decayCurve = 4.0; const std::vector poly = buildEnvelopePolyline(env, overlayOf(area), 4.0); // A narrower clamp than the vertices were built for: nothing escapes it. std::vector trace; const int lo = area.x + 100, hi = area.right() - 200; buildEnvelopeTrace(poly, env, lo, hi, trace); CHECK(!trace.empty()); for (const StrokePoint& p : trace) { CHECK(p.x >= static_cast(lo)); CHECK(p.x <= static_cast(hi)); } // The out vector is REPLACED, not appended to — a stale trace from the previous paint // would otherwise stroke a line across the canvas. buildEnvelopeTrace({}, env, area.x, area.right() - 1, trace); CHECK(trace.empty()); // A degenerate surface still yields the flat baseline the painter needs. const Rect flat = Rect::ltrb(20, 10, 20, 10); const std::vector degen = buildEnvelopePolyline(env, overlayOf(flat), 0.0); buildEnvelopeTrace(degen, env, flat.x, flat.x, trace); CHECK(trace.size() == degen.size()); } int main() { testSegmentCurve(); testNeutralIsTodaysStraightLine(); testKnotsExcludedAndNodesPreserved(); testKnotLiesOnTraceAhdsr(); testKnotLiesOnTraceAhd(); testMidSegmentLevelMatchesTheLaw(); testCurvatureDirection(); testNoOvershoot(); testDensityFollowsWidth(); testFlatSegmentEmitsNoInterior(); testClampAndDegenerate(); if (g_fail == 0) std::printf("curve_tessellate: all tests passed\n"); return g_fail == 0 ? 0 : 1; }