loop: crossfade the Gate sustain seam, and unshadow the loop handles that made loop points look gone
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user