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,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