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
+5 -1
View File
@@ -16,6 +16,10 @@ reasampler_test(master_gain LINK master_gain)
# Declared before sampler_core because the voice now runs one per sounding note.
add_subdirectory(filter)
# The sustain loop's validity + crossfade geometry, shared by the voice and the editor's
# marker layer. After filter: it links play_params' dependency set, which includes it.
add_subdirectory(loop)
# The live-parameter block: the value layer plus its publication, deliberately linking no
# engine — the block is a plain value the voice observes, not a thing the engine owns.
reasampler_pure_library(live_params
@@ -28,7 +32,7 @@ reasampler_test(live_params LINK live_params)
# boundary costs the hot path nothing.
reasampler_pure_library(sampler_core
SOURCES voice.cpp voice_engine.cpp
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law)
LINK PUBLIC peaks pitch_shift velocity_curve filter live_params curve_law loop_span)
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
# or REAPER type reaching the core would fail to compile or link here.
reasampler_test(sampler_core LINK sampler_core)
+60
View File
@@ -0,0 +1,60 @@
# src/core/instrument/engine/loop — the sustain loop's span rule
## Scope
One pure module, `loop_span`: the ONE fold that turns a stored `SampleLoop` + crossfade
length into the `ResolvedLoop` the voice's read path wraps on, plus the editor's default
handle placement for a capture with no loop. No REAPER, no VST3, no allocation, no I/O.
Everything lives in `reasampler::instrument::engine::loop`.
`resolveLoop` is cold — called once per note-on and by the editor. `crossfadeWeight` is
header-inline because it is evaluated per voice per sample.
## Invariants
### The crossfade is PRE-SEAM, and that is what makes it one tap
The fade runs over the last `crossfade` frames before `end`, blending the material running
into `end` toward the material running into `start`. The incoming material is the same read
head one loop length earlier, so the second tap is `pos - length` — no second position to
advance, no second wrap rule, no state. At `end` the incoming tap has arrived at `start`,
which is exactly where the wrap puts the head, so the seam is continuous by construction
rather than by a fade that merely hides it.
The consequence is a hard clamp: **`crossfade <= start`**. A loop starting at frame 0 has no
material ahead of it and therefore gets no crossfade, whatever the user dialled — honest
rather than silently reading before the buffer.
### The seam does not close to zero, it closes as 1/crossfade
The weight reaches 1 only AT `end` — a position no rendered frame lands on — so the last
frame before the wrap carries `(xf-1)/xf` and a residual step of `(seam step)/xf` survives.
Measured on a source-frame ramp with a 19-frame seam: 4.0 at `xf = 4`, 1.5 at 8, 0.25 at 16
(`sampler_core_tests`). It is proportional and therefore inaudible at any musically useful
setting; a claim that the crossfade makes the seam *exactly* continuous is wrong in the
discrete domain and a test asserting it will fail.
### Linear, not equal-power
The two taps are one loop length apart in the same material and are usually well correlated,
where an equal-power pair bulges. Linear also costs a subtract and a multiply on a path that
forbids a transcendental. The filter's morph crossfade is equal-power for a reason specific
to quadrature taps (`engine/filter/CLAUDE.md`) — that reasoning does not transfer here.
### An invalid span is refused, never repaired
An inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield
`active == false`, so the note plays straight through. Repairing a corrupt span into a
plausible one would make a wrong loop audible and a bug invisible; the crossfade length is
the one field that IS clamped rather than refused, because its bound is a property of the
loop it sits in rather than of the user's intent.
## Gotchas
- **`crossfadeWeight` assumes its argument is already wrapped** into `[start, end)`. The
ceiling at 1.0 is a belt against an unwrapped caller, not permission to skip the wrap —
an unwrapped position would otherwise extrapolate past the incoming tap.
- **`defaultLoopBounds` is a UI default living in an engine module** on purpose: the span the
user is offered and the span `resolveLoop` will accept have to be one definition, and the
previous frame-0 default put the loop-start handle underneath the start marker where no
grab could reach it.
@@ -0,0 +1,7 @@
# The loop's validity rule and crossfade geometry. Links peaks only (via play_params' own
# SampleLoop) — deliberately not the voice engine: the resolve is a fold over plain values,
# which is what lets the editor share it without pulling the engine in.
reasampler_pure_library(loop_span
SOURCES loop_span.cpp
LINK PUBLIC peaks filter velocity_curve curve_law)
reasampler_test(loop_span LINK loop_span)
@@ -0,0 +1,37 @@
// loop_span.cpp — see loop_span.h. Pure math; no host types.
#include "core/instrument/engine/loop/loop_span.h"
namespace reasampler::instrument::engine::loop {
ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
std::int64_t frameCount, bool gateMode) {
ResolvedLoop out;
// Trigger is a one-shot by definition, so the loop is not merely unused there — it is
// absent, and the read path branches on this one flag.
if (!gateMode || !loop.hasLoop) return out;
if (loop.start < 0 || loop.end <= loop.start || loop.end > frameCount) return out;
out.active = true;
out.start = loop.start;
out.end = loop.end;
out.length = loop.end - loop.start;
// The incoming tap reads at `pos - length`, i.e. over [start - crossfade, start) — so the
// fade cannot outrun the material ahead of the loop, nor the loop itself.
std::int64_t xf = crossfadeFrames;
if (xf < 0) xf = 0;
if (xf > out.start) xf = out.start;
if (xf > out.length) xf = out.length;
out.crossfade = xf;
out.fadeBegin = static_cast<double>(out.end - xf);
out.fadeInv = xf > 0 ? 1.0 / static_cast<double>(xf) : 0.0;
return out;
}
LoopBounds defaultLoopBounds(std::int64_t frameCount) {
if (frameCount <= 0) return LoopBounds{};
return LoopBounds{frameCount - frameCount / 4, frameCount};
}
} // namespace reasampler::instrument::engine::loop
@@ -0,0 +1,54 @@
#pragma once
// loop_span.h — the sustain loop's ONE validity/clamp rule plus its pre-seam crossfade
// geometry. The resolve is cold (note-on, editor); crossfadeWeight is header-inline because
// it sits on the per-voice-per-sample read.
#include <cstdint>
#include "core/instrument/engine/play_params.h" // SampleLoop
namespace reasampler::instrument::engine::loop {
// A sustain loop folded against one capture: validity, geometry, and the clamped crossfade.
// `active == false` leaves every other field zero, so a caller can wrap on the flag alone.
//
// The fade is PRE-SEAM and its incoming tap is the same read head one loop length earlier —
// `pos - length`, no second position to advance. See CLAUDE.md for why that shape, and for
// the `crossfade <= start` bound it forces.
struct ResolvedLoop {
bool active = false;
std::int64_t start = 0;
std::int64_t end = 0; // half-open
std::int64_t length = 0; // end - start
std::int64_t crossfade = 0; // source frames; 0 = hard seam
double fadeBegin = 0.0; // end - crossfade
double fadeInv = 0.0; // 1 / crossfade; 0 when crossfade == 0
};
// Folds a stored loop + crossfade against the decoded sample. Refuses anything the read path
// could not honour — a non-Gate mode, an unset loop, an inverted or empty span, a span
// reaching outside the PCM — by returning an inactive result rather than a repaired one, so a
// corrupt span silently plays through instead of reading out of bounds.
ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
std::int64_t frameCount, bool gateMode);
// Weight of the INCOMING (pre-loop-start) tap at source position `pos`: 0 before the fade
// region, rising linearly to 1 at `end`. `pos` must already be wrapped into [start, end) —
// the ceiling is a belt for a caller that has not wrapped yet, not a licence to skip it.
inline double crossfadeWeight(const ResolvedLoop& lp, double pos) {
if (lp.crossfade <= 0) return 0.0;
const double d = pos - lp.fadeBegin;
if (d <= 0.0) return 0.0;
return d < static_cast<double>(lp.crossfade) ? d * lp.fadeInv : 1.0;
}
// Where the editor parks the loop handles for a capture that has none — the last quarter,
// where a sustain loop actually goes. Lives here, next to the validity rule, so the span a
// user is offered and the span the engine will accept are one definition.
struct LoopBounds {
std::int64_t start = 0;
std::int64_t end = 0;
};
LoopBounds defaultLoopBounds(std::int64_t frameCount);
} // namespace reasampler::instrument::engine::loop
+6
View File
@@ -170,6 +170,12 @@ struct SampleData {
int rootNote = 60;
SampleLoop loop;
// Pre-seam crossfade at the loop reset, in SOURCE frames — a source-timeline quantity
// like the loop points it belongs to, so no rate resolves it. 0 (the default) is the
// hard seam every instance predating the field plays. engine/loop/loop_span.h owns what
// the fade actually does and how it clamps.
std::int64_t loopCrossfadeFrames = 0;
// Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
std::int64_t startFrame = 0;
+14 -6
View File
@@ -66,6 +66,10 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
readPos_ = static_cast<double>(start);
startFrame_ = start; // the span-offset origin: readPos - startFrame
// The one fold of the stored span + crossfade into what the read path wraps on.
loop_ = instrument::engine::loop::resolveLoop(sample.loop, sample.loopCrossfadeFrames,
frameCount, playMode_ == PlayMode::Gate);
// Amplitude envelope: Gate = AHDSR (all five fields read from play.adsr, resolved to
// frames from stored seconds at load time); Trigger = the staged AHD over the % play span.
const std::int64_t postStart = frameCount - start; // >= 1 (start clamped < frameCount)
@@ -162,9 +166,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
// no per-frame shifter cost.
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
const std::int64_t w = shiftL_.window();
const bool loopWrap = sustainLoopUsable();
const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool loopWrap = loop_.active;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
// The prime may only carry playable source. The per-frame feed stops at feedBound
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
@@ -189,12 +191,18 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
std::int64_t q = start;
for (std::int64_t i = 0; i < primeCount; ++i) {
if (loopWrap) {
while (q >= loop.end) q -= loopLen;
while (q >= loop_.end) q -= loop_.length;
}
// q < frameCount holds by construction on the non-loop path (primeCount is
// bounded); the guard stays as a belt for the loop-wrap walk.
// bounded); the guard stays as a belt for the loop-wrap walk. The prime runs
// the SAME crossfade the per-frame feed does — a ring primed with an un-faded
// seam would put the click back one window into the note.
primeBuf_[static_cast<std::size_t>(i)] =
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
(q < frameCount)
? crossfadedSource(pcmCh, q,
instrument::engine::loop::crossfadeWeight(
loop_, static_cast<double>(q)))
: 0.0f;
++q;
}
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
+58 -22
View File
@@ -16,6 +16,7 @@
#include "core/instrument/engine/filter/filter_params.h"
#include "core/instrument/engine/filter/voice_filter.h"
#include "core/instrument/engine/live_params.h"
#include "core/instrument/engine/loop/loop_span.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/play_params.h"
#include "core/instrument/engine/velocity_curve.h"
@@ -26,6 +27,8 @@ using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
using instrument::engine::loop::ResolvedLoop;
using instrument::engine::loop::crossfadeWeight;
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
// reference-frequency needed.
@@ -151,14 +154,30 @@ public:
}
private:
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
// by the output anchor, the Preserve feed, and the start()-time ring prime.
bool sustainLoopUsable() const {
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
const SampleLoop& loop = sample_->loop;
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
// Linear-interpolated read at a plain (non-wrapping) fractional source position. The
// crossfade tap sits one loop length behind the head, i.e. BEFORE the loop start, so it
// never needs the wrap partner the main read uses.
static double lerpSource(const std::vector<AudioSample>& pcm, std::int64_t frameCount,
double pos) {
const std::int64_t i0 = static_cast<std::int64_t>(pos);
const std::int64_t i1 = i0 + 1;
const double frac = pos - static_cast<double>(i0);
const double a = (i0 >= 0 && i0 < frameCount) ? static_cast<double>(pcm[i0]) : 0.0;
const double b = (i1 >= 0 && i1 < frameCount) ? static_cast<double>(pcm[i1]) : 0.0;
return a + (b - a) * frac;
}
// One integer source frame with the loop crossfade already blended in — the Preserve
// path's read, and the start()-time ring prime's. `pos` must be a valid index; `xw` is
// crossfadeWeight at that position (0 blends nothing).
AudioSample crossfadedSource(const std::vector<AudioSample>& pcm, std::int64_t pos,
double xw) const {
const double v = static_cast<double>(pcm[static_cast<std::size_t>(pos)]);
if (xw <= 0.0) return static_cast<AudioSample>(v);
const std::int64_t tap = pos - loop_.length;
if (tap < 0) return static_cast<AudioSample>(v);
const double in = static_cast<double>(pcm[static_cast<std::size_t>(tap)]);
return static_cast<AudioSample>(v + xw * (in - v));
}
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
@@ -325,14 +344,13 @@ private:
const bool haveR = stereo && sample_->channelCount() == 2;
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A
// valid, non-zero-length loop wraps the read head back into [start, end); a
// zero-length loop is "no loop". Under Preserve the loop is over the source read
// (loop the source, shift the output).
const SampleLoop& loop = sample_->loop;
const bool loopUsable = sustainLoopUsable();
if (loopUsable) {
const double loopLen = static_cast<double>(loop.end - loop.start);
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). The
// span was folded once at note-on (loop_span.h); an invalid or absent loop leaves
// loop_.active false and this whole path off. Under Preserve the loop is over the
// source read (loop the source, shift the output).
const ResolvedLoop& loop = loop_;
if (loop.active) {
const double loopLen = static_cast<double>(loop.length);
while (readPos_ >= static_cast<double>(loop.end)) {
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
}
@@ -410,9 +428,8 @@ private:
// rings were primed with that window at start()), under the same sustain-loop wrap
// rule, reading integer source frames (nothing to interpolate). Past the last real
// frame the shifter's writer is frozen — it recycles the real tail it already holds.
if (loopUsable) {
const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
if (loop.active) {
while (feedPos_ >= loop.end) feedPos_ -= loop.length;
}
// feedPos_ runs one window ahead of readPos_; the last real source frame is
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
@@ -428,7 +445,11 @@ private:
const bool exhausted = feedPos_ >= feedBound;
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
// Crossfaded on the way IN to the shifter, not on the way out: loop the source,
// shift the output.
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
const AudioSample feedL =
feedOk ? crossfadedSource(pcm, feedPos_, feedXw) : 0.0f;
const double shift = baseRatio_ * envFactor;
shiftL_.setShiftRatio(shift);
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
@@ -447,7 +468,7 @@ private:
// not leak a previous note.
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR =
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
feedOk ? crossfadedSource(pcmR, feedPos_, feedXw) : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
@@ -471,7 +492,7 @@ private:
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
const double frac = readPos_ - static_cast<double>(i0);
std::int64_t i1 = i0 + 1;
if (loopUsable && i1 >= loop.end) {
if (loop.active && i1 >= loop.end) {
i1 = loop.start; // seamless wrap for the interpolation partner.
}
const bool i0ok = (i0 >= 0 && i0 < frameCount);
@@ -486,6 +507,16 @@ private:
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
outRlocal = srcR;
}
// Loop crossfade: blend toward the same read head one loop length earlier, which
// is the material the wrap is about to hand over to. Zero outside the fade region
// (and always, with no fade dialled), so the un-crossfaded read stays exactly the
// shape it was.
const double xw = crossfadeWeight(loop, readPos_);
if (xw > 0.0) {
const double tap = readPos_ - static_cast<double>(loop.length);
outL += xw * (lerpSource(pcm, frameCount, tap) - outL);
if (stereo) outRlocal += xw * (lerpSource(pcmR, frameCount, tap) - outRlocal);
}
ratio_ = baseRatio_ * envFactor;
}
@@ -569,6 +600,11 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// The sustain loop folded ONCE at note-on: the sample, the play mode and the stored span
// are all fixed for the note's lifetime, so re-deriving validity per frame bought nothing.
// Shared by the output anchor, the Preserve feed, and the start()-time ring prime.
ResolvedLoop loop_;
// The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
// different envelope phases are filtered independently. filterCutoffNorm_ keeps the
// unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants