instrument: fix AHD node-tracking/tie-break/live-latch defects and close staged-envelope-curve test gaps

This commit is contained in:
2026-07-31 09:52:17 -04:00
parent d60ab1524a
commit 2fa1405b06
27 changed files with 360 additions and 86 deletions
+21
View File
@@ -681,6 +681,26 @@ static void testNonFiniteFilterFieldsLiftToTheNeutralDefault() {
CHECK(g.enabled);
}
// A non-finite attackSeconds/decaySeconds on a stored AHD (a corrupt blob) must lift to 0
// seconds rather than reach resolvePlay's static_cast<std::int64_t> (sample_map.cpp) — UB on
// NaN, and on a large-enough finite value. Mirrors
// testNonFiniteFilterFieldsLiftToTheNeutralDefault's per-field precedent, on the v10 AHD tail.
static void testNonFiniteAhdSecondsLiftToZero() {
ComponentState in;
in.selectionId = "pad";
in.params.play.trigAhd.attackSeconds = std::numeric_limits<double>::quiet_NaN();
in.params.play.trigAhd.decaySeconds = std::numeric_limits<double>::infinity();
in.params.play.filter.trigEnv.attackSeconds = -std::numeric_limits<double>::infinity();
in.params.play.filter.trigEnv.decaySeconds = std::numeric_limits<double>::quiet_NaN();
const ComponentState out =
deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.play.trigAhd.attackSeconds == 0.0);
CHECK(out.params.play.trigAhd.decaySeconds == 0.0);
CHECK(out.params.play.filter.trigEnv.attackSeconds == 0.0);
CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0);
}
// The WRITER emits the CURRENT payload version, and the marker + version sit at the head of
// the payload — the self-describing property every legacy branch depends on. Asserted
// against the semantic constants, not literals.
@@ -1273,6 +1293,7 @@ int main() {
testV8RecordLiftsToTheOffNeutralFilter();
testFilterTailRoundTripsLosslessly();
testNonFiniteFilterFieldsLiftToTheNeutralDefault();
testNonFiniteAhdSecondsLiftToZero();
if (failures == 0) {
std::printf("component_state_io_tests: all tests passed\n");
return 0;
+26 -1
View File
@@ -193,9 +193,20 @@ static void testAhdStageTimesTrackTheWallClockScale() {
const StageEnvelope attack =
resolveNodeDrag(e, EnvNode::AttackEnd, overlayOf(a), kTotal, bounds(), 100, 0);
CHECK(std::fabs(attack.attackSeconds - (e.attackSeconds + 100 * secPerPx)) < 1e-9);
// DecayEnd's underlying param (decaySeconds) does NOT move 1:1 with the cursor: the drawn
// endpoint is t0 + total, and Hold eats a holdFraction share of whatever decay gives up
// (d(total)/d(decay) = 1 - holdFraction), so decaySeconds itself has to move faster than
// the cursor to make the DRAWN node track it. Assert on the RENDERED position, not the
// raw param — that is the property a drag actually has to deliver, and asserting the old
// 1:1 param delta here is exactly what let the node-tracking defect through undetected.
EnvVertex before;
CHECK(findNode(buildEnvelopePolyline(e, overlayOf(a), kTotal), EnvNode::DecayEnd, before));
const StageEnvelope decay =
resolveNodeDrag(e, EnvNode::DecayEnd, overlayOf(a), kTotal, bounds(), 100, 0);
CHECK(std::fabs(decay.decaySeconds - (e.decaySeconds + 100 * secPerPx)) < 1e-9);
EnvVertex after;
CHECK(findNode(buildEnvelopePolyline(decay, overlayOf(a), kTotal), EnvNode::DecayEnd, after));
CHECK(std::abs((after.x - before.x) - 100) <= 1); // 1:1 with the cursor, to rounding
}
// Hold is a fraction of what attack and decay left, so the node's pixel motion converts through
@@ -270,6 +281,19 @@ static void testKnotOnALevelSegmentIsANoOp() {
CHECK(out.decayCurve == 2.5);
}
// A NEAR-level segment (sustain 0.99) is not caught by the exact-equality guard above, but its
// tiny divisor turns a one-pixel drag into a saturating swing of the exponent — the drag must
// still be a no-op rather than slam to a domain endpoint.
static void testKnotOnANearLevelSegmentIsANoOp() {
const Rect a = wideArea();
StageEnvelope e = ahdsrEnv();
e.sustainLevel = 0.99;
e.decayCurve = 2.5;
const StageEnvelope out =
resolveNodeDrag(e, EnvNode::DecayCurve, overlayOf(a), kTotal, bounds(), 0, -1);
CHECK(out.decayCurve == 2.5);
}
// --- degenerate ----------------------------------------------------------------
static void testDegenerateInputsAreNoOps() {
@@ -299,6 +323,7 @@ int main() {
testKnotDragMovesTheExponentWithinItsDomain();
testKnotAndModelCannotDiverge();
testKnotOnALevelSegmentIsANoOp();
testKnotOnANearLevelSegmentIsANoOp();
testDegenerateInputsAreNoOps();
+60
View File
@@ -97,6 +97,18 @@ static void testDegenerateAreaAndDuration() {
CHECK(gatePxPerSecond(Rect{}) == 0.0);
}
// The literal PARAM-DOMAIN scale, independent of any sample duration: usable px = canvas width
// minus the last column minus 4 node-separation bases, spread over 4 x kGateStageMaxSeconds.
// This is what makes a dragged handle track the cursor 1:1 (envelope_edit's own inverse reads
// this same function) — a scale regression here is exactly what a relational-only check misses.
static void testGatePxPerSecond() {
const double expected =
(1000.0 - 1.0 - 4.0 * kGateNodeSepPx) / (4.0 * kGateStageMaxSeconds); // 967/8 px/s
CHECK(gatePxPerSecond(wideArea()) == expected);
CHECK(gatePxPerSecond(Rect::ltrb(5, 5, 5, 45)) == 0.0); // zero-width area -> 0
CHECK(gatePxPerSecond(Rect::ltrb(0, 0, 10, 10)) > 0.0); // tiny area: usable floors at 1px, > 0
}
// --- the AHDSR schematic ------------------------------------------------------
static void testAhdsrNodeOrderAndLevels() {
@@ -122,6 +134,33 @@ static void testAhdsrNodeOrderAndLevels() {
CHECK(v.x == a.right() - 1); // ANCHORED, whatever the release is
}
// The literal per-node x placement, hand-derived from the documented formula (pps = 120.875
// px/s per testGatePxPerSecond; each timed stage is prefixed by the kGateNodeSepPx=8 base):
// attack .2s -> raw 8+24.175=32.175 -> px 32; hold .1s -> raw 32.175+8+12.0875=52.2625 -> px 52;
// decay .3s -> raw 52.2625+8+36.2625=96.525 -> px 97; plateau -> raw 999-8-48.35=942.65 -> px
// 943; release end pinned at the last column, 999. A literal regression pin — no relational or
// bounds-only check catches a formula-shape change the way an exact pixel count does.
static void testAhdsrSchematicPlacement() {
const Rect a = wideArea();
const std::vector<EnvVertex> poly =
buildEnvelopePolyline(ahdsr(0.2, 0.1, 0.3, 0.5, 0.4), overlayOf(a), 4.0);
EnvVertex v;
CHECK(findNode(poly, EnvNode::AttackEnd, v) && v.x == a.x + 32);
CHECK(findNode(poly, EnvNode::HoldEnd, v) && v.x == a.x + 52);
CHECK(findNode(poly, EnvNode::DecayEnd, v) && v.x == a.x + 97);
CHECK(findNode(poly, EnvNode::ReleaseStart, v) && v.x == a.x + 943);
CHECK(findNode(poly, EnvNode::ReleaseEnd, v) && v.x == a.x + 999);
}
// The AHDSR schematic is scaled by the PARAM domain, NOT the capture length: the same params
// produce the SAME polyline whether totalSeconds is 0.3 or 10 (gatePolyline doesn't even take
// totalSeconds — only the sustain-less AHD's x-axis is wall-clock/PCM-aligned).
static void testGateLayoutIndependentOfSampleDuration() {
const Rect a = wideArea();
const StageEnvelope e = ahdsr(0.2, 0.1, 0.3, 0.5, 0.06);
CHECK(buildEnvelopePolyline(e, overlayOf(a), 0.3) == buildEnvelopePolyline(e, overlayOf(a), 10.0));
}
// The layout failure this policy exists to fix: at zero release the sustain plateau must run to
// (near) the right edge instead of the figure bunching left.
static void testZeroReleasePutsTheSustainPlateauAtTheRightEdge() {
@@ -176,12 +215,29 @@ static void testMaxedStagesCompressWithoutOverrunning() {
for (std::size_t i = 1; i < 6; ++i) {
CHECK(poly[i].x >= poly[i - 1].x);
CHECK(poly[i].x <= a.right() - 1);
// The compression exists to preserve MINIMUM gaps under overrun, not merely
// non-strict monotonicity — a compression that let two nodes collapse onto one
// pixel would still pass a `>=` check but defeat the whole point of kGateNodeSepPx.
CHECK(poly[i].x - poly[i - 1].x >= kGateNodeSepPx - 1);
}
EnvVertex end;
CHECK(findNode(poly, EnvNode::ReleaseEnd, end));
CHECK(end.x == a.right() - 1);
}
// Absurd stage values must clamp in double space, not overflow the integer cast (32-bit long
// on Windows would wrap negative and land on the WRONG edge) — a regression named for the bug
// it once was. gateVtx's own double-space clamp is what this exercises.
static void testAbsurdReleaseValueStaysInBounds() {
const Rect a = wideArea();
StageEnvelope huge = ahdsr(0.1, 0.1, 0.1, 0.5, 0.1);
huge.releaseSeconds = 1e12;
for (const EnvVertex& v : buildEnvelopePolyline(huge, overlayOf(a), 4.0)) {
CHECK(v.x >= a.x && v.x < a.right());
CHECK(v.y >= a.y && v.y < a.bottom());
}
}
// --- the AHD split ------------------------------------------------------------
// The combined-time bound, asserted structurally across the full domains: no (attack, decay,
@@ -322,12 +378,16 @@ int main() {
testTimeToXClampsBothEnds();
testLevelToY();
testDegenerateAreaAndDuration();
testGatePxPerSecond();
testAhdsrNodeOrderAndLevels();
testAhdsrSchematicPlacement();
testGateLayoutIndependentOfSampleDuration();
testZeroReleasePutsTheSustainPlateauAtTheRightEdge();
testReleaseGrowsLeftwardFromTheAnchor();
testTierZeroDefaultsKeepEveryNodeDistinct();
testMaxedStagesCompressWithoutOverrunning();
testAbsurdReleaseValueStaysInBounds();
testAhdSplitNeverExceedsTheSpan();
testHoldFractionEndpoints();
+56
View File
@@ -474,6 +474,62 @@ static void testEveryEnvelopeStageTimeAndLevelMovesTheSoundingNote() {
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnv.peakSemitones = 0.0; }, -1},
// A small hold that finishes the envelope well inside the render window (Hold ends at
// frame ~5090, comfortably short of the window) vs. a live move that opens the hold out
// near the whole span: with the fraction alone unmoved, the boundary the two renders
// cross (or don't) inside the observed tail is what makes this audible, not a level
// change — Hold's own output is flat regardless of exactly where inside it pos_ sits.
{"pitch env hold fraction",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.peakSemitones = 12.0;
s.play.pitchEnv.shape.attackFrames = 100;
s.play.pitchEnv.shape.decayFrames = 100;
s.play.pitchEnv.shape.holdFraction = 0.05;
},
[](LiveValues& v) { v.pitchEnv.shape.holdFraction = 1.0; }, -1},
// The Trigger AHDs — Voice::applyLive's ampAhd_/filterAhd_ branches, otherwise
// unexercised by this table (every case above is Gate/AdsrEnvelope).
{"trigger amp attack (AHD)",
[](SampleData& s) {
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0;
s.play.trigAhd.attackFrames = 48000;
},
[](LiveValues& v) { v.ampAhd.attackFrames = 4000; }, -1},
{"trigger filter attack (AHD)",
[](SampleData& s) {
s.play.playMode = PlayMode::Trigger;
s.play.trigger.lengthFraction = 1.0;
filterSweep(s);
s.play.filter.trigEnv.attackFrames = 48000;
},
[](LiveValues& v) { v.filterAhd.attackFrames = 4000; }, -1},
// One curve exponent per envelope (amp/pitch/filter), reusing each envelope's own
// attack/decay rig above so only the mutated field differs.
{"amp attack curve",
[](SampleData& s) { s.play.adsr.attackFrames = 48000; },
[](LiveValues& v) { v.adsr.attackCurve = 5.0; }, -1},
{"amp release curve",
[](SampleData& s) { s.play.adsr.releaseFrames = 48000; },
[](LiveValues& v) { v.adsr.releaseCurve = 5.0; }, 2},
{"pitch env attack curve",
[](SampleData& s) {
s.play.pitchEnv.enabled = true;
s.play.pitchEnv.shape.attackFrames = 48000;
s.play.pitchEnv.shape.decayFrames = 48000;
s.play.pitchEnv.peakSemitones = 12.0;
},
[](LiveValues& v) { v.pitchEnv.shape.attackCurve = 5.0; }, -1},
{"filter env decay curve",
[](SampleData& s) {
filterSweep(s);
s.play.filter.env.decayFrames = 48000;
s.play.filter.env.sustainLevel = 0.0;
},
[](LiveValues& v) { v.filterEnv.decayCurve = 5.0; }, -1},
};
for (const Case& c : cases) {
assertLiveFieldMovesTheSoundingNote(c.name, c.rig, c.mutate, c.noteOffBlock);
+15 -12
View File
@@ -1068,21 +1068,24 @@ static void testTriggerLengthWithStart() {
}
// --- Trigger fade-in / fade-out ramp shape (equal-power default). ---
static void testTriggerFadeShape() {
// 100 frames, 100% length, fadeIn 20, fadeOut 20. Head ramps 0->1, tail ramps 1->0, unity
// between. Equal-power: sin/cos ramps, monotonic, endpoints ~0 and ~1.
// 100 frames, 100% length, attack 20 / decay 20, holdFraction 1.0 — triggerSample() sets no
// curve exponent, so both stages default to util::kCurveNeutral (1.0): the ramps are LINEAR,
// not the retired fade pair's equal-power sin/cos. Asserted against the closed form rather
// than monotonicity alone — a monotonicity-only check is blind to exactly this shape change.
static void testTriggerAhdFadeShape() {
SampleData km = (triggerSample(100, 1.0, 20, 20));
VoiceEngine eng(1, km);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 120);
CHECK(approx(out[0], 0.0, 1e-3)); // fade-in starts at 0
// Fade-in monotonic non-decreasing.
for (std::size_t i = 1; i < 20; ++i) CHECK(out[i] >= out[i - 1] - 1e-4);
// Unity plateau in the middle.
for (std::size_t i = 25; i < 75; ++i) CHECK(approx(out[i], 1.0, 1e-3));
// Fade-out monotonic non-increasing over [80,100).
for (std::size_t i = 81; i < 100; ++i) CHECK(out[i] <= out[i - 1] + 1e-4);
for (std::size_t i = 0; i < 20; ++i) {
CHECK(approx(out[i], static_cast<double>(i) / 20.0, 1e-3));
}
// Hold plateau at unity.
for (std::size_t i = 20; i < 80; ++i) CHECK(approx(out[i], 1.0, 1e-3));
for (std::size_t i = 80; i < 100; ++i) {
CHECK(approx(out[i], 1.0 - static_cast<double>(i - 80) / 20.0, 1e-3));
}
// Past playEnd = silence.
for (std::size_t i = 100; i < 120; ++i) CHECK(approx(out[i], 0.0, 1e-6));
}
@@ -1099,7 +1102,7 @@ static void testTriggerEdgeCases() {
for (float v : out) CHECK(approx(v, 0.0, 1e-6));
CHECK(eng.activeVoiceCount() == 0);
}
// Fades that sum beyond the play length are clamped (no crash, no negative gain, amp in [0,1]).
// AHD attack + decay beyond the play length are fitted by fitAhd, not overflowed (no crash, no negative gain, amp in [0,1]).
{
// 40 frames, 100% -> playLen 40; fadeIn 30 + fadeOut 30 = 60 > 40 -> clamped.
SampleData km = (triggerSample(40, 1.0, 30, 30));
@@ -2578,7 +2581,7 @@ int main() {
testAhdsrHoldZeroEqualsAdsr();
testTriggerLengthFractionFrames();
testTriggerLengthWithStart();
testTriggerFadeShape();
testTriggerAhdFadeShape();
testTriggerEdgeCases();
testTriggerIgnoresNoteOff();
+50 -12
View File
@@ -249,27 +249,36 @@ static void testEachModePlaysItsOwnStageValuesAndTheOtherSurvives() {
s.play.trigAhd.attackFrames = 0;
s.play.trigAhd.decayFrames = 2000;
s.play.trigAhd.holdFraction = 0.0;
const AdsrParams originalAdsr = s.play.adsr;
const auto renderFirst = [&](PlayMode mode) {
SampleData copy = s;
copy.play.playMode = mode;
VoiceEngine eng(1, copy);
const auto render = [&]() {
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1000);
return out;
};
const std::vector<AudioSample> gate = renderFirst(PlayMode::Gate);
s.play.playMode = PlayMode::Gate;
const std::vector<AudioSample> gate = render();
CHECK(gate[0] < 0.01f); // halfway up a 2000-frame attack
CHECK(std::fabs(gate[999] - 999.0f / 2000.0f) < 1e-3f);
const std::vector<AudioSample> trig = renderFirst(PlayMode::Trigger);
// The actual excursion: flip the SAME SampleData to Trigger and render through it — not a
// fresh copy. A prior revision of this test copied `s` per render and never wrote it, so
// "back to Gate" compared two renders of an object nothing had touched: it would have
// passed with trigAhd deleted from the struct.
s.play.playMode = PlayMode::Trigger;
const std::vector<AudioSample> trig = render();
CHECK(trig[0] > 0.99f); // instant onset
CHECK(std::fabs(trig[999] - (1.0f - 999.0f / 2000.0f)) < 1e-3f);
// Back to Gate: the AHDSR values were never touched by the excursion.
const std::vector<AudioSample> again = renderFirst(PlayMode::Gate);
// Flip back to Gate on the SAME object: the AHDSR fields must have survived the round trip
// and the render must reproduce the pre-excursion output exactly.
s.play.playMode = PlayMode::Gate;
CHECK(s.play.adsr.attackFrames == originalAdsr.attackFrames);
CHECK(s.play.adsr.sustainLevel == originalAdsr.sustainLevel);
const std::vector<AudioSample> again = render();
for (std::size_t i = 0; i < gate.size(); ++i) CHECK(again[i] == gate[i]);
}
@@ -366,7 +375,10 @@ static void testTriggerPreserveEndsWithoutATerminalDiscontinuity() {
const double kSourceSlope = 0.8 * 2.0 * kPi / 40.0;
for (VoiceMode vm : {VoiceMode::Poly, VoiceMode::Mono}) {
const TailMeasure m = renderTail(vm, PitchEngine::Preserve, 8000);
CHECK(m.soundingFrames > 0);
// > 0 alone would pass on a hard cut with no blend at all; the ring-out is ~185 frames
// past the kRingSpanFrames=4000 span, so requiring it past the span is what confirms
// the blend actually engaged rather than merely stopping somewhere.
CHECK(m.soundingFrames > 4000);
CHECK(m.worstStep <= kSourceSlope * 1.5);
// And the voice genuinely reaches silence rather than being left ringing.
CHECK(m.lastLevel < 1e-3);
@@ -411,9 +423,9 @@ static void testTriggerPreserveAhdEndingEarlyAlsoRingsOut() {
}
// Varispeed is not implicated and must be left exactly as it was: its terminal sample is real
// source content at its natural end, so no ring-out is armed there. Asserted as byte-identity
// between two renders of the same rig, one of which would differ if the Preserve-only guard
// were ever widened.
// source content at its natural end, so no ring-out is armed there. Asserted directly against
// the closed-form source waveform (not by comparing two renders): the read head reproduces the
// source exactly through its span, then stops dead with no ring-out appended.
static void testVarispeedTailIsUntouched() {
const auto render = [](std::size_t frames) {
SampleData s;
@@ -597,6 +609,30 @@ static void testPitchEnvelopeSpanIsOutputFramesUnderVarispeed() {
CHECK(soundingFrames(84, -24.0) == 1750);
}
// --- Live delivery must not resurrect a finished AHD ---------------------------
// A live move must never UN-finish a completed AHD. Reachable on a sounding voice past its
// fitted total but still active() (the ring-out past a Trigger AHD's own early end, voice.h) —
// VoiceEngine::applyLiveToActive runs on any active() voice, so a live move there that reopens
// the total could otherwise make a finished one-shot briefly resurge.
static void testApplyLiveNeverUnfinishesACompletedAhd() {
AhdParams p;
p.attackFrames = 10;
p.decayFrames = 10;
p.holdFraction = 0.0; // total = 20, well short of the 1000-frame span
AhdEnvelope ahd;
ahd.configure(1000, p);
CHECK(ahd.amplitudeAt(500.0) == 0.0); // past the fitted total: latched finished
CHECK(ahd.finished());
// A live move that would, on a FRESH fit, reopen the total out across the whole span.
AhdParams reopened = p;
reopened.holdFraction = 1.0;
ahd.applyLive(500.0, reopened); // same sourceOffset the voice is sitting at
CHECK(ahd.finished()); // must stay finished — no resurge
CHECK(ahd.amplitudeAt(500.0) == 0.0);
}
// --- Migration shape ----------------------------------------------------------
// A prior ZERO fade-out migrates to Decay = 0 and keeps the abrupt end the old controls could
@@ -634,6 +670,8 @@ int main() {
testPitchEnvelopeSpanIsOutputFramesUnderVarispeed();
testApplyLiveNeverUnfinishesACompletedAhd();
testZeroFadeOutMigratesToAnAbruptEnd();
if (g_fail == 0) {
+6 -1
View File
@@ -132,13 +132,18 @@ static void testOverlayAccentSeparatesFromTheWaveformAccent() {
const KitColor overlay = roleColor(Role::AccentTertiary);
const KitColor prior = roleColor(Role::AccentSecondary);
CHECK(contrastRatio(overlay, wave) > contrastRatio(prior, wave));
// Pin the MEASURED value, not just the relational improvement: today's pair sits at
// ~1.37:1 — BELOW the 3:1 indicator floor every other assertion in this file uses. That
// is a known, held deviation (Daniel's palette call, tracked separately) rather than a
// silently accepted one; a regression toward the old ~1.12:1 secondary pairing must fail.
CHECK(contrastRatio(overlay, wave) >= 1.35);
// Hue divergence against the waveform: the waveform's green dominates its red, the
// overlay's red dominates its green — opposite balances, not two shades of one.
CHECK(wave.g > wave.r);
CHECK(overlay.r > overlay.g);
const int delta = std::abs(int(wave.r) - int(overlay.r)) +
std::abs(int(wave.g) - int(overlay.g)) +
std::abs(int(wave.b) - int(overlay.b)) ;
std::abs(int(wave.b) - int(overlay.b));
CHECK(delta >= 60);
}