loop: fix the crossfade seam's residual discontinuity, plus six review minors
Normalizes crossfadeWeight over crossfade-1 so the last rendered frame lands at exactly the incoming tap instead of a residual step; corrects the CLAUDE.md invariant and seam test to match. Shares lerpSource/crossfadedSource/maxCrossfade, fixes stale docs/constants, and clears crossfade on the loop-OFF gesture.
This commit is contained in:
@@ -25,14 +25,17 @@ The consequence is a hard clamp: **`crossfade <= start`**. A loop starting at fr
|
|||||||
material ahead of it and therefore gets no crossfade, whatever the user dialled — honest
|
material ahead of it and therefore gets no crossfade, whatever the user dialled — honest
|
||||||
rather than silently reading before the buffer.
|
rather than silently reading before the buffer.
|
||||||
|
|
||||||
### The seam does not close to zero, it closes as 1/crossfade
|
### The weight is normalized over `crossfade - 1`, so the last rendered frame lands AT 1
|
||||||
|
|
||||||
The weight reaches 1 only AT `end` — a position no rendered frame lands on — so the last
|
`crossfadeWeight` normalizes by `1/(crossfade-1)`, not `1/crossfade`: `d` at the last rendered
|
||||||
frame before the wrap carries `(xf-1)/xf` and a residual step of `(seam step)/xf` survives.
|
frame (`end - 1`) is always exactly `crossfade - 1` — the ceiling's own threshold — for every
|
||||||
Measured on a source-frame ramp with a 19-frame seam: 4.0 at `xf = 4`, 1.5 at 8, 0.25 at 16
|
REAL crossfade length (`crossfade >= 2`), so that frame is the incoming tap outright rather than
|
||||||
(`sampler_core_tests`). It is proportional and therefore inaudible at any musically useful
|
a blend approaching it. The seam across the wrap is therefore the material's OWN one-frame step
|
||||||
setting; a claim that the crossfade makes the seam *exactly* continuous is wrong in the
|
(`sampler_core_tests`, `testSeamStepMatchesTheNaturalStepForAnyCrossfade`), not a residual that
|
||||||
discrete domain and a test asserting it will fail.
|
merely shrinks with a longer fade — the earlier `1/crossfade` normalization left `(xf-1)/xf` at
|
||||||
|
that frame, which is what the `crossfade - 1` fix closes. `crossfade == 1` degenerates to the
|
||||||
|
hard seam instead: its one frame sits at `d == 0`, caught by the `d <= 0` floor before the
|
||||||
|
multiply/ceiling ever runs, so `fadeInv` is guarded to 0 rather than dividing by zero.
|
||||||
|
|
||||||
### Linear, not equal-power
|
### Linear, not equal-power
|
||||||
|
|
||||||
@@ -41,6 +44,12 @@ where an equal-power pair bulges. Linear also costs a subtract and a multiply on
|
|||||||
forbids a transcendental. The filter's morph crossfade is equal-power for a reason specific
|
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.
|
to quadrature taps (`engine/filter/CLAUDE.md`) — that reasoning does not transfer here.
|
||||||
|
|
||||||
|
**Known exception:** a full-mix or stem bounce (in this tool's own stated material scope) is
|
||||||
|
not quasi-periodic, so its two taps are effectively decorrelated — a linear pair then dips
|
||||||
|
~3 dB at the fade midpoint the way it wouldn't on a correlated tonal/one-shot loop. Accepted
|
||||||
|
rather than fixed: an equal-power pair would cost the transcendental this path forbids, and the
|
||||||
|
dip is a fade-region loudness wobble, not the seam click the crossfade exists to kill.
|
||||||
|
|
||||||
### An invalid span is refused, never repaired
|
### An invalid span is refused, never repaired
|
||||||
|
|
||||||
An inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield
|
An inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# The loop's validity rule and crossfade geometry. Links peaks only (via play_params' own
|
# The loop's validity rule and crossfade geometry. Links peaks/filter/velocity_curve/curve_law
|
||||||
# SampleLoop) — deliberately not the voice engine: the resolve is a fold over plain values,
|
# (play_params' own dependency set) — deliberately not the voice engine: the resolve is a fold
|
||||||
# which is what lets the editor share it without pulling the engine in.
|
# over plain values, which is what lets the editor share it without pulling the engine in.
|
||||||
reasampler_pure_library(loop_span
|
reasampler_pure_library(loop_span
|
||||||
SOURCES loop_span.cpp
|
SOURCES loop_span.cpp
|
||||||
LINK PUBLIC peaks filter velocity_curve curve_law)
|
LINK PUBLIC peaks filter velocity_curve curve_law)
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
|
|||||||
// fade cannot outrun the material ahead of the loop, nor the loop itself.
|
// fade cannot outrun the material ahead of the loop, nor the loop itself.
|
||||||
std::int64_t xf = crossfadeFrames;
|
std::int64_t xf = crossfadeFrames;
|
||||||
if (xf < 0) xf = 0;
|
if (xf < 0) xf = 0;
|
||||||
if (xf > out.start) xf = out.start;
|
const std::int64_t bound = maxCrossfade(out.start, out.length);
|
||||||
if (xf > out.length) xf = out.length;
|
if (xf > bound) xf = bound;
|
||||||
out.crossfade = xf;
|
out.crossfade = xf;
|
||||||
out.fadeBegin = static_cast<double>(out.end - xf);
|
out.fadeBegin = static_cast<double>(out.end - xf);
|
||||||
out.fadeInv = xf > 0 ? 1.0 / static_cast<double>(xf) : 0.0;
|
// xf == 1 has no fractional region to normalize (crossfadeWeight's d <= 0 check already
|
||||||
|
// catches its only frame) — guard rather than divide by zero.
|
||||||
|
out.fadeInv = xf > 1 ? 1.0 / static_cast<double>(xf - 1) : 0.0;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,22 @@
|
|||||||
// it sits on the per-voice-per-sample read.
|
// it sits on the per-voice-per-sample read.
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/audio/peaks.h" // AudioSample
|
||||||
#include "core/instrument/engine/play_params.h" // SampleLoop
|
#include "core/instrument/engine/play_params.h" // SampleLoop
|
||||||
|
|
||||||
namespace reasampler::instrument::engine::loop {
|
namespace reasampler::instrument::engine::loop {
|
||||||
|
|
||||||
|
using audio::AudioSample;
|
||||||
|
|
||||||
|
// The crossfade's own bound: it cannot outrun the material ahead of the loop (`start` source
|
||||||
|
// frames precede it) nor the loop's own length (the incoming tap is one loop length behind the
|
||||||
|
// head). Shared by resolveLoop's clamp and the editor's drag clamp so the two cannot diverge.
|
||||||
|
inline std::int64_t maxCrossfade(std::int64_t start, std::int64_t length) {
|
||||||
|
return start < length ? start : length;
|
||||||
|
}
|
||||||
|
|
||||||
// A sustain loop folded against one capture: validity, geometry, and the clamped crossfade.
|
// 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.
|
// `active == false` leaves every other field zero, so a caller can wrap on the flag alone.
|
||||||
//
|
//
|
||||||
@@ -22,7 +33,7 @@ struct ResolvedLoop {
|
|||||||
std::int64_t length = 0; // end - start
|
std::int64_t length = 0; // end - start
|
||||||
std::int64_t crossfade = 0; // source frames; 0 = hard seam
|
std::int64_t crossfade = 0; // source frames; 0 = hard seam
|
||||||
double fadeBegin = 0.0; // end - crossfade
|
double fadeBegin = 0.0; // end - crossfade
|
||||||
double fadeInv = 0.0; // 1 / crossfade; 0 when crossfade == 0
|
double fadeInv = 0.0; // 1 / (crossfade - 1); 0 when crossfade <= 1
|
||||||
};
|
};
|
||||||
|
|
||||||
// Folds a stored loop + crossfade against the decoded sample. Refuses anything the read path
|
// Folds a stored loop + crossfade against the decoded sample. Refuses anything the read path
|
||||||
@@ -33,13 +44,46 @@ ResolvedLoop resolveLoop(const SampleLoop& loop, std::int64_t crossfadeFrames,
|
|||||||
std::int64_t frameCount, bool gateMode);
|
std::int64_t frameCount, bool gateMode);
|
||||||
|
|
||||||
// Weight of the INCOMING (pre-loop-start) tap at source position `pos`: 0 before the fade
|
// 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) —
|
// region, reaching exactly 1 at the LAST rendered frame (`end - 1`), not merely approaching it —
|
||||||
// the ceiling is a belt for a caller that has not wrapped yet, not a licence to skip it.
|
// normalizing over `crossfade - 1` rather than `crossfade` is what buys that: d at `end - 1` is
|
||||||
|
// always exactly `crossfade - 1`, the ceiling's own threshold, for any REAL crossfade
|
||||||
|
// (`crossfade >= 2`). That last frame is therefore the incoming tap outright, which is exactly
|
||||||
|
// the value the wrap hands over, so the step across the seam is the material's own natural step
|
||||||
|
// — not a residual that merely shrinks with a longer fade. `crossfade == 1` degenerates to the
|
||||||
|
// hard seam instead: its one frame sits at `d == 0`, caught by the `d <= 0` floor below before
|
||||||
|
// the ceiling ever runs. `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) {
|
inline double crossfadeWeight(const ResolvedLoop& lp, double pos) {
|
||||||
if (lp.crossfade <= 0) return 0.0;
|
if (lp.crossfade <= 0) return 0.0;
|
||||||
const double d = pos - lp.fadeBegin;
|
const double d = pos - lp.fadeBegin;
|
||||||
if (d <= 0.0) return 0.0;
|
if (d <= 0.0) return 0.0;
|
||||||
return d < static_cast<double>(lp.crossfade) ? d * lp.fadeInv : 1.0;
|
return d < static_cast<double>(lp.crossfade - 1) ? d * lp.fadeInv : 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
inline 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).
|
||||||
|
inline AudioSample crossfadedSource(const std::vector<AudioSample>& pcm, const ResolvedLoop& lp,
|
||||||
|
std::int64_t pos, double xw) {
|
||||||
|
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 - lp.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));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where the editor parks the loop handles for a capture that has none — the last quarter,
|
// Where the editor parks the loop handles for a capture that has none — the last quarter,
|
||||||
|
|||||||
@@ -199,9 +199,8 @@ void Voice::start(int note, int velocity, const SampleData& sample, bool declick
|
|||||||
// seam would put the click back one window into the note.
|
// seam would put the click back one window into the note.
|
||||||
primeBuf_[static_cast<std::size_t>(i)] =
|
primeBuf_[static_cast<std::size_t>(i)] =
|
||||||
(q < frameCount)
|
(q < frameCount)
|
||||||
? crossfadedSource(pcmCh, q,
|
? crossfadedSource(pcmCh, loop_, q, crossfadeWeight(loop_,
|
||||||
instrument::engine::loop::crossfadeWeight(
|
static_cast<double>(q)))
|
||||||
loop_, static_cast<double>(q)))
|
|
||||||
: 0.0f;
|
: 0.0f;
|
||||||
++q;
|
++q;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ using instrument::engine::VelocityCurve;
|
|||||||
using instrument::engine::VelocityPoint;
|
using instrument::engine::VelocityPoint;
|
||||||
using instrument::engine::loop::ResolvedLoop;
|
using instrument::engine::loop::ResolvedLoop;
|
||||||
using instrument::engine::loop::crossfadeWeight;
|
using instrument::engine::loop::crossfadeWeight;
|
||||||
|
using instrument::engine::loop::crossfadedSource;
|
||||||
|
using instrument::engine::loop::lerpSource;
|
||||||
|
|
||||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
|
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
|
||||||
// reference-frequency needed.
|
// reference-frequency needed.
|
||||||
@@ -154,32 +156,6 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// 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
|
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
|
// output frame (envelope time is wall-clock, independent of read rate). Trigger: the AHD
|
||||||
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
|
// is evaluated at the source offset (readPos - startFrame) so its stages anchor to source
|
||||||
@@ -449,7 +425,7 @@ private:
|
|||||||
// shift the output.
|
// shift the output.
|
||||||
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
|
const double feedXw = crossfadeWeight(loop, static_cast<double>(feedPos_));
|
||||||
const AudioSample feedL =
|
const AudioSample feedL =
|
||||||
feedOk ? crossfadedSource(pcm, feedPos_, feedXw) : 0.0f;
|
feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f;
|
||||||
const double shift = baseRatio_ * envFactor;
|
const double shift = baseRatio_ * envFactor;
|
||||||
shiftL_.setShiftRatio(shift);
|
shiftL_.setShiftRatio(shift);
|
||||||
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||||
@@ -468,7 +444,7 @@ private:
|
|||||||
// not leak a previous note.
|
// not leak a previous note.
|
||||||
if (exhausted) shiftR_.freezeTail();
|
if (exhausted) shiftR_.freezeTail();
|
||||||
const AudioSample feedR =
|
const AudioSample feedR =
|
||||||
feedOk ? crossfadedSource(pcmR, feedPos_, feedXw) : 0.0f;
|
feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f;
|
||||||
shiftR_.setShiftRatio(shift);
|
shiftR_.setShiftRatio(shift);
|
||||||
outRlocal =
|
outRlocal =
|
||||||
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
|
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ std::int64_t xToFrame(const OverlayArea& area, std::int64_t frameCount, int x);
|
|||||||
// strip, the column owns everything below it. Without that split, first-in-draw-order wins
|
// strip, the column owns everything below it. Without that split, first-in-draw-order wins
|
||||||
// every coincident tie and the loser can never be dragged apart again.
|
// every coincident tie and the loser can never be dragged apart again.
|
||||||
inline constexpr int kMarkerHandleHeight = 10;
|
inline constexpr int kMarkerHandleHeight = 10;
|
||||||
inline constexpr int kMarkerHandleHalfWidth = 5;
|
// Same half-width as the column's own grab band on purpose: the handle is that same grab
|
||||||
|
// tolerance, just confined to the top strip, not an independent tuning.
|
||||||
|
inline constexpr int kMarkerHandleHalfWidth = kMarkerGrabWidth;
|
||||||
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame);
|
Rect markerHandleRect(const OverlayArea& area, std::int64_t frameCount, std::int64_t frame);
|
||||||
|
|
||||||
// Which marker (index into the caller's parallel `frames` array, in draw order) a grab at
|
// Which marker (index into the caller's parallel `frames` array, in draw order) a grab at
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/instrument/engine/loop/loop_span.h" // maxCrossfade (the shared drag-clamp bound)
|
||||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
|
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
|
||||||
#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap
|
#include "core/instrument/ui/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap
|
||||||
#include "shell/instrument/editor_internal.h"
|
#include "shell/instrument/editor_internal.h"
|
||||||
@@ -21,6 +22,7 @@ namespace reasampler::vst {
|
|||||||
|
|
||||||
using namespace reasampler::ui;
|
using namespace reasampler::ui;
|
||||||
using namespace reasampler::instrument::ui;
|
using namespace reasampler::instrument::ui;
|
||||||
|
using instrument::engine::loop::maxCrossfade;
|
||||||
|
|
||||||
bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||||
@@ -54,7 +56,10 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
|||||||
const SetupMarkers m = pickedMarkers(frames);
|
const SetupMarkers m = pickedMarkers(frames);
|
||||||
// The crossfade handle first, and only when there IS a loop to fade: at a zero fade it
|
// The crossfade handle first, and only when there IS a loop to fade: at a zero fade it
|
||||||
// sits exactly on the loop start, so it can only stay reachable by owning the top strip
|
// sits exactly on the loop start, so it can only stay reachable by owning the top strip
|
||||||
// (waveform_view.h's handle-vs-column split) and being asked first.
|
// (waveform_view.h's handle-vs-column split) and being asked first. The same ambiguity
|
||||||
|
// recurs whenever ANY marker's frame lands on loopStart - crossfade (most plausibly the
|
||||||
|
// start marker dragged up against the fade edge), so this check has to run before the
|
||||||
|
// marker array below regardless of which marker the collision is with.
|
||||||
if (m.hasLoop &&
|
if (m.hasLoop &&
|
||||||
contains(markerHandleRect(overlay, frames, m.loopStart - m.crossfade), x, y)) {
|
contains(markerHandleRect(overlay, frames, m.loopStart - m.crossfade), x, y)) {
|
||||||
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
|
beginMarkerDrag(WaveMarker::kLoopXfade, m, frames, x);
|
||||||
@@ -140,10 +145,9 @@ void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
|
|||||||
}
|
}
|
||||||
if (m.start < 0) m.start = 0;
|
if (m.start < 0) m.start = 0;
|
||||||
if (m.start > frames - 1) m.start = frames - 1;
|
if (m.start > frames - 1) m.start = frames - 1;
|
||||||
// Mirror resolveLoop's own bound so the handle can't be dragged somewhere the engine
|
// Shares resolveLoop's own bound (loop_span.h's maxCrossfade) so the handle can't be
|
||||||
// would silently clamp back: the fade reads the material ahead of the loop, and cannot
|
// dragged somewhere the engine would silently clamp back.
|
||||||
// outrun either that material or the loop itself.
|
m.crossfade = (std::min)(m.crossfade, maxCrossfade(m.loopStart, m.loopEnd - m.loopStart));
|
||||||
m.crossfade = (std::min)(m.crossfade, (std::min)(m.loopStart, m.loopEnd - m.loopStart));
|
|
||||||
if (m.crossfade < 0) m.crossfade = 0;
|
if (m.crossfade < 0) m.crossfade = 0;
|
||||||
|
|
||||||
applyMarkers(m);
|
applyMarkers(m);
|
||||||
|
|||||||
@@ -225,7 +225,10 @@ void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
|
|||||||
loop.start = m.loopStart;
|
loop.start = m.loopStart;
|
||||||
loop.end = m.loopEnd;
|
loop.end = m.loopEnd;
|
||||||
params_.loopOverride = loop;
|
params_.loopOverride = loop;
|
||||||
params_.loopCrossfadeFrames = m.crossfade;
|
// OFF parks the crossfade at 0 too — loadSelection's own clear (a fresh capture has no
|
||||||
|
// loop to fade) is the same rule; leaving a stale length here would silently re-apply it
|
||||||
|
// (clamped) the next time a loop is dragged back in.
|
||||||
|
params_.loopCrossfadeFrames = loop.hasLoop ? m.crossfade : 0;
|
||||||
params_.startPoint = m.start;
|
params_.startPoint = m.start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-10
@@ -93,18 +93,34 @@ static void testZeroCrossfadeWeighsNothingAnywhere() {
|
|||||||
CHECK(crossfadeWeight(lp, 399.999) == 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
|
// The weight rises from exactly 0 at the region's start to exactly 1 at the LAST rendered
|
||||||
// is what makes the seam continuous: at `end` the incoming tap has reached `start`, and the
|
// frame (end - 1, d == crossfade - 1) — normalizing over crossfade - 1 rather than crossfade is
|
||||||
// wrap puts the head there.
|
// what lands the ceiling exactly there instead of merely approaching it, which is what makes
|
||||||
|
// the seam continuous: at that frame the incoming tap has fully replaced the raw read, and the
|
||||||
|
// wrap hands over exactly that value.
|
||||||
static void testWeightRunsZeroToOneAcrossTheFadeRegion() {
|
static void testWeightRunsZeroToOneAcrossTheFadeRegion() {
|
||||||
const ResolvedLoop lp = resolveLoop(span(100, 400), 100, 1000, true);
|
// xf = 101 so xf - 1 = 100, a clean denominator (loopStart 200 keeps the clamp out of the
|
||||||
CHECK(lp.crossfade == 100);
|
// way: max is min(200, 200)).
|
||||||
CHECK(lp.fadeBegin == 300.0);
|
const ResolvedLoop lp = resolveLoop(span(200, 400), 101, 1000, true);
|
||||||
|
CHECK(lp.crossfade == 101);
|
||||||
|
CHECK(lp.fadeBegin == 299.0);
|
||||||
|
CHECK(crossfadeWeight(lp, 298.0) == 0.0);
|
||||||
CHECK(crossfadeWeight(lp, 299.0) == 0.0);
|
CHECK(crossfadeWeight(lp, 299.0) == 0.0);
|
||||||
CHECK(crossfadeWeight(lp, 300.0) == 0.0);
|
CHECK(crossfadeWeight(lp, 349.0) == 0.5); // d = 50
|
||||||
CHECK(crossfadeWeight(lp, 350.0) == 0.5);
|
CHECK(crossfadeWeight(lp, 374.0) == 0.75); // d = 75
|
||||||
CHECK(crossfadeWeight(lp, 375.0) == 0.75);
|
CHECK(crossfadeWeight(lp, 399.0) == 1.0); // d = 100 == xf - 1, the ceiling's own threshold
|
||||||
CHECK(crossfadeWeight(lp, 400.0) == 1.0);
|
CHECK(crossfadeWeight(lp, 400.0) == 1.0); // past it too (the unwrapped-caller belt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// xf - 1 == 0 would divide by zero; the guard parks fadeInv at 0 instead. Unreachable via the
|
||||||
|
// multiply branch anyway (the region's only frame has d == 0, caught by the d <= 0 check
|
||||||
|
// first), but fadeInv must still be a sane value rather than +inf.
|
||||||
|
static void testCrossfadeOfOneNeedsNoDivisionGuard() {
|
||||||
|
const ResolvedLoop lp = resolveLoop(span(100, 400), 1, 1000, true);
|
||||||
|
CHECK(lp.crossfade == 1);
|
||||||
|
CHECK(lp.fadeInv == 0.0);
|
||||||
|
CHECK(crossfadeWeight(lp, 399.0) == 0.0); // d == 0, the region's one frame
|
||||||
|
CHECK(crossfadeWeight(lp, 400.0) == 1.0); // past it, the ceiling belt still holds
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testWeightIsMonotoneAndBoundedAcrossTheRegion() {
|
static void testWeightIsMonotoneAndBoundedAcrossTheRegion() {
|
||||||
@@ -158,6 +174,7 @@ int main() {
|
|||||||
testNegativeCrossfadeIsZero();
|
testNegativeCrossfadeIsZero();
|
||||||
testZeroCrossfadeWeighsNothingAnywhere();
|
testZeroCrossfadeWeighsNothingAnywhere();
|
||||||
testWeightRunsZeroToOneAcrossTheFadeRegion();
|
testWeightRunsZeroToOneAcrossTheFadeRegion();
|
||||||
|
testCrossfadeOfOneNeedsNoDivisionGuard();
|
||||||
testWeightIsMonotoneAndBoundedAcrossTheRegion();
|
testWeightIsMonotoneAndBoundedAcrossTheRegion();
|
||||||
testWeightSaturatesPastTheLoopEnd();
|
testWeightSaturatesPastTheLoopEnd();
|
||||||
testDefaultBoundsSitInTheLastQuarterAndClearFrameZero();
|
testDefaultBoundsSitInTheLastQuarterAndClearFrameZero();
|
||||||
|
|||||||
+28
-18
@@ -810,7 +810,9 @@ static void testCrossfadeBlendsMonotonelyAcrossTheRegion() {
|
|||||||
CHECK(approx(sourceFrameOf(out[51]), 51.0, 1e-3));
|
CHECK(approx(sourceFrameOf(out[51]), 51.0, 1e-3));
|
||||||
CHECK(approx(sourceFrameOf(out[52]), 52.0, 1e-3)); // weight 0 at the region edge
|
CHECK(approx(sourceFrameOf(out[52]), 52.0, 1e-3)); // weight 0 at the region edge
|
||||||
for (int n = 52; n < 60; ++n) {
|
for (int n = 52; n < 60; ++n) {
|
||||||
const double w = static_cast<double>(n - 52) / 8.0;
|
// Normalized over crossfade - 1 (= 7), not crossfade: weight reaches exactly 1 at
|
||||||
|
// n == 59 (d == 7 == xf - 1), not merely approaching it.
|
||||||
|
const double w = static_cast<double>(n - 52) / 7.0;
|
||||||
const double want = static_cast<double>(n) * (1.0 - w) + static_cast<double>(n - 20) * w;
|
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));
|
CHECK(approx(sourceFrameOf(out[static_cast<std::size_t>(n)]), want, 1e-3));
|
||||||
}
|
}
|
||||||
@@ -819,25 +821,33 @@ static void testCrossfadeBlendsMonotonelyAcrossTheRegion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a longer fade actually buys, measured rather than asserted by adjective. The last
|
// What the crossfade actually buys, measured rather than asserted by adjective: normalizing
|
||||||
// rendered frame before the wrap carries weight (xf-1)/xf, not 1 — the weight only reaches 1
|
// over crossfade - 1 (loop_span.h) lands the weight at exactly 1 on the last rendered frame
|
||||||
// AT `end`, a position no frame lands on — so a residual step of (seam step)/xf survives.
|
// (end - 1) for every REAL crossfade length (xf >= 2), not merely approaching it — that frame
|
||||||
// It shrinks in exact proportion to the fade length, which is the property that makes the
|
// is the incoming tap outright, which is exactly the value the wrap hands over. The step across
|
||||||
// parameter meaningful and the seam inaudible at any musically useful setting.
|
// the seam is therefore the material's own natural one-frame step (indexSample's slope is 1 raw
|
||||||
static void testSeamStepShrinksInProportionToTheFadeLength() {
|
// frame per frame), constant for any xf >= 2 — not a residual that merely shrinks with a longer
|
||||||
|
// fade. The |out[60]-out[59]| metric is otherwise a trap: it conflates the natural step with any
|
||||||
|
// leftover discontinuity, and at xf == loop length the OLD 1/xf normalization happened to score
|
||||||
|
// 0 by this same metric — a coincidence of that one ratio, not a property of the fix. Comparing
|
||||||
|
// against the natural step rather than "small" or "zero" closes that hole.
|
||||||
|
static void testSeamStepMatchesTheNaturalStepForAnyCrossfade() {
|
||||||
auto seamStep = [](std::int64_t xf) {
|
auto seamStep = [](std::int64_t xf) {
|
||||||
return std::fabs(loopedFrameValue(xf, 60) - loopedFrameValue(xf, 59));
|
return std::fabs(loopedFrameValue(xf, 60) - loopedFrameValue(xf, 59));
|
||||||
};
|
};
|
||||||
const double hard = seamStep(0);
|
// No crossfade: the seam is the whole loop length less one frame — the wart the fade fixes.
|
||||||
CHECK(approx(hard, 19.0, 1e-3)); // 59 -> 40, the whole loop length less one frame
|
CHECK(approx(seamStep(0), 19.0, 1e-3));
|
||||||
CHECK(approx(seamStep(4), 4.0, 1e-3));
|
// xf == 1 has no fractional region to blend — its one frame sits exactly at d == 0, caught
|
||||||
CHECK(approx(seamStep(8), 1.5, 1e-3));
|
// by crossfadeWeight's own d <= 0 floor before the multiply/ceiling ever runs — so it is
|
||||||
CHECK(approx(seamStep(16), 0.25, 1e-3));
|
// still the hard seam, not a one-frame fade.
|
||||||
// Each doubling roughly halves it, and even the shortest fade tested is a quarter of the
|
CHECK(approx(seamStep(1), 19.0, 1e-3));
|
||||||
// hard seam.
|
// Any REAL crossfade length: the step is exactly the natural one-frame step, not merely
|
||||||
CHECK(seamStep(4) < hard * 0.25);
|
// small — and constant regardless of the fade length, unlike the old formula's proportional
|
||||||
CHECK(seamStep(8) < seamStep(4) * 0.5);
|
// shrink.
|
||||||
CHECK(seamStep(16) < seamStep(8) * 0.5);
|
for (std::int64_t xf : {std::int64_t{4}, std::int64_t{8}, std::int64_t{16},
|
||||||
|
std::int64_t{20}}) {
|
||||||
|
CHECK(approx(seamStep(xf), 1.0, 1e-3));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testCrossfadeLengthFollowsItsParameter() {
|
static void testCrossfadeLengthFollowsItsParameter() {
|
||||||
@@ -2810,7 +2820,7 @@ int main() {
|
|||||||
testLoopReadWrapsSampleExactOverManyCycles();
|
testLoopReadWrapsSampleExactOverManyCycles();
|
||||||
testZeroCrossfadeLeavesTheSeamHard();
|
testZeroCrossfadeLeavesTheSeamHard();
|
||||||
testCrossfadeBlendsMonotonelyAcrossTheRegion();
|
testCrossfadeBlendsMonotonelyAcrossTheRegion();
|
||||||
testSeamStepShrinksInProportionToTheFadeLength();
|
testSeamStepMatchesTheNaturalStepForAnyCrossfade();
|
||||||
testCrossfadeLengthFollowsItsParameter();
|
testCrossfadeLengthFollowsItsParameter();
|
||||||
testCrossfadeIsSuppressedForALoopAtFrameZero();
|
testCrossfadeIsSuppressedForALoopAtFrameZero();
|
||||||
testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice();
|
testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice();
|
||||||
|
|||||||
@@ -361,6 +361,28 @@ static void testMarkerHandleOnDegenerateAreas() {
|
|||||||
CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4);
|
CHECK(markerHandleRect(overlayOf(thin), 1000, 500).height == 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The shell (editor_input_waveform.cpp) checks the loop crossfade's own grab handle — at
|
||||||
|
// loopStart - crossfade — before it iterates the ordinary marker array, because a zero-length
|
||||||
|
// fade puts that handle exactly on the loop-start marker's frame. The same coincidence recurs
|
||||||
|
// whenever ANY marker shares that frame, most plausibly the START marker dragged up against the
|
||||||
|
// fade edge: this module can't exercise the shell's check-order itself, but it can prove the
|
||||||
|
// geometric ambiguity that makes the ordering load-bearing — the array's own first-match rule
|
||||||
|
// would otherwise resolve the top strip to the START marker, not the fade handle.
|
||||||
|
static void testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge() {
|
||||||
|
const Rect a = wideArea();
|
||||||
|
const std::int64_t loopStart = 400, crossfade = 30;
|
||||||
|
const std::int64_t fadeEdge = loopStart - crossfade; // where the crossfade handle sits
|
||||||
|
const std::int64_t markers[3] = {fadeEdge, loopStart, loopStart + 100}; // start dialled here
|
||||||
|
const int mx = frameToX(overlayOf(a), 1000, fadeEdge);
|
||||||
|
const int topY = a.y; // inside the handle's top strip
|
||||||
|
// Without the shell's priority check, the array's own first-match rule already resolves the
|
||||||
|
// column to the start marker (index 0) at this x/y...
|
||||||
|
CHECK(markerAtPoint(overlayOf(a), 1000, markers, 3, mx, topY) == 0);
|
||||||
|
// ...and the fade handle's rect claims the exact same pixel — the ambiguity the shell
|
||||||
|
// resolves by asking the handle first, same as it does for the zero-fade/loop-start case.
|
||||||
|
CHECK(contains(markerHandleRect(overlayOf(a), 1000, fadeEdge), mx, topY));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Per-lane envelope content -------------------------------------------------
|
// --- Per-lane envelope content -------------------------------------------------
|
||||||
|
|
||||||
static void testAsymmetricStereoLanesCarryDifferentContent() {
|
static void testAsymmetricStereoLanesCarryDifferentContent() {
|
||||||
@@ -435,6 +457,7 @@ int main() {
|
|||||||
testCoincidentMarkersStayIndependentlyGrabbable();
|
testCoincidentMarkersStayIndependentlyGrabbable();
|
||||||
testMarkerHandleClipsIntoTheArea();
|
testMarkerHandleClipsIntoTheArea();
|
||||||
testMarkerHandleOnDegenerateAreas();
|
testMarkerHandleOnDegenerateAreas();
|
||||||
|
testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge();
|
||||||
|
|
||||||
testAsymmetricStereoLanesCarryDifferentContent();
|
testAsymmetricStereoLanesCarryDifferentContent();
|
||||||
testLaneEnvelopeRejectsOutOfRangeLane();
|
testLaneEnvelopeRejectsOutOfRangeLane();
|
||||||
|
|||||||
Reference in New Issue
Block a user