diff --git a/src/core/instrument/engine/loop/CLAUDE.md b/src/core/instrument/engine/loop/CLAUDE.md index 60c639f..e69e8a2 100644 --- a/src/core/instrument/engine/loop/CLAUDE.md +++ b/src/core/instrument/engine/loop/CLAUDE.md @@ -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 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 -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. +`crossfadeWeight` normalizes by `1/(crossfade-1)`, not `1/crossfade`: `d` at the last rendered +frame (`end - 1`) is always exactly `crossfade - 1` — the ceiling's own threshold — for every +REAL crossfade length (`crossfade >= 2`), so that frame is the incoming tap outright rather than +a blend approaching it. The seam across the wrap is therefore the material's OWN one-frame step +(`sampler_core_tests`, `testSeamStepMatchesTheNaturalStepForAnyCrossfade`), not a residual that +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 @@ -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 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 inverted span, a span reaching past the PCM, a negative start, a Trigger voice: all yield diff --git a/src/core/instrument/engine/loop/CMakeLists.txt b/src/core/instrument/engine/loop/CMakeLists.txt index 21f98d5..9bb84ff 100644 --- a/src/core/instrument/engine/loop/CMakeLists.txt +++ b/src/core/instrument/engine/loop/CMakeLists.txt @@ -1,6 +1,6 @@ -# 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. +# The loop's validity rule and crossfade geometry. Links peaks/filter/velocity_curve/curve_law +# (play_params' own dependency set) — 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) diff --git a/src/core/instrument/engine/loop/loop_span.cpp b/src/core/instrument/engine/loop/loop_span.cpp index 900b6ff..4a60448 100644 --- a/src/core/instrument/engine/loop/loop_span.cpp +++ b/src/core/instrument/engine/loop/loop_span.cpp @@ -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. std::int64_t xf = crossfadeFrames; if (xf < 0) xf = 0; - if (xf > out.start) xf = out.start; - if (xf > out.length) xf = out.length; + const std::int64_t bound = maxCrossfade(out.start, out.length); + if (xf > bound) xf = bound; out.crossfade = xf; out.fadeBegin = static_cast(out.end - xf); - out.fadeInv = xf > 0 ? 1.0 / static_cast(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(xf - 1) : 0.0; return out; } diff --git a/src/core/instrument/engine/loop/loop_span.h b/src/core/instrument/engine/loop/loop_span.h index ddb11ff..168ac5f 100644 --- a/src/core/instrument/engine/loop/loop_span.h +++ b/src/core/instrument/engine/loop/loop_span.h @@ -4,11 +4,22 @@ // it sits on the per-voice-per-sample read. #include +#include +#include "core/audio/peaks.h" // AudioSample #include "core/instrument/engine/play_params.h" // SampleLoop 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. // `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 crossfade = 0; // source frames; 0 = hard seam 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 @@ -33,13 +44,46 @@ 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. +// region, reaching exactly 1 at the LAST rendered frame (`end - 1`), not merely approaching 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) { if (lp.crossfade <= 0) return 0.0; const double d = pos - lp.fadeBegin; if (d <= 0.0) return 0.0; - return d < static_cast(lp.crossfade) ? d * lp.fadeInv : 1.0; + return d < static_cast(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& pcm, std::int64_t frameCount, + double pos) { + const std::int64_t i0 = static_cast(pos); + const std::int64_t i1 = i0 + 1; + const double frac = pos - static_cast(i0); + const double a = (i0 >= 0 && i0 < frameCount) ? static_cast(pcm[i0]) : 0.0; + const double b = (i1 >= 0 && i1 < frameCount) ? static_cast(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& pcm, const ResolvedLoop& lp, + std::int64_t pos, double xw) { + const double v = static_cast(pcm[static_cast(pos)]); + if (xw <= 0.0) return static_cast(v); + const std::int64_t tap = pos - lp.length; + if (tap < 0) return static_cast(v); + const double in = static_cast(pcm[static_cast(tap)]); + return static_cast(v + xw * (in - v)); } // Where the editor parks the loop handles for a capture that has none — the last quarter, diff --git a/src/core/instrument/engine/voice.cpp b/src/core/instrument/engine/voice.cpp index 1673d50..ea62534 100644 --- a/src/core/instrument/engine/voice.cpp +++ b/src/core/instrument/engine/voice.cpp @@ -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. primeBuf_[static_cast(i)] = (q < frameCount) - ? crossfadedSource(pcmCh, q, - instrument::engine::loop::crossfadeWeight( - loop_, static_cast(q))) + ? crossfadedSource(pcmCh, loop_, q, crossfadeWeight(loop_, + static_cast(q))) : 0.0f; ++q; } diff --git a/src/core/instrument/engine/voice.h b/src/core/instrument/engine/voice.h index c82d3dd..6de1e79 100644 --- a/src/core/instrument/engine/voice.h +++ b/src/core/instrument/engine/voice.h @@ -29,6 +29,8 @@ using instrument::engine::VelocityCurve; using instrument::engine::VelocityPoint; using instrument::engine::loop::ResolvedLoop; 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 // reference-frequency needed. @@ -154,32 +156,6 @@ public: } 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& pcm, std::int64_t frameCount, - double pos) { - const std::int64_t i0 = static_cast(pos); - const std::int64_t i1 = i0 + 1; - const double frac = pos - static_cast(i0); - const double a = (i0 >= 0 && i0 < frameCount) ? static_cast(pcm[i0]) : 0.0; - const double b = (i1 >= 0 && i1 < frameCount) ? static_cast(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& pcm, std::int64_t pos, - double xw) const { - const double v = static_cast(pcm[static_cast(pos)]); - if (xw <= 0.0) return static_cast(v); - const std::int64_t tap = pos - loop_.length; - if (tap < 0) return static_cast(v); - const double in = static_cast(pcm[static_cast(tap)]); - return static_cast(v + xw * (in - v)); - } - // 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 // is evaluated at the source offset (readPos - startFrame) so its stages anchor to source @@ -449,7 +425,7 @@ private: // shift the output. const double feedXw = crossfadeWeight(loop, static_cast(feedPos_)); const AudioSample feedL = - feedOk ? crossfadedSource(pcm, feedPos_, feedXw) : 0.0f; + feedOk ? crossfadedSource(pcm, loop, feedPos_, feedXw) : 0.0f; const double shift = baseRatio_ * envFactor; shiftL_.setShiftRatio(shift); const double shiftedL = static_cast(shiftL_.process(feedL)); @@ -468,7 +444,7 @@ private: // not leak a previous note. if (exhausted) shiftR_.freezeTail(); const AudioSample feedR = - feedOk ? crossfadedSource(pcmR, feedPos_, feedXw) : 0.0f; + feedOk ? crossfadedSource(pcmR, loop, feedPos_, feedXw) : 0.0f; shiftR_.setShiftRatio(shift); outRlocal = static_cast(shiftR_.processLinked(feedR, shiftL_.lastSplice())); diff --git a/src/core/instrument/ui/waveform_view.h b/src/core/instrument/ui/waveform_view.h index d30784a..e59002d 100644 --- a/src/core/instrument/ui/waveform_view.h +++ b/src/core/instrument/ui/waveform_view.h @@ -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 // every coincident tie and the loser can never be dragged apart again. 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); // Which marker (index into the caller's parallel `frames` array, in draw order) a grab at diff --git a/src/shell/instrument/editor_input_waveform.cpp b/src/shell/instrument/editor_input_waveform.cpp index e1c9ff2..8ce057a 100644 --- a/src/shell/instrument/editor_input_waveform.cpp +++ b/src/shell/instrument/editor_input_waveform.cpp @@ -12,6 +12,7 @@ #include #include +#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/waveform_view.h" // waveformOverlayArea / markerAtPoint / snap #include "shell/instrument/editor_internal.h" @@ -21,6 +22,7 @@ namespace reasampler::vst { using namespace reasampler::ui; using namespace reasampler::instrument::ui; +using instrument::engine::loop::maxCrossfade; bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { const std::vector& pcm = monoPcmFor(selectedId_); @@ -54,7 +56,10 @@ bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) { const SetupMarkers m = pickedMarkers(frames); // 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 - // (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 && contains(markerHandleRect(overlay, frames, m.loopStart - m.crossfade), x, y)) { 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 > frames - 1) m.start = frames - 1; - // Mirror resolveLoop's own bound so the handle can't be dragged somewhere the engine - // would silently clamp back: the fade reads the material ahead of the loop, and cannot - // outrun either that material or the loop itself. - m.crossfade = (std::min)(m.crossfade, (std::min)(m.loopStart, m.loopEnd - m.loopStart)); + // Shares resolveLoop's own bound (loop_span.h's maxCrossfade) so the handle can't be + // dragged somewhere the engine would silently clamp back. + m.crossfade = (std::min)(m.crossfade, maxCrossfade(m.loopStart, m.loopEnd - m.loopStart)); if (m.crossfade < 0) m.crossfade = 0; applyMarkers(m); diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp index 1e7ce3c..3f9eab3 100644 --- a/src/shell/instrument/editor_session.cpp +++ b/src/shell/instrument/editor_session.cpp @@ -225,7 +225,10 @@ void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) { loop.start = m.loopStart; loop.end = m.loopEnd; 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; } diff --git a/tests/test_loop_span.cpp b/tests/test_loop_span.cpp index 092d052..283409c 100644 --- a/tests/test_loop_span.cpp +++ b/tests/test_loop_span.cpp @@ -93,18 +93,34 @@ static void testZeroCrossfadeWeighsNothingAnywhere() { 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 -// is what makes the seam continuous: at `end` the incoming tap has reached `start`, and the -// wrap puts the head there. +// The weight rises from exactly 0 at the region's start to exactly 1 at the LAST rendered +// frame (end - 1, d == crossfade - 1) — normalizing over crossfade - 1 rather than crossfade is +// 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() { - const ResolvedLoop lp = resolveLoop(span(100, 400), 100, 1000, true); - CHECK(lp.crossfade == 100); - CHECK(lp.fadeBegin == 300.0); + // xf = 101 so xf - 1 = 100, a clean denominator (loopStart 200 keeps the clamp out of the + // way: max is min(200, 200)). + 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, 300.0) == 0.0); - CHECK(crossfadeWeight(lp, 350.0) == 0.5); - CHECK(crossfadeWeight(lp, 375.0) == 0.75); - CHECK(crossfadeWeight(lp, 400.0) == 1.0); + CHECK(crossfadeWeight(lp, 349.0) == 0.5); // d = 50 + CHECK(crossfadeWeight(lp, 374.0) == 0.75); // d = 75 + CHECK(crossfadeWeight(lp, 399.0) == 1.0); // d = 100 == xf - 1, the ceiling's own threshold + 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() { @@ -158,6 +174,7 @@ int main() { testNegativeCrossfadeIsZero(); testZeroCrossfadeWeighsNothingAnywhere(); testWeightRunsZeroToOneAcrossTheFadeRegion(); + testCrossfadeOfOneNeedsNoDivisionGuard(); testWeightIsMonotoneAndBoundedAcrossTheRegion(); testWeightSaturatesPastTheLoopEnd(); testDefaultBoundsSitInTheLastQuarterAndClearFrameZero(); diff --git a/tests/test_sampler_core.cpp b/tests/test_sampler_core.cpp index c67ebb9..3e9683f 100644 --- a/tests/test_sampler_core.cpp +++ b/tests/test_sampler_core.cpp @@ -810,7 +810,9 @@ static void testCrossfadeBlendsMonotonelyAcrossTheRegion() { 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(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(n - 52) / 7.0; const double want = static_cast(n) * (1.0 - w) + static_cast(n - 20) * w; CHECK(approx(sourceFrameOf(out[static_cast(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 -// 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() { +// What the crossfade actually buys, measured rather than asserted by adjective: normalizing +// over crossfade - 1 (loop_span.h) lands the weight at exactly 1 on the last rendered frame +// (end - 1) for every REAL crossfade length (xf >= 2), not merely approaching it — that frame +// is the incoming tap outright, which is exactly the value the wrap hands over. The step across +// the seam is therefore the material's own natural one-frame step (indexSample's slope is 1 raw +// 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) { 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); + // No crossfade: the seam is the whole loop length less one frame — the wart the fade fixes. + CHECK(approx(seamStep(0), 19.0, 1e-3)); + // xf == 1 has no fractional region to blend — its one frame sits exactly at d == 0, caught + // by crossfadeWeight's own d <= 0 floor before the multiply/ceiling ever runs — so it is + // still the hard seam, not a one-frame fade. + CHECK(approx(seamStep(1), 19.0, 1e-3)); + // Any REAL crossfade length: the step is exactly the natural one-frame step, not merely + // small — and constant regardless of the fade length, unlike the old formula's proportional + // shrink. + 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() { @@ -2810,7 +2820,7 @@ int main() { testLoopReadWrapsSampleExactOverManyCycles(); testZeroCrossfadeLeavesTheSeamHard(); testCrossfadeBlendsMonotonelyAcrossTheRegion(); - testSeamStepShrinksInProportionToTheFadeLength(); + testSeamStepMatchesTheNaturalStepForAnyCrossfade(); testCrossfadeLengthFollowsItsParameter(); testCrossfadeIsSuppressedForALoopAtFrameZero(); testNoteOffDuringLoopSustainRunsTheReleaseAndFreesTheVoice(); diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index 8e988a3..ce1989d 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -361,6 +361,28 @@ static void testMarkerHandleOnDegenerateAreas() { 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 ------------------------------------------------- static void testAsymmetricStereoLanesCarryDifferentContent() { @@ -435,6 +457,7 @@ int main() { testCoincidentMarkersStayIndependentlyGrabbable(); testMarkerHandleClipsIntoTheArea(); testMarkerHandleOnDegenerateAreas(); + testStartMarkerSharesTheHandleStripWhenItSitsAtTheFadeEdge(); testAsymmetricStereoLanesCarryDifferentContent(); testLaneEnvelopeRejectsOutOfRangeLane();