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