loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone

This commit is contained in:
2026-07-31 17:36:36 -04:00
parent a90ccd9a00
commit 0fe4166d7d
25 changed files with 991 additions and 64 deletions
+127 -4
View File
@@ -418,6 +418,7 @@ static void testGoldenFullBlobFixture() {
loopA.start = 1000;
loopA.end = 5000;
in.params.loopOverride = loopA;
in.params.loopCrossfadeFrames = 256;
in.params.startPoint = 250;
in.params.keyTrack = 0.5;
in.params.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(
@@ -449,7 +450,7 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,
0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75,
0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0a,0x00,0x00,
0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0b,0x00,0x00,
0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00,
0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00,
@@ -504,6 +505,8 @@ static void testGoldenFullBlobFixture() {
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD hold 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD att curve 1.0
0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x3f, // filt AHD dec curve 1.0
// --- payload v11 loop-crossfade tail ---
0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00, // loopCrossfadeFrames 256
};
// clang-format on
CHECK(bytes.size() == sizeof(kGolden));
@@ -551,13 +554,16 @@ static void testEnvelopePrefixBytesFrozen() {
CHECK(bytes[4] == 0); // ChannelMode::Mono
}
CHECK(kComponentStateVersion == 11);
CHECK(kParamsPayloadVersion == 10);
CHECK(kParamsPayloadVersion == 11);
CHECK(kParamsSingleRecordVersion == 8);
CHECK(kParamsFormatMarker == 0xFFFFFF00u);
// The filter and staged-curve tails rode PAYLOAD bumps, not envelope ones — the two axes
// stay independent, so a future envelope field cannot collide with either on one number.
// The filter, staged-curve and loop tails rode PAYLOAD bumps, not envelope ones — the two
// axes stay independent, so a future envelope field cannot collide with any of them on one
// number.
CHECK(kParamsFilterVersion > kParamsSingleRecordVersion);
CHECK(kParamsCurveVersion > kParamsFilterVersion);
CHECK(kParamsLoopVersion > kParamsCurveVersion);
CHECK(kParamsPayloadVersion == kParamsLoopVersion);
}
// --- The filter tail (payload v9) --------------------------------------------
@@ -701,6 +707,120 @@ static void testNonFiniteAhdSecondsLiftToZero() {
CHECK(out.params.play.filter.trigEnv.decaySeconds == 0.0);
}
// --- The loop tail (payload v11) ---------------------------------------------
// The loop span and its crossfade survive a save/reload intact, alongside the two overrides
// that share the record's head — a codec that read the crossfade into a neighbouring int64
// fails here rather than at the ear.
static void testLoopSpanAndCrossfadeRoundTrip() {
ComponentState in;
in.selectionId = "pad";
SampleLoop lp;
lp.hasLoop = true;
lp.start = 4096;
lp.end = 65536;
in.params.loopOverride = lp;
in.params.loopCrossfadeFrames = 1024;
in.params.startPoint = 512;
in.params.rootOverride = 55;
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
CHECK(out.params.loopOverride && out.params.loopOverride->start == 4096);
CHECK(out.params.loopOverride && out.params.loopOverride->end == 65536);
CHECK(out.params.loopCrossfadeFrames == 1024);
CHECK(out.params.startPoint && *out.params.startPoint == 512);
CHECK(out.params.rootOverride && *out.params.rootOverride == 55);
}
// A negative fade cannot mean anything and would only reach resolveLoop's clamp; refusing it
// at the wire keeps the parameter set the editor reads back sane.
static void testNegativeCrossfadeOnTheWireLiftsToZero() {
ComponentState in;
in.selectionId = "pad";
in.params.loopCrossfadeFrames = -4096;
const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0);
CHECK(out.params.loopCrossfadeFrames == 0);
}
// Payload tails are strict SUFFIXES by construction, so a vN blob IS the current writer's
// output with version N stamped in and the (N+1..current) tails cut. Building the older blobs
// that way exercises the tolerant-reader path rather than assuming it: if a tail ever stopped
// being a pure suffix, these would decode as garbage instead of as the documented lift.
static const std::size_t kLoopTailBytes = 8; // v11: crossfade, one int64
static const std::size_t kCurveTailBytes = 19 * 8; // v10: nineteen doubles
static const std::size_t kFilterTailBytes =
1 + 4 * 8 + 1 + 3 * 8 + 5 * 8 + (4 + 2 * 2 * 8); // v9: the filter block + its 2-pt curve
static std::vector<std::uint8_t> payloadDowngradedTo(const ComponentState& state,
std::uint32_t pv, std::size_t cutBytes) {
std::vector<std::uint8_t> bytes = serializeComponentState(state);
bool stamped = false;
for (std::size_t i = 0; i + 8 <= bytes.size(); ++i) {
const std::uint32_t m = 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);
if (m != kParamsFormatMarker) continue;
// Guard the naive marker scan: a false positive inside payload data would not be
// sitting in front of the CURRENT version.
CHECK(bytes[i + 4] == static_cast<std::uint8_t>(kParamsPayloadVersion));
bytes[i + 4] = static_cast<std::uint8_t>(pv);
stamped = true;
break;
}
CHECK(stamped);
CHECK(bytes.size() > cutBytes);
bytes.resize(bytes.size() - cutBytes);
return bytes;
}
// A project saved before this change reopens sounding identical: its loop span still applies
// and its seam is still hard, at EVERY prior single-record version.
static void testPriorPayloadVersionsLiftToAHardSeam() {
ComponentState in;
in.selectionId = "pad";
SampleLoop lp;
lp.hasLoop = true;
lp.start = 2000;
lp.end = 9000;
in.params.loopOverride = lp;
in.params.startPoint = 128;
in.params.keyTrack = 0.5;
in.params.play.adsr.releaseSeconds = 0.25;
// Set on the in-state only so a v10 lift can be checked to keep it and a v9 lift to drop
// it — proving the cuts land where the ladder says they do.
in.params.play.adsr.attackCurve = 4.0;
in.params.loopCrossfadeFrames = 777; // present in the bytes only at v11
struct Case {
std::uint32_t pv;
std::size_t cut;
bool keepsCurveTail;
};
const Case cases[] = {
{10, kLoopTailBytes, true},
{9, kLoopTailBytes + kCurveTailBytes, false},
{8, kLoopTailBytes + kCurveTailBytes + kFilterTailBytes, false},
};
for (const Case& c : cases) {
const ComponentState out =
deserializeComponentState(payloadDowngradedTo(in, c.pv, c.cut), 48000.0);
// The span itself has been in the format since v2 and must survive untouched.
CHECK(out.params.loopOverride && out.params.loopOverride->hasLoop);
CHECK(out.params.loopOverride && out.params.loopOverride->start == 2000);
CHECK(out.params.loopOverride && out.params.loopOverride->end == 9000);
CHECK(out.params.startPoint && *out.params.startPoint == 128);
CHECK(out.params.keyTrack == 0.5);
CHECK(out.params.play.adsr.releaseSeconds == 0.25);
// The documented pre-change behaviour: a hard seam.
CHECK(out.params.loopCrossfadeFrames == 0);
// And the cut landed on the tail boundary the ladder claims, not somewhere inside it.
CHECK(out.params.play.adsr.attackCurve ==
(c.keepsCurveTail ? 4.0 : reasampler::util::kCurveNeutral));
}
}
// 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.
@@ -1270,6 +1390,9 @@ int main() {
testGoldenFullBlobFixture();
testDefaultStateRoundTripsToDefaults();
testEnvelopePrefixBytesFrozen();
testLoopSpanAndCrossfadeRoundTrip();
testNegativeCrossfadeOnTheWireLiftsToZero();
testPriorPayloadVersionsLiftToAHardSeam();
testWriterEmitsCurrentPayloadVersion();
testSingleZoneMigrationIsLossless();
testMigratedFadeContourTracksTheRetiredEqualPowerShape();
+167
View File
@@ -0,0 +1,167 @@
// Standalone tests for reasampler::instrument::engine::loop — no VST3, no REAPER, no test
// framework. Covers the loop's validity/clamp rule, the pre-seam crossfade geometry, and the
// editor's default handle placement.
#include "../src/core/instrument/engine/loop/loop_span.h"
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::engine::loop;
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 SampleLoop span(std::int64_t start, std::int64_t end, bool has = true) {
SampleLoop l;
l.hasLoop = has;
l.start = start;
l.end = end;
return l;
}
// --- Validity -----------------------------------------------------------------
static void testValidGateLoopResolvesToItsOwnSpan() {
const ResolvedLoop lp = resolveLoop(span(100, 400), 0, 1000, /*gateMode=*/true);
CHECK(lp.active);
CHECK(lp.start == 100);
CHECK(lp.end == 400);
CHECK(lp.length == 300);
CHECK(lp.crossfade == 0);
}
static void testTriggerModeNeverLoops() {
const ResolvedLoop lp = resolveLoop(span(100, 400), 32, 1000, /*gateMode=*/false);
CHECK(!lp.active);
CHECK(lp.length == 0);
CHECK(lp.crossfade == 0);
}
static void testUnsetLoopIsInactive() {
const ResolvedLoop lp = resolveLoop(span(100, 400, /*has=*/false), 32, 1000, true);
CHECK(!lp.active);
}
// An inverted, empty, negative, or out-of-range span is REFUSED rather than repaired: a
// wrong loop the user can hear beats a wrong loop the engine invented, and refusing is what
// keeps the read path from indexing outside the PCM.
static void testMalformedSpansAreRefusedNotRepaired() {
CHECK(!resolveLoop(span(400, 100), 0, 1000, true).active); // inverted
CHECK(!resolveLoop(span(200, 200), 0, 1000, true).active); // empty
CHECK(!resolveLoop(span(-5, 400), 0, 1000, true).active); // negative start
CHECK(!resolveLoop(span(100, 1001), 0, 1000, true).active); // end past the PCM
CHECK(resolveLoop(span(100, 1000), 0, 1000, true).active); // end AT the PCM is fine
}
// --- Crossfade clamping -------------------------------------------------------
static void testCrossfadeClampsToTheMaterialAheadOfTheLoop() {
// The incoming tap reads [start - xf, start), so the fade cannot outrun `start`.
const ResolvedLoop lp = resolveLoop(span(50, 400), 500, 1000, true);
CHECK(lp.active);
CHECK(lp.crossfade == 50);
CHECK(lp.fadeBegin == 350.0);
}
static void testCrossfadeClampsToTheLoopLength() {
const ResolvedLoop lp = resolveLoop(span(500, 600), 400, 1000, true);
CHECK(lp.active);
CHECK(lp.crossfade == 100); // loop length, not the 400 asked for or the 500 before it
}
static void testLoopAtFrameZeroGetsNoCrossfade() {
const ResolvedLoop lp = resolveLoop(span(0, 400), 64, 1000, true);
CHECK(lp.active);
CHECK(lp.crossfade == 0); // nothing precedes the loop to fade in from
CHECK(lp.fadeInv == 0.0);
}
static void testNegativeCrossfadeIsZero() {
const ResolvedLoop lp = resolveLoop(span(100, 400), -20, 1000, true);
CHECK(lp.active);
CHECK(lp.crossfade == 0);
}
// --- Crossfade weight ---------------------------------------------------------
static void testZeroCrossfadeWeighsNothingAnywhere() {
const ResolvedLoop lp = resolveLoop(span(100, 400), 0, 1000, true);
CHECK(crossfadeWeight(lp, 100.0) == 0.0);
CHECK(crossfadeWeight(lp, 399.0) == 0.0);
CHECK(crossfadeWeight(lp, 399.999) == 0.0);
}
// The weight rises from exactly 0 at the region's start to exactly 1 at the loop end, which
// is what makes the seam continuous: at `end` the incoming tap has reached `start`, and the
// wrap puts the head there.
static void testWeightRunsZeroToOneAcrossTheFadeRegion() {
const ResolvedLoop lp = resolveLoop(span(100, 400), 100, 1000, true);
CHECK(lp.crossfade == 100);
CHECK(lp.fadeBegin == 300.0);
CHECK(crossfadeWeight(lp, 299.0) == 0.0);
CHECK(crossfadeWeight(lp, 300.0) == 0.0);
CHECK(crossfadeWeight(lp, 350.0) == 0.5);
CHECK(crossfadeWeight(lp, 375.0) == 0.75);
CHECK(crossfadeWeight(lp, 400.0) == 1.0);
}
static void testWeightIsMonotoneAndBoundedAcrossTheRegion() {
const ResolvedLoop lp = resolveLoop(span(100, 400), 60, 1000, true);
double prev = -1.0;
for (int i = 0; i <= 400; ++i) {
const double pos = 300.0 + static_cast<double>(i) * 0.25; // sweeps 300..400
const double w = crossfadeWeight(lp, pos);
CHECK(w >= prev);
CHECK(w >= 0.0 && w <= 1.0);
prev = w;
}
CHECK(prev == 1.0);
}
// The ceiling is a belt for an unwrapped caller: without it the linear ramp would extrapolate
// past the incoming tap and amplify it.
static void testWeightSaturatesPastTheLoopEnd() {
const ResolvedLoop lp = resolveLoop(span(100, 400), 50, 1000, true);
CHECK(crossfadeWeight(lp, 500.0) == 1.0);
}
// --- Default handle placement -------------------------------------------------
static void testDefaultBoundsSitInTheLastQuarterAndClearFrameZero() {
const LoopBounds d = defaultLoopBounds(1000);
CHECK(d.start == 750);
CHECK(d.end == 1000);
CHECK(d.start > 0); // the whole point: it does not land under the start marker
CHECK(defaultLoopBounds(0).start == 0 && defaultLoopBounds(0).end == 0);
CHECK(defaultLoopBounds(-5).end == 0);
}
// A default span is itself a valid loop, so the handles a user is offered describe a loop the
// engine will actually accept.
static void testDefaultBoundsResolveActive() {
const LoopBounds d = defaultLoopBounds(888);
const ResolvedLoop lp = resolveLoop(span(d.start, d.end), 0, 888, true);
CHECK(lp.active);
CHECK(lp.start == d.start && lp.end == d.end);
}
int main() {
testValidGateLoopResolvesToItsOwnSpan();
testTriggerModeNeverLoops();
testUnsetLoopIsInactive();
testMalformedSpansAreRefusedNotRepaired();
testCrossfadeClampsToTheMaterialAheadOfTheLoop();
testCrossfadeClampsToTheLoopLength();
testLoopAtFrameZeroGetsNoCrossfade();
testNegativeCrossfadeIsZero();
testZeroCrossfadeWeighsNothingAnywhere();
testWeightRunsZeroToOneAcrossTheFadeRegion();
testWeightIsMonotoneAndBoundedAcrossTheRegion();
testWeightSaturatesPastTheLoopEnd();
testDefaultBoundsSitInTheLastQuarterAndClearFrameZero();
testDefaultBoundsResolveActive();
if (g_fail == 0) std::printf("loop_span_tests: all passed\n");
return g_fail == 0 ? 0 : 1;
}
+253
View File
@@ -712,6 +712,251 @@ static void testStartAfterLoopEndWrapsIntoLoop() {
}
}
// ---------------------------------------------------------------------------
// Gate loop sustain: sample-exact wrapping, the crossfaded seam, and release out.
// ---------------------------------------------------------------------------
// A sample whose every frame carries its own index scaled down, so an observed output value
// names the exact source frame it came from — which is what makes "sample-exact" assertable
// rather than merely plausible.
static SampleData indexSample(std::size_t frames, int rootNote = 60) {
SampleData s;
s.frames.resize(frames);
for (std::size_t i = 0; i < frames; ++i) {
s.frames[i] = static_cast<float>(i) * 0.001f;
}
s.rootNote = rootNote;
return s;
}
// The source frame an output value names, inverted from indexSample's encoding.
static double sourceFrameOf(double out) { return out * 1000.0; }
static void testLoopReadWrapsSampleExactOverManyCycles() {
// Loop [40, 60) over a 100-frame index sample: the head must walk 40..59 and jump back to
// exactly 40, cycle after cycle, with nothing skipped or repeated at the seam.
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 200); // 8 full cycles past the loop entry
CHECK(eng.activeVoiceCount() == 1);
for (std::size_t i = 0; i < out.size(); ++i) {
// Output frame i reads source frame i while i < 60, then wraps by 20 each cycle.
const std::int64_t expected = i < 60 ? static_cast<std::int64_t>(i)
: 40 + ((static_cast<std::int64_t>(i) - 60) % 20);
CHECK(approx(sourceFrameOf(out[i]), static_cast<double>(expected), 1e-3));
}
}
static void testZeroCrossfadeLeavesTheSeamHard() {
// With no fade dialled, the frame at the loop end and the frame after it are the raw
// source frames — the step is the whole point of the default, and the whole thing a
// nonzero fade has to smooth.
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.loopCrossfadeFrames = 0;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 80);
CHECK(approx(sourceFrameOf(out[59]), 59.0, 1e-3));
CHECK(approx(sourceFrameOf(out[60]), 40.0, 1e-3)); // hard jump, no blend
}
// The output at output-frame n, for loop [40,60) over the index sample under fade length xf.
static double loopedFrameValue(std::int64_t xf, std::size_t n) {
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.loopCrossfadeFrames = xf;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 80);
return sourceFrameOf(out[n]);
}
static void testCrossfadeBlendsMonotonelyAcrossTheRegion() {
// Loop [40, 60) with an 8-frame pre-seam fade: over output frames 52..59 the read blends
// from source frame n toward source frame n-20 (the same head one loop length back). The
// region ENTRY is exactly continuous — frame 52 carries weight 0 — and every frame in it
// is the exact linear blend, falling monotonically.
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.loopCrossfadeFrames = 8;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 80);
CHECK(approx(sourceFrameOf(out[51]), 51.0, 1e-3));
CHECK(approx(sourceFrameOf(out[52]), 52.0, 1e-3)); // weight 0 at the region edge
for (int n = 52; n < 60; ++n) {
const double w = static_cast<double>(n - 52) / 8.0;
const double want = static_cast<double>(n) * (1.0 - w) + static_cast<double>(n - 20) * w;
CHECK(approx(sourceFrameOf(out[static_cast<std::size_t>(n)]), want, 1e-3));
}
for (int n = 53; n < 60; ++n) {
CHECK(out[static_cast<std::size_t>(n)] < out[static_cast<std::size_t>(n - 1)]);
}
}
// What a longer fade actually buys, measured rather than asserted by adjective. The last
// rendered frame before the wrap carries weight (xf-1)/xf, not 1 — the weight only reaches 1
// AT `end`, a position no frame lands on — so a residual step of (seam step)/xf survives.
// It shrinks in exact proportion to the fade length, which is the property that makes the
// parameter meaningful and the seam inaudible at any musically useful setting.
static void testSeamStepShrinksInProportionToTheFadeLength() {
auto seamStep = [](std::int64_t xf) {
return std::fabs(loopedFrameValue(xf, 60) - loopedFrameValue(xf, 59));
};
const double hard = seamStep(0);
CHECK(approx(hard, 19.0, 1e-3)); // 59 -> 40, the whole loop length less one frame
CHECK(approx(seamStep(4), 4.0, 1e-3));
CHECK(approx(seamStep(8), 1.5, 1e-3));
CHECK(approx(seamStep(16), 0.25, 1e-3));
// Each doubling roughly halves it, and even the shortest fade tested is a quarter of the
// hard seam.
CHECK(seamStep(4) < hard * 0.25);
CHECK(seamStep(8) < seamStep(4) * 0.5);
CHECK(seamStep(16) < seamStep(8) * 0.5);
}
static void testCrossfadeLengthFollowsItsParameter() {
// A longer fade starts earlier and nowhere else: the region begin is end - crossfade, so
// doubling the parameter doubles the number of blended frames.
auto firstFadedFrame = [](std::int64_t xf) {
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 40;
s.loop.end = 60;
s.loopCrossfadeFrames = xf;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 70);
for (int n = 40; n < 60; ++n) {
const double got = sourceFrameOf(out[static_cast<std::size_t>(n)]);
if (!approx(got, static_cast<double>(n), 1e-3)) return n;
}
return 60;
};
CHECK(firstFadedFrame(0) == 60); // never diverges from the raw read
CHECK(firstFadedFrame(4) == 57); // region [56,60); frame 56 carries weight 0
CHECK(firstFadedFrame(8) == 53); // region [52,60)
CHECK(firstFadedFrame(16) == 45); // region [44,60)
}
// The fade cannot read before frame 0, so a loop starting at 0 gets none — silently clamped
// rather than reading out of bounds or refusing the loop outright.
static void testCrossfadeIsSuppressedForALoopAtFrameZero() {
SampleData s = indexSample(100);
s.loop.hasLoop = true;
s.loop.start = 0;
s.loop.end = 20;
s.loopCrossfadeFrames = 16;
s.play.adsr = flatAdsr();
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 60);
CHECK(eng.activeVoiceCount() == 1);
for (int n = 0; n < 20; ++n) {
CHECK(approx(sourceFrameOf(out[static_cast<std::size_t>(n)]),
static_cast<double>(n), 1e-3));
}
}
static void testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice() {
// The loop is the SUSTAIN: held, the voice never ends; released, it must leave the
// sustain level down the release ramp and free itself — not keep cycling forever.
SampleData s = dcSample(60, 60); // flat 1.0 so output IS the envelope
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
s.loopCrossfadeFrames = 4;
AdsrParams a = flatAdsr();
a.releaseFrames = 32;
s.play.adsr = a;
VoiceEngine eng(1, s);
eng.noteOn(60, 127);
std::vector<AudioSample> held;
eng.render(held, 500); // far past the sample end
CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(held.back(), 1.0, 1e-4)); // still at sustain, still looping
eng.noteOff(60);
std::vector<AudioSample> tail;
eng.render(tail, 16);
// Mid-release: audibly decaying, not yet done.
CHECK(tail.back() < 0.75 && tail.back() > 0.0);
CHECK(eng.activeVoiceCount() == 1);
std::vector<AudioSample> rest;
eng.render(rest, 64);
CHECK(eng.activeVoiceCount() == 0);
CHECK(approx(rest.back(), 0.0, 1e-6));
}
// Preserve's contract is LOOP THE SOURCE, SHIFT THE OUTPUT: the loop points name source
// frames, so a transposed note loops the same source span and keeps sounding at level. If the
// span were treated as an output-domain fact, an up-shifted voice would outrun it, freeze its
// shifter tail and decay. Run with and without a crossfade, since the fade is applied to the
// SOURCE feed ahead of the shifter and the ring prime walks the same blend.
static void testPreserveLoopsTheSourceSpanAtEveryTransposition() {
for (std::int64_t xf : {std::int64_t{0}, std::int64_t{16}}) {
for (int note : {48, 60, 72}) {
SampleData s;
s.frames.resize(200, 0.0f);
for (int i = 60; i < 120; ++i) s.frames[i] = 0.5f; // the loop body + its run-in
s.rootNote = 60;
s.sampleRate = 48000;
s.loop.hasLoop = true;
s.loop.start = 80;
s.loop.end = 120;
s.loopCrossfadeFrames = xf;
s.play.adsr = flatAdsr();
s.play.pitchEngine = PitchEngine::Preserve;
VoiceEngine eng(1, s);
eng.noteOn(note, 127);
std::vector<AudioSample> out;
eng.render(out, 4000); // 20x the sample length
// The source span is finite; only the loop can keep a voice alive this long, and
// it does so at every transposition because the span is a source-frame fact.
CHECK(eng.activeVoiceCount() == 1);
// And it is still delivering the loop body, not a decaying frozen tail. The body
// and its run-in are one constant, so the shifter's splices reproduce it whatever
// the shift ratio and whatever the fade blends.
double sum = 0.0;
for (std::size_t i = out.size() - 200; i < out.size(); ++i) sum += out[i];
CHECK(approx(sum / 200.0, 0.5, 0.05));
}
}
}
// ---------------------------------------------------------------------------
// velocity -> volume.
// ---------------------------------------------------------------------------
@@ -2562,6 +2807,14 @@ int main() {
testStartFrameOutOfRangeClampsToZero();
testStartFrameWithLoop();
testStartAfterLoopEndWrapsIntoLoop();
testLoopReadWrapsSampleExactOverManyCycles();
testZeroCrossfadeLeavesTheSeamHard();
testCrossfadeBlendsMonotonelyAcrossTheRegion();
testSeamStepShrinksInProportionToTheFadeLength();
testCrossfadeLengthFollowsItsParameter();
testCrossfadeIsSuppressedForALoopAtFrameZero();
testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice();
testPreserveLoopsTheSourceSpanAtEveryTransposition();
testVelocityDefaultCurveIsFlatUnity();
testVelocityLinearCurveReproducesRamp();
testVelocityShapedCurveDrivesGain();
+57
View File
@@ -5,6 +5,7 @@
//
// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width);
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
// markerHandleRect (the top-strip tab that keeps coincident markers independently grabbable);
// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
// no-crossing keeps target, target clamp, degenerate buffers); waveformSurface (two stacked
@@ -309,6 +310,57 @@ static void testMarkerGrabInMonoSpansTheBand() {
CHECK(markerAtPoint(s.overlay, frames, markers, 1, mx, b.bottom() + 5) == -1);
}
// --- The marker grab handle ----------------------------------------------------
static void testMarkerHandleIsATopStripCentredOnTheMarker() {
const Rect a = wideArea();
const int mx = frameToX(overlayOf(a), 1000, 250);
const Rect h = markerHandleRect(overlayOf(a), 1000, 250);
CHECK(h.x == mx - kMarkerHandleHalfWidth);
CHECK(h.right() == mx + kMarkerHandleHalfWidth + 1);
CHECK(h.y == a.y);
CHECK(h.height == kMarkerHandleHeight);
CHECK(contains(h, mx, a.y));
CHECK(contains(h, mx, a.y + kMarkerHandleHeight - 1));
CHECK(!contains(h, mx, a.y + kMarkerHandleHeight)); // below the strip is the column's
}
// The whole reason the handle exists: two markers that share a frame both stay reachable —
// markerAtPoint gives its full-height column to the first in draw order, and the handle owns
// the strip above. Without the split, the loser could never be dragged apart again.
static void testCoincidentMarkersStayIndependentlyGrabbable() {
const Rect a = wideArea();
const std::int64_t markers[2] = {250, 250};
const int mx = frameToX(overlayOf(a), 1000, 250);
// The column resolves to the first marker at every height, including the top strip.
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.y) == 0);
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 2, mx, a.bottom() - 1) == 0);
// The handle, asked first, resolves the second one in that same top strip.
CHECK(contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.y));
CHECK(!contains(markerHandleRect(overlayOf(a), 1000, 250), mx, a.bottom() - 1));
}
static void testMarkerHandleClipsIntoTheArea() {
const Rect a = wideArea();
// At the last frame the marker maps to right(); an unclipped tab would claim pixels
// outside the band the caller already hit-tested.
const Rect hi = markerHandleRect(overlayOf(a), 1000, 1000);
CHECK(hi.right() == a.right());
CHECK(!contains(hi, a.right(), a.y));
CHECK(contains(hi, a.right() - 1, a.y));
// And at frame 0 it cannot reach left of the band.
const Rect lo = markerHandleRect(overlayOf(a), 1000, 0);
CHECK(lo.x == a.x);
CHECK(!contains(lo, a.x - 1, a.y));
}
static void testMarkerHandleOnDegenerateAreas() {
CHECK(markerHandleRect(overlayOf(Rect{}), 1000, 0).empty());
// A band shorter than the strip yields a handle the height of the band, never taller.
const Rect thin = Rect{0, 0, 100, 4};
CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4);
}
// --- Per-lane envelope content -------------------------------------------------
static void testAsymmetricStereoLanesCarryDifferentContent() {
@@ -379,6 +431,11 @@ int main() {
testMarkerGrabReachesTheLowerStereoLane();
testMarkerGrabInMonoSpansTheBand();
testMarkerHandleIsATopStripCentredOnTheMarker();
testCoincidentMarkersStayIndependentlyGrabbable();
testMarkerHandleClipsIntoTheArea();
testMarkerHandleOnDegenerateAreas();
testAsymmetricStereoLanesCarryDifferentContent();
testLaneEnvelopeRejectsOutOfRangeLane();