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